diff --git a/README.md b/README.md index 2156463..69aea66 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,82 @@ # Kysymys -Kysymys is a platform for training programming. \ No newline at end of file +Kysymys はプログラミング学習・研修のためのプラットフォームです。学習者が問題に向きあい、解答を誰かに見てもらい、その過程を講師が伴走します。 + +## 何を解決するのか + +プログラミングの研修や勉強会では、教材を配って「あとは各自やってみて」で終わりがちです。すると二つのことが見えなくなります。**解答そのもの**と、**解いている途中の様子**です。 + +提出された解答だけを見ても、その人がどこで何時間つまずいていたかは分かりません。逆に、机を回って様子を見ても、20人が同時に手を動かしていれば誰が本当に手助けを要しているかは埋もれます。Kysymys はこの両方を扱います。解答を submit して受け取り、レビューコメントで返す仕組みと、submit を待たずに作業状況をリアルタイムに集める仕組みを、一つのプラットフォームに収めています。 + +## 学びのループ + +Kysymys の中心には、問題と解答をめぐる循環があります。 + +先生が Git リポジトリ上に問題を用意し、Kysymys に登録します。学習者は自分の手元でその問題に取り組み、書けたところで手元の Maven プラグインから解答を submit します。submit された解答には先生や仲間がレビューコメントを付けられ、コメントは学習者に通知として届きます。 + +一人で閉じないのがこのプラットフォームの狙いです。学習者どうしはフォローし合え、フォローしている相手の解答は互いに見えます。他人がどう書いたかを読むこと自体が学びになる、という前提でモデルを組んでいます。誰の解答が誰に見えてよいかはフォロー関係で決まるので、この判定にはユーザ間のつながりの知識が要ります。 + +問題には生成からアーカイブまでの一生があり、その節目 (作成・更新・アーカイブ) をイベントとして記録します。今の状態を上書きで持つのではなく、状態を変えた出来事を時系列に残す作りなので、「この問題はいつ誰が作り、いつアーカイブされたか」を後から辿れます。 + +## 研修中の作業状況を見る + +解答の submit は結果でしかありません。研修で本当に知りたいのは、今まさに詰まっている人が誰か、です。 + +Kysymys は参加者のマシンで動く軽量な仕掛けから、作業中の信号を集めます。`mvn` を回すたびのビルド成否、ファイルを編集し続けているかどうかの heartbeat、そして本人からの「詰まった」「解消した」の申告です。これらは参加者ごとに配られたトークンで識別され、Kysymys サーバに送られます。 + +先生はダッシュボードを開くだけで、参加者が「手助けが要りそうな順」に並んだ一覧をリアルタイムに見られます。ビルドが失敗し続けている人、しばらく手が止まっている人、自分から助けを求めた人が上に来て、色で危険度が分かります。机を回る代わりに、画面を見て声をかけに行けます。 + +参加者側の準備は演習リポジトリに設定ファイルを二つ置くだけで、常駐プロセスやバイナリの手動配置は要りません。仕組みの詳細と配布手順は [`doc/training-activity-setup.md`](doc/training-activity-setup.md) にまとめています。 + +## ドメインの区切り + +Kysymys は四つの領域に分けて設計しています。将来それぞれを別サービスに切り出せるよう、境界を最初から引いています。 + +| 領域 | 役割 | +| --- | --- | +| Lesson | 問題の作成、解答の submit、レビューコメント。プラットフォームの核 | +| User | ユーザ登録、プロフィール、フォロー申請などのつながり | +| Avatar | ユーザのアバター画像の生成と表示 | +| Notification | コメントや更新を本人に届ける通知 | + +ユーザの情報はどの領域からも必要になりますが、User 以外の領域はユーザの ID しか持ちません。名前やメールアドレスが要るとき (通知メールなど) は、発火元がイベントに必要な情報を載せて渡します。領域どうしを直接参照させず、出来事を介してつなぐ設計です。判断の記録は [`doc/adr/`](doc/adr/) に番号付きで残しています。 + +## 何でできているか + +Kysymys は複数のモジュールの集まりです。サーバ本体に加えて、参加者の手元で動く小さな道具がいくつかあります。 + +| モジュール | 役割 | +| --- | --- | +| `kysymys-app` | サーバ本体。JSON API として全機能を露出する | +| `kysymys-frontend` | React/TypeScript の SPA。学習者向け画面と講師ダッシュボード | +| `kysymys-maven-plugin` | 学習者が手元から解答を submit するための Maven プラグイン | +| `kysymys-activity-agent` | ビルド成否を送る Maven 拡張。作業状況テレメトリの入口 | +| `kysymys-agent` | ファイル編集の heartbeat を送る常駐ウォッチャー (Go 単一バイナリ) | +| `kysymys-scorer-java` | Java 解答をテストで採点するロジック | + +このリポジトリは、kawasima が開発しているフレームワーク群 (Enkan / Kotowari / Raoh / Bouncr) のドッグフーディングの場も兼ねています。教材プラットフォームとして役立てながら、同じコードベースでフレームワークを実際の要件にさらします。技術構成の詳細は [`CLAUDE.md`](CLAUDE.md) と [`doc/adr/004_replatform_to_enkan.md`](doc/adr/004_replatform_to_enkan.md) を参照してください。 + +## はじめかた + +開発用サーバは H2 のインメモリ DB で、追加のセットアップなしに起動できます。 + +```bash +# サーバ本体を開発モードで起動 (ポート 3000) +cd kysymys-app && mvn exec:java + +# フロントエンド (別ターミナル、ポート 5173) +cd kysymys-frontend && npm install && npm run dev +``` + +研修で作業状況テレメトリまで動かす手順 (トークンの配り方、参加者側の設定) は [`doc/training-activity-setup.md`](doc/training-activity-setup.md) にあります。 + +## ドキュメント + +- [`doc/adr/`](doc/adr/) — 設計判断の記録 (Bounded Context の分割、Problem のライフサイクル、Enkan への全面移行 ほか) +- [`doc/usecase/`](doc/usecase/) — 各操作のユースケース記述 +- [`doc/training-activity-setup.md`](doc/training-activity-setup.md) — 研修での作業状況リアルタイム収集の使い方 +- [`examples/`](examples/) — 動く演習リポジトリのサンプルと、ループ全体を実コマンドで通す検証スクリプト + +## ライセンス + +[LICENSE](LICENSE) を参照してください。 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..26fe7e5 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,65 @@ +# examples — 動く演習と検証 + +Kysymys の学びのループを、実際に動く演習リポジトリで通すためのサンプルです。二つの役割を +兼ねています。 + +- **見本**: 研修の講師・学習者が「演習リポジトリはこう作る」とコピーして使える参照実装 +- **検証**: `verify.sh` が submit → コメント → 通知 → 採点 → テレメトリ → ダッシュボードまでを + 本物のコマンドで一気通貫に通し、README のコンセプトが現状のコードで回ることを確かめる + +## 構成 + +``` +examples/ +├── fizzbuzz/ 演習問題プロジェクト (研修参加者が clone するもの) +├── bin/mint-jwt dev 用の HS256 JWT 発行ヘルパ +└── verify.sh ループ全体を実コマンドで駆動する検証スクリプト +``` + +`fizzbuzz/` 単体の使い方 (実装・自己採点・提出) は [`fizzbuzz/README.md`](fizzbuzz/README.md) にあります。 +これがそのまま問題文で、kysymys に問題を登録するときの `readmePath` になります。 + +## 検証を回す + +```bash +# 依存する kysymys モジュールを ~/.m2 に入れておく (初回のみ) +mvn -N install -DskipTests +mvn -pl kysymys-scorer-java,kysymys-maven-plugin,kysymys-activity-agent install -DskipTests + +# ループを通す (dev サーバが未起動なら verify.sh が mvn exec:java で起動・停止する) +./examples/verify.sh +``` + +`verify.sh` が通す流れ: + +1. dev サーバの応答を待つ (`GET /health`) +2. dev JWT を発行 (teacher / student) +3. 遅延サインアップ (`GET /users`) +4. teacher が問題を登録 (`POST /problems`) +5. student が `mvn kysymys:submit` で解答を提出 (git リモート URL + commit を送る) +6. teacher がレビューコメントを投稿 (`POST /answers/:id/comments`) +7. 通知機構を確認 (フォロー申請 → 対象者へ通知) +8. 手元で自己採点 (`mvn exec:java` → `100`) +9. `mvn kysymys:stuck` / `:resolved` とビルドイベントを送信 +10. 講師ダッシュボードへの反映を確認 (`GET /activity/status`) + +## スコープ境界と、検証で分かった現状との差 + +README のコンセプトと今のコードには、いくつか差があります。verify.sh はこれを踏まえて、 +実装済みの範囲をグリーンにしつつ差分を出力します。 + +- **コメントは通知を生成しない (README との差)**。README は「コメントは学習者に通知として + 届きます」と述べますが、`PostComment` はイベントを発火せず、`CommentPostedEvent` 自体が + ありません。通知を作る `RecordWhatsNew` は `SubmittedAnswerEvent` (フォロワー宛て) と + `OfferedToFollowEvent` (対象者宛て) にしか反応しません。verify.sh はこの差分を警告として + 出し、通知機構そのものはフォロー申請の経路で動作確認します。README どおりにするには、 + コメント投稿時にイベントを発火し、`RecordWhatsNew` にコメント通知の分岐を加える必要が + あります。 +- **サーバ採点は存在しない**。採点は `kysymys-scorer-java` が学習者の手元で走り、点数を + stdout に出すだけです。submit は解答リポジトリの URL とコミットを記録するだけで、採点は + 返しません (`KysymysTestLauncher` の javadoc に明記)。 +- **本物の Bouncr スタックは使わない**。`mint-jwt` が dev の HMAC 固定鍵で自前署名します。 + 本番の Envoy + bouncr-proxy 接続 (移行計画の Sub-C 後半) は未達です。 +- **Go 常駐ウォッチャーの heartbeat は best-effort**。verify.sh は Maven 拡張のビルドイベントと + `stuck`/`resolved` の到達までを必須検証とし、Go バイナリ配信 (`GET /agent/`、 + サーバの `mvn package` 時にクロスコンパイル) は任意扱いです。 diff --git a/examples/bin/mint-jwt b/examples/bin/mint-jwt new file mode 100755 index 0000000..9981915 --- /dev/null +++ b/examples/bin/mint-jwt @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""dev 用の HS256 JWT を発行する。 + +kysymys の BouncrBackend は x-bouncr-credential ヘッダの JWT を KYSYMYS_JWT_SECRET +(dev 既定 "kysymys-dev-jwt-secret-not-for-production") で検証し、claims から +UserPermissionPrincipal を作る。読む claim は: + + sub -> principal 名 = UserId (必須) + permissions -> 権限の配列 (例 ["TEACHER"] / ["STUDENT"]) + uid -> 数値 id (任意) + その他 -> profiles (name / email など) + +本物の Bouncr スタックの代わりに研修 dev / verify.sh で使う。本番トークンではない。 + +使い方: + mint-jwt --sub alice --perm STUDENT --name Alice --email alice@example.com + mint-jwt --sub teacher1 --perm TEACHER + KYSYMYS_JWT_SECRET=... mint-jwt --sub bob # 秘密鍵を環境変数で上書き +""" +import argparse +import base64 +import hashlib +import hmac +import json +import os +import sys + +DEV_SECRET = "kysymys-dev-jwt-secret-not-for-production" + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def main() -> int: + p = argparse.ArgumentParser(description="Mint an HS256 dev JWT for kysymys.") + p.add_argument("--sub", required=True, help="principal id (sub claim = UserId)") + p.add_argument("--perm", action="append", default=[], + help="permission, repeatable (e.g. --perm TEACHER)") + p.add_argument("--uid", type=int, default=None, help="numeric uid claim (optional)") + p.add_argument("--name", default=None, help="display name (profile claim)") + p.add_argument("--email", default=None, help="email (profile claim)") + p.add_argument("--secret", default=os.environ.get("KYSYMYS_JWT_SECRET", DEV_SECRET), + help="HMAC secret (default: env KYSYMYS_JWT_SECRET or dev secret)") + args = p.parse_args() + + header = {"alg": "HS256", "typ": "JWT"} + claims: dict = {"sub": args.sub, "permissions": args.perm} + if args.uid is not None: + claims["uid"] = args.uid + if args.name is not None: + claims["name"] = args.name + if args.email is not None: + claims["email"] = args.email + + signing_input = ( + b64url(json.dumps(header, separators=(",", ":")).encode()) + + "." + + b64url(json.dumps(claims, separators=(",", ":")).encode()) + ) + sig = hmac.new(args.secret.encode(), signing_input.encode(), hashlib.sha256).digest() + print(signing_input + "." + b64url(sig)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/fizzbuzz/.gitignore b/examples/fizzbuzz/.gitignore new file mode 100644 index 0000000..87401ab --- /dev/null +++ b/examples/fizzbuzz/.gitignore @@ -0,0 +1,2 @@ +target/ +kysymys.properties diff --git a/examples/fizzbuzz/.mvn/extensions.xml b/examples/fizzbuzz/.mvn/extensions.xml new file mode 100644 index 0000000..c57895d --- /dev/null +++ b/examples/fizzbuzz/.mvn/extensions.xml @@ -0,0 +1,13 @@ + + + + + net.unit8.kysymys + kysymys-activity-agent + 0.2.0-SNAPSHOT + + diff --git a/examples/fizzbuzz/README.md b/examples/fizzbuzz/README.md new file mode 100644 index 0000000..05c8427 --- /dev/null +++ b/examples/fizzbuzz/README.md @@ -0,0 +1,49 @@ +# 演習: FizzBuzz + +Kysymys の演習問題プロジェクトのサンプルです。この README がそのまま問題文になります +(kysymys に問題を登録するとき `readmePath` に指定します)。 + +## 課題 + +`src/main/java/net/unit8/kysymys/example/fizzbuzz/FizzBuzz.java` の `convert(int n)` を実装してください。 +1 以上の整数 `n` を受け取り、次の規則で文字列を返します。 + +- 3 の倍数なら `Fizz` +- 5 の倍数なら `Buzz` +- 3 と 5 の両方の倍数なら `FizzBuzz` +- それ以外はその数の 10 進表記 (例: `1` → `"1"`) + +## 自分でテスト・採点する + +```bash +# テストを走らせる +mvn test + +# 点数 (通ったテストの割合 0〜100) を表示する +mvn -q test-compile exec:java +``` + +採点はあなたの手元で走ります。kysymys サーバは採点をしません +(提出は解答リポジトリの URL とコミットを記録するだけです)。 + +## 提出する + +`kysymys.properties` に接続先・問題 ID・自分のトークンを記入し、コミットしてから: + +```bash +mvn net.unit8.kysymys:kysymys-maven-plugin:submit +``` + +提出は「今の git リモートの URL + HEAD コミット」をサーバに送ります。事前に +リモートへ push しておいてください。 + +## 作業状況の共有 (任意) + +`.mvn/extensions.xml` が置いてあると、`mvn` を回すたびにビルドの成否が、ファイルを +編集するたびに heartbeat が講師の kysymys サーバに送られます。詰まったとき・解消した +ときは自分から知らせられます。 + +```bash +mvn net.unit8.kysymys:kysymys-maven-plugin:stuck # 「詰まった」 +mvn net.unit8.kysymys:kysymys-maven-plugin:resolved # 「解消した」 +``` diff --git a/examples/fizzbuzz/kysymys.properties.template b/examples/fizzbuzz/kysymys.properties.template new file mode 100644 index 0000000..5f91543 --- /dev/null +++ b/examples/fizzbuzz/kysymys.properties.template @@ -0,0 +1,11 @@ +# 演習リポジトリのルート (pom.xml と同じ階層) に kysymys.properties として置く。 +# 値は研修主催者から配布される。 + +# 講師の kysymys サーバ +kysymys.url=http://INSTRUCTOR_HOST:3000 + +# 取り組む問題の ID +kysymys.problemId=PROBLEM_ID + +# 参加者ごとに配布される Bouncr JWT (これで本人として識別される) +kysymys.token=YOUR_JWT_HERE diff --git a/examples/fizzbuzz/pom.xml b/examples/fizzbuzz/pom.xml new file mode 100644 index 0000000..e896939 --- /dev/null +++ b/examples/fizzbuzz/pom.xml @@ -0,0 +1,69 @@ + + + + 4.0.0 + + net.unit8.kysymys.example + fizzbuzz + 1.0.0 + jar + + kysymys-example-fizzbuzz + Kysymys 演習問題のサンプル: FizzBuzz + + + UTF-8 + 17 + + 0.2.0-SNAPSHOT + 5.10.2 + + + + + + net.unit8.kysymys + kysymys-scorer-java + ${kysymys.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + org.codehaus.mojo + exec-maven-plugin + 3.2.0 + + net.unit8.kysymys.example.fizzbuzz.Scoring + test + + + + + diff --git a/examples/fizzbuzz/solution/FizzBuzz.java b/examples/fizzbuzz/solution/FizzBuzz.java new file mode 100644 index 0000000..0406462 --- /dev/null +++ b/examples/fizzbuzz/solution/FizzBuzz.java @@ -0,0 +1,23 @@ +package net.unit8.kysymys.example.fizzbuzz; + +/** + * 参考解答。verify.sh はこれをスタブに上書きして「解けた状態」の提出を再現する。 + * 研修で配るときはこの solution/ ディレクトリを外してよい。 + */ +public class FizzBuzz { + + public String convert(int n) { + boolean fizz = n % 3 == 0; + boolean buzz = n % 5 == 0; + if (fizz && buzz) { + return "FizzBuzz"; + } + if (fizz) { + return "Fizz"; + } + if (buzz) { + return "Buzz"; + } + return Integer.toString(n); + } +} diff --git a/examples/fizzbuzz/src/main/java/net/unit8/kysymys/example/fizzbuzz/FizzBuzz.java b/examples/fizzbuzz/src/main/java/net/unit8/kysymys/example/fizzbuzz/FizzBuzz.java new file mode 100644 index 0000000..237e224 --- /dev/null +++ b/examples/fizzbuzz/src/main/java/net/unit8/kysymys/example/fizzbuzz/FizzBuzz.java @@ -0,0 +1,26 @@ +package net.unit8.kysymys.example.fizzbuzz; + +/** + * FizzBuzz 変換。 + * + *

1 以上の整数を受け取り、次の規則で文字列を返す: + *

+ */ +public class FizzBuzz { + + /** + * {@code n} を FizzBuzz 規則で変換する。 + * + * @param n 1 以上の整数 + * @return 変換結果 + */ + public String convert(int n) { + // TODO: ここを実装してください。 + throw new UnsupportedOperationException("FizzBuzz#convert is not implemented yet"); + } +} diff --git a/examples/fizzbuzz/src/test/java/net/unit8/kysymys/example/fizzbuzz/FizzBuzzTest.java b/examples/fizzbuzz/src/test/java/net/unit8/kysymys/example/fizzbuzz/FizzBuzzTest.java new file mode 100644 index 0000000..e084363 --- /dev/null +++ b/examples/fizzbuzz/src/test/java/net/unit8/kysymys/example/fizzbuzz/FizzBuzzTest.java @@ -0,0 +1,39 @@ +package net.unit8.kysymys.example.fizzbuzz; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 採点テスト。全部通れば scorer は 100 を出す。 + */ +class FizzBuzzTest { + private final FizzBuzz fizzBuzz = new FizzBuzz(); + + @Test + void plainNumbers() { + assertEquals("1", fizzBuzz.convert(1)); + assertEquals("2", fizzBuzz.convert(2)); + assertEquals("4", fizzBuzz.convert(4)); + } + + @Test + void multiplesOfThree() { + assertEquals("Fizz", fizzBuzz.convert(3)); + assertEquals("Fizz", fizzBuzz.convert(6)); + assertEquals("Fizz", fizzBuzz.convert(9)); + } + + @Test + void multiplesOfFive() { + assertEquals("Buzz", fizzBuzz.convert(5)); + assertEquals("Buzz", fizzBuzz.convert(10)); + assertEquals("Buzz", fizzBuzz.convert(20)); + } + + @Test + void multiplesOfFifteen() { + assertEquals("FizzBuzz", fizzBuzz.convert(15)); + assertEquals("FizzBuzz", fizzBuzz.convert(30)); + } +} diff --git a/examples/fizzbuzz/src/test/java/net/unit8/kysymys/example/fizzbuzz/Scoring.java b/examples/fizzbuzz/src/test/java/net/unit8/kysymys/example/fizzbuzz/Scoring.java new file mode 100644 index 0000000..6dd08ab --- /dev/null +++ b/examples/fizzbuzz/src/test/java/net/unit8/kysymys/example/fizzbuzz/Scoring.java @@ -0,0 +1,21 @@ +package net.unit8.kysymys.example.fizzbuzz; + +import net.unit8.kysymys.scorer.KysymysTestLauncher; + +/** + * 自己採点のエントリポイント。 + * + *

{@code KysymysTestLauncher} が {@link FizzBuzzTest} を JUnit Platform で走らせ、 + * 成功したテストの割合 (0〜100) を stdout に出す。研修参加者は提出前に + * {@code mvn test-compile exec:java} で自分の点数を確認できる。 + * + *

サーバ側採点は kysymys-app には存在しない (scorer はローカル実行)。 + */ +public final class Scoring { + private Scoring() { + } + + public static void main(String[] args) { + KysymysTestLauncher.run(FizzBuzzTest.class); + } +} diff --git a/examples/verify.sh b/examples/verify.sh new file mode 100755 index 0000000..3ed920a --- /dev/null +++ b/examples/verify.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# +# Kysymys の学びのループを、本物のコマンドでエンドツーエンドに通す検証スクリプト。 +# +# 通す流れ: +# 1. dev サーバ起動を待つ (未起動なら自分で mvn exec:java で起動) +# 2. dev JWT を発行 (teacher / student) +# 3. 遅延サインアップ (GET /users) +# 4. teacher が問題を登録 POST /problems +# 5. student が解答を提出 mvn kysymys:submit (git リモート URL + commit を送る) +# 6. teacher がレビューコメント POST /answers/:id/comments +# 7. student に通知が届く GET /whats-news +# 8. 手元で自己採点 mvn exec:java -> 100 +# 9. 作業状況テレメトリ mvn kysymys:stuck / :resolved + ビルドイベント +# 10. 講師ダッシュボードに反映 GET /activity/status +# +# スコープ境界 (README のコンセプトと現状の差): +# - サーバ採点は存在しない。採点は student のローカルで走る (scorer)。 +# - 本物の Bouncr スタックではなく dev の HMAC 固定鍵で自前署名する。 +# - Go 常駐ウォッチャーの heartbeat は best-effort。ここでは拡張のビルドイベントと +# stuck/resolved の到達までを必須検証とする。 +# +set -uo pipefail + +# --- 設定 ------------------------------------------------------------------- +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$HERE/.." && pwd)" +HOST="${KYSYMYS_URL:-http://localhost:3000}" +MINT="$HERE/bin/mint-jwt" +EXAMPLE="$HERE/fizzbuzz" +PLUGIN="net.unit8.kysymys:kysymys-maven-plugin:0.2.0-SNAPSHOT" + +# UserId はこのシステムでは 21 文字の NanoID。offer の targetUserId が fixedLength(21) を +# 要求するので、dev トークンの sub も 21 文字に揃える。 +STUDENT_ID="$(printf '%-21s' alice | tr ' ' '0')" # alice0000000000000000 +TEACHER_ID="$(printf '%-21s' sensei | tr ' ' '0')" # sensei000000000000000 + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/kysymys-verify.XXXXXX")" +SERVER_LOG="$WORK/server.log" +STARTED_SERVER=0 +MVN_PID="" + +PASS=0 +FAIL=0 + +# --- 出力ヘルパ ------------------------------------------------------------- +c_green='\033[0;32m'; c_red='\033[0;31m'; c_yellow='\033[0;33m'; c_reset='\033[0m' +info() { printf "%b\n" "${c_yellow}• $*${c_reset}"; } +pass() { printf "%b\n" "${c_green}✓ $*${c_reset}"; PASS=$((PASS + 1)); } +fail() { printf "%b\n" "${c_red}✗ $*${c_reset}"; FAIL=$((FAIL + 1)); } + +cleanup() { + if [[ "$STARTED_SERVER" == "1" ]]; then + info "dev サーバを停止します" + [[ -n "$MVN_PID" ]] && kill "$MVN_PID" 2>/dev/null + pkill -f 'net.unit8.kysymys.KysymysDevMain' 2>/dev/null + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +# --- HTTP ヘルパ ------------------------------------------------------------ +# req METHOD PATH TOKEN [JSON_BODY] -> グローバル RESP_CODE / RESP_BODY を設定 +req() { + local method="$1" path="$2" token="$3" body="${4:-}" + local out="$WORK/resp.$$" + local -a args=(-s -o "$out" -w '%{http_code}' -X "$method" -H "x-bouncr-credential: $token" -H "Accept: application/json") + if [[ -n "$body" ]]; then + args+=(-H 'Content-Type: application/json' --data "$body") + fi + RESP_CODE="$(curl "${args[@]}" "$HOST$path")" + RESP_BODY="$(cat "$out")" + rm -f "$out" +} + +# jget JSON PYEXPR ('d' に読み込んだ dict/list を渡す python 式) +jget() { python3 -c 'import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1]))' "$2" <<<"$1"; } + +# --- サーバ起動 ------------------------------------------------------------- +wait_health() { + for _ in $(seq 1 90); do + if curl -sf "$HOST/health" >/dev/null 2>&1; then return 0; fi + sleep 1 + done + return 1 +} + +info "対象サーバ: $HOST" +if curl -sf "$HOST/health" >/dev/null 2>&1; then + info "既に起動中のサーバを使います" +else + info "サーバが見つからないので mvn exec:java で起動します (ログ: $SERVER_LOG)" + ( cd "$REPO_ROOT/kysymys-app" && exec mvn -q exec:java ) >"$SERVER_LOG" 2>&1 & + MVN_PID=$! + STARTED_SERVER=1 + if ! wait_health; then + fail "サーバが起動しませんでした。ログ末尾:" + tail -20 "$SERVER_LOG" || true + exit 1 + fi +fi +pass "サーバが応答 (GET /health)" + +# --- トークン発行 ----------------------------------------------------------- +STUDENT_TOKEN="$("$MINT" --sub "$STUDENT_ID" --perm STUDENT --name Alice --email alice@example.com)" +TEACHER_TOKEN="$("$MINT" --sub "$TEACHER_ID" --perm TEACHER --name Sensei --email sensei@example.com)" +pass "dev JWT を発行 (student=$STUDENT_ID / teacher=$TEACHER_ID)" + +# --- 遅延サインアップ (User 行を作る) --------------------------------------- +req GET /users "$STUDENT_TOKEN"; [[ "$RESP_CODE" =~ ^2 ]] && pass "student 遅延サインアップ (GET /users -> $RESP_CODE)" || fail "student サインアップ失敗 ($RESP_CODE): $RESP_BODY" +req GET /users "$TEACHER_TOKEN"; [[ "$RESP_CODE" =~ ^2 ]] && pass "teacher 遅延サインアップ (GET /users -> $RESP_CODE)" || fail "teacher サインアップ失敗 ($RESP_CODE): $RESP_BODY" + +# --- 4. 問題登録 ------------------------------------------------------------ +PROBLEM_BODY='{"name":"FizzBuzz","repository":{"type":"github","url":"https://github.com/example/fizzbuzz","branch":"main","readmePath":"/README.md"}}' +req POST /problems "$TEACHER_TOKEN" "$PROBLEM_BODY" +if [[ "$RESP_CODE" == "201" ]]; then + PROBLEM_ID="$(jget "$RESP_BODY" "d['id']")" + pass "問題を登録 (POST /problems -> 201, id=$PROBLEM_ID)" +else + fail "問題登録に失敗 ($RESP_CODE): $RESP_BODY"; exit 1 +fi + +# --- 5. 解答リポジトリを用意して提出 ---------------------------------------- +ANSWER="$WORK/answer" +cp -r "$EXAMPLE" "$ANSWER" +rm -rf "$ANSWER/target" "$ANSWER/solution" +# スタブを参考解答で上書き (「解けた状態」を再現) +cp "$EXAMPLE/solution/FizzBuzz.java" "$ANSWER/src/main/java/net/unit8/kysymys/example/fizzbuzz/FizzBuzz.java" +# 提出は git リモート URL + HEAD コミットを送るので、独立した git リポジトリにする +( + cd "$ANSWER" + git init -q + git config user.email "alice@example.com"; git config user.name "Alice" + git remote add origin "https://github.com/example/fizzbuzz-answer.git" + git add -A && git commit -q -m "Solve FizzBuzz" +) +# 拡張 (activity-agent) が読む kysymys.properties を用意 -> mvn を回すたびビルドイベントが飛ぶ +cat >"$ANSWER/kysymys.properties" <"$WORK/submit.log" 2>&1 +if [[ $? -eq 0 ]]; then pass "解答を提出 (mvn kysymys:submit)"; else fail "提出に失敗。ログ末尾:"; tail -15 "$WORK/submit.log"; fi + +# 提出された answer の id を取る +req GET /answers "$STUDENT_TOKEN" +ANSWER_ID="$(jget "$RESP_BODY" "([a['id'] for a in (d if isinstance(d,list) else d.get('items',d.get('answers',[]))) if a.get('problemId')=='$PROBLEM_ID'] or [''])[0]" 2>/dev/null || echo "")" +if [[ -n "$ANSWER_ID" ]]; then pass "解答が記録された (GET /answers, id=$ANSWER_ID)"; else fail "解答が見つからない: $RESP_BODY"; fi + +# --- 6. レビューコメント ---------------------------------------------------- +if [[ -n "${ANSWER_ID:-}" ]]; then + req POST "/answers/$ANSWER_ID/comments" "$TEACHER_TOKEN" '{"description":"Fizz と Buzz の順序を確認してみて"}' + [[ "$RESP_CODE" == "201" ]] && pass "teacher がレビューコメント投稿 (201)" || fail "コメント投稿失敗 ($RESP_CODE): $RESP_BODY" +fi + +# news_count TOKEN -> 標準出力に whats-news の件数 (レスポンスは JSON 配列) +news_count() { req GET /whats-news "$1"; jget "$RESP_BODY" "len(d if isinstance(d,list) else d.get('items',[]))" 2>/dev/null || echo 0; } + +# --- 7a. コメント -> 通知 (README は約束するが現状は未実装: 既知の差分) --------- +COMMENT_NEWS="$(news_count "$STUDENT_TOKEN")" +if [[ "${COMMENT_NEWS:-0}" -ge 1 ]]; then + pass "コメントが通知として届いた (GET /whats-news, $COMMENT_NEWS 件)" +else + info "既知の差分: コメントは通知を生成しない。README は「コメントは学習者に通知として届く」" + info " と述べるが、PostComment はイベントを発火せず (CommentPostedEvent 自体が無い)、" + info " 通知を作る RecordWhatsNew は SubmittedAnswer / OfferedToFollow にしか反応しない。" +fi + +# --- 7b. 通知機構そのものの検証: フォロー申請 -> 対象者へ通知 ------------------- +req POST /offers "$TEACHER_TOKEN" "{\"targetUserId\":\"$STUDENT_ID\"}" +if [[ "$RESP_CODE" == "201" ]]; then + pass "teacher が student にフォロー申請 (POST /offers -> 201)" + OFFER_NEWS="$(news_count "$STUDENT_TOKEN")" + if [[ "${OFFER_NEWS:-0}" -ge 1 ]]; then + pass "通知が届いた (OfferedToFollow -> GET /whats-news, $OFFER_NEWS 件) — 通知機構は動作" + else + fail "フォロー申請の通知が届かない: $RESP_BODY" + fi +else + fail "フォロー申請に失敗 ($RESP_CODE): $RESP_BODY" +fi + +# --- 8. 自己採点 ------------------------------------------------------------ +info "自己採点 (mvn exec:java)" +SCORE="$( cd "$ANSWER" && mvn -q -B test-compile exec:java 2>/dev/null | grep -Eo '^[0-9]+$' | tail -1 )" +if [[ "$SCORE" == "100" ]]; then pass "解答が満点 (scorer -> 100)"; else fail "採点が 100 でない: '$SCORE' (ログは mvn exec:java を直接実行して確認)"; fi + +# --- 9-10. テレメトリと講師ダッシュボード ----------------------------------- +info "「詰まった」を送信 (mvn kysymys:stuck)" +( cd "$ANSWER" && mvn -q -B "$PLUGIN:stuck" \ + -Dkysymys.url="$HOST" -Dkysymys.problemId="$PROBLEM_ID" -Dkysymys.token="$STUDENT_TOKEN" ) >/dev/null 2>&1 \ + && pass "stuck を送信" || fail "stuck 送信に失敗" + +req GET /activity/status "$TEACHER_TOKEN" +if [[ "$RESP_CODE" == "200" ]]; then + STUCK="$(jget "$RESP_BODY" "([p['stuck'] for p in d['participants'] if p['participantId']=='$STUDENT_ID'] or ['MISSING'])[0]")" + if [[ "$STUCK" == "True" ]]; then pass "ダッシュボードに student が stuck で現れた (GET /activity/status)"; else fail "status に stuck 状態が反映されない (=$STUCK): $RESP_BODY"; fi +else + fail "status 取得に失敗 ($RESP_CODE): $RESP_BODY" +fi + +info "「解消した」を送信 (mvn kysymys:resolved)" +( cd "$ANSWER" && mvn -q -B "$PLUGIN:resolved" \ + -Dkysymys.url="$HOST" -Dkysymys.problemId="$PROBLEM_ID" -Dkysymys.token="$STUDENT_TOKEN" ) >/dev/null 2>&1 \ + && pass "resolved を送信" || fail "resolved 送信に失敗" + +req GET /activity/status "$TEACHER_TOKEN" +STUCK_AFTER="$(jget "$RESP_BODY" "([p['stuck'] for p in d['participants'] if p['participantId']=='$STUDENT_ID'] or ['MISSING'])[0]" 2>/dev/null || echo MISSING)" +[[ "$STUCK_AFTER" == "False" ]] && pass "resolved 後は stuck=false に戻った" || fail "resolved 後も stuck が解けていない (=$STUCK_AFTER)" + +# ビルドイベントが届いているか (submit / scoring の mvn 実行で拡張が送ったはず) +LAST_BUILD="$(jget "$RESP_BODY" "([p.get('lastBuildKind') for p in d['participants'] if p['participantId']=='$STUDENT_ID'] or ['MISSING'])[0]" 2>/dev/null || echo MISSING)" +if [[ "$LAST_BUILD" == "BUILD_SUCCESS" || "$LAST_BUILD" == "BUILD_FAILURE" ]]; then + pass "拡張からビルドイベントが届いている (lastBuildKind=$LAST_BUILD)" +else + info "ビルドイベント未確認 (lastBuildKind=$LAST_BUILD) — 拡張の Go バイナリ配信は best-effort" +fi + +# --- 集計 ------------------------------------------------------------------- +echo +printf "%b\n" "結果: ${c_green}${PASS} passed${c_reset}, ${c_red}${FAIL} failed${c_reset}" +[[ "$FAIL" -eq 0 ]]