From ba13cf21b7a1887a0424272e56d1bd222873ab48 Mon Sep 17 00:00:00 2001 From: Yu-Tsen Wei <59054102+frobel0520@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:31:54 +0800 Subject: [PATCH 01/15] release: promote workshop curriculum updates to main (#49) * Sync CI triggers and ignore local artifacts (#3) * Run CI for dev and main branches * Ignore local session artifacts * docs: refine task dependency breakdown (#4) * docs: add project plan SA and SD (#5) * docs: refine task dependency breakdown * docs: add project plan SA and SD * docs: define guardrail rebuild integration (#6) * feat: define TopicModule contract (#7) * feat: add progress repository adapter * feat: add route registry contract * docs: add accessibility acceptance checklist * test: add simulator test harness * feat: add shared topic lesson and lab shell * docs: define remote collaboration acceptance * feat: add remote collaboration lesson fixtures * fix: make remote sync state explicit * docs: clarify remote sync state contract * feat: add remote collaboration simulator * feat: add core and extension progress aggregation * docs: define cli acceptance * feat: add cli lesson fixtures * feat: add cli simulator * feat: add cli lab ui * feat: integrate cli topic * docs: define ide acceptance * feat: add ide lesson fixtures * feat: add ide debugger simulator * feat: add ide debugger lab ui * feat: integrate ide topic * feat: add track-first curriculum flow * feat: add remote collaboration lab UI * feat: add remote collaboration lab UI * feat: integrate remote collaboration topic * test: update ready counts after remote integration * feat: add remote collaboration lab UI * feat: add guardrail lesson fixtures * feat: add guardrail deterministic simulator * feat: add guardrail lab UI * feat: integrate guardrail extension topic * docs: define package management acceptance contract * feat: add package management lesson fixtures * feat: add package management simulator * feat: add package management lab UI * feat: integrate package topic * fix: guard topic routes and ready progress * docs: sync project status and delivery backlog * refactor: dispatch topic views through registry * docs: define Git release acceptance * feat: expand Git cowork and pipeline lesson * docs: record Git release review checkpoint * fix: expose focus ring for form controls * docs: approve Git release review * docs: close Git progress status * feat: add interactive FastAPI request workshop (#48) --- .gitignore | 1 + README.md | 18 +- docs/accessibility-checklist.md | 84 ++++ docs/cli-acceptance.md | 128 ++++++ docs/git-acceptance.md | 142 ++++++ docs/guardrail-rebuild-feature-brief.md | 158 +++++++ docs/ide-acceptance.md | 129 ++++++ docs/package-acceptance.md | 182 ++++++++ docs/project-plan.md | 160 +++++++ docs/project-sa.md | 184 ++++++++ docs/project-sd.md | 337 +++++++++++++++ docs/remote-acceptance.md | 130 ++++++ docs/rest-acceptance.md | 98 +++++ docs/task-breakdown.md | 233 ++++++++++ frontend/src/App.tsx | 133 ++++-- frontend/src/components/CliLab.tsx | 151 +++++++ frontend/src/components/CurriculumMap.tsx | 26 +- frontend/src/components/GitLab.tsx | 212 +++++---- frontend/src/components/GitLesson.tsx | 36 +- frontend/src/components/IdeLab.tsx | 206 +++++++++ frontend/src/components/TopicRouteView.tsx | 26 ++ frontend/src/components/TopicShell.tsx | 89 ++++ frontend/src/components/TrackPage.tsx | 65 +++ frontend/src/content/git.test.ts | 33 ++ frontend/src/content/git.ts | 120 ++++++ frontend/src/git/release-simulator.test.ts | 70 +++ frontend/src/git/release-simulator.ts | 252 +++++++++++ frontend/src/progress/aggregation.test.ts | 88 ++++ frontend/src/progress/aggregation.ts | 45 ++ frontend/src/progress/repository.test.ts | 65 +++ frontend/src/progress/repository.ts | 40 ++ frontend/src/routes/registry.test.ts | 67 +++ frontend/src/routes/registry.ts | 94 ++++ frontend/src/styles.css | 406 +++++++++++++++++- frontend/src/testing/simulator.test.ts | 38 ++ frontend/src/testing/simulator.ts | 24 ++ frontend/src/topics/cli/content.test.ts | 71 +++ frontend/src/topics/cli/content.ts | 212 +++++++++ frontend/src/topics/cli/integration.test.ts | 34 ++ frontend/src/topics/cli/lesson.tsx | 36 ++ frontend/src/topics/cli/simulator.test.ts | 83 ++++ frontend/src/topics/cli/simulator.ts | 193 +++++++++ frontend/src/topics/guardrail/content.test.ts | 36 ++ frontend/src/topics/guardrail/content.ts | 176 ++++++++ frontend/src/topics/guardrail/lab.test.ts | 21 + frontend/src/topics/guardrail/lab.tsx | 141 ++++++ frontend/src/topics/guardrail/lesson.tsx | 29 ++ .../src/topics/guardrail/simulator.test.ts | 58 +++ frontend/src/topics/guardrail/simulator.ts | 213 +++++++++ frontend/src/topics/ide/content.test.ts | 65 +++ frontend/src/topics/ide/content.ts | 168 ++++++++ frontend/src/topics/ide/integration.test.ts | 33 ++ frontend/src/topics/ide/lesson.tsx | 36 ++ frontend/src/topics/ide/simulator.test.ts | 90 ++++ frontend/src/topics/ide/simulator.ts | 182 ++++++++ frontend/src/topics/package/content.test.ts | 58 +++ frontend/src/topics/package/content.ts | 220 ++++++++++ .../src/topics/package/integration.test.ts | 33 ++ frontend/src/topics/package/lab.test.ts | 11 + frontend/src/topics/package/lab.tsx | 220 ++++++++++ frontend/src/topics/package/lesson.tsx | 36 ++ frontend/src/topics/package/simulator.test.ts | 83 ++++ frontend/src/topics/package/simulator.ts | 233 ++++++++++ frontend/src/topics/registry.test.ts | 23 + frontend/src/topics/registry.tsx | 49 +++ frontend/src/topics/remote/content.test.ts | 49 +++ frontend/src/topics/remote/content.ts | 145 +++++++ frontend/src/topics/remote/lab.test.ts | 11 + frontend/src/topics/remote/lab.tsx | 126 ++++++ frontend/src/topics/remote/lesson.tsx | 36 ++ frontend/src/topics/remote/simulator.test.ts | 59 +++ frontend/src/topics/remote/simulator.ts | 135 ++++++ frontend/src/topics/rest/content.test.ts | 37 ++ frontend/src/topics/rest/content.ts | 285 ++++++++++++ frontend/src/topics/rest/integration.test.ts | 37 ++ frontend/src/topics/rest/lab.test.ts | 23 + frontend/src/topics/rest/lab.tsx | 190 ++++++++ frontend/src/topics/rest/lesson.tsx | 28 ++ frontend/src/topics/rest/simulator.test.ts | 80 ++++ frontend/src/topics/rest/simulator.ts | 180 ++++++++ frontend/src/topics/types.test.ts | 62 +++ frontend/src/topics/types.ts | 49 +++ frontend/src/types.ts | 4 + frontend/vite.config.ts | 8 +- shared/curriculum.json | 19 +- 85 files changed, 8528 insertions(+), 148 deletions(-) create mode 100644 docs/accessibility-checklist.md create mode 100644 docs/cli-acceptance.md create mode 100644 docs/git-acceptance.md create mode 100644 docs/guardrail-rebuild-feature-brief.md create mode 100644 docs/ide-acceptance.md create mode 100644 docs/package-acceptance.md create mode 100644 docs/project-plan.md create mode 100644 docs/project-sa.md create mode 100644 docs/project-sd.md create mode 100644 docs/remote-acceptance.md create mode 100644 docs/rest-acceptance.md create mode 100644 docs/task-breakdown.md create mode 100644 frontend/src/components/CliLab.tsx create mode 100644 frontend/src/components/IdeLab.tsx create mode 100644 frontend/src/components/TopicRouteView.tsx create mode 100644 frontend/src/components/TopicShell.tsx create mode 100644 frontend/src/components/TrackPage.tsx create mode 100644 frontend/src/content/git.test.ts create mode 100644 frontend/src/git/release-simulator.test.ts create mode 100644 frontend/src/git/release-simulator.ts create mode 100644 frontend/src/progress/aggregation.test.ts create mode 100644 frontend/src/progress/aggregation.ts create mode 100644 frontend/src/progress/repository.test.ts create mode 100644 frontend/src/progress/repository.ts create mode 100644 frontend/src/routes/registry.test.ts create mode 100644 frontend/src/routes/registry.ts create mode 100644 frontend/src/testing/simulator.test.ts create mode 100644 frontend/src/testing/simulator.ts create mode 100644 frontend/src/topics/cli/content.test.ts create mode 100644 frontend/src/topics/cli/content.ts create mode 100644 frontend/src/topics/cli/integration.test.ts create mode 100644 frontend/src/topics/cli/lesson.tsx create mode 100644 frontend/src/topics/cli/simulator.test.ts create mode 100644 frontend/src/topics/cli/simulator.ts create mode 100644 frontend/src/topics/guardrail/content.test.ts create mode 100644 frontend/src/topics/guardrail/content.ts create mode 100644 frontend/src/topics/guardrail/lab.test.ts create mode 100644 frontend/src/topics/guardrail/lab.tsx create mode 100644 frontend/src/topics/guardrail/lesson.tsx create mode 100644 frontend/src/topics/guardrail/simulator.test.ts create mode 100644 frontend/src/topics/guardrail/simulator.ts create mode 100644 frontend/src/topics/ide/content.test.ts create mode 100644 frontend/src/topics/ide/content.ts create mode 100644 frontend/src/topics/ide/integration.test.ts create mode 100644 frontend/src/topics/ide/lesson.tsx create mode 100644 frontend/src/topics/ide/simulator.test.ts create mode 100644 frontend/src/topics/ide/simulator.ts create mode 100644 frontend/src/topics/package/content.test.ts create mode 100644 frontend/src/topics/package/content.ts create mode 100644 frontend/src/topics/package/integration.test.ts create mode 100644 frontend/src/topics/package/lab.test.ts create mode 100644 frontend/src/topics/package/lab.tsx create mode 100644 frontend/src/topics/package/lesson.tsx create mode 100644 frontend/src/topics/package/simulator.test.ts create mode 100644 frontend/src/topics/package/simulator.ts create mode 100644 frontend/src/topics/registry.test.ts create mode 100644 frontend/src/topics/registry.tsx create mode 100644 frontend/src/topics/remote/content.test.ts create mode 100644 frontend/src/topics/remote/content.ts create mode 100644 frontend/src/topics/remote/lab.test.ts create mode 100644 frontend/src/topics/remote/lab.tsx create mode 100644 frontend/src/topics/remote/lesson.tsx create mode 100644 frontend/src/topics/remote/simulator.test.ts create mode 100644 frontend/src/topics/remote/simulator.ts create mode 100644 frontend/src/topics/rest/content.test.ts create mode 100644 frontend/src/topics/rest/content.ts create mode 100644 frontend/src/topics/rest/integration.test.ts create mode 100644 frontend/src/topics/rest/lab.test.ts create mode 100644 frontend/src/topics/rest/lab.tsx create mode 100644 frontend/src/topics/rest/lesson.tsx create mode 100644 frontend/src/topics/rest/simulator.test.ts create mode 100644 frontend/src/topics/rest/simulator.ts create mode 100644 frontend/src/topics/types.test.ts create mode 100644 frontend/src/topics/types.ts diff --git a/.gitignore b/.gitignore index f40480f..6c2ce48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ work/ +/artifacts/ .DS_Store node_modules/ frontend/dist/ diff --git a/README.md b/README.md index 00008f1..46b8010 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,15 @@ 目前部署來源:`frobel0520/software-engineering-workshop`。 -目前完成兩個主題:**Git** 與 **身分驗證/授權**。其餘主題保留在課程路線圖。 +目前可操作的 Core 主題有 7 / 19 個:**Git**、**GitHub/GitLab 遠端協作**、**命令列**、**IDE/除錯器**、**套件管理**、**REST API/FastAPI**、**身分驗證/授權**;另有 1 個不計入 Core 進度的 Extension:**Guardrails**。其餘 Core 主題保留在課程路線圖。 ## Git 單元 -- 三段短教材:版本、暫存區、分支與合併。 -- 指令式 Git Lab:親自輸入 `git status`、`git add`、`git commit`、`git switch` 與 `git merge`。 +- Git Lesson:涵蓋 `clone`、`add`、`commit`、`push`、`pull`、`fetch`、`checkout`、`rebase`、`stash`、`cherry-pick`、`merge` 與 GitHub/GitLab `fork`。 +- 指令式 cowork Lab:親自走過 local history → remote branch → PR/MR → pipeline → merge。 +- Pipeline fixture:顯示 `checkout`、`npm ci`、`test`、`lint`、`build` jobs 與 conflict/retry 情境。 - 確定性的瀏覽器模擬引擎,不會動到使用者電腦上的真實 repository。 -- 進度保存在瀏覽器;完成 Lab 後才會標記 Git 主題完成。 +- 進度保存在瀏覽器;每個已開放主題完成 Lab 後,才會標記該主題完成。 ## Auth 單元 @@ -19,6 +20,15 @@ - 假資料驅動的設定判斷與 Authorization Code + PKCE 流程 Demo。 - 不連線 Microsoft、不處理真實帳號或 Secret。 +## 其他已開放主題 + +- 遠端協作:模擬 `branch → commit → fetch → rebase → push → PR → CI → merge` 閉環。 +- 命令列:在固定 fixture 中練習工作目錄、檔案讀取、搜尋與檢查流程。 +- IDE/除錯器:模擬 breakpoint、paused frame、variables、step over 與 continue。 +- 套件管理:練習 manifest、lockfile、registry 與 deterministic install 狀態。 +- REST API/FastAPI:逐行追蹤 React fetch、routing、validation、dependency、SQLModel/SQLite 與 JSON response。 +- Guardrails Extension:模擬輸入、輸出與工具呼叫的安全防線,不連線真實模型。 + ## 本機啟動 ```bash diff --git a/docs/accessibility-checklist.md b/docs/accessibility-checklist.md new file mode 100644 index 0000000..7404ca0 --- /dev/null +++ b/docs/accessibility-checklist.md @@ -0,0 +1,84 @@ +# Accessibility Checklist + +> 適用範圍:App shell、課程地圖、所有 Lesson、所有 Lab 與 Extension topics +> 對應 task:`CORE-007` +> 狀態:Contract ready;完整 accessibility audit pending + +本清單是每個 ready topic 在 integration/release 前的共用驗收契約。它不取代瀏覽器與輔助技術的實測;每個 topic PR 應指出已驗證的 route、操作流程與例外。 + +## 最近驗證紀錄 + +### 2026-08-16:頁面流通 smoke test + +- 6 個 Track 頁面均可返回課程地圖。 +- 7 個已開放 Lesson 均可進入對應 Lab,再返回課程地圖。 +- 7 個已開放 Lab 均可返回課程地圖。 +- planned 與未知 topic route 會回到課程地圖;瀏覽器 Console 無錯誤。 + +這次只確認 route reachability 與離開路徑,不宣稱已完成鍵盤、200% zoom、窄 viewport、讀屏工具或 reduced-motion 的完整驗收。 + +### 2026-08-16:Git topic release review completed + +- Git Lesson → Git Lab、Lab error feedback、Lab reset、Lab → 課程地圖已完成瀏覽器 smoke check。 +- Desktop 1280px 無水平溢出;Git Lab 的 native controls、`aria-live`/`role="alert"` 狀態回饋與 `progressbar` 語意已確認。 +- 375px 下 Git Lesson/Git Lab 無水平溢出,mobile menu 可開關,Git Lab 的 reset、terminal input 與 17 個 workflow controls 可見;640px 等效 200% zoom viewport 也無水平溢出。 +- `styles.css` 已確認含 `max-width: 720px` responsive rules 與 `prefers-reduced-motion` rules。 +- 手動驗收已通過:使用實體鍵盤確認 `Tab`/`Enter`/`Space` 可操作 Lesson/Lab controls,terminal submit、reset、錯誤回饋與返回課程地圖流程可完成;reduced-motion 偏好下內容與操作仍完整。`GIT-REVIEW` 已通過。 + +## 1. Keyboard 與焦點 + +- [ ] 所有互動控制項都能以 `Tab`、`Shift+Tab`、`Enter` 或 `Space` 操作。 +- [ ] 焦點指示清楚可見,不只依賴顏色或 hover。 +- [ ] mobile menu 開啟後,焦點能進入 menu;關閉後回到 menu button。 +- [ ] route 切換後,焦點移到新頁面的主要標題或內容起點。 +- [ ] Lab reset、submit、選項與錯誤回饋不需要拖曳或精準滑鼠操作。 +- [ ] 沒有 keyboard trap;使用者可以離開每一個 dialog、menu 或互動區。 + +## 2. 語意與讀屏資訊 + +- [ ] 頁面有唯一且有意義的 `h1`,標題階層沒有跳級。 +- [ ] 導航使用 `nav`,主要內容使用 `main`,補充資訊使用適當的 landmark。 +- [ ] button、link、input 使用原生語意,不以可點擊 `div` 取代。 +- [ ] icon-only control 有可理解的 accessible name。 +- [ ] Lab 的目前步驟、完成狀態與錯誤訊息能被讀屏工具理解。 +- [ ] 裝飾圖示與背景圖不會重複朗讀;必要資訊不只存在圖示或顏色中。 + +## 3. Live region 與狀態回饋 + +- [ ] Lab 成功、失敗、reset 與完成狀態有可讀文字。 +- [ ] 動態訊息使用適當的 `aria-live`,不會每次 render 重複朗讀整個區塊。 +- [ ] 錯誤訊息說明「發生什麼事」與「下一步怎麼做」。 +- [ ] disabled control 有可理解的原因,不讓使用者只看到不能按。 +- [ ] 非同步或長時間操作有開始、完成與失敗回饋;Phase 1 simulator 不假裝正在呼叫真實服務。 + +## 4. 視覺、響應式與動作 + +- [ ] 文字與背景達到可讀對比;資訊不只用紅/綠/顏色區分。 +- [ ] 放大至 200% 或較窄 viewport 時,主要 Lesson/Lab 仍可操作。 +- [ ] mobile menu、表格、terminal、錯誤訊息不會水平溢出或被裁切。 +- [ ] `prefers-reduced-motion` 下不依賴動畫傳達必要資訊。 +- [ ] focus、hover、active、disabled、error、success 狀態均有可辨識差異。 + +## 5. Topic Lab 特別檢查 + +每個 ready topic PR 都要記錄: + +| 項目 | 驗收內容 | 結果/備註 | +| --- | --- | --- | +| Lesson route | 鍵盤進入、標題焦點、內容順序 | | +| Lab route | 初始狀態、操作中、錯誤、完成、reset | | +| Keyboard path | 從第一個控制項到完成條件的完整路徑 | | +| Live feedback | 成功/失敗/完成是否只朗讀必要訊息 | | +| Mobile | 窄 viewport 下仍能完成 Lab | | +| Reduced motion | 關閉動畫後資訊與操作仍完整 | | + +## 6. 驗證層級 + +- PR review:檢查語意 HTML、focus path、錯誤文字與 topic-specific 例外。 +- Automated checks:保留 simulator、completion、route 與 progress tests;必要時補 accessibility assertion。 +- Manual browser check:鍵盤、窄 viewport、200% zoom、reduced motion。 +- Release audit:`RELEASE-004` 彙整 19 個 Core topics 與所有已開放 Extension topics 的結果。 + +## 7. 完成定義 + +CORE-007 完成後,新增 topic 的 Ready/Done 模板必須引用本清單;任何未通過項目都要列出原因、替代驗收或後續 issue,不得以「視覺上正常」代替可及性驗收。 diff --git a/docs/cli-acceptance.md b/docs/cli-acceptance.md new file mode 100644 index 0000000..49e3195 --- /dev/null +++ b/docs/cli-acceptance.md @@ -0,0 +1,128 @@ +# CLI-01:命令列 acceptance + +> 類型:Topic acceptance/fixture contract +> 狀態:可供 CLI-02、CLI-03 開發 +> 依賴:M1 module foundation +> 範圍:瀏覽器內 deterministic shell simulator;不執行使用者本機 shell、不讀取真實檔案系統 + +本文件只鎖定命令列主題的學習目標、Lab pageflow、失敗狀態與 simulator 邊界,不新增 feature-level SA/SD。 + +## 1. 學習目標 + +完成主題後,學習者應能: + +1. 用 `pwd` 確認目前工作目錄,理解相對路徑必須依賴目前 context。 +2. 用 `cd` 在固定工作目錄中移動,知道目錄錯誤不應靜默改變狀態。 +3. 用 `ls`、`cat` 與 `grep` 讀取檔案與篩選線索,而不是盲目重跑指令。 +4. 理解 stdout、stderr 與 exit code 的差別,能判斷命令是否成功。 +5. 用一組可重複的命令完成檢查,並在失敗後 reset 回到相同初始 fixture。 + +## 2. Pageflow 與 Lab happy path + +```text +map → /cli lesson → /cli-lab + → 確認 cwd + → 進入 src + → 列出檔案 + → 搜尋 TODO 線索 + → 執行固定檢查 + → completion treatment → map +``` + +Lab 從 `/workspace/project` 開始,所有檔案、輸出與 exit code 都由 fixture 決定。學習者不會真的碰到瀏覽器外的 shell 或檔案系統。 + +概念操作與 simulator event 對應如下: + +| 概念操作 | 教學指令/UI 動作 | event | +| --- | --- | --- | +| 確認目前目錄 | `pwd` | `print-working-directory` | +| 進入來源目錄 | `cd src` | `change-directory` | +| 查看目前檔案 | `ls` | `list-files` | +| 搜尋待辦線索 | `grep TODO app.ts` | `search-file` | +| 執行固定檢查 | `npm test` | `run-check` | + +## 3. Fixture contract + +CLI-03 應以以下固定 fixture 作為初始狀態: + +```ts +type CliLabPhase = "initial" | "active" | "failed" | "completed"; +type CliStream = "stdout" | "stderr"; +type CliStepId = "context" | "navigate" | "inspect" | "search" | "verify"; + +interface CliFileFixture { + path: string; + content: string; +} + +interface CliLabState { + cwd: "/workspace/project" | "/workspace/project/src"; + files: readonly CliFileFixture[]; + commandHistory: readonly string[]; + stdout: readonly string[]; + stderr: readonly string[]; + lastStream: CliStream | null; + exitCode: number | null; + phase: CliLabPhase; + completedStepIds: readonly CliStepId[]; + lastMessage: string; + canReset: true; +} +``` + +初始 fixture: + +```ts +{ + cwd: "/workspace/project", + files: [ + { path: "README.md", content: "# CLI fixture" }, + { path: "package.json", content: "{\"scripts\":{\"test\":\"vitest run\"}}" }, + { path: "src/app.ts", content: "export const ready = true; // TODO: add example" }, + { path: ".env.example", content: "API_URL=https://example.invalid" }, + ], + commandHistory: [], + stdout: [], + stderr: [], + lastStream: null, + exitCode: null, + phase: "initial", + completedStepIds: [], + lastMessage: "準備從固定的 project fixture 開始。", + canReset: true, +} +``` + +## 4. 固定輸出與完成條件 + +| event | accepted output | exit code | 完成 step | +| --- | --- | --- | --- | +| `print-working-directory` | `/workspace/project` 或目前 cwd | `0` | `context` | +| `change-directory` | `現在位於 /workspace/project/src` | `0` | `navigate` | +| `list-files` | `app.ts` | `0` | `inspect` | +| `search-file` | `app.ts:1: TODO: add example` | `0` | `search` | +| `run-check` | `Tests: 3 passed` | `0` | `verify` | + +只有 `completedStepIds` 包含 `context`、`navigate`、`inspect`、`search`、`verify` 時,Lab 才能標記完成。單獨看到 stdout 或 exit code 不能標記 topic 完成。 + +## 5. 失敗狀態與回饋 + +| 情境 | 結果 | 必須呈現的概念 | +| --- | --- | --- | +| 未知命令 | `stderr` 顯示 command not found,exit code `127`,cwd 不變 | shell 錯誤不等於整個 session 消失 | +| `cd missing` | `stderr` 顯示 no such directory,exit code `1`,cwd 不變 | 路徑錯誤不可靜默成功 | +| 在 project root 執行 `grep TODO app.ts` | `stderr` 顯示 file not found,exit code `2` | 相對路徑依賴 cwd | +| 在尚未進入 src 前執行 `npm test` | blocked,提示先完成 context/navigate | 可重複流程需要先確認工作位置 | +| 空白輸入 | 不新增 history,保留既有 state | 不把無效輸入誤算成進度 | + +每個錯誤至少要說明 stream、exit code、目前 cwd 與下一步提示;不可只用顏色表達結果。 + +## 6. CLI-02/CLI-03 驗收向量 + +1. **happy path**:依 `print-working-directory → change-directory → list-files → search-file → run-check` 完成,最後 `phase = "completed"`。 +2. **錯誤保留狀態**:未知命令與錯誤路徑不改變 cwd,也不偽造 completed step。 +3. **stream/exit code**:成功輸出走 stdout、錯誤走 stderr,且 exit code 固定可測。 +4. **reset**:完成或 failed 後 reset,結果與初始 fixture deep-equal。 +5. **determinism**:相同初始 state 加相同 event sequence,必須得到相同 state、history 與輸出。 + +CLI-02 可依本文件的目標與 command mapping 撰寫教材;CLI-03 必須維持上述 state、event、stream、exit code、錯誤與 completion 邊界。真實 OS、shell、使用者檔案、process、network 與 secret 不在本 topic Phase 1 範圍內。 diff --git a/docs/git-acceptance.md b/docs/git-acceptance.md new file mode 100644 index 0000000..ae36804 --- /dev/null +++ b/docs/git-acceptance.md @@ -0,0 +1,142 @@ +# GIT-REVIEW:Git 基礎與團隊協作 acceptance + +> 類型:Git topic release gate/acceptance contract +> 狀態:Approved v1(2026-08-16) +> 依賴:M1 module foundation +> 範圍:瀏覽器內 deterministic Git/協作 simulator;不連線真實 GitHub、GitLab 或使用者 repository + +## 0. 2026-08-16 release review checkpoint + +本輪已完成 Git topic 的第一輪 release smoke check: + +- Git Lesson 可進入 Git Lab;Git Lab 的錯誤指令會提供阻擋原因與下一步。 +- `重設 Lab` 可回到 `0 / 17`;Git Lab 可返回課程地圖。 +- Desktop 1280px、375px mobile 與 640px(200% zoom 的等效 CSS viewport)下均無水平溢出;375px 下 menu 可開關,Git Lab 的 reset、terminal input 與 17 個 workflow controls 均存在。 +- Git Lab 使用原生 `button`、`form`、`select`,狀態與錯誤訊息已有 `aria-live`/`role="alert"`,進度有 `progressbar` 語意;CSS 已包含 `max-width: 720px` 的窄版規則與 `prefers-reduced-motion` 規則。 + +手動驗收已完成:使用實體鍵盤確認 `Tab`/`Enter`/`Space` 可操作 Git Lesson/Lab controls,並確認 terminal submit、reset、錯誤回饋與返回課程地圖流程;在 reduced-motion 偏好下,內容與操作仍完整。Git release gate 已核准。 + +本文件把 Git topic 的上線標準寫成可驗收條件。Git topic 不只教本地 commit;完成後,學習者應能在一般軟體專案中理解變更如何從工作目錄進入協作平台、CI pipeline 與合併流程。 + +## 1. 學習目標 + +完成 Git topic 後,學習者應能: + +1. 說明工作目錄、暫存區、commit、branch、remote 與 hosted repository 的責任邊界。 +2. 根據情境選擇本文件列出的 Git/平台操作,而不是只依照固定按鈕順序操作。 +3. 以功能分支、清楚 commit、同步基線、發布分支與 PR/MR 完成一次可 review 的 cowork 流程。 +4. 說明 commit、push、PR/MR、CI pipeline、merge 與部署之間的因果關係。 +5. 遇到未提交修改、分支落後、衝突或需要移植單一修正時,能選擇 stash、fetch、rebase、merge 或 cherry-pick。 + +## 2. 必須理解的 12 項操作 + +| 操作 | 類型 | 何時使用 | 必須說清楚的邊界 | +| --- | --- | --- | --- | +| `git clone ` | Git CLI | 第一次把遠端 repository 複製到本機 | 建立本地 repository 與 remote 設定;不是建立 GitHub/GitLab fork | +| `git add ` | Git CLI | 挑選要放入下一個 commit 的修改 | 只更新暫存區,不等於 commit,也不會發布到遠端 | +| `git commit -m "..."` | Git CLI | 將暫存內容建立成可追蹤的本地快照 | 只存在本地歷史;不會自動 push、開 PR 或觸發遠端 CI | +| `git push` | Git CLI | 將本地 commit 發布到遠端 branch | 更新遠端 branch,可能觸發 push pipeline;不等於 merge 到 `dev`/`main` | +| `git pull` | Git CLI | 需要把遠端 branch 的變更整合進目前本地 branch | 通常是 fetch 加 merge 或 rebase;可能改變工作目錄,不能與 fetch 混為一談 | +| `git fetch` | Git CLI | 先取得遠端最新狀態,再決定如何整合 | 只更新 remote-tracking refs,不直接修改目前 branch 或工作檔案 | +| `git checkout` | Git CLI/legacy interface | 切換 branch,或從歷史取出檔案 | 現代 Git 通常用 `git switch` 切 branch、`git restore` 還原檔案;不得把 checkout 當成同步遠端 | +| `git rebase` | Git CLI | 將自己的功能分支接到最新基線後面 | 整理自己的 branch 歷史;可能改寫 commit ID,不應任意改寫已被他人共同使用的 branch | +| `git merge` | Git CLI | 將一條 branch 的歷史整合進目前 branch | 可能產生 merge commit 或衝突;不等於把工作發布到遠端 | +| `git stash` | Git CLI | 暫時收起未完成修改,以便切換 branch 或處理緊急工作 | 是暫存工作,不是正式歷史;之後必須知道如何恢復或清理 stash | +| `fork` | GitHub/GitLab platform | 沒有原 repository 寫入權限時建立自己的 hosted copy | 不是 Git CLI 指令;fork 後通常 clone 自己的 copy,再以 upstream/PR/MR 協作 | +| `git cherry-pick ` | Git CLI | 將單一既有 commit 移植到目前 branch,例如挑選 hotfix | 會建立新的 commit;不是整條 branch merge,也可能產生衝突與重複修正 | + +`checkout` 必須同時教 legacy 形式與現代替代方式,例如 `git checkout -b feature/x` 對應 `git switch -c feature/x`;`git checkout -- file` 對應 `git restore file`。 + +## 3. Pageflow 與 cowork happy path + +```text +課程地圖 + → Git Lesson:理解本地狀態與操作選擇 + → Git Lab:clone/branch/add/commit/fetch/rebase/push + → PR/MR Lesson:理解 review 與 pipeline + → Pipeline Lab:觀察 commit/push 後的檢查與部署結果 + → merge/完成判定 + → 回到課程地圖 +``` + +建議的一般軟體專案情境: + +1. 從既有 repository `clone`;若無寫入權限,先 `fork` 再 clone 自己的 copy。 +2. 從 `dev` 或指定基線建立功能 branch;可用 `checkout`/`switch`。 +3. 修改檔案,用 `git diff` 檢查,再用 `add` 與 `commit` 留下小而完整的變更。 +4. 若本地有未完成修改但需要切換任務,使用 `stash`;若需要移植單一修正,使用 `cherry-pick`。 +5. 發布前用 `fetch` 取得遠端狀態,再用 `rebase` 或 `merge` 整合;確認後 `push`,建立 PR/MR。 + +Lab 必須讓學習者在至少一個分支錯誤情境中做選擇,而不是只能依序點擊唯一正確按鈕。 + +## 4. Commit/push/pipeline 實際發生什麼事 + +| 時點 | 本地或平台事件 | 學習者必須觀察到的結果 | +| --- | --- | --- | +| `commit` | Git 將暫存內容寫入本地 object database,並移動目前 branch ref | 本地歷史增加;遠端看不到;不會自動執行 GitHub/GitLab pipeline | +| `push` | Git 將缺少的 objects 與 branch ref 傳到 hosted repository | 遠端 branch 更新;平台可以收到 push event;其他人能看到新的 branch/commit | +| PR/MR 建立或更新 | 平台比較 source branch 與 target branch,建立 review context | 顯示 diff、commit、review 對話與 required checks;GitHub 使用 PR,GitLab 使用 Merge Request | +| pipeline 觸發 | 平台依 workflow/`.gitlab-ci.yml` 規則建立 pipeline 與 jobs | 學習者能看到 pending/running/passed/failed,而不是只有一個抽象的 CI 按鈕 | +| job 執行 | runner checkout 指定 commit,安裝依賴並執行檢查 | 本專案現行 CI 是 `checkout → setup Node → npm ci → npm test → npm run lint → npm run build` | +| merge | 平台在 required checks、review 與 branch protection 通過後整合 source branch | target branch 出現整合結果;push 到 `main` 可再觸發部署流程 | +| Pages deploy | deploy workflow 建置並發布靜態輸出 | 本專案現行流程是 `main push → test/build → publish frontend/dist to gh-pages` | + +GitHub 與 GitLab 的名稱可以不同,但教材必須保留共同概念:hosted repository、source/target branch、PR/MR、pipeline、job、runner、required check、merge gate 與 deployment。 + +## 5. Fixture contract + +Git/pipeline simulator 應使用下列可重設 fixture,不連線外部 provider: + +```ts +type GitReleasePhase = "initial" | "local" | "published" | "review" | "blocked" | "merged"; +type WorkingTreeState = "clean" | "dirty" | "staged"; +type PipelineState = "not-started" | "pending" | "running" | "passed" | "failed"; +type ReviewState = "none" | "open" | "approved" | "merged"; + +interface GitReleaseState { + phase: GitReleasePhase; + provider: "github" | "gitlab"; + localBranch: "dev" | "feature/profile"; + targetBranch: "dev" | "main"; + workingTree: WorkingTreeState; + localCommitCount: number; + remoteBranch: "absent" | "published"; + review: ReviewState; + pipeline: PipelineState; + pipelineJobs: readonly ("checkout" | "install" | "test" | "lint" | "build")[]; + stashCount: number; + selectedCommit: string | null; + lastMessage: string; + canReset: true; +} +``` + +初始狀態必須表示:位於 `dev`、工作目錄乾淨、沒有功能 branch、沒有遠端 branch、沒有 PR/MR、pipeline 尚未開始。 + +## 6. 失敗情境與回饋 + +| 情境 | 結果 | 必須教會的判斷 | +| --- | --- | --- | +| 未 `add` 就 `commit` | commit 不包含未暫存修改 | commit 只記錄暫存區內容 | +| 未 `fetch` 就直接 rebase | blocked 或提示基線可能過期 | 先取得遠端認知,再整理歷史 | +| 工作目錄 dirty 時切換 branch | blocked,或要求先 stash/commit | 不要用 checkout 藏掉未完成修改 | +| 未 push 就建立 PR/MR | blocked | hosted platform 看不到本地 commit | +| pipeline 尚未通過就 merge | blocked | merge gate 由 checks/review/branch protection 控制 | +| rebase 已共享的 branch | warning/blocked | 改寫他人正在使用的歷史有協作風險 | +| cherry-pick 不存在的 commit | failed,state 不產生部分套用 | 先確認 commit ID 與來源歷史 | +| merge 或 cherry-pick 發生衝突 | conflict state,不能偽造完成 | 使用者必須理解解衝突後才能繼續 | + +每個錯誤回饋都要說明目前狀態、原因與下一步;不可只顯示顏色、錯誤代碼或「操作失敗」。 + +## 7. 完成條件 + +Git topic 只有在下列條件全部成立時,才能標記 ready/complete: + +- Lesson 明確解釋 12 項操作的用途、使用時機與不可混淆的邊界。 +- Lab 至少覆蓋 local change → commit → remote publish → PR/MR → pipeline → merge 的完整閉環。 +- Lab 至少讓學習者練習 `stash`、`cherry-pick`、`fetch`/`pull` 差異與一個 conflict/blocked 情境。 +- Pipeline UI 能顯示 job 階段與結果,並連結到本專案實際的 test/lint/build/Pages 概念。 +- GitHub PR 與 GitLab Merge Request 的共同概念與名稱差異有明確說明。 +- reset、determinism、completion、keyboard、mobile 與錯誤回饋均有測試或手動驗收紀錄。 + +Git v1 已把 12 項操作、cowork pipeline、pipeline jobs、stash/cherry-pick、conflict/retry 與 GitHub/GitLab provider terminology 接入 Lesson/Lab;2026-08-16 已完成 keyboard、mobile、200% zoom、reduced-motion 與 release review,文件狀態為 approved。 diff --git a/docs/guardrail-rebuild-feature-brief.md b/docs/guardrail-rebuild-feature-brief.md new file mode 100644 index 0000000..71531ae --- /dev/null +++ b/docs/guardrail-rebuild-feature-brief.md @@ -0,0 +1,158 @@ +# Guardrail Workshop Rebuild:Feature Brief + +> 狀態:Implemented v1 +> 日期:2026-08-16 +> 參考來源:`C:\Users\ytwei\Projects\Guardrail-Workshop` +> 依附架構:`docs/project-sa.md`、`docs/project-sd.md` + +## 1. 定位 + +本 feature 不是把 Guardrail Workshop repository 搬進本專案,也不是直接重用它的 React、FastAPI 或 CSS。它是以原專案的教材目標、互動概念與驗收情境為參考,在 Software Engineering Workshop 內重新設計並實作一個符合既有 TopicModule 契約的 AI/LLM Extension topic。 + +Guardrail Workshop 原本同時包含靜態 Demo 與可選的 FastAPI/SQLite backend;本次 rebuild 第一階段只交付能在 GitHub Pages 執行的 deterministic browser simulator。完整 backend 留作後續 Capstone,不成為本專案 Phase 1 的執行依賴。 + +## 2. 學習目標 + +完成本 topic 後,學習者應能: + +1. 說明 LLM 應用為何需要輸入、輸出與工具呼叫的驗證層。 +2. 分辨 Validator、Guard、Hub 與 OnFailAction 的責任。 +3. 操作 exception、fix、reask、pass 四種失敗處理策略。 +4. 理解檢查成本、串行/平行延遲與縱深防禦的取捨。 +5. 以規則資料與測試案例設計一個最小 guardrail pipeline。 + +## 3. 第一階段範圍 + +### In scope + +- 新增 `AI/LLM Engineering` Extension track。 +- 新增一個 `guardrail` topic,不計入 Core 19 topics 的必修完成總數。 +- 以 Lesson 呈現風險、三個掛載點、常見檢查、框架定位、延遲與縱深防禦。 +- 以 Lab 操作 input/output/tool 三個 stage 的 deterministic simulator。 +- 提供代表性 validator fixture:prompt injection、PII/secret、moderation、off-topic、structured output 與 tool side effect。 +- 提供 `exception > reask > fix > pass` 的整體結果優先序。 +- 以固定 latency fixture 呈現檢查成本,不呼叫真實模型或外部分類器。 +- 完成 Lab 指定情境後,以獨立 progress key 標記完成。 + +### Out of scope + +- FastAPI、SQLite、PostgreSQL 或任何 server runtime。 +- 真實 OpenAI、Anthropic、Guardrails AI、NeMo Guardrails 或 LlamaGuard 呼叫。 +- 真實 API key、帳號、外部資料或使用者對話收集。 +- 直接複製 Guardrail Workshop 的 source code、repository history 或 UI。 +- 修改 Core 19 topics 的 learning order、完成條件或既有 Git/Auth contracts。 + +## 4. 使用者流程 + +```text +課程地圖 + → AI/LLM Engineering Extension track + → Guardrail Lesson + → Guardrail Lab + → 選擇 stage 與 validators + → 送出固定情境 + → 顯示每層結果、失敗策略與 latency + → 完成三個指定情境 + → 標記 guardrail topic complete +``` + +學習者可以從 Lesson 直接進入 Lab;Lab 的任何失敗都必須能說明原因並可 reset,不得把資料送出瀏覽器。 + +## 5. TopicModule 接入契約 + +### Metadata + +```text +topic id: guardrail +track id: ai-engineering +track kind: extension +lesson route: #/guardrail +lab route: #/guardrail-lab +completion key: se-workshop-guardrail-complete +``` + +### 建議 module 邊界 + +```text +frontend/src/topics/guardrail/ + lesson.tsx + lab.tsx + content.ts + simulator.ts + simulator.test.ts +``` + +若規則資料需要與 Lesson fixture 分離,可新增 `shared/guardrail-rules.json`;該檔案只存可版控的教學規則,不存 secret、模型輸出或使用者資料。 + +### 不可違反的整合規則 + +- 不新增第二套路由、進度 repository 或 App-level topic-specific 分支。 +- 不改名、不覆蓋 `se-workshop-git-complete` 與 `se-workshop-auth-complete`。 +- Guardrail progress 與 Core progress 分開計算;完成 Guardrail 不會改變 Core 19 的完成率或 ready gate。 +- Lesson、Lab、simulator、completion 與 tests 各自維持 TopicModule 責任邊界。 +- Simulator 不直接依賴 React、localStorage、network 或 backend。 + +## 6. Simulator 最小契約 + +```text +GuardrailState { + stage: input | output | tool + enabledValidators: ValidatorId[] + lastInput: string + results: ValidatorResult[] + outcome: pass | fixed | reask | blocked + latencyMs: number + phase: initial | active | failed | completed + completedScenarioIds: GuardrailScenarioId[] + lastMessage: string + canReset: true +} +``` + +```text +events: + selectStage(stage) + setInput(text) + toggleValidator(id) + submitScenario(id) + reset() +``` + +同一個初始狀態與事件序列必須得到相同結果。Lab 不執行真實 LLM;`reask` 以固定的示範結果呈現,不進行第二次網路呼叫。 + +`completedScenarioIds` 用來保存本次 Lab 已完成的 required scenario;它只代表 simulator session 內的驗收狀態,不取代 progress repository 的 topic completion key。 + +## 7. 完成條件 + +`guardrail` topic 只有在以下條件全部成立時才能標記完成: + +- 成功通過一個安全 input scenario。 +- 觸發一個 input 或 output 的 `exception`/`fix`/`reask` 情境並能看到原因。 +- 完成一個 tool side effect 被攔截的情境。 +- 學習者能 reset 並重新執行 Lab。 +- refresh 後 progress 仍保留,且不影響 Core 19 progress。 + +## 8. 依賴與拆分 + +| Task | 輸出 | 硬依賴 | +| --- | --- | --- | +| GUARDRAIL-01 | topic acceptance、lesson outline、fixture contract | M0 | +| GUARDRAIL-02 | Lesson 與教學 fixture | GUARDRAIL-01 | +| GUARDRAIL-03 | deterministic guardrail simulator | GUARDRAIL-01、CORE-001、CORE-006 | +| GUARDRAIL-04 | Guardrail Lab UI | GUARDRAIL-03、CORE-005 | +| GUARDRAIL-05 | route、progress、integration、QA | GUARDRAIL-02、GUARDRAIL-04、CORE-002、CORE-004 | + +`GUARDRAIL-01` 可在 M0 完成後與 `CORE-001` 平行;`GUARDRAIL-02` 與 `GUARDRAIL-03` 可平行。Guardrail 不依賴原本 Guardrail Workshop repository 的 branch 或 commit。 + +## 9. 驗收方式 + +- `npm test`:simulator 的正常、非法、reset、優先序與 completion 測試通過。 +- `npm run lint`:TypeScript 檢查通過。 +- `npm run build`:GitHub Pages 靜態 build 通過。 +- 手動驗收:map → lesson → lab → complete → map pageflow 通過。 +- 手動驗收:keyboard、mobile、live announcement、reduced motion 通過。 +- 手動驗收:瀏覽器 network 不出現真實模型、API 或 secret 傳送。 + +## 10. 後續 Capstone 邊界 + +若未來要重建原 Guardrail Workshop 的 FastAPI/SQLite/Live API 能力,必須另立 Capstone 或 Phase 2 feature,透過 API adapter 與本 topic 的 Lesson/Lab 分離。該 Capstone 不得反向要求 GitHub Pages 具備 backend,也不得讓 Phase 1 的 Guardrail simulator 依賴 server。 diff --git a/docs/ide-acceptance.md b/docs/ide-acceptance.md new file mode 100644 index 0000000..2107fb2 --- /dev/null +++ b/docs/ide-acceptance.md @@ -0,0 +1,129 @@ +# IDE-01:IDE/除錯器 acceptance + +> 類型:Topic acceptance/fixture contract +> 狀態:IDE-02~IDE-05 已實作;本地整合驗證通過 +> 依賴:M1 module foundation +> 範圍:瀏覽器內 deterministic editor/debugger sandbox;不執行真實 process、不讀取使用者檔案 + +本文件只鎖定 IDE/除錯器主題的學習目標、Lab pageflow、固定程式、debug state 與驗收邊界,不新增 feature-level SA/SD。 + +## 1. 學習目標 + +完成主題後,學習者應能: + +1. 在固定專案中開啟指定檔案,理解 editor context 與目前執行位置是兩種不同狀態。 +2. 在明確的程式行設定 breakpoint,知道 breakpoint 是暫停條件,不是錯誤修正本身。 +3. 啟動固定函式並閱讀 call stack、目前行號與 variables,而不是只看最後輸出。 +4. 用 step over 與 continue 控制執行,觀察變數如何在每一行後改變。 +5. 區分「程式已完成」、「程式在 breakpoint 暫停」與「debugger 操作不合法」三種結果。 + +## 2. Pageflow 與 Lab happy path + +```text +map → /ide lesson → /ide-lab + → 開啟 src/order.ts + → 在第 3 行設定 breakpoint + → 執行 calculateTotal(10, 2, 3) + → 觀察 paused state 與 variables + → step over 到 return + → continue 完成程式 + → completion treatment → map +``` + +Lab 使用固定 `order.ts` 與固定函式參數;所有 editor、debugger、call stack 與 variables 都由 fixture 決定,不會啟動瀏覽器外的 process。 + +概念操作與 simulator event 對應如下: + +| 概念操作 | 教學指令/UI 動作 | event | +| --- | --- | --- | +| 開啟檔案 | `open src/order.ts` | `open-file` | +| 設定 breakpoint | `breakpoint 3` | `set-breakpoint` | +| 啟動函式 | `run calculateTotal(10, 2, 3)` | `run` | +| 讀取目前變數 | `inspect variables` | `inspect` | +| 執行下一行 | `step over` | `step-over` | +| 繼續到結束 | `continue` | `continue` | + +## 3. Fixture contract + +IDE-03 應以以下固定 fixture 作為初始狀態: + +```ts +type IdeLabPhase = "initial" | "active" | "paused" | "failed" | "completed"; +type IdeStepId = "open" | "breakpoint" | "run" | "inspect" | "step" | "continue"; + +interface IdeFileFixture { + path: "src/order.ts"; + content: string; + language: "typescript"; +} + +interface IdeLabState { + phase: IdeLabPhase; + selectedFile: string | null; + breakpointLines: readonly number[]; + currentLine: number | null; + callStack: readonly string[]; + variables: Readonly>; + output: readonly string[]; + lastStream: "stdout" | "stderr" | null; + exitCode: number | null; + completedStepIds: readonly IdeStepId[]; + lastMessage: string; + canReset: true; +} +``` + +固定檔案: + +```ts +{ + path: "src/order.ts", + language: "typescript", + content: [ + "export function calculateTotal(price: number, quantity: number, discount: number) {", + " const subtotal = price * quantity;", + " const discounted = subtotal - discount;", + " return discounted;", + "}", + ].join("\\n"), +} +``` + +固定執行參數:`price = 10`、`quantity = 2`、`discount = 3`。 + +## 4. 固定狀態與完成條件 + +| event | accepted state/output | 完成 step | +| --- | --- | --- | +| `open-file` | `selectedFile = "src/order.ts"`,editor 顯示第 1–5 行 | `open` | +| `set-breakpoint` | `breakpointLines = [3]` | `breakpoint` | +| `run` | `phase = "paused"`、`currentLine = 3`、`callStack = ["calculateTotal"]`、variables 含 `subtotal = "20"` | `run` | +| `inspect` | 保留 paused state,顯示 `price=10`、`quantity=2`、`discount=3`、`subtotal=20` | `inspect` | +| `step-over` | `currentLine = 4`、variables 新增 `discounted = "17"` | `step` | +| `continue` | `phase = "completed"`、`currentLine = null`、output 為 `17` | `continue` | + +只有 `completedStepIds` 包含 `open`、`breakpoint`、`run`、`inspect`、`step`、`continue` 時,Lab 才能標記完成。只看到最後 output 或只設定 breakpoint 都不能完成 topic。 + +## 5. 失敗狀態與回饋 + +| 情境 | 結果 | 必須呈現的概念 | +| --- | --- | --- | +| 開啟未知檔案 | `stderr` 顯示 file not found,selected file 不變,exit code `1` | editor context 不等於任意路徑都存在 | +| 設定第 99 行 breakpoint | `stderr` 顯示 invalid line,breakpoint 不變,exit code `1` | breakpoint 必須落在檔案有效行號 | +| 沒有 breakpoint 就執行 `run` | `phase = "failed"`,提示先設定 breakpoint,call stack 保持空值,exit code `2` | 跑完程式不等於完成 debug 流程 | +| 尚未 paused 就 `step over` | `stderr` 顯示 debugger is not paused,current line 不變,exit code `2` | step 操作需要 paused state | +| 尚未 paused 就 inspect | `stderr` 顯示 no active frame,variables 不變,exit code `2` | variables 必須從 active frame 讀取 | +| 在 run 前 continue | `stderr` 顯示 program has not started,output 不變,exit code `2` | continue 不會代替 run | +| 空白輸入 | 不新增 history/progress,保留既有 state | 無效輸入不可誤算進度 | + +每個錯誤至少要說明 stream、exit code、目前 phase/current line 與下一步提示;不可只用顏色或 breakpoint 圖示表達結果。 + +## 6. IDE-02/IDE-03 驗收向量 + +1. **happy path**:依 `open-file → set-breakpoint → run → inspect → step-over → continue` 完成,最後 `phase = "completed"` 且 output 為 `17`。 +2. **錯誤保留狀態**:未知檔案、無效 breakpoint、未 paused 的 step/inspect 不破壞 selected file、breakpoint、call stack 或 variables。 +3. **debug state**:run 後必須能看見 current line、call stack 與 variables;step-over 後 `discounted` 才能出現。 +4. **reset**:完成或 failed 後 reset,結果與初始 fixture deep-equal。 +5. **determinism**:相同初始 state 加相同 event sequence,必須得到相同 phase、line、variables、output 與 completion。 + +IDE-02 可依本文件撰寫教材與 debug mental model;IDE-03 必須維持上述 state、event、current line、error、reset 與 completion 邊界。真實 editor workspace、process、extension、network、使用者檔案與 secret 不在本 topic Phase 1 範圍內。 diff --git a/docs/package-acceptance.md b/docs/package-acceptance.md new file mode 100644 index 0000000..024bc94 --- /dev/null +++ b/docs/package-acceptance.md @@ -0,0 +1,182 @@ +# PACKAGE-01:套件管理 acceptance + +> 類型:Topic acceptance/fixture contract +> 狀態:可供 PACKAGE-02、PACKAGE-03 開發 +> 依賴:M1 module foundation +> 範圍:瀏覽器內 deterministic package-manager sandbox;不連線 registry、不執行真實 install、不修改使用者檔案 + +本文件只鎖定套件管理主題的學習目標、Lab pageflow、manifest/lockfile fixture 與驗收邊界,不新增 feature-level SA/SD。 + +## 1. 學習目標 + +完成主題後,學習者應能: + +1. 分辨 `package.json` 的依賴宣告、lockfile 的解析結果,以及安裝目錄的實際狀態。 +2. 用版本範圍新增依賴,理解 `^1.2.0` 與 lockfile 中的 exact version 不是同一件事。 +3. 由固定 registry fixture 解析 direct dependency 與 transitive dependency,不把網路成功當成可重現性。 +4. 用 `npm install` 更新 manifest/lockfile,並用 `npm ci` 從 lockfile 重建乾淨的安裝結果。 +5. 在 manifest、lockfile 與 installed modules 不一致時,判斷應該重新安裝、修正 lockfile,或停止並回報錯誤。 + +## 2. Pageflow 與 Lab happy path + +```text +map → /package lesson → /package-lab + → inspect package.json + → add @workshop/format@^1.2.0 + → npm install + → inspect package-lock.json + → npm ci + → completion treatment → map +``` + +Lab 使用固定 `package.json`、`package-lock.json` 與 fixture registry。所有版本解析、相依套件與安裝結果由 fixture 決定,不會呼叫真實 npm registry,也不會在瀏覽器外建立 `node_modules`。 + +概念操作與 simulator event 對應如下: + +| 概念操作 | 教學指令/UI 動作 | event | +| --- | --- | --- | +| 查看依賴宣告 | 開啟 `package.json` | `inspect-manifest` | +| 新增版本範圍 | `npm install @workshop/format@^1.2.0` | `add-dependency` | +| 解析並安裝依賴 | `npm install` | `install` | +| 查看 exact resolution | 開啟 `package-lock.json` | `inspect-lockfile` | +| 以 lockfile 重建 | `npm ci` | `clean-install` | + +## 3. Fixture contract + +PACKAGE-03 應以以下固定 fixture 作為初始狀態: + +```ts +type PackageLabPhase = "initial" | "active" | "blocked" | "failed" | "completed"; +type ManifestState = "unchanged" | "updated"; +type LockfileState = "missing" | "stale" | "synced"; +type InstallState = "empty" | "installed" | "clean-installed"; +type PackageStepId = + | "inspect-manifest" + | "add-dependency" + | "install" + | "inspect-lockfile" + | "clean-install"; + +interface PackageManifestFixture { + name: "workshop-package-lab"; + private: true; + packageManager: "npm@10.8.2"; + dependencies: Readonly>; +} + +interface RegistryPackageFixture { + name: "@workshop/format" | "@workshop/shared"; + versions: Readonly> }>>; +} + +interface PackageLockFixture { + lockfileVersion: 3; + packages: Readonly> }>>; +} + +interface PackageLabState { + phase: PackageLabPhase; + packageManager: "npm"; + manifest: PackageManifestFixture; + manifestState: ManifestState; + lockfile: PackageLockFixture | null; + lockfileState: LockfileState; + installedModules: readonly string[]; + installState: InstallState; + completedStepIds: readonly PackageStepId[]; + lastCommand: string | null; + lastMessage: string; + canReset: true; +} +``` + +初始 `package.json`: + +```ts +{ + name: "workshop-package-lab", + private: true, + packageManager: "npm@10.8.2", + dependencies: {}, +} +``` + +初始 Lab state: + +```ts +{ + phase: "initial", + packageManager: "npm", + manifest: { + name: "workshop-package-lab", + private: true, + packageManager: "npm@10.8.2", + dependencies: {}, + }, + manifestState: "unchanged", + lockfile: null, + lockfileState: "missing", + installedModules: [], + installState: "empty", + completedStepIds: [], + lastCommand: null, + lastMessage: "準備從固定 package.json fixture 開始。", + canReset: true, +} +``` + +固定 registry fixture: + +```ts +[ + { + name: "@workshop/format", + versions: { + "1.2.0": { dependencies: { "@workshop/shared": "^1.0.0" } }, + "1.3.0": { dependencies: { "@workshop/shared": "^1.0.0" } }, + }, + }, + { + name: "@workshop/shared", + versions: { + "1.0.0": { dependencies: {} }, + }, + }, +] +``` + +`npm install @workshop/format@^1.2.0` 的 deterministic resolution 必須選擇 `@workshop/format@1.3.0` 與 `@workshop/shared@1.0.0`,並由 fixture 標記 `resolvedFrom = "fixture-registry"`。不得產生真實 registry URL、integrity 以外的網路資料或使用者資訊。 + +## 4. 固定狀態與完成條件 + +| event | accepted state/output | 完成 step | +| --- | --- | --- | +| `inspect-manifest` | 顯示空的 dependencies 與 `packageManager = "npm@10.8.2"`,state 保持 initial/active | `inspect-manifest` | +| `add-dependency` | manifest 新增 `"@workshop/format": "^1.2.0"`,`manifestState = "updated"`、`lockfileState = "stale"` | `add-dependency` | +| `install` | lockfile 解析 `@workshop/format@1.3.0` 與 `@workshop/shared@1.0.0`,`lockfileState = "synced"`、`installState = "installed"` | `install` | +| `inspect-lockfile` | 顯示 exact versions、transitive dependency 與 fixture registry 來源 | `inspect-lockfile` | +| `clean-install` | 清空後由 lockfile 重建相同兩個 installed modules,`installState = "clean-installed"` | `clean-install` | + +只有 `completedStepIds` 包含 `inspect-manifest`、`add-dependency`、`install`、`inspect-lockfile`、`clean-install`,且 manifest、lockfile 與 clean-installed modules 一致時,Lab 才能標記完成。只修改 manifest、只看到 lockfile,或只完成一次 install 都不能完成 topic。 + +## 5. 失敗狀態與回饋 + +| 情境 | 結果 | 必須呈現的概念 | +| --- | --- | --- | +| 新增 registry 不存在的套件 | `phase = "failed"`,manifest 不新增,lockfile 與 installed modules 不變 | package manager 不能把未知套件當成成功安裝 | +| 在 manifest 尚未更新前執行 `npm ci` | `blocked`,保留初始 state | `npm ci` 需要可用且一致的 lockfile | +| manifest 更新後直接執行 `npm ci` | `blocked`,`lockfileState = "stale"` 不變 | `npm ci` 不負責替代 `npm install` 更新 lockfile | +| lockfile 與 manifest dependency 不一致 | `failed`,不得產生部分安裝結果 | lockfile 是可重現安裝的契約,不是裝飾檔 | +| 解析 transitive dependency 失敗 | `failed`,installed modules 保持空或上一個穩定結果 | 失敗不可留下半套依賴 | +| 任一非法 event 或空白 package name | 保留既有 state,不偽造 completed step | 輸入驗證與錯誤回饋必須 deterministic | + +每個錯誤至少要說明目前 manifest/lockfile/install 狀態與下一步提示;不可只顯示顏色或例外訊息。 + +## 6. PACKAGE-02/PACKAGE-03 驗收向量 + +1. **happy path**:依 `inspect-manifest → add-dependency → install → inspect-lockfile → clean-install` 完成,最後 `phase = "completed"`。 +2. **一致性邊界**:manifest、lockfile 或 installed modules 任一不一致時,不得標記完成,也不得留下部分成功結果。 +3. **reset**:完成或 failed 後 reset,結果與初始 fixture deep-equal。 +4. **determinism**:相同初始 state 加相同 event sequence,必須產生相同 manifest、lockfile、installed modules、feedback 與 completion。 + +PACKAGE-02 可依本文件的學習目標與 npm command mapping 撰寫教材;PACKAGE-03 必須維持上述 state、event、版本解析、錯誤與 completion 邊界。真實 npm registry、network、credential、postinstall script、native module、使用者檔案與任意 shell 不在本 topic Phase 1 範圍內。 diff --git a/docs/project-plan.md b/docs/project-plan.md new file mode 100644 index 0000000..127188d --- /dev/null +++ b/docs/project-plan.md @@ -0,0 +1,160 @@ +# Software Engineering Workshop:專案計畫 + +> 狀態:Active +> 日期:2026-08-16 +> 基線:`dev`(route guard 與目前 ready topics 已合併) + +## 1. 專案目標 + +建立一個面向繁體中文初學工程師的互動式軟體工程教材站,將 19 個工程能力拆成可閱讀、可操作、可測試、可重複練習的主題模組。 + +每個已開放主題應包含: + +- 短教材:解釋概念與工程取捨 +- Interactive Lab:讓使用者完成一個安全的模擬任務 +- Deterministic simulator:不連線真實服務,狀態可重設、可測試 +- 完成條件:完成 Lab 後才更新課程進度 + +## 2. 現況基線 + +- 課程清單:`shared/curriculum.json`,共 19 個主題 +- Core 已開放:Git、GitHub/GitLab 遠端協作、命令列、IDE/除錯器、套件管理、REST API/FastAPI、身分驗證與授權,共 7 / 19 +- Git v1 release gate:已完成 cowork/pipeline、keyboard、mobile、200% zoom 與 reduced-motion 驗收,`GIT-REVIEW` 已於 2026-08-16 通過。 +- Extension 已開放:Guardrails,共 1 個;不計入 Core 19 的完成分母 +- 學習者完成數:依瀏覽器 `localStorage` 個別計算,不在專案文件中固定寫死 +- 前端:Vite + React + TypeScript,靜態站點 +- 持久化:瀏覽器 `localStorage` +- 交付:GitHub Actions CI、GitHub Pages +- 協作:`feature/* → dev → main → GitHub Pages` +- 既有保護契約:Git/Auth routes、完成 key、Git simulator、鍵盤與 mobile 行為 + +## 3. 專案範圍 + +### In scope + +- 19 個主題的課程地圖與狀態管理 +- 統一的「教材 → Lab → 完成」模組模式 +- Git、Auth 與後續 CLI、Web、資料庫、品質、交付主題 +- 可測試的互動 simulator 與 fixture-first 教學資料 +- 鍵盤操作、live announcement、mobile layout 與 reduced motion +- 測試、TypeScript 檢查、production build、GitHub Pages 部署 + +### Out of scope + +- 第一階段不建立後端、帳號系統或資料庫 +- 不連線使用者的真實 GitHub/GitLab、Microsoft 或其他外部服務 +- 不保存真實 token、secret、帳號資料或 repository 內容 +- 不把教材站變成企業管理後台、dashboard 或一般產品 SaaS + +## 4. 專案級 Pageflow + +```text +課程地圖 + → 選擇能力分類 + → 選擇該分類中的已開放主題 + → 閱讀教材 + → 進入該主題 Lab + → 完成操作與驗證 + → 寫入該主題完成狀態 + → 回到原分類查看進度 +``` + +分類是導覽與學習順序,不是跨分類的完成 gate;每個主題都遵守相同的高階契約,主題內部的教材節奏、Lab 狀態與錯誤情境,則由該主題的 pageflow 規格補充。 + +## 5. 架構方向 + +- `shared/curriculum.json` 是課程清單與 planned/ready 狀態的唯一來源。 +- 前端以 topic module 為邊界,分離 lesson copy、Lab UI、simulator、tests 與完成持久化。 +- 新主題優先採 fixture-first;未來若接真實 API,替換資料來源,不改變教材核心任務契約。 +- 既有 `#/map`、`#/git`、`#/lab` 與 Auth routes 視為受保護介面;新增 route 不得破壞既有連結。 +- 每個主題的完成狀態使用獨立、命名清楚的 persistence key;不得改寫既有 Git/Auth key。 + +## 6. 交付路線 + +### Phase 0:專案契約與規格 + +1. 專案級 Pageflow +2. 專案級 SA +3. 專案級 SD +4. Topic module、完成狀態、simulator 與測試契約 +5. Milestone 與 task breakdown + +### Phase 1:開發基本功與 Web + +- Git(已完成) +- GitHub/GitLab 遠端協作(已完成) +- 命令列(已完成) +- IDE/除錯器(已完成) +- 套件管理(已完成) +- 環境變數 +- 建置工具 +- REST API/FastAPI(已完成) +- 身分驗證與授權(已完成) + +### Extension track + +- Guardrails/AI/LLM Engineering(已完成;不改變 Core 19 的學習順序與完成統計) + +### Phase 2:資料庫 + +- SQL +- 資料庫設計 +- 索引與交易 +- NoSQL 基礎 + +### Phase 3:品質與可觀測性 + +- 單元測試 +- 整合測試 +- 日誌 + +### Phase 4:交付與部署 + +- Docker 基礎 +- CI/CD +- 部署 + +CI、GitHub Pages 與 branch protection 已是專案交付基礎;對應的 CI/CD/部署教材仍需另外製作,不因基礎設施已存在而自動標記完成。 + +## 7. 專案交付物 + +1. 專案級 Pageflow、SA、SD +2. 可重用的 topic module 開發契約 +3. 19 個主題的課程地圖與狀態更新 +4. 各已開放主題的教材、Lab、simulator 與測試 +5. CI、build、部署與交接文件 +6. 可轉換成 GitHub Issues/Milestones 的 task breakdown + +Feature-level pageflow 或 SA/SD 只在專案級契約確認後,針對需要複雜互動的主題追加;不再把單一 feature 計畫誤當成專案計畫。 + +## 8. 專案完成條件 + +- 19 個主題都在課程地圖中可見,狀態與唯一課程清單一致。 +- 每個 `ready` 主題都有教材、Lab、完成判定與至少一組自動化測試。 +- Git/Auth 既有 routes、完成狀態、鍵盤與 mobile 行為不回歸。 +- 使用者不需要外部帳號或 secret,就能完成所有第一階段互動教材。 +- `npm test`、TypeScript 檢查與 `npm run build` 通過。 +- `dev` 與 `main` 依既有 PR、required check、release merge 規則交付。 + +## 9. 主要風險與決策 + +| 風險 | 專案決策 | 後續 rework 影響 | +| --- | --- | --- | +| 19 個主題範圍過大 | 以 topic module 逐站交付,不追求一次完成 | 各主題需遵守共同契約 | +| 既有路由與 persistence 互相耦合 | 先鎖 protected contracts,再擴充 route | 新主題需新增而非改寫既有 key | +| 真實外部服務導致帳號與網路依賴 | 第一階段採 deterministic simulator | 未來接 API 時需替換資料邊界 | +| 不同主題各自發展造成 UI/教學不一致 | 先定 project-level pageflow、SA、SD | 新主題需通過規格 review | +| CI/部署基礎與教材主題混淆 | 基礎設施與學習模組分開計算 | `cicd`、`deploy` 仍需獨立教材 | + +## 10. 開發閘門 + +此計畫確認後,依序進行: + +1. Project-level Pageflow review +2. Project-level SA review +3. Project-level SD review +4. 以第一個主題(GitHub/GitLab 遠端協作)驗證 module 契約 +5. 逐主題開發、測試、PR → `dev` +6. 依 release 規則合併到 `main` 並部署 + +本計畫本身不授權真實外部服務連線,也不改變既有 `main` release 流程。 diff --git a/docs/project-sa.md b/docs/project-sa.md new file mode 100644 index 0000000..9da4d35 --- /dev/null +++ b/docs/project-sa.md @@ -0,0 +1,184 @@ +# Software Engineering Workshop:專案級 SA + +> 狀態:Approved +> 日期:2026-08-16 +> 上位文件:[project-plan.md](./project-plan.md) +> 基線:`dev`(目前 ready topics 與 route guard 已合併) + +## 1. 分析目的 + +本文件把專案計畫轉成可驗證的系統需求,定義教材站服務誰、解決什麼問題、有哪些核心流程與規則。專案包含 19 個 Core topics,以及遵守相同模組契約的可選 Extension tracks。實作細節、component、route 命名、TypeScript 型別與 simulator API 留到專案級 SD。 + +## 2. 問題與目標 + +### 現況問題 + +軟體工程知識常被拆成零散文章與指令,學習者知道名詞,卻不一定能把概念串成可重複的工程流程。真實 GitHub、雲端帳號、secret 與資料庫又會增加初學者的操作風險與環境成本。 + +### 系統目標 + +- 讓初學者沿著 19 個 Core topics 的路線圖逐步學習。 +- 讓進階學習者能從 Extension track 進入專門主題,例如 AI/LLM Engineering 的 Guardrail rebuild。 +- 每個已開放主題都能完成「教材 → Lab → 驗證 → 完成」閉環。 +- 用 deterministic simulator 將高風險或外部依賴轉成安全的練習情境。 +- 讓學習者看見指令、狀態、錯誤與工程取捨之間的因果關係。 +- 讓後續主題能沿用相同模組契約,不破壞已完成教材。 + +## 3. 角色與邊界 + +| 角色 | 目標 | 本階段能力 | +| --- | --- | --- | +| 學習者 | 瀏覽路線、理解概念、完成 Lab | 在瀏覽器內操作,不需登入外部服務 | +| 教材維護者 | 新增主題、更新教材、驗證互動流程 | 透過 repository、CI 與 Pages 交付 | +| GitHub Pages | 提供公開靜態網站 | 發布建置後的 HTML、CSS、JavaScript | +| 未來 API/服務 | 同步資料或連接真實平台 | Phase 1 不啟用;保留 Phase 2 擴充邊界 | + +本階段沒有學習者帳號、教師後台、多人協作資料或 server-side session。 + +## 4. 核心概念 + +- **Curriculum**:Core 19 topics 與 Extension tracks 的唯一清單。 +- **Track**:依能力領域分組的主題集合,分為 Core 與 Extension。 +- **Topic**:一個可被 planned 或 ready 管理的學習主題。 +- **Lesson**:主題的概念說明、示例與工程取捨。 +- **Lab**:讓學習者完成任務並得到回饋的互動場景。 +- **Simulator session**:Lab 的可重設、可預測操作狀態。 +- **Topic progress**:該學習者在目前瀏覽器上的完成狀態。 +- **Release**:經過測試、build、PR 與部署的教材版本。 + +### Core 與 Extension 邊界 + +- Core curriculum 目前固定包含 19 個 topics,維持既有學習路線與完成統計。 +- Extension track 使用同一套 Lesson、Lab、TopicModule、simulator、progress 與 release 契約,但不改變 Core 19 的必修範圍。 +- Extension topic 的完成狀態獨立計算;完成 Extension topic 不會增加 Core 19 的完成數,也不會解鎖或阻塞 Core topic。 +- Guardrail rebuild 是第一個規劃中的 Extension topic,完整定義見 `docs/guardrail-rebuild-feature-brief.md`。 + +概念關係: + +```text +Curriculum + → Track + → Topic + → Lesson + Lab + → Simulator session + → Topic progress +``` + +## 5. 主要使用情境 + +| 編號 | 使用情境 | 結果 | +| --- | --- | --- | +| UC-01 | 學習者開啟課程地圖 | 看見 19 個 Core topics、Extension tracks 與 planned/ready 狀態 | +| UC-02 | 學習者選擇 ready 主題 | 進入該主題教材,不需要外部登入 | +| UC-03 | 學習者閱讀教材 | 理解概念、流程、指令或設定值及其取捨 | +| UC-04 | 學習者進入 Lab | 依提示操作 simulator,得到即時成功或錯誤回饋 | +| UC-05 | 學習者遇到錯誤或想重練 | 可 reset Lab,回到明確的初始狀態 | +| UC-06 | 學習者完成 Lab | 系統驗證完成條件,更新該 topic 的本機進度 | +| UC-07 | 學習者重新整理或回訪 | 已完成 topic 的狀態仍保留在同一瀏覽器 | +| UC-08 | 維護者發布版本 | CI 通過後由 GitHub Pages 發布靜態教材 | + +## 6. 專案級 Pageflow 與狀態 + +### 正常流程 + +```text +課程地圖 + → 選擇 Core/Extension track + → 選擇 track 中的 ready topic + → Lesson + → Lab 初始狀態 + → 學習者操作 + → 驗證完成條件 + → Topic marked complete + → 回到原 track +``` + +### Topic 可見性規則 + +- `planned` topic:可在地圖看見標題與摘要,但不可進入未完成的教材流程。 +- Track:可在課程地圖進入分類頁;分類頁列出該分類所有 topic 與 ready/planned 狀態。 +- `ready` topic:可進入 Lesson 與 Lab。 +- `complete` 不取代 curriculum 的 `ready` 狀態;它是學習者在本機的個人進度。 + +### Lab 狀態規則 + +每個 Lab 至少要能表達以下概念狀態,實際狀態名稱由 SD 定義: + +- 初始/尚未完成 +- 操作中 +- 可理解的失敗或拒絕 +- 可重設 +- 完成 + +錯誤不應破壞整個課程路線,也不應造成真實外部副作用。 + +## 7. 功能需求 + +| ID | 需求 | 驗證方式 | +| --- | --- | --- | +| FR-01 | 系統必須從唯一課程清單呈現 19 個 Core topics 與 Extension tracks | 檢查地圖與 `curriculum.json` 一致 | +| FR-02 | 系統必須區分 planned 與 ready topic 的可進入性 | 點擊與 route 行為測試 | +| FR-03 | ready topic 必須提供 Lesson | UI 與內容 review | +| FR-04 | ready topic 必須提供可重複操作的 Lab | Lab happy path 與 reset 測試 | +| FR-05 | Lab 必須以 deterministic 結果回應操作 | simulator 單元測試 | +| FR-06 | 只有完成 Lab 驗證後才能標記 topic 完成 | 完成條件測試 | +| FR-07 | 完成狀態必須能在同一瀏覽器重新整理後保留 | persistence 測試 | +| FR-08 | 課程地圖必須顯示總進度與個別完成狀態 | map UI 測試 | +| FR-09 | 學習者必須能從 Lab reset 並重新開始 | reset 行為測試 | +| FR-10 | 維護者必須能透過既有 CI/Pages 流程發布版本 | workflow 與 build 驗證 | +| FR-11 | Extension topic 必須使用既有 TopicModule、route、progress 與 test 契約 | Guardrail rebuild integration review | +| FR-12 | Extension progress 不得污染 Core 19 的完成統計或可見性規則 | progress aggregation 測試 | + +## 8. 非功能需求 + +- **安全與隱私**:Phase 1 不要求帳號,不收集 secret,不對真實 repository 或外部 API 發出變更。 +- **可理解性**:繁體中文文案需說明「做什麼、為什麼、失敗代表什麼」,而非只展示正確答案。 +- **可及性**:互動控制項可用鍵盤完成;重要狀態變化可被 live announcement 感知。 +- **響應式**:桌面與 mobile 都能完成 Lesson 與 Lab;不得依賴 hover 才能操作。 +- **可重現性**:相同初始狀態與操作序列必須得到相同 simulator 結果。 +- **可維護性**:新增 topic 不得複製整個 App 的路由與進度邏輯,也不得破壞 Git/Auth 保護契約。 +- **範圍隔離**:Extension topic 可以擴充課程,但不得改變 Core 19 的學習順序、完成統計或既有保護契約。 +- **交付可靠性**:測試、TypeScript 檢查與 production build 必須在 PR gate 中通過。 + +## 9. 資料與持久化規則 + +- 課程定義屬於 repository 內容,以 `shared/curriculum.json` 為準;Core 與 Extension 的 track metadata 也由此檔案管理。 +- Lesson、Lab 與 simulator 的教學 fixture 可隨版本發布,不需要後端資料庫。 +- Phase 1 的 Topic progress 只存在使用者瀏覽器的 localStorage。 +- Git 與 Auth 現有 persistence key 視為不可破壞契約;新增 topic 使用獨立 key。 +- Guardrail rebuild 使用 `se-workshop-guardrail-complete`,不得重用 Core topic key。 +- 若 Phase 2 需要跨裝置同步、帳號、分析或教師檢視,新增 API/資料庫同步層,不直接把 secret 放進 Pages 前端。 + +## 10. 驗收與完成定義 + +專案級 SA 可接受的條件: + +1. 19 個 Core topics、Extension tracks 與 planned/ready 規則清楚。 +2. 學習者從 map 到 topic completion 的正常與失敗流程清楚。 +3. Lesson、Lab、simulator、progress、release 的責任邊界清楚。 +4. Phase 1 無後端的理由與 Phase 2 擴充條件清楚。 +5. 既有 Git/Auth routes、completion keys、可及性與部署契約被列為保護條件。 +6. Guardrail rebuild 如何接入 TopicModule、如何隔離 Core progress 與如何維持 Phase 1 無 backend 已被定義。 + +## 11. 待決策但不阻塞 Phase 1 + +- 何種需求會觸發 Phase 2 backend:跨裝置進度、登入、教師 dashboard、真實 provider integration,或其他需求。 +- Phase 2 API/資料庫的 hosting 與成本策略。 +- 是否要保存匿名學習分析,以及保存多久。 +- GitHub/GitLab 真實整合是否仍維持 simulator-first 的替換邊界。 +- 哪些 Extension topics 在完成 feature-level review 後提升為 ready。 + +這些問題由後續 project-level SD 或 Phase 2 計畫處理;不阻塞第一階段的靜態教材與 simulator 交付。 + +## 12. 與 SD 的交界 + +本 SA 已確定「系統要提供什麼」;SD 下一步需定義: + +- topic module 的實作邊界 +- Core/Extension track metadata 與 progress aggregation 的資料邊界 +- route 與導航資料模型 +- simulator state machine 與事件契約 +- progress persistence adapter +- 測試分層與 CI 交付流程 + +SD 不得反向改變本文件的學習目標、外部副作用邊界與既有保護契約;若需要改變,先回到 SA review。 diff --git a/docs/project-sd.md b/docs/project-sd.md new file mode 100644 index 0000000..ccaad0a --- /dev/null +++ b/docs/project-sd.md @@ -0,0 +1,337 @@ +# Software Engineering Workshop:專案級 SD + +> 狀態:Approved +> 日期:2026-08-16 +> 上位文件:[project-plan.md](./project-plan.md)、[project-sa.md](./project-sa.md) +> 基線:`dev`(目前 ready topics 與 route guard 已合併) + +## 1. 設計目標與原則 + +本文件將專案 SA 轉成可實作的技術契約。第一階段維持靜態前端、瀏覽器本機進度與 deterministic simulator;後續若需要 backend,透過明確的 adapter 邊界擴充,不把 API、token 或資料庫耦合進教材元件。Core 19 與 Extension tracks 共用同一套 module contract,但維持各自的 progress 邊界。 + +設計原則: + +- 保護既有 Git/Auth routes、完成 keys、simulator 行為與可及性。 +- 新增 topic 優先採 registry/module 方式,不持續擴張 `App.tsx` 的條件分支。 +- simulator 是純狀態邏輯;UI、localStorage 與未來 API 不放進 simulator reducer。 +- 課程定義、學習者進度與執行中 Lab state 分離。 +- 所有 Lab 都能 reset,且同一輸入序列得到同一結果。 + +## 2. 目標架構 + +```text +Browser +┌──────────────────────────────────────────────┐ +│ App shell │ +│ ├─ route registry │ +│ ├─ curriculum map │ +│ ├─ topic lesson │ +│ └─ topic lab │ +│ ├─ simulator reducer │ +│ ├─ lesson/lab fixtures │ +│ └─ progress repository │ +└──────────────────────────────────────────────┘ + │ + ├─ shared/curriculum.json + └─ localStorage(Phase 1) + +Repository → GitHub Actions → frontend/dist → GitHub Pages + +Curriculum tracks: + ├─ Core tracks(目前 19 topics) + └─ Extension tracks(可選;Guardrail rebuild first) + +Phase 2 optional: +Browser → API adapter → external API/serverless → database +``` + +GitHub Pages 只負責發布靜態輸出;API、認證與資料庫若未來加入,必須是外部服務,不能把 server runtime 假設成 Pages 的能力。 + +## 3. 目錄與 module 邊界 + +### 現有結構 + +- `shared/curriculum.json`:課程清單與 topic metadata +- `frontend/src/App.tsx`:目前 route、進度與頁面組合入口 +- `frontend/src/components/`:地圖、教材、Lab UI +- `frontend/src/content/`:教材內容與 fixture +- `frontend/src/git/`、`frontend/src/auth/`:可測試 simulator 與測試 +- `frontend/src/styles.css`:全站視覺與響應式規則 + +### 目標 topic module 結構 + +```text +frontend/src/topics// + lesson.tsx # Lesson presentation + lab.tsx # Lab presentation + content.ts # copy and fixtures + simulator.ts # pure state machine + simulator.test.ts +``` + +現有 Git/Auth 可先維持原路徑;新主題採上述邊界。後續重構以「不改既有 URL 與 persistence key」為前提逐步移動,不一次大改。 + +Guardrail rebuild 使用相同的 topic module 目錄邊界: + +```text +frontend/src/topics/guardrail/ + lesson.tsx + lab.tsx + content.ts + simulator.ts + simulator.test.ts +``` + +它是 Extension topic,不建立第二套路由、App shell 或 ProgressRepository。原本 `Guardrail-Workshop` repository 只提供需求與教材參考,不是 runtime dependency,也不是 source code import 來源。 + +## 4. Topic module 契約 + +每個 `ready` topic 應能提供下列概念資料與能力。這是設計契約,不要求第一版立刻建立完整 runtime registry: + +```text +TopicModule { + id: string + trackKind: core | extension + lesson: LessonDefinition + lab: LabDefinition + simulator: SimulatorDefinition + progress: ProgressDefinition +} + +LessonDefinition { + title: string + objectives: string[] + sections: LessonSection[] +} + +LabDefinition { + title: string + initialState: unknown + completionRule: (state) → boolean +} + +SimulatorDefinition { + createInitialState() → State + reduce(state, event) → State + reset() → State +} + +ProgressDefinition { + completionKey: string + isComplete() → boolean + markComplete() → void +} +``` + +實際 TypeScript 型別由各 topic 的 SD 或 implementation PR 補齊;此處先鎖定責任,不鎖定過度通用的泛型 API。 + +## 5. Route 與導航設計 + +### 受保護 routes + +以下既有 routes 不得移除或改變語意: + +- `#/map` +- `#/git` +- `#/lab` +- `#/auth` +- `#/auth-lab` + +### 新 topic route + +新主題採可預測命名: + +- `#/track/`:能力分類頁 +- `#/`:教材 +- `#/-lab`:Lab + +例如 Foundations 使用 `#/track/foundations`,遠端協作使用 `#/remote` 與 `#/remote-lab`。若既有命名與規則不一致,優先保留舊 route,透過 alias 或 registry 兼容,不直接改 URL。 + +### Route registry + +目標是集中管理: + +```text +route → page kind → track/topic id → completion key +``` + +`App` 只負責讀取 route、選擇 page 與共用 shell;topic-specific lesson、Lab 與 simulator 不應回寫 App 的全域條件分支。 + +## 6. 課程與進度資料設計 + +### Curriculum source + +`shared/curriculum.json` 維持現有格式: + +```text +Curriculum + tracks: Track[] + +Track + id, title, description, kind: core | extension, topics + +Topic + id, title, summary, status: planned | ready +``` + +既有 tracks 的 `kind` 預設為 `core`;Guardrail 使用 `id: ai-engineering`、`kind: extension`。Extension topic 的 metadata 必須能被 map 顯示,但不能改變 Core 19 的完成總數或 ready gate。 + +`status` 表示教材是否已發布,不表示單一學習者是否完成。 + +### Learner progress + +```text +TopicProgress { + topicId: string + completed: boolean +} +``` + +Phase 1 使用以 topic id 命名的 localStorage key: + +```text +se-workshop--complete = "true" +``` + +Git 的 `se-workshop-git-complete` 與 Auth 的 `se-workshop-auth-complete` 是既有 protected keys,不改名、不遷移、不覆蓋。 + +Guardrail 使用獨立的 `se-workshop-guardrail-complete`。Progress aggregation 至少要能分別產出 `coreProgress` 與 `extensionProgress`;Extension completion 不計入 Core 19 的分母或分子。 + +### Progress aggregation + +完成數量應由 curriculum 中的 topics 與 progress repository 計算,不再只把 Git/Auth completion boolean 相加。planned topic 不得因為 localStorage 有未知值而被算入 ready 或完成。 + +## 7. Simulator 設計 + +### 狀態模型 + +每個 simulator 至少分離: + +```text +SimulatorState { + phase: initial | active | failed | completed + workspace: topic-specific state + lastMessage: learner-facing feedback + canReset: boolean +} +``` + +不同主題可以擴充 `workspace`,但不得把瀏覽器、網路、localStorage 或 React state 放入純 simulator 核心。 + +### 事件模型 + +```text +reduce(state, event) → nextState + +event = topic-specific action +``` + +規則: + +- 相同 `state + event` 必須得到相同 `nextState`。 +- 非法操作產生可理解的失敗狀態,不直接丟失整個 session。 +- `reset` 回到固定初始 fixture。 +- `completed` 只能由明確 completion predicate 判定。 +- UI 只呈現狀態與派送事件,不自行複製規則。 + +Guardrail simulator 的最小狀態還需表達: + +```text +stage: input | output | tool +enabledValidators: ValidatorId[] +results: ValidatorResult[] +outcome: pass | fixed | reask | blocked +latencyMs: number +``` + +第一階段的 `reask` 只呈現 deterministic fixture 結果,不呼叫真實 LLM;`exception > reask > fix > pass` 是共用的結果優先序。 + +## 8. Progress repository 與 Phase 2 adapter + +Phase 1 定義最小 repository 邊界: + +```text +ProgressRepository { + read(topicId) → boolean + markComplete(topicId) → void + clear(topicId) → void +} +``` + +目前實作可由 localStorage adapter 提供。Phase 2 若加入帳號與同步,改以 API adapter 實作相同概念介面;前端 topic module 不直接知道 token、資料庫或 provider SDK。 + +不得把敏感資料放進: + +- `shared/curriculum.json` +- `frontend/public/` +- bundle、localStorage 或 GitHub Pages 靜態輸出 + +Guardrail 的 rules fixture 可以放在 `shared/guardrail-rules.json`,但只能包含可版控的教學規則與 latency reference,不得包含 secret、真實 prompt、模型輸出或使用者資料。 + +## 9. UI 與可及性設計契約 + +- App shell 統一提供 sidebar、breadcrumb、mobile menu 與總進度。 +- Lesson 與 Lab 使用相同的 topic header、status feedback 與 completion treatment。 +- 互動控制項必須使用可識別的 button/input 語意,不以純點擊 div 取代。 +- route 變更後將焦點與 scroll 狀態移到新頁面可理解的位置。 +- Lab 的錯誤、成功與 reset 結果需提供可讀文字與 live announcement。 +- 操作流程不可只依賴 hover、顏色或拖曳;需支援鍵盤與 mobile viewport。 +- reduced-motion 狀態下不依賴動畫傳達必要資訊。 + +## 10. 測試設計 + +### Unit tests + +- 每個 simulator 的初始狀態、正常事件、非法事件、reset、完成 predicate。 +- Progress repository 的 read/mark/clear 行為;既有 Git/Auth key 不回歸。 +- curriculum aggregation 對 planned/ready/complete 的計算。 + +### Integration/UI checks + +- map → lesson → lab → complete → map 的 pageflow。 +- planned topic 不可進入,ready topic 可進入。 +- refresh 後 progress 保留。 +- keyboard navigation、live announcement、mobile menu 不破壞。 + +### Delivery checks + +```text +npm test +npm run lint +npm run build +``` + +CI 必須在 PR 進入 `dev` 與 `main` 前執行;Pages 僅部署通過 build 的 `frontend/dist`。 + +## 11. 分階段實作順序 + +1. 保持現有 Git/Auth route 與 simulator 不動。 +2. 抽出或建立 progress repository 的共用邊界,先以既有 keys 驗證。 +3. 建立 topic route registry,但保留既有 route alias。 +4. 用 GitHub/GitLab 遠端協作作為第一個新 topic,驗證 Lesson、Lab、simulator、completion 與 tests 契約。 +5. 依相同 module contract 擴充其餘主題。 +6. 需要跨裝置、帳號或真實 provider 時,再實作 Phase 2 API adapter。 +7. 在 TopicModule contract 穩定後,以 Guardrail rebuild 驗證 Extension track、分離 progress 與 deterministic multi-stage simulator。 +8. 若需要 FastAPI/SQLite/Live API,再另立 Capstone,不把 backend 放進 GitHub Pages Phase 1。 + +## 12. 技術風險與處理 + +| 風險 | 處理方式 | +| --- | --- | +| App 目前集中管理 route 與 completion | 先保留舊行為,再以 registry/repository 漸進抽離 | +| 19 個 topic 導致型別過度抽象 | 先鎖責任與事件邊界,不急著建立萬用 generic | +| simulator 與 UI 規則重複 | reducer 是唯一狀態規則來源,UI 只派送事件 | +| 未來接 backend 需要重做前端 | 先定 ProgressRepository/API adapter 邊界 | +| GitHub Pages 不能執行 backend | Phase 1 靜態部署,Phase 2 將 API 部署到外部服務 | +| Extension progress 污染 Core 統計 | track kind、獨立 completion key、雙層 aggregation 測試 | +| Guardrail rebuild 變成原專案搬移 | 只採用需求與教材概念,重新實作 TopicModule;禁止 source import | +| 規則資料與 simulator 邏輯漂移 | 規則以 fixture/JSON 版控,reducer 只負責狀態轉換,測試固定情境 | + +## 13. SD 驗收條件 + +- 新工程師可依本文件新增一個 topic,而不需修改既有 Git/Auth simulator 規則。 +- 新 topic 有明確 lesson、lab、simulator、progress 與 test 邊界。 +- 既有 route、completion key、課程清單格式與 CI/Pages 流程保持相容。 +- Phase 1 不需要 backend 也能完整執行教材;Phase 2 的 API 擴充點清楚。 +- Guardrail rebuild 可在 GitHub Pages 以 deterministic simulator 完成 Lesson/Lab/progress/test 閉環;其 FastAPI Capstone 不阻塞 Phase 1。 +- 任何超出本 SD 的技術選擇,先更新 SD 或建立決策紀錄,再進入 implementation。 diff --git a/docs/remote-acceptance.md b/docs/remote-acceptance.md new file mode 100644 index 0000000..bccffed --- /dev/null +++ b/docs/remote-acceptance.md @@ -0,0 +1,130 @@ +# REMOTE-01:GitHub/GitLab 遠端協作 acceptance + +> 類型:Topic acceptance/fixture contract +> 狀態:可供 REMOTE-02、REMOTE-03 開發 +> 依賴:M0 project contract +> 範圍:瀏覽器內 deterministic simulator;不連線真實 GitHub/GitLab、不要求登入 + +本文件只鎖定遠端協作主題的學習目標、Lab pageflow、失敗狀態與 simulator 邊界,不新增 feature-level SA/SD。 + +## 1. 學習目標 + +完成主題後,學習者應能: + +1. 分辨 local branch、remote-tracking branch 與 `origin`。 +2. 在開始工作前用 `git fetch origin dev` 更新本地對遠端 `dev` 的認知。 +3. 用 `git rebase origin/dev` 將功能分支對齊最新基線,再發布到遠端。 +4. 用 `git push -u origin ` 建立可供 review 的遠端分支。 +5. 理解 PR、CI、review、merge/auto-merge 的先後與檢查條件。 + +## 2. Pageflow 與 Lab happy path + +```text +map → /remote lesson → /remote-lab + → 建立功能分支與 commit + → fetch origin/dev + → rebase origin/dev + → push -u origin feature/remote-work + → 開 PR → CI 通過 + → merge 至 dev + → completion treatment → map +``` + +Lab 的初始情境是在 `dev` 上準備開始工作。學習者需要先切出 `feature/remote-work` 並建立本地 commit,再完成同步、rebase、push 與 PR lifecycle;所有結果由 fixture 決定。 + +概念操作與 simulator event 的對應如下: + +| 概念操作 | 教學指令/UI 動作 | event | +| --- | --- | --- | +| 檢查目前狀態 | `git status` | `inspect` | +| 建立功能分支 | `git switch -c feature/remote-work` | `branch` | +| 建立本地變更 | `git commit -m "add remote lesson"` | `commit` | +| 更新遠端基線資訊 | `git fetch origin dev` | `fetch` | +| 對齊遠端基線 | `git rebase origin/dev` | `rebase` | +| 發布功能分支 | `git push -u origin feature/remote-work` | `push` | +| 建立 pull request | 開啟 PR,base 為 `dev` | `open-pr` | +| 通過品質檢查 | CI checks pass | `checks-pass` | +| 合併功能 | merge PR 至 `dev` | `merge` | + +## 3. Fixture contract + +REMOTE-03 應以以下固定 fixture 作為初始狀態,不連線、不產生遠端副作用: + +```ts +type RemoteLabPhase = "initial" | "active" | "blocked" | "completed"; +type WorkingTree = "clean" | "dirty"; +type RemoteBranch = "absent" | "published"; +type PullRequest = "none" | "open" | "merged"; +type Checks = "not-run" | "pending" | "passed" | "failed"; +type SyncState = "stale" | "fetched" | "rebased"; + +interface RemoteLabState { + phase: RemoteLabPhase; + remoteName: "origin"; + baseBranch: "dev"; + localBranch: "dev" | "feature/remote-work"; + workingTree: WorkingTree; + localCommitCount: number; + localBaseRef: "dev" | "origin/dev"; + syncState: SyncState; + remoteBranch: RemoteBranch; + pullRequest: PullRequest; + checks: Checks; + lastMessage: string; + canReset: true; +} +``` + +初始 fixture: + +```ts +{ + phase: "initial", + remoteName: "origin", + baseBranch: "dev", + localBranch: "dev", + workingTree: "clean", + localCommitCount: 0, + localBaseRef: "dev", + syncState: "stale", + remoteBranch: "absent", + pullRequest: "none", + checks: "not-run", + lastMessage: "準備建立第一個遠端協作分支。", + canReset: true, +} +``` + +## 4. 失敗狀態與回饋 + +| 情境 | 結果 | 必須呈現的概念 | +| --- | --- | --- | +| 尚未 commit 就 fetch | 可接受,但保持 `localCommitCount = 0` | fetch 更新的是遠端參照,不等於提交變更 | +| 尚未 fetch 就 rebase | blocked,狀態不進入完成流程 | 先更新 `origin/dev`,再對齊 | +| 尚未 rebase 就 push | blocked,狀態不發布 | 功能分支需先確認基線;提示可能遇到 non-fast-forward | +| 尚未 push 就開 PR | blocked,PR 保持 `none` | PR 需要可被遠端看到的 branch | +| CI 尚未通過就 merge | blocked,PR 保持 `open` | merge 受 branch protection/checks 約束 | +| 任一非法 event | blocked 或保留原狀態,不可偽造進度 | 錯誤必須可讀,且不得破壞既有 session | + +錯誤回饋至少要說明「目前狀態、下一步可做什麼」,不可只顯示顏色或例外訊息。 + +## 5. Completion contract + +只有在下列條件全部成立時,Lab 才算完成: + +- `localCommitCount > 0`。 +- 已依序完成 `branch → commit → fetch → rebase → push → open-pr → checks-pass → merge`。 +- `syncState = "rebased"`,表示本地已先取得並套用最新的 `origin/dev`。 +- `remoteBranch = "published"`、`pullRequest = "merged"`、`checks = "passed"`。 +- simulator 的 completion predicate 回傳 `true`,並寫入 `se-workshop-remote-complete`。 + +單獨完成 fetch、push、開 PR 或通過 CI 不得標記 topic 完成。reset 必須回到初始 fixture,清除本次 Lab 的 PR、checks 與 commit 狀態。 + +## 6. REMOTE-02/REMOTE-03 驗收向量 + +1. **happy path**:依固定順序完成所有 event,最後得到 `phase = "completed"`。 +2. **順序錯誤**:在 fetch 前 rebase、在 push 前開 PR、在 checks 前 merge,均不得完成且回饋原因。 +3. **reset**:完成或 blocked 後 reset,結果與初始 fixture deep-equal。 +4. **determinism**:相同初始 state 加相同 event sequence,必須產生相同 state 與 feedback。 + +REMOTE-02 可依本文件的學習目標與指令 mapping 撰寫教材;REMOTE-03 必須維持上述 state、event、錯誤與 completion 邊界。真實 provider API、帳號、token、網路錯誤與多人 review 不在本 topic Phase 1 範圍內。 diff --git a/docs/rest-acceptance.md b/docs/rest-acceptance.md new file mode 100644 index 0000000..45db6a4 --- /dev/null +++ b/docs/rest-acceptance.md @@ -0,0 +1,98 @@ +# REST/FastAPI Workshop 驗收規格 + +> 狀態:Accepted for implementation +> 日期:2026-08-16 +> Task:REST-01~REST-05 +> 上位契約:`docs/project-sa.md`、`docs/project-sd.md` + +## 1. 學習目標 + +完成本主題後,學習者應能: + +1. 從 React `fetch` 追蹤一個 HTTP request 如何進入 FastAPI。 +2. 說明 CORS、routing、Pydantic validation 與 dependency injection 的執行順序。 +3. 分辨 FastAPI、SQLModel Session、database engine 與 SQLite 的責任。 +4. 說明 request model、table model 與 response model 為何要分離。 +5. 從實際程式碼判斷 `201`、`404` 與 `422` 在哪一層產生。 + +## 2. Lesson/Lab Pageflow + +```text +REST Lesson + → FastAPI request lifecycle mental model + → REST Lab + → 選擇 request scenario + → 送出 deterministic request + → 逐站追蹤 React/FastAPI/Pydantic/Session/SQLite/response + → 點選每一行閱讀執行時機、連接關係與錯誤後果 + → 完成四個 required scenarios + → 標記 REST topic complete +``` + +## 3. Required scenarios + +| Scenario | 預期結果 | 教學重點 | +| --- | --- | --- | +| `create-success` | `POST /items` → `201` | request body、validation、Session、INSERT、response model | +| `read-success` | `GET /items/1` → `200` | path parameter、SELECT、serialization | +| `not-found` | `GET /items/99` → `404` | 查詢成功執行,但 resource 不存在 | +| `validation-error` | invalid `POST /items` → `422` | validation 在 route 與 database 前拒絕 request | + +## 4. 程式碼教學契約 + +- 顯示 `frontend/src/api.ts`、`backend/database.py`、`backend/models.py`、`backend/main.py`。 +- 每個非空白程式碼行都有對應說明。 +- 每行說明至少包含:做什麼、何時執行、如何連到相鄰層、刪除或寫錯的後果。 +- 「學習模式」顯示逐行摘要;「原始碼模式」保留可直接複製的乾淨程式碼。 +- request trace 必須同步標示目前執行階段與相關程式碼行。 + +## 5. Simulator 契約 + +```text +RestLabState { + selectedScenarioId + requestStarted + activeStageId + currentVisitedStageIds + learnedStageIds + completedScenarioIds + databaseItems + response + phase: initial | tracing | error | completed + lastMessage +} +``` + +```text +events: + selectScenario(id) + startRequest() + inspectStage(id) + nextStage() + reset() +``` + +相同 initial state 與 event sequence 必須得到相同 state。Simulator 不執行 network、Python、SQLModel 或 SQLite。 + +## 6. 完成條件 + +- 四個 required scenarios 全部執行到各自的 terminal stage。 +- 學習者至少跨情境走過七個 lifecycle stages。 +- `validation-error` 不得產生 SQL 或改變 database fixture。 +- `create-success` 只能產生 deterministic item `id=3`;重跑不重複新增。 +- 完成後使用 `se-workshop-rest-complete` 保存進度。 + +## 7. Out of scope + +- 任意 Python 執行、Pyodide 或真實 FastAPI server。 +- 真實 database、transaction concurrency、migration 或 authentication。 +- 完整 Swagger UI 複製。 +- 把 GitHub Pages 當成 backend runtime。 + +## 8. 驗收 + +- Simulator tests:happy path、`404`、`422`、非法 stage、reset、determinism、completion。 +- Content tests:每個非空白 code line 都有四欄解說,scenario 與 code stage mapping 完整。 +- Integration tests:route、completion key、Core progress denominator 與 curriculum ready 狀態一致。 +- `npm test`、`npm run lint`、`npm run build` 通過。 +- Keyboard、mobile、live announcement 與 reduced-motion 行為符合共用 checklist。 diff --git a/docs/task-breakdown.md b/docs/task-breakdown.md new file mode 100644 index 0000000..0497b88 --- /dev/null +++ b/docs/task-breakdown.md @@ -0,0 +1,233 @@ +# Software Engineering Workshop:Milestone/Task Breakdown + +> 狀態:Active backlog +> 日期:2026-08-16 +> 依據:[project-plan.md](./project-plan.md)、[project-sa.md](./project-sa.md)、[project-sd.md](./project-sd.md) + +## 1. 為什麼需要 Milestone + +需要。Milestone 是可驗收的交付閘門;Task 是可獨立完成、審查與合併的最小工作單位。 + +```text +Milestone:一個可對外說明的成果 + → Work package:一個 topic 或共用能力 + → Task:一個清楚輸出、通常一個 PR +``` + +Milestone 不用來表示每個小步驟,也不先綁死日期;日期與 owner 等 task 進入 GitHub Issues 後再補。 + +## 2. Task 顆粒度規則 + +每個 task 必須具備: + +- 一個主要輸出:一份規格、一個 simulator、一個 Lab 或一組測試。 +- 一個明確驗收條件,不以「大致完成」結案。 +- 一個主要 owner 與一個主要依賴;跨 topic 依賴必須寫出來。 +- 預設半天到一天可完成;若超過一天,拆成多個 task。 +- 可以獨立開 PR;不能把「順便修改其他主題」藏在 task 內。 + +Task 不等於檔案。若一個 task 同時需要修改多個檔案,但輸出與驗收仍是單一能力,仍可維持一個 task。 + +## 3. Milestone 總表 + +| ID | Milestone | 結果 | 依賴 | 狀態 | +| --- | --- | --- | --- | --- | +| M0 | Project Contract | Project Plan、SA、SD、task breakdown 可供開發對齊 | 無 | done | +| M1 | Module Foundation | 新 topic 可依共同契約接入 route、progress、Lab、tests | M0 | done(P2 架構 follow-up) | +| M2 | Foundations/Web | 開發基本功與 Web 主題逐站開放 | M1 | in progress | +| M3 | Data | SQL、schema、index/transaction、NoSQL 開放 | M1 | planned | +| M4 | Quality | unit、integration、logs 開放 | M1 | planned | +| M5 | Delivery | Docker、CI/CD、deploy 開放 | M1、既有 CI/Pages baseline | planned | +| M6 | Hardening/Release | 19 主題一致性、回歸、文件與正式發布完成 | M2–M5 | planned | + +M2、M3、M4 可在 M1 完成後平行進行;M5 的教材可平行製作,但共用 CI/Pages contract 必須維持穩定。 + +## 4. M0:Project Contract + +| Task ID | Task | 輸出 | 依賴 | 狀態 | +| --- | --- | --- | --- | --- | +| PLAN-001 | 撰寫專案計畫 | `docs/project-plan.md` | 無 | done | +| PAGEFLOW-001 | 固化專案級 pageflow | Plan/SA 中的 pageflow 決策可獨立 review | PLAN-001 | done | +| PLAN-002 | 撰寫專案級 SA | `docs/project-sa.md` | PLAN-001 | done | +| PLAN-003 | 撰寫專案級 SD | `docs/project-sd.md` | PLAN-002、PAGEFLOW-001 | done | +| PLAN-004 | 審核 task/Milestone 分解 | 本文件的 backlog 決策 | PLAN-003 | done | + +M0 完成條件:專案層級目標、需求、技術邊界、Milestone、task 規則都被確認;未確認的內容不得默默變成 implementation 假設。 + +## 5. M1:Module Foundation + +這些 task 只處理共用能力,不實作特定教材內容。 + +| Task ID | Task | 主要輸出 | 依賴 | 狀態 | +| --- | --- | --- | --- | --- | +| CORE-001 | 固化 TopicModule contract | module 欄位、責任邊界、最小 fixture 介面 | M0 | done | +| CORE-002 | 建立 ProgressRepository | localStorage adapter,保護 Git/Auth keys | M0 | done | +| CORE-003 | 建立 route registry | topic route、lab route、既有 route alias | M0 | done | +| CORE-004 | 重作 progress aggregation | 從 curriculum + repository 計算總進度 | CORE-002、`curriculum.json` | done | +| CORE-005 | 建立共用 Lesson/Lab shell | topic header、status feedback、reset、completion treatment | CORE-001 | done | +| CORE-006 | 建立 simulator test harness | 共用測試 fixture、reset、completion assertion pattern | CORE-001 | done | +| CORE-007 | 建立共用可及性檢查清單 | keyboard、live region、mobile、reduced motion 驗收點 | M0 | done(完整 audit pending) | +| CORE-008 | App registry-driven dispatcher | 移除 App 中 topic-specific render 分支,讓 route registry/module contract 成為唯一 dispatch 入口 | CORE-001、CORE-003、CORE-005 | backlog | + +M1 的 foundation contract、progress、route resolver、test harness 與 checklist 已完成。原始的「App 不新增 topic-specific 分支」目標仍是 P2 架構 follow-up,追蹤於 `CORE-008`;目前不阻塞既有 ready topics 的 pageflow。 + +M1 的可平行工作線: + +```text +CORE-001 ─┬─ CORE-005 + └─ CORE-006 +CORE-002 ─── CORE-004 +CORE-003(獨立) +CORE-007(獨立) +``` + +因此只有 `CORE-004`、`CORE-005`、`CORE-006` 需要等待上游;Progress、route registry、accessibility checklist 不互相卡住。 + +## 6. Topic Task Packet + +所有未完成 topic 使用同一組五 task。這是「可獨立且清楚」的基本模板,不代表每個 topic 必須共用同一份程式碼。 + +| 後綴 | Task | 輸出 | 驗收 | +| --- | --- | --- | --- | +| -01 | Topic acceptance | 該 topic 的學習目標、Lab happy path、失敗狀態與完成條件 | 可用於 review,不新增 feature-level SA/SD 文件 | +| -02 | Lesson + fixtures | 教材內容、示例、fixture 與 GitHub/GitLab 或其他概念 mapping | 文案能解釋做什麼、為什麼、失敗代表什麼 | +| -03 | Simulator | 純 reducer、事件、initial state、reset、completion predicate | unit tests 覆蓋正常、非法、reset、完成 | +| -04 | Lab UI | 操作介面、狀態回饋、keyboard/mobile 行為 | 可依 pageflow 完成 Lab,不直接操作外部服務 | +| -05 | Integration + QA | route、progress、map status、tests、build 修正 | 完成後才標記 ready,既有回歸測試通過 | + +依賴關係: + +```text +-01 ─┬─ -02 + └─ -03 ── -04 +-02 ──────────┘ +-04 + -02 + CORE-002 + CORE-004 → -05 +``` + +更精確地說: + +- `-01` 只依賴 M0 的 project contract,可先開始。 +- `-02` 與 `-03` 可平行;`-03` 另外依賴 `CORE-001`、`CORE-006`。 +- `-04` 依賴 `-03` 與 `CORE-005`;可使用 `-01` 已定義的 fixture contract,不必等待全部文案完成。 +- `-05` 才整合 lesson、Lab、progress 與 QA,因此是 topic 的唯一收尾 task。 +- 不同 topic 之間沒有 hard dependency;課程學習順序是 soft dependency,不應阻塞工程開發。 + +## 7. M2:Foundations/Web Backlog + +每個 topic 都展開為 `-01` 到 `-05` 五個 task: + +| Topic ID | 主題 | 第一個 task prefix | 狀態 | +| --- | --- | --- | --- | +| remote | GitHub/GitLab 遠端協作 | REMOTE | ready | +| cli | 命令列 | CLI | ready | +| ide | IDE/除錯器 | IDE | ready | +| package | 套件管理 | PACKAGE | ready | +| env | 環境變數 | ENV | planned | +| build | 建置工具 | BUILD | planned | +| rest | REST API/FastAPI | REST | ready | + +例如第一個主題會產生:`REMOTE-01` acceptance、`REMOTE-02` lesson、`REMOTE-03` simulator、`REMOTE-04` Lab、`REMOTE-05` integration/QA。 + +M2 的 hard dependency 只有 M1。Remote、CLI、IDE、Package、Env、Build、REST 之間的先後是學習順序,不是工程阻塞;若某 topic 需要共用 fixture,應把 fixture contract 放進該 topic 的 `-01`,不要直接依賴另一個 topic 的完成。 + +Git 與 Auth 都已有可執行 implementation;Git 的 cowork/pipeline 上線門檻與 accessibility release review 已通過。兩者仍需在 M6 建立 regression/migration 檢查。 + +| Task ID | Task | 輸出 | 依賴 | 狀態 | +| --- | --- | --- | --- | --- | +| GIT-REVIEW | Git 基礎、cowork 與 pipeline release gate | `docs/git-acceptance.md`、12 項操作與 pipeline 驗收 | M1 | done(2026-08-16;manual keyboard/mobile/reduced-motion review passed) | + +## 8. Extension track backlog + +Guardrails 是第一個 Extension topic,使用既有 TopicModule、route、progress 與 simulator 契約,但不計入 Core 19。 + +| Task ID | Task | 依賴 | 狀態 | +| --- | --- | --- | --- | +| GUARDRAIL-01 | topic acceptance、lesson outline、fixture contract | M0 | done | +| GUARDRAIL-02 | Lesson 與教學 fixture | GUARDRAIL-01 | done | +| GUARDRAIL-03 | deterministic guardrail simulator | GUARDRAIL-01、CORE-001、CORE-006 | done | +| GUARDRAIL-04 | Guardrail Lab UI | GUARDRAIL-03、CORE-005 | done | +| GUARDRAIL-05 | route、progress、integration、QA | GUARDRAIL-02、GUARDRAIL-04、CORE-002、CORE-004 | done | + +Guardrail 的完成狀態與 Core progress 分離;未來其他 Extension topic 可沿用同一組五 task packet。 + +## 9. M3:Data Backlog + +| Topic ID | 主題 | Task prefix | +| --- | --- | --- | +| sql | SQL | SQL | +| schema | 資料庫設計 | SCHEMA | +| index | 索引與交易 | INDEX | +| nosql | NoSQL 基礎 | NOSQL | + +每個 topic 使用 Topic Task Packet 五個 task。資料庫主題第一版仍以 fixture/模擬資料呈現,不因教學內容而提前引入正式 database。 + +建議學習順序是 `SQL → Schema → Index/Transaction`,`NoSQL` 可與 `Schema` 平行開發。這是 soft dependency;只有實際共用的 fixture 或 module contract 才建立 hard dependency。 + +## 10. M4:Quality Backlog + +| Topic ID | 主題 | Task prefix | +| --- | --- | --- | +| unit | 單元測試 | UNIT | +| integration | 整合測試 | INTEGRATION | +| logs | 日誌 | LOGS | + +每個 topic 使用 Topic Task Packet 五個 task;共用測試工具的改動歸 M1 或獨立 task,不重複塞進每個 topic。 + +Unit、Integration、Logs 都只依賴 M1 的共用 contract;Integration 可在 Unit 教材完成前開發,避免把「測試概念順序」誤當成程式依賴。 + +## 11. M5:Delivery Backlog + +| Topic ID | 主題 | Task prefix | +| --- | --- | --- | +| docker | Docker 基礎 | DOCKER | +| cicd | CI/CD | CICD | +| deploy | 部署 | DEPLOY | + +既有 GitHub Actions、Pages 與 branch protection 是產品交付基礎,不直接等同於這三個教材完成;教材 task 仍需解釋概念、取捨與可操作 Lab。 + +M5 的 topic tasks 可平行開發。對教材內容而言,Docker、CI/CD、Deploy 不互相阻塞;只有 M6 的實際 release audit 需要等待相關教材與 workflow 都完成。 + +## 12. M6:Hardening/Release + +| Task ID | Task | 驗收 | +| --- | --- | --- | +| RELEASE-001 | 19 topic status audit | `curriculum.json`、map、route、progress 一致 | +| RELEASE-002 | Git/Auth regression audit | 既有 route、completion key、simulator、keyboard 行為不回歸 | +| RELEASE-003 | Cross-topic UX audit | lesson、Lab、錯誤、reset、completion UI 一致 | +| RELEASE-004 | Accessibility/mobile audit | keyboard、live announcement、responsive、reduced motion 通過 | +| RELEASE-005 | Full test/lint/build audit | CI required checks 綠燈 | +| RELEASE-006 | Release/Pages audit | `main` release merge 與 Pages deployment 可追蹤 | +| RELEASE-007 | Documentation handoff | Plan、SA、SD、task breakdown 與進度交接一致 | + +M6 的 task 依賴分成兩層:`RELEASE-001`、`RELEASE-002`、`RELEASE-003`、`RELEASE-004` 可在各 Milestone 完成後分批執行;`RELEASE-005` 依賴所有 topic integration,`RELEASE-006` 依賴 `RELEASE-005`,`RELEASE-007` 最後執行。 + +## 13. Definition of Ready / Done + +### Task Ready + +- Task ID、目標、輸出、驗收條件、依賴與 out-of-scope 已寫清楚。 +- 所需的上游 contract 已確認,沒有以「等資料」掩蓋未決策的格式。 +- 不會同時修改另一個 topic 的未聲明行為。 + +### Task Done + +- 輸出已存在並符合驗收條件。 +- 必要的 simulator/UI/integration test 已加入或更新。 +- `npm test`、`npm run lint`、`npm run build` 通過(若 task 只改文件,至少通過 diff/文件檢查)。 +- PR 描述列出 task ID、測試結果與已知風險。 + +## 14. GitHub 對應方式 + +本文件先是 repository 內的 canonical backlog;經 review 後再轉成 GitHub 管理物件: + +- Milestone:使用 `M0` 到 `M6`。 +- Issue title:`[REMOTE-03] 建立遠端協作 simulator`。 +- Labels:`area:*`、`type:spec|content|simulator|ui|qa|release`。 +- Dependencies:寫在 issue body,以 `Blocked by #...` 或 task ID 表示。 +- 每個 task 一個主要 PR;跨 task 變更必須先更新依賴關係。 + +本輪不直接建立 GitHub Milestones/Issues;先讓 task 顆粒度與 Milestone 邊界被確認,避免把錯誤拆分同步到遠端。 + +## 15. 目前下一個可開工 task + +下一個可開工的是尚未 ready 的 `ENV-01` 或 `BUILD-01`,兩者只依賴 M1,可平行進行。`REST-01` 到 `REST-05` 已依 `docs/rest-acceptance.md` 完成。若先處理架構債務,則開 `CORE-008`;它不阻塞上述教材 task。M6 的 release audit 要等所有 Core topic integration 完成後再收斂。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 43e530a..e497737 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,88 +1,129 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import curriculumData from "@shared/curriculum.json"; +import { aggregateProgress, completedReadyTopicIds } from "./progress/aggregation"; +import { createLocalStorageProgressRepository } from "./progress/repository"; +import { parseRoute, resolveRoute, topicPath, trackPath, type RouteDefinition } from "./routes/registry"; +import { TOPIC_MODULE_IDS } from "./topics/registry"; import { CurriculumMap } from "./components/CurriculumMap"; -import { GitLab } from "./components/GitLab"; -import { GitLesson } from "./components/GitLesson"; -import { AuthLesson } from "./components/AuthLesson"; -import { AuthLab } from "./components/AuthLab"; +import { TrackPage } from "./components/TrackPage"; +import { TopicRouteView } from "./components/TopicRouteView"; import type { Curriculum } from "./types"; const curriculum = curriculumData as Curriculum; -type Route = "map" | "git" | "lab" | "auth" | "auth-lab"; -const routes: Route[] = ["map", "git", "lab", "auth", "auth-lab"]; -const completionKey = "se-workshop-git-complete"; -const authCompletionKey = "se-workshop-auth-complete"; -function readRoute(): Route { - const route = window.location.hash.replace(/^#\/?/, "") as Route; - return routes.includes(route) ? route : "map"; +function routeLabel(route: RouteDefinition): string { + if (route.kind === "map") return "MAP"; + if (route.kind === "track") return `TRACK / ${route.trackId?.toUpperCase() ?? "CURRICULUM"}`; + const topic = route.topicId?.toUpperCase() ?? "TOPIC"; + return route.kind === "lab" ? `${topic} LAB` : topic; } export default function App() { - const [route, setRoute] = useState(readRoute); + const [route, setRoute] = useState(() => resolveRoute(window.location.hash, curriculum, TOPIC_MODULE_IDS)); const [menuOpen, setMenuOpen] = useState(false); - const [gitComplete, setGitComplete] = useState(() => localStorage.getItem(completionKey) === "true"); - const [authComplete, setAuthComplete] = useState(() => localStorage.getItem(authCompletionKey) === "true"); - const completedCount = Number(gitComplete) + Number(authComplete); - + const [progressRevision, setProgressRevision] = useState(0); + const progressRepository = useMemo(() => createLocalStorageProgressRepository(window.localStorage), []); + const progress = aggregateProgress(curriculum, progressRepository); + const completedTopicIds = useMemo( + () => completedReadyTopicIds(curriculum, progressRepository), + [progressRepository, progressRevision], + ); + const activeTrack = route.kind === "track" ? curriculum.tracks.find((track) => track.id === route.trackId) : undefined; useEffect(() => { - const onHashChange = () => { setRoute(readRoute()); setMenuOpen(false); window.scrollTo({ top: 0 }); }; - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); + const syncRoute = () => { + const parsedRoute = parseRoute(window.location.hash); + const nextRoute = resolveRoute(window.location.hash, curriculum, TOPIC_MODULE_IDS); + if (parsedRoute.path !== nextRoute.path) { + window.location.hash = nextRoute.path; + return; + } + setRoute(nextRoute); + setMenuOpen(false); + window.scrollTo({ top: 0 }); + }; + window.addEventListener("hashchange", syncRoute); + syncRoute(); + return () => window.removeEventListener("hashchange", syncRoute); }, []); - function go(next: Route) { - window.location.hash = `/${next}`; + function goPath(path: string) { + window.location.hash = path; + } + + function goTopic(topicId: string, kind: "lesson" | "lab") { + goPath(topicPath(topicId, kind)); } - function completeGit() { - localStorage.setItem(completionKey, "true"); - setGitComplete(true); + function completeTopic(topicId: string) { + progressRepository.markComplete(topicId); + setProgressRevision((value) => value + 1); } - function completeAuth() { - localStorage.setItem(authCompletionKey, "true"); - setAuthComplete(true); + function openCurrentTopicLab() { + if (route.topicId) goTopic(route.topicId, "lab"); + } + + function completeCurrentTopic() { + if (route.topicId) completeTopic(route.topicId); } return (
- -
WORKSHOP/{route === "map" ? "MAP" : route === "git" ? "GIT" : route === "lab" ? "GIT LAB" : route === "auth" ? "AUTH" : "AUTH LAB"}
-
已開放 2 / 19
+ +
WORKSHOP/{routeLabel(route)}
+
已開放 {progress.coreProgress.ready} / {progress.coreProgress.total}
- {route === "map" ? go("git")} onOpenAuth={() => go("auth")} /> : null} - {route === "git" ? go("lab")} /> : null} - {route === "lab" ? : null} - {route === "auth" ? go("auth-lab")} /> : null} - {route === "auth-lab" ? : null} + {route.kind === "map" ? goPath(trackPath(trackId))} /> : null} + {route.kind === "track" && activeTrack ? goPath("/map")} onOpenTopic={(topicId) => goTopic(topicId, "lesson")} /> : null} + {route.kind === "track" && !activeTrack ? goPath(trackPath(trackId))} /> : null} +
- {menuOpen ?
); } diff --git a/frontend/src/components/CliLab.tsx b/frontend/src/components/CliLab.tsx new file mode 100644 index 0000000..9c1e991 --- /dev/null +++ b/frontend/src/components/CliLab.tsx @@ -0,0 +1,151 @@ +import { FormEvent, useMemo, useState } from "react"; +import { cliLessonSteps, type CliLabState } from "../topics/cli/content"; +import { + cliLabIsComplete, + createCliLabState, + runCliCommand, +} from "../topics/cli/simulator"; +import { + TopicCompletionCard, + TopicLabShell, + TopicStatusFeedback, + type TopicStatusTone, +} from "./TopicShell"; + +interface TerminalEntry { + command?: string; + lines: readonly string[]; + accepted?: boolean; + stream?: "stdout" | "stderr"; + exitCode?: number | null; +} + +const initialHistory: readonly TerminalEntry[] = [ + { lines: ["CLI Workshop sandbox v1", "固定 project fixture 已準備好。輸入第一個指令開始。"] }, +]; + +function statusTone(state: CliLabState): TopicStatusTone { + if (state.phase === "completed") return "success"; + if (state.phase === "failed") return "error"; + return "neutral"; +} + +function statusMessage(state: CliLabState): string { + if (state.phase === "failed") { + return `${state.lastStream ?? "stderr"} · exit ${state.exitCode ?? "—"} · cwd ${state.cwd} · ${state.lastMessage}`; + } + return state.lastMessage; +} + +export function CliLab({ onComplete }: { onComplete: () => void }) { + const [cli, setCli] = useState(createCliLabState); + const [command, setCommand] = useState(""); + const [history, setHistory] = useState(initialHistory); + const completedCount = cli.completedStepIds.length; + const progress = Math.round((completedCount / cliLessonSteps.length) * 100); + const currentStep = useMemo( + () => cliLessonSteps.find((step) => !cli.completedStepIds.includes(step.id)) ?? cliLessonSteps[cliLessonSteps.length - 1], + [cli.completedStepIds], + ); + + function submit(event: FormEvent) { + event.preventDefault(); + if (!command.trim() || cliLabIsComplete(cli)) return; + + const result = runCliCommand(cli, command); + setCli(result.state); + setHistory((items) => [ + ...items, + { + command: command.trim(), + lines: result.output, + accepted: result.accepted, + stream: result.stream ?? undefined, + exitCode: result.state.exitCode, + }, + ]); + setCommand(""); + if (!cliLabIsComplete(cli) && cliLabIsComplete(result.state)) onComplete(); + } + + function reset() { + setCli(createCliLabState()); + setCommand(""); + setHistory([{ lines: ["Sandbox 已重設。從 pwd 重新開始。"] }]); + } + + return ( + 在固定工作目錄中
讀懂命令列} + progressLabel={cliLabIsComplete(cli) ? "完成" : `${completedCount} / ${cliLessonSteps.length}`} + progress={progress} + onReset={reset} + > + + +
+
+
+ + cli-workshop — {cli.cwd} + exit {cli.exitCode ?? "—"} +
+
+ {history.map((entry, index) => ( +
+ {entry.command ?

{entry.command}

: null} + {entry.lines.map((line, lineIndex) => ( + + {entry.stream ? `${entry.stream} · exit ${entry.exitCode ?? "—"} · ` : null}{line} + + ))} +
+ ))} +
+
+ + setCommand(event.target.value)} + placeholder={cliLabIsComplete(cli) ? "Lab 已完成" : "輸入命令…"} + autoComplete="off" + spellCheck={false} + aria-describedby="cli-command-help" + disabled={cliLabIsComplete(cli)} + /> + +
+

輸入固定 fixture 支援的命令,按 Enter 或執行送出。

+
+ + +
+
+ ); +} diff --git a/frontend/src/components/CurriculumMap.tsx b/frontend/src/components/CurriculumMap.tsx index 269d28b..ab96d26 100644 --- a/frontend/src/components/CurriculumMap.tsx +++ b/frontend/src/components/CurriculumMap.tsx @@ -1,7 +1,7 @@ import type { Curriculum } from "../types"; -export function CurriculumMap({ curriculum, onOpenGit, onOpenAuth }: { curriculum: Curriculum; onOpenGit: () => void; onOpenAuth: () => void }) { - const topicCount = curriculum.tracks.reduce((total, track) => total + track.topics.length, 0); +export function CurriculumMap({ curriculum, onOpenTrack }: { curriculum: Curriculum; onOpenTrack: (trackId: string) => void }) { + const topicCount = curriculum.tracks.filter((track) => (track.kind ?? "core") === "core").reduce((total, track) => total + track.topics.length, 0); return (
@@ -10,11 +10,11 @@ export function CurriculumMap({ curriculum, onOpenGit, onOpenAuth }: { curriculu

SOFTWARE ENGINEERING / FIELD WORK

一次練好
一個工程能力。

- 19 個主題是一張地圖,不是一份今天要清空的待辦清單。現在只走第一站:Git。 + 19 個主題是一張地圖,不是一份今天要清空的待辦清單。先選一個能力分類,再沿著分類內的 topic 前進。

- - 約 20 分鐘 · 含互動 Lab + + 先選分類 · 再進入 topic Lab
@@ -30,7 +30,7 @@ export function CurriculumMap({ curriculum, onOpenGit, onOpenAuth }: { curriculu

CURRICULUM MAP

完整路線圖

-

Git 已開放;其餘主題會逐項製作、測試、上線。

+

Core 主題與 Guardrail Extension 會分開計算進度;每個開放 topic 都先經過 Lesson、Lab 與 QA。

@@ -38,20 +38,22 @@ export function CurriculumMap({ curriculum, onOpenGit, onOpenAuth }: { curriculu
{String(trackIndex + 1).padStart(2, "0")} -

{track.title}

{track.description}

+
+

{track.title}

+

{track.description}

+ +
{track.topics.map((topic) => ( - + {topic.status === "ready" ? "查看分類" : "規劃中"} +
))}
diff --git a/frontend/src/components/GitLab.tsx b/frontend/src/components/GitLab.tsx index 0a52aeb..3ee5229 100644 --- a/frontend/src/components/GitLab.tsx +++ b/frontend/src/components/GitLab.tsx @@ -1,99 +1,161 @@ -import { FormEvent, useMemo, useState } from "react"; -import { initialGitState, LAB_STEPS, runGitCommand, type GitState } from "../git/simulator"; +import { type FormEvent, useState } from "react"; +import { + GIT_RELEASE_PIPELINE_JOBS, + GIT_RELEASE_STEPS, + createInitialGitReleaseState, + gitReleaseProgress, + isGitReleaseComplete, + nextGitReleaseStep, + runGitReleaseCommand, + runGitReleaseEvent, + type GitProvider, + type GitReleaseEvent, + type GitReleaseState, +} from "../git/release-simulator"; +import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "./TopicShell"; -interface TerminalEntry { command?: string; lines: string[]; accepted?: boolean } +interface TerminalEntry { + command?: string; + lines: readonly string[]; + accepted?: boolean; +} + +const initialHistory: readonly TerminalEntry[] = [ + { lines: ["Git cowork sandbox v2", "固定 hosted repository 與 pipeline fixture 已準備好。"] }, +]; + +function statusTone(state: GitReleaseState, accepted: boolean): TopicStatusTone { + if (!accepted) return "error"; + if (state.complete) return "success"; + return "neutral"; +} export function GitLab({ onComplete }: { onComplete: () => void }) { - const [git, setGit] = useState(initialGitState); + const [state, setState] = useState(createInitialGitReleaseState); const [command, setCommand] = useState(""); - const [history, setHistory] = useState([ - { lines: ["Git Workshop sandbox v1", "Repository 已準備好。輸入第一個指令開始。"] }, - ]); - const currentStep = LAB_STEPS[Math.min(git.step, LAB_STEPS.length - 1)]; - const progress = Math.round((git.step / LAB_STEPS.length) * 100); + const [history, setHistory] = useState(initialHistory); + const [feedback, setFeedback] = useState({ accepted: true, message: state.lastMessage }); + const currentStep = nextGitReleaseStep(state); + const completed = isGitReleaseComplete(state); - const branches = useMemo(() => ({ - main: git.commits.filter((commit) => commit.branch === "main"), - feature: git.commits.filter((commit) => commit.branch === "feature/avatar"), - }), [git.commits]); + function apply(result: ReturnType) { + setState(result.state); + setFeedback({ accepted: result.accepted, message: result.output.join(" ") }); + setHistory((items) => [...items, { command: result.state.lastCommand ?? undefined, lines: result.output, accepted: result.accepted }]); + if (result.accepted && isGitReleaseComplete(result.state)) onComplete(); + } + + function dispatch(event: GitReleaseEvent) { + const result = runGitReleaseEvent(state, event); + apply(result); + } function submit(event: FormEvent) { event.preventDefault(); - if (!command.trim() || git.complete) return; - const result = runGitCommand(git, command); - setGit(result.state); - setHistory((items) => [...items, { command, lines: result.output, accepted: result.accepted }]); + if (!command.trim() || completed) return; + const submittedCommand = command; setCommand(""); - if (result.state.complete) onComplete(); + const result = runGitReleaseCommand(state, submittedCommand); + setState(result.state); + setFeedback({ accepted: result.accepted, message: result.output.join(" ") }); + setHistory((items) => [...items, { command: submittedCommand, lines: result.output, accepted: result.accepted }]); + if (result.accepted && isGitReleaseComplete(result.state)) onComplete(); } function reset() { - setGit(initialGitState()); + const nextState = createInitialGitReleaseState(); + setState(nextState); setCommand(""); - setHistory([{ lines: ["Sandbox 已重設。從 git status 重新開始。"] }]); + setFeedback({ accepted: true, message: nextState.lastMessage }); + setHistory([{ lines: ["Sandbox 已重設。從 fork/clone 重新開始。"] }]); } + const currentCommand = GIT_RELEASE_STEPS.find((step) => step.id === currentStep)?.command ?? "完成"; + return ( -
-
-

INTERACTIVE LAB / GIT

把一個功能
安全合回 main

-
{git.complete ? "完成" : `${git.step} / ${LAB_STEPS.length}`}
-
+ 把一個功能
送進 pipeline} + progressLabel={completed ? "完成" : `${state.completedStepIds.length} / ${GIT_RELEASE_STEPS.length} STEPS`} + progress={gitReleaseProgress(state)} + onReset={reset} + > + {completed ? ( + + ) : ( + <> +
+
+
git-cowork — {state.localBranch}
+
+ {history.map((entry, index) => ( +
+ {entry.command ?

{entry.command}

: null} + {entry.lines.map((line, lineIndex) => {line})} +
+ ))} +
+
+ + setCommand(event.target.value)} placeholder="輸入 Git 指令或 pipeline 操作…" autoComplete="off" spellCheck={false} /> + +
+
-
-
-
git-workshop — {git.branch}
-
- {history.map((entry, index) => ( -
- {entry.command ?

{entry.command}

: null} - {entry.lines.map((line, lineIndex) => {line})} +
-
- - setCommand(event.target.value)} - placeholder={git.complete ? "Lab 已完成" : "輸入 Git 指令…"} - autoComplete="off" - spellCheck={false} - disabled={git.complete} - /> - -
-
- -
-
-

LIVE HISTORY

Commit graph

指令成功後,分支指標與歷史會立即更新。

-
-
main
{branches.main.map((commit) => {commit.id}{commit.message})}
-
feature/avatar
{branches.feature.length ? branches.feature.map((commit) => {commit.id}{commit.message}) : 尚未建立}
-
-
-
+
+

PIPELINE JOBS

push 之後 runner 執行什麼?

每個 job 都是可讀的 fixture 結果,不連線真實 CI provider。

+
+ {GIT_RELEASE_PIPELINE_JOBS.map((job) =>
{state.pipelineJobs.includes(job) ? "✓" : "○"}{job}
)} +
+ +
+ + )} +
); } diff --git a/frontend/src/components/GitLesson.tsx b/frontend/src/components/GitLesson.tsx index 6ed3e3a..3461ca8 100644 --- a/frontend/src/components/GitLesson.tsx +++ b/frontend/src/components/GitLesson.tsx @@ -1,4 +1,4 @@ -import { gitLessons, gitWorkflow } from "../content/git"; +import { gitCommandGuide, gitLessons, gitPipeline, gitWorkflow } from "../content/git"; export function GitLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { return ( @@ -7,7 +7,7 @@ export function GitLesson({ completed, onOpenLab }: { completed: boolean; onOpen

MODULE 01 / FOUNDATIONS

Git:讓改變
有跡可循

-

先建立正確的心智模型,再到 Lab 親手走過一次 branch → commit → merge。

+

先建立本地歷史與遠端協作的心智模型,再到 Lab 親手走過 clone → commit → push → pipeline → merge。

{completed ? "✓" : "01"} @@ -44,8 +44,38 @@ export function GitLesson({ completed, onOpenLab }: { completed: boolean; onOpen ))} +
+

COMMAND DECISION GUIDE

知道指令,更要知道什麼時候用

每個操作都要能說明它改變了哪一層狀態。

+ {gitCommandGuide.map((guide, index) => ( +
+
{String(index + 1).padStart(2, "0")}{guide.category}
+
+

{guide.title}

+

{guide.body}

+ $ {guide.command} +
+ +
+ ))} +
+ +
+
+

FROM COMMIT TO DEPLOY

push 之後,平台做了什麼?

+

Git command 改變 repository;hosted platform 再依規則啟動 review、pipeline 與部署。

+
+
+ {gitPipeline.map(([label, title, description], index) => ( +
+ 0{index + 1}

{title}

{label} · {description}

+ {index < gitPipeline.length - 1 ? : null} +
+ ))} +
+
+
-

READY TO PRACTICE?

看懂了,現在親手做一次。

8 個指令,約 8 分鐘。輸錯不會破壞任何真實檔案。

+

READY TO PRACTICE?

看懂了,現在親手做一次。

一條 cowork workflow,約 12 分鐘。輸錯不會破壞任何真實 repository。

diff --git a/frontend/src/components/IdeLab.tsx b/frontend/src/components/IdeLab.tsx new file mode 100644 index 0000000..ef1c360 --- /dev/null +++ b/frontend/src/components/IdeLab.tsx @@ -0,0 +1,206 @@ +import { type FormEvent, useMemo, useState } from "react"; +import { ideFileFixture, type IdeLabState, type IdeStepId } from "../topics/ide/content"; +import { + createIdeLabState, + ideLabIsComplete, + runIdeCommand, +} from "../topics/ide/simulator"; +import { + TopicCompletionCard, + TopicLabShell, + TopicStatusFeedback, + type TopicStatusTone, +} from "./TopicShell"; + +interface IdeAction { + id: IdeStepId; + label: string; + command: string; +} +interface DebugHistoryEntry { + command?: string; + lines: readonly string[]; + accepted?: boolean; + stream?: "stdout" | "stderr"; + exitCode?: number | null; +} + +const IDE_ACTIONS: readonly IdeAction[] = [ + { id: "open", label: "開啟固定檔案", command: "open src/order.ts" }, + { id: "breakpoint", label: "設定第 3 行 breakpoint", command: "breakpoint 3" }, + { id: "run", label: "啟動 calculateTotal", command: "run calculateTotal(10, 2, 3)" }, + { id: "inspect", label: "檢查目前 variables", command: "inspect variables" }, + { id: "step", label: "執行 step over", command: "step over" }, + { id: "continue", label: "繼續到程式結束", command: "continue" }, +]; + +const initialHistory: readonly DebugHistoryEntry[] = [ + { lines: ["IDE Workshop sandbox v1", "固定 order.ts fixture 已準備好。選擇一個 debugger action 開始。"] }, +]; + +function statusTone(state: IdeLabState): TopicStatusTone { + if (state.phase === "completed") return "success"; + if (state.phase === "failed") return "error"; + return "neutral"; +} + +function statusMessage(state: IdeLabState): string { + if (state.phase === "failed") { + return `${state.lastStream ?? "stderr"} · exit ${state.exitCode ?? "—"} · phase ${state.phase} · line ${state.currentLine ?? "—"} · ${state.lastMessage}`; + } + return state.lastMessage; +} + +export function IdeLab({ onComplete }: { onComplete?: () => void }) { + const [ide, setIde] = useState(createIdeLabState); + const [command, setCommand] = useState(""); + const [history, setHistory] = useState(initialHistory); + const completed = ideLabIsComplete(ide); + const completedCount = ide.completedStepIds.length; + const currentAction = useMemo( + () => IDE_ACTIONS.find((action) => !ide.completedStepIds.includes(action.id)) ?? IDE_ACTIONS[IDE_ACTIONS.length - 1], + [ide.completedStepIds], + ); + + function dispatch(rawCommand: string) { + if (!rawCommand.trim() || completed) return; + const result = runIdeCommand(ide, rawCommand); + setIde(result.state); + setHistory((items) => [ + ...items, + { + command: rawCommand.trim(), + lines: result.output, + accepted: result.accepted, + stream: result.stream ?? undefined, + exitCode: result.state.exitCode, + }, + ]); + if (!completed && ideLabIsComplete(result.state)) onComplete?.(); + } + + function submit(event: FormEvent) { + event.preventDefault(); + dispatch(command); + setCommand(""); + } + + function reset() { + setIde(createIdeLabState()); + setCommand(""); + setHistory([{ lines: ["Sandbox 已重設。從開啟 src/order.ts 重新開始。"] }]); + } + + return ( + 用除錯器看見
程式正在做什麼} + progressLabel={completed ? "完成" : `${completedCount} / ${IDE_ACTIONS.length}`} + progress={Math.round((completedCount / IDE_ACTIONS.length) * 100)} + onReset={reset} + > + + + {completed ? ( + + ) : ( +
+
+
+ + {ide.selectedFile ?? "workspace"} + {ide.phase} +
+
TS src/order.ts
+
+ {ideFileFixture.content.split("\n").map((line, index) => { + const lineNumber = index + 1; + const isCurrent = ide.currentLine === lineNumber; + const hasBreakpoint = ide.breakpointLines.includes(lineNumber); + return ( + + ); + })} +
+
+ {history.map((entry, index) => ( +
+ {entry.command ?

{entry.command}

: null} + {entry.lines.map((line, lineIndex) => {entry.stream ? `${entry.stream} · exit ${entry.exitCode ?? "—"} · ` : null}{line})} +
+ ))} +
+
+ + setCommand(event.target.value)} + placeholder="輸入 debugger 指令…" + autoComplete="off" + spellCheck={false} + aria-describedby="ide-command-help" + /> + +
+

可輸入教材中的 debugger 指令,或使用右側 mission buttons。

+
+ + +
+ )} + +
+

LIVE DEBUG STATE

目前的執行線索

只顯示 simulator 狀態,不連接本機 process。

+
+
PHASE{ide.phase}
+
CURRENT LINE{ide.currentLine ?? "—"}
+
CALL STACK{ide.callStack.join(" → ") || "—"}
+
BREAKPOINTS{ide.breakpointLines.join(", ") || "—"}
+
VARIABLES{Object.entries(ide.variables).map(([name, value]) => `${name}=${value}`).join(" · ") || "—"}
+
OUTPUT{ide.output.at(-1) ?? "—"}
+
+
+
+ ); +} diff --git a/frontend/src/components/TopicRouteView.tsx b/frontend/src/components/TopicRouteView.tsx new file mode 100644 index 0000000..c470092 --- /dev/null +++ b/frontend/src/components/TopicRouteView.tsx @@ -0,0 +1,26 @@ +import type { RouteDefinition } from "../routes/registry"; +import { getTopicViewModule } from "../topics/registry"; + +export interface TopicRouteViewProps { + route: RouteDefinition; + completed: boolean; + onOpenLab: () => void; + onComplete: () => void; +} + +export function TopicRouteView({ route, completed, onOpenLab, onComplete }: TopicRouteViewProps) { + const topicModule = getTopicViewModule(route.topicId); + if (!topicModule) return null; + + if (route.kind === "lesson") { + const Lesson = topicModule.lesson; + return ; + } + + if (route.kind === "lab") { + const Lab = topicModule.lab; + return ; + } + + return null; +} diff --git a/frontend/src/components/TopicShell.tsx b/frontend/src/components/TopicShell.tsx new file mode 100644 index 0000000..cd2b57f --- /dev/null +++ b/frontend/src/components/TopicShell.tsx @@ -0,0 +1,89 @@ +import type { ReactNode } from "react"; + +export interface TopicLessonShellProps { + eyebrow: string; + title: ReactNode; + description: ReactNode; + moduleNumber?: string; + completed: boolean; + children: ReactNode; +} + +export function TopicLessonShell({ + eyebrow, + title, + description, + moduleNumber = "01", + completed, + children, +}: TopicLessonShellProps) { + return ( +
+
+
+

{eyebrow}

+

{title}

+

{description}

+
+
+ +
MODULE STATUS{completed ? "已完成" : "學習中"}
+
+
+
{children}
+
+ ); +} + +export interface TopicLabShellProps { + eyebrow: string; + title: ReactNode; + progressLabel: string; + progress: number; + onReset: () => void; + children: ReactNode; +} + +export function TopicLabShell({ + eyebrow, + title, + progressLabel, + progress, + onReset, + children, +}: TopicLabShellProps) { + const boundedProgress = Math.max(0, Math.min(100, progress)); + + return ( +
+
+

{eyebrow}

{title}

+
+ {progressLabel} +
+
+
+
+ +
+
{children}
+
+ ); +} + +export type TopicStatusTone = "neutral" | "error" | "success"; + +export function TopicStatusFeedback({ tone = "neutral", message }: { tone?: TopicStatusTone; message: string }) { + return

{message}

; +} + +export function TopicCompletionCard({ title, description, onReset }: { title: string; description: string; onReset?: () => void }) { + return ( +
+ +

{title}

+

{description}

+ {onReset ? : null} +
+ ); +} diff --git a/frontend/src/components/TrackPage.tsx b/frontend/src/components/TrackPage.tsx new file mode 100644 index 0000000..7eccc53 --- /dev/null +++ b/frontend/src/components/TrackPage.tsx @@ -0,0 +1,65 @@ +import type { Track } from "../types"; + +interface TrackPageProps { + track: Track; + completedTopicIds: readonly string[]; + onBackToMap: () => void; + onOpenTopic: (topicId: string) => void; +} + +export function TrackPage({ track, completedTopicIds, onBackToMap, onOpenTopic }: TrackPageProps) { + const completed = new Set(completedTopicIds); + const readyCount = track.topics.filter((topic) => topic.status === "ready").length; + const completedCount = track.topics.filter((topic) => topic.status === "ready" && completed.has(topic.id)).length; + const progress = track.topics.length === 0 ? 0 : Math.round((completedCount / track.topics.length) * 100); + + return ( +
+ + +
+
+

TRACK / {track.id.toUpperCase()}

+

{track.title}

+

{track.description}。先從已開放的 topic 開始,完成後再回到這個分類繼續。

+
+
+ {String(completedCount).padStart(2, "0")} / {String(track.topics.length).padStart(2, "0")} +
+ {readyCount} 個 topic 已開放 +
+
+ +
+
+

TOPIC SEQUENCE

這個分類包含什麼?

+

先理解主題,再進入對應的 Lab。

+
+ +
+ {track.topics.map((topic, index) => { + const isReady = topic.status === "ready"; + const isComplete = isReady && completed.has(topic.id); + return ( +
+
{String(index + 1).padStart(2, "0")}
+
+

{isComplete ? "已完成" : isReady ? "READY" : "規劃中"}

+

{topic.title}

+

{topic.summary}

+
+
+ {isReady ? ( + + ) : 尚未開放} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/content/git.test.ts b/frontend/src/content/git.test.ts new file mode 100644 index 0000000..443b4d6 --- /dev/null +++ b/frontend/src/content/git.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { gitCommandGuide, gitPipeline } from "./git"; + +describe("Git lesson release contract", () => { + it("explains all required Git and platform operations", () => { + expect(gitCommandGuide).toHaveLength(12); + expect(gitCommandGuide.map((guide) => guide.id)).toEqual([ + "clone", + "checkout", + "add", + "commit", + "stash", + "fetch", + "pull", + "rebase", + "cherry-pick", + "push", + "merge", + "fork", + ]); + gitCommandGuide.forEach((guide) => { + expect(guide.body.length).toBeGreaterThan(30); + expect(guide.when.length).toBeGreaterThan(15); + expect(guide.takeaway.length).toBeGreaterThan(10); + }); + }); + + it("explains the hosted pipeline after commit and push", () => { + expect(gitPipeline.map(([label]) => label)).toEqual(["commit", "push", "PR/MR", "pipeline", "merge", "deploy"]); + expect(gitPipeline.join(" ")).toContain("runner"); + expect(gitPipeline.join(" ")).toContain("npm ci"); + }); +}); diff --git a/frontend/src/content/git.ts b/frontend/src/content/git.ts index be00e79..7c450d9 100644 --- a/frontend/src/content/git.ts +++ b/frontend/src/content/git.ts @@ -25,6 +25,126 @@ export const gitLessons = [ }, ] as const; +export const gitCommandGuide = [ + { + id: "clone", + category: "START", + title: "先把專案帶到本機", + command: "git clone ", + body: "第一次參與既有專案時,用 clone 建立本地 repository,同時設定 origin。若你沒有原 repository 的寫入權限,先在 GitHub/GitLab fork,再 clone 自己的 copy。", + when: "第一次開始一個既有 repository;fork 是平台動作,不是 Git 指令。", + takeaway: "clone 複製專案;fork 建立 hosted copy。", + }, + { + id: "checkout", + category: "BRANCH", + title: "切換你要工作的分支", + command: "git checkout -b feature/profile", + body: "checkout 的 legacy 用法可以建立並切換分支。現代 Git 通常用 git switch -c feature/profile;checkout 也能從歷史取出檔案,這正是它容易讓人混淆的地方。", + when: "開始功能工作或切換既有分支;只想還原檔案時改用 git restore。", + takeaway: "切換分支前先確認未提交修改,不要把 checkout 當成同步遠端。", + }, + { + id: "add", + category: "LOCAL", + title: "挑選這次要提交的修改", + command: "git add src/profile.ts", + body: "add 把指定檔案的目前版本放進 staging area。它不是存檔,也不會建立 commit;你可以用它把一個大修改拆成幾個可 review 的單位。", + when: "準備建立 commit,且只想提交部分檔案或部分變更。", + takeaway: "先挑選,再提交;staging 是下一個 commit 的草稿。", + }, + { + id: "commit", + category: "LOCAL", + title: "留下可追蹤的本地快照", + command: 'git commit -m "add profile page"', + body: "commit 將暫存內容寫進本地歷史,形成可比較、可回復、可 review 的變更單位。commit 完成後,遠端與其他 coworker 還看不到它。", + when: "一組完整且能說明目的的變更已經準備好。", + takeaway: "commit 是本地歷史,不是發布,也不會自動觸發遠端 CI。", + }, + { + id: "stash", + category: "LOCAL", + title: "暫時收起還沒完成的工作", + command: "git stash", + body: "stash 把未完成的工作暫時收起,讓工作目錄回到乾淨狀態。它適合切換任務或處理緊急修正,但不是正式歷史,也不是團隊共享的備份。", + when: "手上的修改還不能 commit,卻必須先切換分支或處理另一件事。", + takeaway: "stash 是暫存工作;回來後要用 stash pop 或 stash apply 恢復。", + }, + { + id: "fetch", + category: "REMOTE", + title: "先更新你對遠端的認知", + command: "git fetch origin", + body: "fetch 下載遠端最新的 objects 與 branch 參照,更新 origin/dev 等 remote-tracking branch,但不直接修改你目前的工作檔案。", + when: "要比較遠端進度、準備 rebase/merge,或先確認 coworker 是否已發布新 commit。", + takeaway: "fetch 只更新參照;它不會替你整合變更。", + }, + { + id: "pull", + category: "REMOTE", + title: "取得並整合遠端變更", + command: "git pull --rebase origin dev", + body: "pull 通常等於 fetch 加上 merge 或 rebase,會把遠端變更整合進目前分支,因此可能改變工作目錄。團隊應明確知道專案採 merge 還是 rebase,避免每個人得到不同歷史。", + when: "你確定要把遠端變更直接整合到目前分支,而不是只先觀察遠端狀態。", + takeaway: "pull 會整合;fetch 只觀察。", + }, + { + id: "rebase", + category: "HISTORY", + title: "把自己的分支接到最新基線", + command: "git rebase origin/dev", + body: "rebase 將自己的 commit 重新接到最新 origin/dev 後面,讓待審查歷史更直。它可能改寫 commit ID,不應任意對已被多人共用的 branch 使用。", + when: "發布功能分支前,想先整理自己的歷史並提早發現衝突。", + takeaway: "rebase 整理自己的分支,不是覆寫遠端 dev。", + }, + { + id: "cherry-pick", + category: "HISTORY", + title: "只移植一個特定修正", + command: "git cherry-pick a1b2c3d", + body: "cherry-pick 把指定 commit 的變更套用到目前分支,並建立一個新的 commit。它適合把 hotfix 或單一修正移植到另一條 release branch。", + when: "只需要某一個 commit,不想把整條功能分支 merge 進來。", + takeaway: "cherry-pick 移植單一 commit;可能造成衝突或重複修正。", + }, + { + id: "push", + category: "REMOTE", + title: "把本地歷史發布給團隊", + command: "git push -u origin feature/profile", + body: "push 將本地缺少的 objects 與 branch ref 傳到 hosted repository。遠端 branch 更新後,其他人、PR/MR 與 CI 才能看到這組 commit。", + when: "本地 commit 已整理好,準備讓 coworker review 或觸發平台流程。", + takeaway: "push 發布 branch,不等於 merge 到 dev 或 main。", + }, + { + id: "merge", + category: "INTEGRATE", + title: "把兩條歷史整合起來", + command: "git merge feature/profile", + body: "merge 將指定分支的歷史整合到目前分支,可能產生 merge commit,也可能需要解決衝突。平台上的 PR/MR merge 是同一個概念加上 review、checks 與權限規則。", + when: "功能分支已 review 且通過 required checks,要整合進 dev 或 main。", + takeaway: "merge 是整合歷史;先確認 review、CI 與 branch protection。", + }, + { + id: "fork", + category: "PLATFORM", + title: "沒有寫入權限時先建立副本", + command: "Fork repository → git clone ", + body: "fork 是 GitHub/GitLab 上的 hosted copy,常用於沒有原 repository 寫入權限的協作者。你 clone 自己的 fork,再透過 upstream 與 PR/MR 把變更送回原專案。", + when: "外部 contributor 或跨團隊協作者需要在沒有直接 push 權限的情況下貢獻。", + takeaway: "fork 是平台上的權限與協作邊界;不是本地 branch。", + }, +] as const; + +export const gitPipeline = [ + ["commit", "本地歷史增加", "只改變本地 repository,遠端與 CI 還看不到。"], + ["push", "遠端 branch 更新", "hosted repository 收到 objects 與 branch ref。"], + ["PR/MR", "建立 review context", "平台比較 source/target branch,等待 review 與 checks。"], + ["pipeline", "runner 執行 jobs", "checkout、npm ci、test、lint、build 產生可讀結果。"], + ["merge", "整合到 dev/main", "required checks 與權限通過後,平台才整合歷史。"], + ["deploy", "發布靜態產品", "本專案 push 到 main 後再建置並發布 frontend/dist。"], +] as const; + export const gitWorkflow = [ ["工作目錄", "修改尚未選入下一次提交"], ["暫存區", "下一個 commit 的精確草稿"], diff --git a/frontend/src/git/release-simulator.test.ts b/frontend/src/git/release-simulator.test.ts new file mode 100644 index 0000000..c60191b --- /dev/null +++ b/frontend/src/git/release-simulator.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + createInitialGitReleaseState, + isGitReleaseComplete, + runGitReleaseCommand, + runGitReleaseEvent, + type GitReleaseState, +} from "./release-simulator"; + +function runCommands(commands: readonly string[]): GitReleaseState { + return commands.reduce((state, command) => { + const result = runGitReleaseCommand(state, command); + expect(result.accepted, `${command}: ${result.output.join(" ")}`).toBe(true); + return result.state; + }, createInitialGitReleaseState()); +} + +describe("Git cowork release simulator", () => { + it("completes the local-to-pipeline cowork workflow", () => { + const state = runCommands([ + "Fork repository", + "git clone ", + "git checkout -b feature/profile", + "git stash", + "git stash pop", + "git diff", + "git add src/profile.ts", + 'git commit -m "add profile page"', + "git fetch origin", + "git pull --rebase origin dev", + "git rebase origin/dev", + "git cherry-pick a1b2c3d", + "git push -u origin feature/profile", + "Open PR", + ]); + + const failedPipeline = runGitReleaseCommand(state, "Run pipeline"); + expect(failedPipeline.accepted).toBe(true); + expect(failedPipeline.state.pipeline).toBe("failed"); + expect(failedPipeline.state.complete).toBe(false); + + const resolved = runGitReleaseEvent(failedPipeline.state, { type: "resolve-conflict" }); + expect(resolved.accepted).toBe(true); + const passed = runGitReleaseCommand(resolved.state, "Run pipeline"); + expect(passed.state.pipeline).toBe("passed"); + expect(passed.state.pipelineJobs).toEqual(["checkout", "install", "test", "lint", "build"]); + + const merged = runGitReleaseCommand(passed.state, "Merge PR"); + expect(merged.accepted).toBe(true); + expect(isGitReleaseComplete(merged.state)).toBe(true); + expect(merged.state.review).toBe("merged"); + expect(merged.state.localCommitCount).toBe(2); + }); + + it("keeps invalid order deterministic and explains the next operation", () => { + const initial = createInitialGitReleaseState(); + const result = runGitReleaseCommand(initial, "git pull --rebase origin dev"); + + expect(result.accepted).toBe(false); + expect(result.state).toBe(initial); + expect(result.output.join(" ")).toContain("Fork"); + }); + + it("supports GitLab Merge Request terminology", () => { + let state = createInitialGitReleaseState(); + state = runGitReleaseEvent(state, { type: "set-provider", provider: "gitlab" }).state; + expect(state.provider).toBe("gitlab"); + expect(state.lastMessage).toContain("GitLab Merge Request"); + }); +}); diff --git a/frontend/src/git/release-simulator.ts b/frontend/src/git/release-simulator.ts new file mode 100644 index 0000000..a55039a --- /dev/null +++ b/frontend/src/git/release-simulator.ts @@ -0,0 +1,252 @@ +export type GitReleasePhase = "initial" | "local" | "published" | "review" | "blocked" | "merged"; +export type GitProvider = "github" | "gitlab"; +export type RepositoryAccess = "none" | "forked" | "cloned"; +export type WorkingTreeState = "clean" | "dirty" | "staged"; +export type RemoteBaseState = "unknown" | "fetched" | "pulled" | "rebased"; +export type PipelineState = "not-started" | "running" | "failed" | "passed"; +export type ReviewState = "none" | "open" | "merged"; + +export type GitReleaseStepId = + | "fork" + | "clone" + | "checkout" + | "stash" + | "stash-pop" + | "diff" + | "add" + | "commit" + | "fetch" + | "pull" + | "rebase" + | "cherry-pick" + | "push" + | "open-review" + | "pipeline" + | "resolve-conflict" + | "merge"; + +export interface GitReleaseState { + phase: GitReleasePhase; + provider: GitProvider; + repositoryAccess: RepositoryAccess; + localBranch: "none" | "dev" | "feature/profile"; + targetBranch: "dev" | "main"; + workingTree: WorkingTreeState; + remoteBase: RemoteBaseState; + localCommitCount: number; + remoteBranch: "absent" | "published"; + review: ReviewState; + pipeline: PipelineState; + pipelineJobs: readonly ("checkout" | "install" | "test" | "lint" | "build")[]; + stashCount: number; + selectedCommit: string | null; + conflict: boolean; + completedStepIds: readonly GitReleaseStepId[]; + lastCommand: string | null; + lastMessage: string; + complete: boolean; + canReset: true; +} + +export interface GitReleaseEvent { + type: GitReleaseStepId | "set-provider"; + provider?: GitProvider; +} + +export interface GitReleaseCommandResult { + state: GitReleaseState; + output: readonly string[]; + accepted: boolean; +} + +export const GIT_RELEASE_STEPS: readonly { id: GitReleaseStepId; command: string; hint: string }[] = [ + { id: "fork", command: "Fork repository", hint: "沒有原 repository 寫入權限,先建立自己的 hosted copy。" }, + { id: "clone", command: "git clone ", hint: "把 fork 的 repository 複製到本機,建立 origin。" }, + { id: "checkout", command: "git checkout -b feature/profile", hint: "從 dev 切出功能分支;現代替代寫法是 git switch -c。" }, + { id: "stash", command: "git stash", hint: "先暫存一個尚未完成的修改,練習安全切換工作。" }, + { id: "stash-pop", command: "git stash pop", hint: "回到功能工作,恢復剛才暫存的修改。" }, + { id: "diff", command: "git diff", hint: "提交前檢查這次到底改了什麼。" }, + { id: "add", command: "git add src/profile.ts", hint: "把 profile 變更放入下一個 commit 的 staging area。" }, + { id: "commit", command: 'git commit -m "add profile page"', hint: "留下第一個可 review 的本地快照。" }, + { id: "fetch", command: "git fetch origin", hint: "先取得遠端最新參照,不直接改工作目錄。" }, + { id: "pull", command: "git pull --rebase origin dev", hint: "示範 pull 如何取得並整合遠端基線。" }, + { id: "rebase", command: "git rebase origin/dev", hint: "把自己的 commit 接到最新基線後面。" }, + { id: "cherry-pick", command: "git cherry-pick a1b2c3d", hint: "把一個 hotfix commit 移植到目前功能分支。" }, + { id: "push", command: "git push -u origin feature/profile", hint: "把本地 branch 發布到 hosted repository。" }, + { id: "open-review", command: "Open PR / MR", hint: "建立 source branch 到 target branch 的 review context。" }, + { id: "pipeline", command: "Run pipeline", hint: "觀察 runner 如何執行 checkout、install、test、lint、build。" }, + { id: "resolve-conflict", command: "Resolve conflict", hint: "先處理 pipeline 暴露的衝突,再重新執行檢查。" }, + { id: "merge", command: "Merge PR / MR", hint: "required checks 通過後,將功能整合進 dev。" }, +]; + +export const GIT_RELEASE_PIPELINE_JOBS: readonly GitReleaseState["pipelineJobs"][number][] = [ + "checkout", + "install", + "test", + "lint", + "build", +]; + +export function createInitialGitReleaseState(): GitReleaseState { + return { + phase: "initial", + provider: "github", + repositoryAccess: "none", + localBranch: "none", + targetBranch: "dev", + workingTree: "clean", + remoteBase: "unknown", + localCommitCount: 0, + remoteBranch: "absent", + review: "none", + pipeline: "not-started", + pipelineJobs: [], + stashCount: 0, + selectedCommit: null, + conflict: false, + completedStepIds: [], + lastCommand: null, + lastMessage: "準備從 fork/clone 開始一次可 review 的 cowork workflow。", + complete: false, + canReset: true, + }; +} + +function withCompletedStep(state: GitReleaseState, step: GitReleaseStepId): GitReleaseState { + return state.completedStepIds.includes(step) + ? state + : { ...state, completedStepIds: [...state.completedStepIds, step] }; +} + +function blocked(current: GitReleaseState, message: string): GitReleaseCommandResult { + return { + state: current, + accepted: false, + output: ["操作被阻擋。", message], + }; +} + +export function nextGitReleaseStep(state: GitReleaseState): GitReleaseStepId | null { + return GIT_RELEASE_STEPS.find((step) => !state.completedStepIds.includes(step.id))?.id ?? null; +} + +function accepted(state: GitReleaseState, output: readonly string[]): GitReleaseCommandResult { + return { state, accepted: true, output }; +} + +export function isGitReleaseComplete(state: GitReleaseState): boolean { + return state.complete && GIT_RELEASE_STEPS.every((step) => state.completedStepIds.includes(step.id)); +} + +export function gitReleaseProgress(state: GitReleaseState): number { + return Math.round((state.completedStepIds.length / GIT_RELEASE_STEPS.length) * 100); +} + +export function runGitReleaseEvent(current: GitReleaseState, event: GitReleaseEvent): GitReleaseCommandResult { + if (event.type === "set-provider") { + if (!event.provider || current.review !== "none") return blocked(current, "PR/MR 建立後不能切換 provider;請 reset 後重新選擇。"); + return accepted({ ...current, provider: event.provider, lastMessage: `目前使用 ${event.provider === "github" ? "GitHub PR" : "GitLab Merge Request"}。` }, [ + event.provider === "github" ? "GitHub PR 已選定。" : "GitLab Merge Request 已選定。", + "兩者都會比較 source/target branch 並等待 pipeline checks。", + ]); + } + + if (current.complete) return blocked(current, "流程已完成;如要重練,請先 reset。 "); + + if (event.type === "resolve-conflict") { + if (!current.conflict || current.pipeline !== "failed") return blocked(current, "目前沒有待處理的 pipeline conflict。"); + const state = { + ...withCompletedStep(current, "resolve-conflict"), + phase: "review" as const, + pipeline: "not-started" as const, + pipelineJobs: [], + conflict: false, + lastCommand: "Resolve conflict", + lastMessage: "衝突已處理;請重新執行 pipeline,確認所有 jobs 都通過。", + }; + return accepted(state, ["Conflict resolved。", "請重新執行 pipeline。"]); + } + + const expected = nextGitReleaseStep(current); + if (event.type !== expected) { + return blocked(current, expected ? `目前應先完成:${GIT_RELEASE_STEPS.find((step) => step.id === expected)?.command}` : "目前沒有可執行的下一步。"); + } + + switch (event.type) { + case "fork": + return accepted({ ...withCompletedStep(current, "fork"), phase: "local", repositoryAccess: "forked", lastCommand: "Fork repository", lastMessage: "已建立自己的 hosted copy;接著 clone 這個 URL。" }, ["Fork completed。", "你現在可以對自己的 fork clone,但還沒有修改原 repository。"]); + case "clone": + if (current.repositoryAccess !== "forked") return blocked(current, "先 fork 或取得可讀取的 hosted repository,再執行 clone。"); + return accepted({ ...withCompletedStep(current, "clone"), phase: "local", repositoryAccess: "cloned", localBranch: "dev", lastCommand: "git clone ", lastMessage: "本地 repository 已建立,origin 指向你的 hosted copy。" }, ["Cloning into 'workshop'...", "origin 已設定;目前在 dev。"]); + case "checkout": + if (current.localBranch !== "dev") return blocked(current, "clone 完成後應先位於 dev,才能切出功能分支。"); + return accepted({ ...withCompletedStep(current, "checkout"), phase: "local", localBranch: "feature/profile", workingTree: "dirty", lastCommand: "git checkout -b feature/profile", lastMessage: "已切到 feature/profile;fixture 放入尚未完成的 profile 修改。" }, ["Switched to a new branch 'feature/profile'。", "現代替代寫法:git switch -c feature/profile。"]); + case "stash": + if (current.workingTree !== "dirty") return blocked(current, "目前沒有未提交修改可 stash。"); + return accepted({ ...withCompletedStep(current, "stash"), workingTree: "clean", stashCount: current.stashCount + 1, lastCommand: "git stash", lastMessage: "未完成修改已暫存,工作目錄回到 clean。" }, ["Saved working directory and index state。", "現在可以安全切換或處理其他工作。"]); + case "stash-pop": + if (current.stashCount < 1 || current.workingTree !== "clean") return blocked(current, "先有一筆 stash 且工作目錄必須是 clean,才能 stash pop。"); + return accepted({ ...withCompletedStep(current, "stash-pop"), workingTree: "dirty", stashCount: current.stashCount - 1, lastCommand: "git stash pop", lastMessage: "profile 修改已恢復,準備檢查差異。" }, ["On branch feature/profile。", "Changes restored from stash。"]); + case "diff": + if (current.workingTree !== "dirty") return blocked(current, "目前沒有未提交修改可檢查;先恢復 stash 內容。"); + return accepted({ ...withCompletedStep(current, "diff"), lastCommand: "git diff", lastMessage: "已檢查 profile diff,確認沒有把不相關修改帶進 commit。" }, ["diff -- src/profile.ts", "+ add profile card", "先看清楚,再決定要 add 哪些檔案。"]); + case "add": + if (current.workingTree !== "dirty") return blocked(current, "目前沒有 dirty 修改可加入 staging area。"); + return accepted({ ...withCompletedStep(current, "add"), workingTree: "staged", lastCommand: "git add src/profile.ts", lastMessage: "profile 變更已進入 staging area。" }, ["src/profile.ts 已加入暫存區。", "下一個 commit 只會包含 staged 內容。"]); + case "commit": + if (current.workingTree !== "staged") return blocked(current, "先用 git add 把要提交的檔案放進 staging area。"); + return accepted({ ...withCompletedStep(current, "commit"), workingTree: "clean", localCommitCount: 1, lastCommand: 'git commit -m "add profile page"', lastMessage: "本地已有可 review 的 commit;遠端仍然不知道這個變更。" }, ["[feature/profile a1b2c3d] add profile page", "1 file changed", "commit 只存在本地,尚未觸發遠端 pipeline。"]); + case "fetch": + if (current.workingTree !== "clean") return blocked(current, "先處理未提交修改,再 fetch 遠端基線。"); + return accepted({ ...withCompletedStep(current, "fetch"), remoteBase: "fetched", lastCommand: "git fetch origin", lastMessage: "origin/dev 已更新;目前工作目錄沒有被 fetch 改動。" }, ["origin/dev updated。", "fetch 只更新 remote-tracking ref。"]); + case "pull": + if (current.remoteBase !== "fetched") return blocked(current, "先 fetch,再用 pull --rebase 示範取得並整合遠端基線。"); + return accepted({ ...withCompletedStep(current, "pull"), remoteBase: "pulled", lastCommand: "git pull --rebase origin dev", lastMessage: "pull 已示範 fetch 加 rebase;現在可明確整理自己的 feature branch。" }, ["Already up to date with origin/dev。", "pull 會整合;fetch 本身不會。"]); + case "rebase": + if (current.remoteBase !== "pulled") return blocked(current, "先完成 pull --rebase,確認遠端基線,再整理 feature branch。"); + return accepted({ ...withCompletedStep(current, "rebase"), remoteBase: "rebased", lastCommand: "git rebase origin/dev", lastMessage: "feature/profile 已接到最新 origin/dev 後面。" }, ["Successfully rebased and updated refs/heads/feature/profile。", "這次整理的是自己的 branch。"]); + case "cherry-pick": + if (current.remoteBase !== "rebased") return blocked(current, "先完成 fetch、pull 與 rebase,再移植 hotfix commit。"); + return accepted({ ...withCompletedStep(current, "cherry-pick"), localCommitCount: current.localCommitCount + 1, selectedCommit: "a1b2c3d", lastCommand: "git cherry-pick a1b2c3d", lastMessage: "hotfix commit 已以新的 commit 套用到 feature/profile。" }, ["[feature/profile d4e5f6a] apply hotfix", "1 commit cherry-picked", "cherry-pick 只移植一個 commit,不會整條 branch merge。"]); + case "push": + if (current.remoteBase !== "rebased" || current.localCommitCount < 1) return blocked(current, "先完成本地 commit 與 rebase,再發布 feature branch。"); + return accepted({ ...withCompletedStep(current, "push"), phase: "published", remoteBranch: "published", lastCommand: "git push -u origin feature/profile", lastMessage: "遠端 feature/profile 已更新;平台現在可以建立 PR/MR。" }, ["Enumerating objects...", "feature/profile -> feature/profile", "upstream branch set to origin/feature/profile。"]); + case "open-review": + if (current.remoteBranch !== "published") return blocked(current, "先 push,讓 hosted platform 看得到 source branch,再建立 PR/MR。"); + return accepted({ ...withCompletedStep(current, "open-review"), phase: "review", review: "open", lastCommand: current.provider === "github" ? "Open PR" : "Open Merge Request", lastMessage: `${current.provider === "github" ? "PR" : "Merge Request"} 已建立,等待 review 與 pipeline checks。` }, [current.provider === "github" ? "Pull Request opened: feature/profile → dev" : "Merge Request opened: feature/profile → dev", "source/target branch、diff 與 required checks 已可追蹤。"]); + case "pipeline": + if (current.review !== "open") return blocked(current, "先建立 PR/MR,pipeline 才有 source/target context。"); + if (!current.completedStepIds.includes("resolve-conflict")) { + return accepted({ ...current, phase: "blocked", pipeline: "failed", pipelineJobs: ["checkout", "install", "test"], conflict: true, lastCommand: "Run pipeline", lastMessage: "CI 在 test job 發現 fixture conflict;先處理 conflict 再重跑 pipeline。" }, ["Pipeline started。", "checkout ✓", "install ✓", "test ✕ conflict detected", "下一步:Resolve conflict。"]); + } + return accepted({ ...withCompletedStep(current, "pipeline"), phase: "review", pipeline: "passed", pipelineJobs: GIT_RELEASE_PIPELINE_JOBS, lastCommand: "Run pipeline", lastMessage: "所有 required jobs 通過;現在可以合併 PR/MR。" }, ["checkout ✓", "install ✓", "test ✓", "lint ✓", "build ✓", "Pipeline passed。"]); + case "merge": + if (current.pipeline !== "passed") return blocked(current, "pipeline 尚未通過;required checks 未完成前不能 merge。"); + return accepted({ ...withCompletedStep(current, "merge"), phase: "merged", review: "merged", complete: true, lastCommand: current.provider === "github" ? "Merge PR" : "Merge MR", lastMessage: "功能已安全合併至 dev;Git cowork workflow 完成。" }, ["Required checks passed。", `${current.provider === "github" ? "PR" : "Merge Request"} merged into dev。`, "Git Lab 完成。"]); + default: + return blocked(current, "目前沒有可執行的操作。"); + } +} + +export function runGitReleaseCommand(current: GitReleaseState, rawCommand: string): GitReleaseCommandResult { + const command = rawCommand.trim().replace(/\s+/g, " "); + const lower = command.toLowerCase(); + if (lower === "fork repository" || lower === "fork") return runGitReleaseEvent(current, { type: "fork" }); + if (lower.startsWith("git clone")) return runGitReleaseEvent(current, { type: "clone" }); + if (lower.startsWith("git checkout") || lower.startsWith("git switch")) return runGitReleaseEvent(current, { type: "checkout" }); + if (lower === "git stash") return runGitReleaseEvent(current, { type: "stash" }); + if (lower === "git stash pop" || lower === "git stash apply") return runGitReleaseEvent(current, { type: "stash-pop" }); + if (lower === "git diff") return runGitReleaseEvent(current, { type: "diff" }); + if (lower.startsWith("git add")) return runGitReleaseEvent(current, { type: "add" }); + if (lower.startsWith("git commit")) return runGitReleaseEvent(current, { type: "commit" }); + if (lower.startsWith("git fetch")) return runGitReleaseEvent(current, { type: "fetch" }); + if (lower.startsWith("git pull")) return runGitReleaseEvent(current, { type: "pull" }); + if (lower.startsWith("git rebase")) return runGitReleaseEvent(current, { type: "rebase" }); + if (lower.startsWith("git cherry-pick")) return runGitReleaseEvent(current, { type: "cherry-pick" }); + if (lower.startsWith("git push")) return runGitReleaseEvent(current, { type: "push" }); + if (lower === "open pr" || lower === "open mr" || lower === "open pr / mr") return runGitReleaseEvent(current, { type: "open-review" }); + if (lower === "run pipeline" || lower === "ci run") return runGitReleaseEvent(current, { type: "pipeline" }); + if (lower === "resolve conflict") return runGitReleaseEvent(current, { type: "resolve-conflict" }); + if (lower === "merge pr" || lower === "merge mr" || lower === "merge pr / mr") return runGitReleaseEvent(current, { type: "merge" }); + return blocked(current, "這個操作不在本次 Git release fixture;請先讀目前任務與下一步提示。"); +} diff --git a/frontend/src/progress/aggregation.test.ts b/frontend/src/progress/aggregation.test.ts new file mode 100644 index 0000000..27f3575 --- /dev/null +++ b/frontend/src/progress/aggregation.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import curriculumData from "../../../shared/curriculum.json"; +import type { Curriculum } from "../types"; +import { aggregateProgress, completedReadyTopicIds } from "./aggregation"; +import type { ProgressRepository } from "./repository"; + +class MemoryProgressRepository implements ProgressRepository { + constructor(private readonly completed = new Set()) {} + + readonly reads: string[] = []; + + read(topicId: string): boolean { + this.reads.push(topicId); + return this.completed.has(topicId); + } + + markComplete(topicId: string): void { + this.completed.add(topicId); + } + + clear(topicId: string): void { + this.completed.delete(topicId); + } +} + +const curriculum = curriculumData as Curriculum; +const coreReadyTopicIds = curriculum.tracks + .filter((track) => (track.kind ?? "core") === "core") + .flatMap((track) => track.topics) + .filter((topic) => topic.status === "ready") + .map((topic) => topic.id); + +describe("progress aggregation", () => { + it("keeps Core progress separate and preserves the 19-topic denominator", () => { + const repository = new MemoryProgressRepository(new Set(["git", "auth", "remote"])); + const result = aggregateProgress(curriculum, repository); + + expect(result.coreProgress).toEqual({ kind: "core", total: 19, ready: coreReadyTopicIds.length, completed: 3, percent: 16 }); + expect(result.extensionProgress).toEqual({ kind: "extension", total: 1, ready: 1, completed: 0, percent: 0 }); + expect(repository.reads).toEqual([...coreReadyTopicIds, "guardrail"]); + }); + + it("does not count planned topics even when the repository contains a value", () => { + const repository = new MemoryProgressRepository(new Set(curriculum.tracks.flatMap((track) => track.topics.map((topic) => topic.id)))); + const result = aggregateProgress(curriculum, repository); + + expect(result.coreProgress.completed).toBe(coreReadyTopicIds.length); + expect(result.extensionProgress.completed).toBe(1); + expect(repository.reads).toEqual([...coreReadyTopicIds, "guardrail"]); + }); + + it("calculates extension progress independently from Core", () => { + const extensionCurriculum: Curriculum = { + version: 1, + tracks: [ + { + id: "core-track", + title: "Core", + description: "Core topics", + topics: [ + { id: "git", title: "Git", summary: "Version control", status: "ready" }, + { id: "future", title: "Future", summary: "Planned", status: "planned" }, + ], + }, + { + id: "extension-track", + title: "Extension", + description: "Optional topics", + kind: "extension", + topics: [ + { id: "guardrail", title: "Guardrail", summary: "Safety", status: "ready" }, + { id: "later", title: "Later", summary: "Planned", status: "planned" }, + ], + }, + ], + }; + const result = aggregateProgress(extensionCurriculum, new MemoryProgressRepository(new Set(["git", "guardrail"]))); + + expect(result.coreProgress).toMatchObject({ total: 2, ready: 1, completed: 1, percent: 50 }); + expect(result.extensionProgress).toMatchObject({ total: 2, ready: 1, completed: 1, percent: 50 }); + }); + + it("ignores completion keys for planned topics", () => { + const repository = new MemoryProgressRepository(new Set(["git", "env"])); + + expect(completedReadyTopicIds(curriculum, repository)).toEqual(["git"]); + }); +}); diff --git a/frontend/src/progress/aggregation.ts b/frontend/src/progress/aggregation.ts new file mode 100644 index 0000000..85c845e --- /dev/null +++ b/frontend/src/progress/aggregation.ts @@ -0,0 +1,45 @@ +import type { Curriculum } from "../types"; +import type { TrackKind } from "../topics/types"; +import type { ProgressRepository } from "./repository"; + +export interface TrackProgress { + kind: TrackKind; + total: number; + ready: number; + completed: number; + percent: number; +} + +export interface ProgressAggregation { + coreProgress: TrackProgress; + extensionProgress: TrackProgress; +} + +export function completedReadyTopicIds(curriculum: Curriculum, repository: ProgressRepository): readonly string[] { + return curriculum.tracks + .flatMap((track) => track.topics) + .filter((topic) => topic.status === "ready" && repository.read(topic.id)) + .map((topic) => topic.id); +} + +function summarize(curriculum: Curriculum, repository: ProgressRepository, kind: TrackKind): TrackProgress { + const tracks = curriculum.tracks.filter((track) => (track.kind ?? "core") === kind); + const topics = tracks.flatMap((track) => track.topics); + const readyTopics = topics.filter((topic) => topic.status === "ready"); + const completed = readyTopics.filter((topic) => repository.read(topic.id)).length; + + return { + kind, + total: topics.length, + ready: readyTopics.length, + completed, + percent: topics.length === 0 ? 0 : Math.round((completed / topics.length) * 100), + }; +} + +export function aggregateProgress(curriculum: Curriculum, repository: ProgressRepository): ProgressAggregation { + return { + coreProgress: summarize(curriculum, repository, "core"), + extensionProgress: summarize(curriculum, repository, "extension"), + }; +} diff --git a/frontend/src/progress/repository.test.ts b/frontend/src/progress/repository.test.ts new file mode 100644 index 0000000..93f06cb --- /dev/null +++ b/frontend/src/progress/repository.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + completionKeyFor, + createLocalStorageProgressRepository, + PROTECTED_COMPLETION_KEYS, +} from "./repository"; + +class MemoryStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + removeItem(key: string): void { + this.values.delete(key); + } +} + +describe("ProgressRepository", () => { + it("reads, marks and clears topic completion", () => { + const storage = new MemoryStorage(); + const repository = createLocalStorageProgressRepository(storage); + + expect(repository.read("guardrail")).toBe(false); + + repository.markComplete("guardrail"); + expect(storage.getItem("se-workshop-guardrail-complete")).toBe("true"); + expect(repository.read("guardrail")).toBe(true); + + repository.clear("guardrail"); + expect(repository.read("guardrail")).toBe(false); + }); + + it("keeps the existing Git and Auth completion keys stable", () => { + const storage = new MemoryStorage(); + const repository = createLocalStorageProgressRepository(storage); + + storage.setItem(PROTECTED_COMPLETION_KEYS.git, "true"); + storage.setItem(PROTECTED_COMPLETION_KEYS.auth, "true"); + + expect(completionKeyFor("git")).toBe("se-workshop-git-complete"); + expect(completionKeyFor("auth")).toBe("se-workshop-auth-complete"); + expect(repository.read("git")).toBe(true); + expect(repository.read("auth")).toBe(true); + + repository.markComplete("git"); + repository.markComplete("auth"); + expect(storage.getItem(PROTECTED_COMPLETION_KEYS.git)).toBe("true"); + expect(storage.getItem(PROTECTED_COMPLETION_KEYS.auth)).toBe("true"); + }); + + it("rejects invalid topic ids before touching storage", () => { + const storage = new MemoryStorage(); + const repository = createLocalStorageProgressRepository(storage); + + expect(() => repository.read("../auth")).toThrow("Invalid topic id"); + expect(() => repository.markComplete(" ")).toThrow("Invalid topic id"); + expect(storage.getItem("se-workshop-auth-complete")).toBe(null); + }); +}); diff --git a/frontend/src/progress/repository.ts b/frontend/src/progress/repository.ts new file mode 100644 index 0000000..048eba7 --- /dev/null +++ b/frontend/src/progress/repository.ts @@ -0,0 +1,40 @@ +export interface ProgressRepository { + read(topicId: string): boolean; + markComplete(topicId: string): void; + clear(topicId: string): void; +} + +export const PROTECTED_COMPLETION_KEYS = { + git: "se-workshop-git-complete", + auth: "se-workshop-auth-complete", +} as const; + +type StorageLike = Pick; + +const topicIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function completionKeyFor(topicId: string): string { + const normalizedTopicId = topicId.trim(); + if (!topicIdPattern.test(normalizedTopicId)) { + throw new Error(`Invalid topic id: ${topicId}`); + } + + return PROTECTED_COMPLETION_KEYS[normalizedTopicId as keyof typeof PROTECTED_COMPLETION_KEYS] + ?? `se-workshop-${normalizedTopicId}-complete`; +} + +export function createLocalStorageProgressRepository( + storage: StorageLike = window.localStorage, +): ProgressRepository { + return { + read(topicId) { + return storage.getItem(completionKeyFor(topicId)) === "true"; + }, + markComplete(topicId) { + storage.setItem(completionKeyFor(topicId), "true"); + }, + clear(topicId) { + storage.removeItem(completionKeyFor(topicId)); + }, + }; +} diff --git a/frontend/src/routes/registry.test.ts b/frontend/src/routes/registry.test.ts new file mode 100644 index 0000000..3215cc1 --- /dev/null +++ b/frontend/src/routes/registry.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { Curriculum } from "../types"; +import { parseRoute, resolveRoute, ROUTE_REGISTRY, topicPath, trackPath } from "./registry"; + +const routeCurriculum: Curriculum = { + version: 1, + tracks: [ + { + id: "foundations", + title: "Foundations", + description: "Core topics", + topics: [ + { id: "git", title: "Git", summary: "Version control", status: "ready" }, + { id: "env", title: "Environment", summary: "Configuration", status: "planned" }, + ], + }, + ], +}; + +describe("route registry", () => { + it("preserves existing Git and Auth routes", () => { + expect(parseRoute("#/git")).toEqual({ path: "/git", kind: "lesson", topicId: "git" }); + expect(parseRoute("#/lab")).toEqual({ path: "/lab", kind: "lab", topicId: "git" }); + expect(parseRoute("#/auth")).toEqual({ path: "/auth", kind: "lesson", topicId: "auth" }); + expect(parseRoute("#/auth-lab")).toEqual({ path: "/auth-lab", kind: "lab", topicId: "auth" }); + expect(ROUTE_REGISTRY).toHaveLength(5); + }); + + it("supports predictable routes for new topics", () => { + expect(parseRoute("#/track/foundations")).toEqual({ path: "/track/foundations", kind: "track", trackId: "foundations" }); + expect(trackPath("foundations")).toBe("/track/foundations"); + expect(parseRoute("#/guardrail")).toEqual({ path: "/guardrail", kind: "lesson", topicId: "guardrail" }); + expect(parseRoute("#/guardrail-lab")).toEqual({ path: "/guardrail-lab", kind: "lab", topicId: "guardrail" }); + expect(topicPath("guardrail", "lesson")).toBe("/guardrail"); + expect(topicPath("guardrail", "lab")).toBe("/guardrail-lab"); + expect(parseRoute("#/cli")).toEqual({ path: "/cli", kind: "lesson", topicId: "cli" }); + expect(parseRoute("#/cli-lab")).toEqual({ path: "/cli-lab", kind: "lab", topicId: "cli" }); + expect(topicPath("cli", "lesson")).toBe("/cli"); + expect(topicPath("cli", "lab")).toBe("/cli-lab"); + expect(parseRoute("#/ide")).toEqual({ path: "/ide", kind: "lesson", topicId: "ide" }); + expect(parseRoute("#/ide-lab")).toEqual({ path: "/ide-lab", kind: "lab", topicId: "ide" }); + expect(topicPath("ide", "lesson")).toBe("/ide"); + expect(topicPath("ide", "lab")).toBe("/ide-lab"); + expect(parseRoute("#/package")).toEqual({ path: "/package", kind: "lesson", topicId: "package" }); + expect(parseRoute("#/package-lab")).toEqual({ path: "/package-lab", kind: "lab", topicId: "package" }); + expect(topicPath("package", "lesson")).toBe("/package"); + expect(topicPath("package", "lab")).toBe("/package-lab"); + }); + + it("falls back to the map for unknown or unsafe paths", () => { + expect(parseRoute("#/unknown path")).toEqual({ path: "/map", kind: "map" }); + expect(parseRoute("#/track/unknown path")).toEqual({ path: "/map", kind: "map" }); + expect(parseRoute("#//")).toEqual({ path: "/map", kind: "map" }); + expect(() => topicPath("../auth", "lesson")).toThrow("Invalid topic id"); + expect(() => trackPath("../foundations")).toThrow("Invalid topic id"); + }); + + it("does not resolve planned or unknown topics as enterable routes", () => { + expect(resolveRoute("#/env", routeCurriculum)).toEqual({ path: "/map", kind: "map" }); + expect(resolveRoute("#/unknown", routeCurriculum)).toEqual({ path: "/map", kind: "map" }); + expect(resolveRoute("#/git-lab", routeCurriculum)).toEqual({ path: "/git-lab", kind: "lab", topicId: "git" }); + }); + + it("does not resolve a ready topic without a registered view", () => { + expect(resolveRoute("#/git", routeCurriculum, new Set(["auth"]))).toEqual({ path: "/map", kind: "map" }); + }); +}); diff --git a/frontend/src/routes/registry.ts b/frontend/src/routes/registry.ts new file mode 100644 index 0000000..bfa4ebb --- /dev/null +++ b/frontend/src/routes/registry.ts @@ -0,0 +1,94 @@ +import type { Curriculum } from "../types"; + +export type RouteKind = "map" | "track" | "lesson" | "lab"; + +export interface RouteDefinition { + path: string; + kind: RouteKind; + trackId?: string; + topicId?: string; +} + +const TOPIC_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export const ROUTE_REGISTRY: readonly RouteDefinition[] = [ + { path: "/map", kind: "map" }, + { path: "/git", kind: "lesson", topicId: "git" }, + { path: "/lab", kind: "lab", topicId: "git" }, + { path: "/auth", kind: "lesson", topicId: "auth" }, + { path: "/auth-lab", kind: "lab", topicId: "auth" }, +]; + +const MAP_ROUTE = ROUTE_REGISTRY[0]; + +function normalizePath(hashOrPath: string): string { + const withoutHash = hashOrPath.trim().replace(/^#/, ""); + const withLeadingSlash = withoutHash.startsWith("/") ? withoutHash : `/${withoutHash}`; + const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, ""); + return withoutTrailingSlash || "/map"; +} + +function assertTopicId(topicId: string): string { + const normalizedTopicId = topicId.trim(); + if (!TOPIC_ID_PATTERN.test(normalizedTopicId)) { + throw new Error(`Invalid topic id: ${topicId}`); + } + return normalizedTopicId; +} + +export function trackPath(trackId: string): string { + return `/track/${assertTopicId(trackId)}`; +} + +export function parseRoute(hashOrPath: string): RouteDefinition { + const path = normalizePath(hashOrPath); + const registeredRoute = ROUTE_REGISTRY.find((route) => route.path === path); + if (registeredRoute) return registeredRoute; + + const trackMatch = path.match(/^\/track\/(.+)$/); + if (trackMatch && TOPIC_ID_PATTERN.test(trackMatch[1])) { + return { path, kind: "track", trackId: trackMatch[1] }; + } + + const labMatch = path.match(/^\/(.+)-lab$/); + if (labMatch && TOPIC_ID_PATTERN.test(labMatch[1])) { + return { path, kind: "lab", topicId: labMatch[1] }; + } + + const lessonMatch = path.match(/^\/(.+)$/); + if (lessonMatch && TOPIC_ID_PATTERN.test(lessonMatch[1])) { + return { path, kind: "lesson", topicId: lessonMatch[1] }; + } + + return MAP_ROUTE; +} + +export function resolveRoute( + hashOrPath: string, + curriculum: Curriculum, + availableTopicIds?: ReadonlySet, +): RouteDefinition { + const route = parseRoute(hashOrPath); + + if (route.kind === "track") { + return curriculum.tracks.some((track) => track.id === route.trackId) ? route : MAP_ROUTE; + } + + if (route.kind === "lesson" || route.kind === "lab") { + const topic = curriculum.tracks + .flatMap((track) => track.topics) + .find((candidate) => candidate.id === route.topicId); + const hasView = !availableTopicIds || (topic && availableTopicIds.has(topic.id)); + return topic?.status === "ready" && hasView ? route : MAP_ROUTE; + } + + return route; +} + +export function topicPath(topicId: string, kind: Exclude): string { + const normalizedTopicId = assertTopicId(topicId); + + if (normalizedTopicId === "git") return kind === "lab" ? "/lab" : "/git"; + if (normalizedTopicId === "auth") return kind === "lab" ? "/auth-lab" : "/auth"; + return kind === "lab" ? `/${normalizedTopicId}-lab` : `/${normalizedTopicId}`; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6fe53d0..5875a10 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -17,6 +17,114 @@ --shadow: 0 24px 48px rgba(39, 33, 22, 0.18); } +/* REST / FastAPI request workbench — v0 */ +.rest-request-bar { + display: grid; + grid-template-columns: minmax(190px, .7fr) minmax(280px, 1.3fr) auto auto; + gap: 10px; + align-items: end; + margin: 26px 0 14px; + padding: 16px; + color: var(--paper); + background: var(--ink); + border: 1px solid var(--ink); +} +.rest-request-bar label { display: grid; gap: 7px; } +.rest-request-bar label > span, +.rest-request-target > span:first-child { color: #9fb0aa; font: 8px "DM Mono", monospace; letter-spacing: .1em; } +.rest-request-bar select { min-height: 42px; padding: 0 12px; color: var(--paper); background: #252923; border: 1px solid #565a4f; border-radius: 0; } +.rest-request-target { min-height: 42px; display: flex; align-items: center; gap: 12px; padding: 0 14px; background: #252923; border: 1px solid #565a4f; overflow: hidden; } +.rest-request-target code { overflow: hidden; color: #ddd5c2; font: 10px "DM Mono", monospace; text-overflow: ellipsis; white-space: nowrap; } +.rest-method { color: #b9ddd7 !important; font-weight: 700 !important; } +.rest-method.get { color: #d9c98c !important; } +.rest-request-bar .button { min-height: 42px; border-radius: 0; } +.rest-request-bar .button.secondary { color: var(--paper); background: transparent; border: 1px solid #77786c; } +.rest-request-bar .button:disabled { cursor: not-allowed; opacity: .35; transform: none; } + +.rest-scenario-progress { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0 0 14px; border: 1px solid var(--line); border-right: 0; } +.rest-scenario-progress > div { min-width: 0; display: grid; grid-template-columns: 22px auto minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 11px 13px; color: var(--muted); border-right: 1px solid var(--line); background: rgba(255,255,255,.28); } +.rest-scenario-progress span, .rest-scenario-progress b, .rest-scenario-progress em { font: 9px "DM Mono", monospace; font-style: normal; } +.rest-scenario-progress small { min-width: 0; overflow: hidden; font: 9px "DM Mono", monospace; text-overflow: ellipsis; white-space: nowrap; } +.rest-scenario-progress em { color: var(--coral); } +.rest-scenario-progress .done { color: var(--ink); background: var(--surface); } +.rest-scenario-progress .done span, .rest-scenario-progress .done em { color: var(--teal-dark); } + +.rest-trace { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); border: 1px solid var(--line); border-right: 0; background: rgba(255, 255, 255, .34); } +.rest-trace button { min-width: 0; min-height: 112px; display: grid; align-content: start; gap: 6px; padding: 14px 12px; text-align: left; color: var(--muted); background: transparent; border: 0; border-right: 1px solid var(--line); transition: background .18s ease, color .18s ease; } +.rest-trace button span { color: var(--teal-dark); font: 9px "DM Mono", monospace; } +.rest-trace button b { font: 600 17px "Newsreader", "Noto Serif TC", serif; } +.rest-trace button small { font-size: 9px; line-height: 1.55; } +.rest-trace button.active { color: var(--paper); background: var(--teal-dark); } +.rest-trace button.active span { color: #b9ddd7; } +.rest-trace button.visited:not(.active) { color: var(--ink); background: var(--surface); } +.rest-trace button:disabled { cursor: not-allowed; opacity: .42; } + +.rest-workbench { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(290px, .65fr); margin-top: 14px; border: 1px solid var(--ink); background: var(--ink); box-shadow: var(--shadow); } +.rest-code-panel { min-width: 0; background: #171a17; } +.rest-code-toolbar { min-height: 54px; display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 0 16px; border-bottom: 1px solid #3b4038; } +.rest-file-tabs, .rest-mode-switch { display: flex; gap: 2px; } +.rest-file-tabs button, .rest-mode-switch button { min-height: 32px; padding: 0 11px; color: #949c8d; background: transparent; border: 1px solid transparent; font: 9px "DM Mono", monospace; } +.rest-file-tabs button.active { color: var(--paper); border-color: #62695c; background: #252923; } +.rest-mode-switch { border: 1px solid #454a41; } +.rest-mode-switch button.active { color: var(--ink); background: var(--surface); } +.rest-code-meta { display: flex; justify-content: space-between; gap: 16px; padding: 11px 16px; color: #aeb5a5; border-bottom: 1px solid #33372f; font: 9px "DM Mono", monospace; } +.rest-code-meta small { color: #72796d; text-align: right; } +.rest-code-lines { max-height: 590px; overflow: auto; padding: 10px 0 20px; } +.rest-code-lines > button { width: 100%; display: grid; grid-template-columns: 42px minmax(260px, .9fr) minmax(250px, 1.1fr); gap: 12px; align-items: baseline; padding: 6px 16px 6px 0; color: #d8ddcf; text-align: left; background: transparent; border: 0; border-left: 3px solid transparent; } +.rest-code-lines > button > span { color: #60675d; text-align: right; font: 9px "DM Mono", monospace; } +.rest-code-lines > button code { color: inherit; font: 10px/1.6 "DM Mono", monospace; white-space: pre; } +.rest-code-lines > button small { color: #747d70; font-size: 9px; line-height: 1.55; } +.rest-code-lines > button.related { background: rgba(78, 139, 145, .1); } +.rest-code-lines > button.related > span { color: var(--teal); } +.rest-code-lines > button.selected { color: #fff; background: #2a332e; border-left-color: var(--teal); } +.rest-code-lines > button.selected small { color: #bbc5b7; } + +.rest-explanation-panel { min-width: 0; padding: 25px; color: var(--ink); background: var(--surface); border-left: 1px solid var(--ink); } +.rest-explanation-heading { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 18px; border-bottom: 1px solid var(--line); } +.rest-explanation-heading span, .rest-explanation-heading b { font: 8px "DM Mono", monospace; letter-spacing: .08em; } +.rest-explanation-heading span { color: var(--teal-dark); }.rest-explanation-heading b { color: var(--muted); } +.rest-explanation-panel > code { display: block; margin-top: 22px; padding: 13px; overflow-wrap: anywhere; color: var(--paper); background: var(--ink); font: 9px/1.6 "DM Mono", monospace; } +.rest-explanation-panel h2 { margin: 24px 0; font: 600 25px/1.24 "Newsreader", "Noto Serif TC", serif; text-wrap: pretty; } +.rest-explanation-panel dl { display: grid; gap: 0; margin: 0; border-top: 1px solid var(--line); } +.rest-explanation-panel dl > div { padding: 15px 0; border-bottom: 1px solid var(--line); } +.rest-explanation-panel dt { color: var(--teal-dark); font: 8px "DM Mono", monospace; letter-spacing: .08em; } +.rest-explanation-panel dd { margin: 7px 0 0; color: var(--muted); font-size: 11px; line-height: 1.65; } + +.rest-io-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 14px; border: 1px solid var(--line); border-right: 0; background: rgba(255,255,255,.32); } +.rest-io-grid article { min-width: 0; min-height: 210px; border-right: 1px solid var(--line); } +.rest-io-grid article header { display: flex; justify-content: space-between; gap: 12px; padding: 13px 15px; border-bottom: 1px solid var(--line); } +.rest-io-grid article header span, .rest-io-grid article header b { font: 8px "DM Mono", monospace; letter-spacing: .06em; } +.rest-io-grid article header span { color: var(--teal-dark); }.rest-io-grid article header b { color: var(--muted); } +.rest-io-grid article.ready header b { color: var(--teal-dark); } +.rest-io-grid pre { margin: 0; padding: 18px; overflow: auto; color: #48524a; font: 10px/1.7 "DM Mono", monospace; white-space: pre-wrap; } +.rest-v0-note { margin: 20px 0 0; color: var(--muted); font: 8px/1.7 "DM Mono", monospace; letter-spacing: .06em; } + +@media (max-width: 1120px) { + .rest-request-bar { grid-template-columns: 1fr 1.5fr; } + .rest-trace { grid-template-columns: repeat(4, minmax(0, 1fr)); border-bottom: 0; } + .rest-trace button { border-bottom: 1px solid var(--line); } + .rest-workbench { grid-template-columns: 1fr; } + .rest-scenario-progress { grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 0; } + .rest-scenario-progress > div { border-bottom: 1px solid var(--line); } + .rest-explanation-panel { border-top: 1px solid var(--ink); border-left: 0; } +} + +@media (max-width: 720px) { + .rest-request-bar { grid-template-columns: 1fr; } + .rest-trace { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .rest-scenario-progress { grid-template-columns: 1fr; } + .rest-code-toolbar, .rest-code-meta { align-items: stretch; flex-direction: column; padding: 12px; } + .rest-file-tabs { display: grid; grid-template-columns: repeat(2, 1fr); } + .rest-code-lines > button { grid-template-columns: 32px minmax(0, 1fr); padding-right: 10px; } + .rest-code-lines > button small { grid-column: 2; } + .rest-io-grid { grid-template-columns: 1fr; border-bottom: 0; } + .rest-io-grid article { border-bottom: 1px solid var(--line); } +} + +@media (prefers-reduced-motion: reduce) { + .rest-trace button { transition: none; } +} + * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; min-width: 320px; min-height: 100vh; } @@ -93,7 +201,9 @@ body { } button:focus-visible, -input:focus-visible { +input:focus-visible, +select:focus-visible, +textarea:focus-visible { outline: 2px solid var(--teal-dark); outline-offset: 3px; } @@ -561,6 +671,197 @@ h1 em { .demo-detail { display: block; min-height: 74px; margin: 0 0 24px; padding: 16px; color: var(--ink); background: var(--paper); font: 11px/1.7 "DM Mono"; } .demo-summary { color: var(--muted); font: 14px/1.8 "Noto Serif TC", serif; } +/* Topic shell contract v1 */ +.topic-lesson-content, +.topic-lab-content { margin-top: 42px; } +.topic-lab-actions { display: flex; justify-content: flex-end; margin-top: 18px; } +.topic-status-feedback { min-height: 22px; margin: 18px 0; color: var(--muted); font-size: 12px; line-height: 1.7; } +.topic-status-feedback.error { color: var(--coral); } +.topic-status-feedback.success { color: var(--teal-dark); } +.topic-completion { max-width: 520px; margin: 48px auto; padding: 42px 32px; border: 1px solid var(--line); background: var(--surface); text-align: center; } +.topic-completion > span { display: grid; place-items: center; width: 56px; height: 56px; margin: 0 auto 20px; color: var(--paper); background: var(--teal-dark); font-size: 24px; } +.topic-completion h2 { margin: 0 0 12px; font: 600 30px/1.2 "Newsreader", "Noto Serif TC", serif; } +.topic-completion p { margin: 0 auto 20px; max-width: 390px; color: var(--muted); font: 14px/1.8 "Noto Serif TC", serif; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +/* IDE lab extension */ +.ide-lab-layout { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); gap: 0; margin-top: 34px; border: 1px solid var(--ink); } +.ide-editor-panel { min-width: 0; color: #dce9e6; background: #172729; } +.ide-editor-top { min-height: 47px; padding: 0 17px; display: flex; align-items: center; justify-content: space-between; gap: 14px; color: #8ca5a2; background: #203638; border-bottom: 1px solid #496063; font: 9px "DM Mono"; } +.ide-window-dots { display: flex; gap: 5px; }.ide-window-dots i { width: 7px; height: 7px; border-radius: 50%; background: #a6725b; }.ide-window-dots i:nth-child(2) { background: #b8a873; }.ide-window-dots i:nth-child(3) { background: #7ca7a3; } +.ide-phase { color: #a7cac6; text-transform: uppercase; } +.ide-file-tab { padding: 11px 18px; color: #b8cbc7; border-bottom: 1px solid #334e50; font: 10px "DM Mono"; }.ide-file-tab span { display: inline-grid; place-items: center; width: 18px; height: 18px; margin-right: 8px; color: #172729; background: #a7cac6; font: 8px "DM Mono"; } +.ide-editor { min-height: 235px; padding: 17px 0; overflow: auto; font: 11px/1.9 "DM Mono"; } +.ide-code-line { width: 100%; min-height: 27px; padding: 0 18px 0 0; display: grid; grid-template-columns: 38px 18px minmax(0, 1fr); align-items: center; border: 0; color: #bfd2d1; background: transparent; text-align: left; font: inherit; } +.ide-code-line:hover { background: rgba(167, 202, 198, .08); }.ide-code-line:focus-visible { outline: 2px solid #a7cac6; outline-offset: -2px; }.ide-code-line.current { color: #172729; background: #a7cac6; }.ide-code-line.current .ide-line-number { color: #496063; }.ide-code-line.current .ide-breakpoint { color: #a04a2a; } +.ide-line-number { color: #6c8886; text-align: right; }.ide-breakpoint { color: transparent; font-size: 10px; text-align: center; }.ide-code-line.has-breakpoint .ide-breakpoint { color: #d3836d; }.ide-code-line code { min-width: 0; overflow-wrap: anywhere; white-space: pre; } +.ide-terminal-output { max-height: 170px; min-height: 92px; padding: 15px 19px; overflow-y: auto; border-top: 1px solid #334e50; }.ide-terminal-output .terminal-entry { margin-bottom: 11px; }.ide-terminal-output .terminal-entry:last-child { margin-bottom: 0; }.ide-terminal-output small { color: #9cb5b1; } +.ide-command-form { border-top-color: #334e50; }.ide-command-form button { border-color: #668582; color: #c3dbd7; background: transparent; } +.ide-control-panel { min-width: 0; padding: 27px; color: var(--ink); background: var(--surface); border-left: 1px solid var(--ink); }.ide-panel-heading { display: flex; justify-content: space-between; align-items: start; gap: 15px; }.ide-panel-heading h2 { margin: 8px 0 0; font: 600 23px/1.35 "Newsreader", "Noto Serif TC", serif; }.ide-lab-meta { max-width: 125px; overflow-wrap: anywhere; color: var(--muted); font: 9px "DM Mono"; text-align: right; } +.ide-action-list { display: grid; gap: 1px; margin-top: 27px; border-block: 1px solid var(--line); }.ide-action { min-width: 0; padding: 14px 0; display: grid; grid-template-columns: 29px minmax(0, 1fr) 20px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--line); color: var(--ink); background: transparent; text-align: left; }.ide-action:last-child { border-bottom: 0; }.ide-action:hover:not(:disabled) { color: var(--teal-dark); }.ide-action:focus-visible { outline: 2px solid var(--teal-dark); outline-offset: -2px; }.ide-action:disabled { cursor: default; }.ide-action.done { color: var(--teal-dark); }.ide-action-index { display: grid; place-items: center; width: 25px; height: 25px; color: var(--paper); background: var(--teal-dark); font: 9px "DM Mono"; }.ide-action:not(.done):disabled .ide-action-index { color: var(--muted); background: var(--line); }.ide-action-copy { min-width: 0; }.ide-action-copy b { display: block; font: 600 12px "Noto Serif TC", serif; }.ide-action-copy code { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--muted); font: 9px "DM Mono"; }.ide-action-arrow { color: var(--teal-dark); font-size: 16px; }.ide-current-hint { margin: 18px 0 0; color: var(--muted); font: 11px/1.7 "Noto Serif TC", serif; } +.ide-debug-state { margin-top: 58px; padding-top: 2px; border-top: 2px solid var(--ink); }.ide-debug-state .section-heading { margin: 25px 0 26px; }.ide-state-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-block: 1px solid var(--line); }.ide-state-grid > div { min-width: 0; min-height: 86px; padding: 16px 17px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }.ide-state-grid > div:nth-child(4n) { border-right: 0; }.ide-state-grid .ide-state-wide { grid-column: span 2; }.ide-state-grid small { display: block; color: var(--muted); font: 8px "DM Mono"; letter-spacing: .08em; }.ide-state-grid b { display: block; margin-top: 9px; overflow-wrap: anywhere; color: var(--teal-dark); font: 11px/1.5 "DM Mono"; } + +@media (max-width: 950px) { + .ide-lab-layout { grid-template-columns: 1fr; }.ide-control-panel { border-top: 1px solid var(--ink); border-left: 0; }.ide-state-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.ide-state-grid > div:nth-child(4n) { border-right: 1px solid var(--line); }.ide-state-grid > div:nth-child(2n) { border-right: 0; } +} + +.remote-lab-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); gap: 16px; } +.remote-action-panel, .remote-context-panel { border: 1px solid var(--line); background: var(--surface); } +.remote-action-panel { padding: 30px; } +.remote-panel-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; padding-bottom: 22px; border-bottom: 1px solid var(--line); } +.remote-panel-heading h2, .remote-context-panel h2 { margin: 7px 0 0; font: 600 26px/1.2 "Newsreader", "Noto Serif TC", serif; } +.remote-lab-meta { color: var(--muted); font: 10px "DM Mono"; } +.remote-action-list { display: grid; gap: 8px; margin-top: 20px; } +.remote-action { display: grid; grid-template-columns: 42px minmax(0, 1fr) 20px; gap: 12px; align-items: center; width: 100%; padding: 15px 13px; border: 1px solid transparent; background: transparent; color: var(--ink); text-align: left; cursor: pointer; } +.remote-action:hover, .remote-action:focus-visible { border-color: var(--teal); background: #f2f7f5; outline: none; } +.remote-action.done { color: var(--teal-dark); } +.remote-action-index { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--line); color: var(--muted); font: 10px "DM Mono"; } +.remote-action.done .remote-action-index { border-color: var(--teal-dark); background: var(--teal-dark); color: var(--paper); } +.remote-action-copy { min-width: 0; } +.remote-action-copy b { display: block; font-size: 13px; } +.remote-action-copy code { display: block; overflow: hidden; margin-top: 5px; color: var(--muted); font: 10px "DM Mono"; text-overflow: ellipsis; white-space: nowrap; } +.remote-action-arrow { color: var(--muted); font-size: 18px; } +.remote-context-panel { align-self: start; padding: 30px; } +.remote-context-list { margin: 25px 0 0; border-top: 1px solid var(--line); } +.remote-context-list div { display: flex; justify-content: space-between; gap: 20px; padding: 13px 0; border-bottom: 1px solid var(--line); } +.remote-context-list dt { color: var(--muted); font: 9px "DM Mono"; letter-spacing: .12em; } +.remote-context-list dd { margin: 0; color: var(--teal-dark); font: 11px "DM Mono"; } +.remote-context-hint { margin: 25px 0 0; color: var(--muted); font-size: 12px; line-height: 1.8; } +.git-release-actions { margin-top: 24px; } +.git-release-pipeline { margin-top: 24px; } +.git-provider-picker { display: grid; gap: 8px; margin-top: 16px; color: var(--muted); font: 9px "DM Mono"; letter-spacing: .08em; text-transform: uppercase; } +.git-provider-picker select { width: 100%; padding: 10px; border: 1px solid var(--line); color: var(--ink); background: var(--paper); font: 10px "DM Mono"; text-transform: none; } +.git-job-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-top: 24px; border-block: 1px solid var(--line); } +.git-job-grid > div { min-width: 0; padding: 18px 12px; border-right: 1px solid var(--line); text-align: center; } +.git-job-grid > div:last-child { border-right: 0; } +.git-job-grid span { display: block; color: var(--muted); font-size: 18px; } +.git-job-grid b { display: block; margin-top: 8px; color: var(--muted); font: 10px "DM Mono"; overflow-wrap: anywhere; } +.git-job-grid .passed span, .git-job-grid .passed b { color: var(--teal-dark); } + +@media (max-width: 720px) { + .git-job-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .git-job-grid > div:nth-child(2n) { border-right: 0; } + .git-job-grid > div { border-bottom: 1px solid var(--line); } +} + +/* PACKAGE lab extension */ +.package-lab-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); gap: 16px; } +.package-workspace-panel, .package-control-panel { min-width: 0; border: 1px solid var(--ink); } +.package-workspace-panel { color: #dce9e6; background: #172729; } +.package-workspace-top { min-height: 47px; padding: 0 17px; display: flex; align-items: center; gap: 13px; color: #9ab0ad; background: #203638; border-bottom: 1px solid #496063; font: 10px "DM Mono"; } +.package-workspace-top b { overflow: hidden; color: #d1dfdc; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } +.package-window-dots { display: flex; gap: 5px; }.package-window-dots i { width: 7px; height: 7px; border-radius: 50%; background: #a6725b; }.package-window-dots i:nth-child(2) { background: #b8a873; }.package-window-dots i:nth-child(3) { background: #7ca7a3; } +.package-phase { margin-left: auto; color: #a7cac6; text-transform: uppercase; } +.package-file-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #334e50; } +.package-file-tabs button { padding: 12px 15px; border: 0; border-right: 1px solid #334e50; color: #799490; background: transparent; font: 10px "DM Mono"; white-space: nowrap; } +.package-file-tabs button:hover, .package-file-tabs button:focus-visible, .package-file-tabs button.active { color: #dce9e6; background: #294446; outline: none; } +.package-editor { min-height: 260px; max-height: 320px; padding: 16px 0; overflow: auto; font: 11px/1.85 "DM Mono"; } +.package-code-line { min-height: 22px; padding-right: 17px; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 16px; color: #bfd2d1; } +.package-code-line > span { color: #6c8886; text-align: right; }.package-code-line code { min-width: 0; overflow-wrap: anywhere; white-space: pre; } +.package-terminal-output { max-height: 180px; min-height: 108px; padding: 15px 19px; overflow-y: auto; border-top: 1px solid #334e50; }.package-terminal-output .terminal-entry { margin-bottom: 11px; }.package-terminal-output .terminal-entry:last-child { margin-bottom: 0; }.package-terminal-output small { color: #9cb5b1; } +.package-command-form { border-top-color: #334e50; }.package-command-form button { border-color: #668582; color: #c3dbd7; background: transparent; } +.package-control-panel { align-self: start; padding: 27px; color: var(--ink); background: var(--surface); } +.package-panel-heading { display: flex; justify-content: space-between; align-items: start; gap: 15px; }.package-panel-heading h2 { margin: 8px 0 0; font: 600 23px/1.35 "Newsreader", "Noto Serif TC", serif; }.package-lab-meta { max-width: 125px; overflow-wrap: anywhere; color: var(--muted); font: 9px/1.6 "DM Mono"; text-align: right; } +.package-action-list { display: grid; gap: 1px; margin-top: 27px; border-block: 1px solid var(--line); }.package-action { min-width: 0; padding: 14px 0; display: grid; grid-template-columns: 29px minmax(0, 1fr) 20px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--line); color: var(--ink); background: transparent; text-align: left; }.package-action:last-child { border-bottom: 0; }.package-action:hover:not(:disabled) { color: var(--teal-dark); }.package-action:focus-visible { outline: 2px solid var(--teal-dark); outline-offset: -2px; }.package-action:disabled { cursor: default; }.package-action.done { color: var(--teal-dark); }.package-action-index { display: grid; place-items: center; width: 25px; height: 25px; color: var(--paper); background: var(--teal-dark); font: 9px "DM Mono"; }.package-action:not(.done):disabled .package-action-index { color: var(--muted); background: var(--line); }.package-action-copy { min-width: 0; }.package-action-copy b { display: block; font: 600 12px "Noto Serif TC", serif; }.package-action-copy code { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--muted); font: 9px "DM Mono"; }.package-action-arrow { color: var(--teal-dark); font-size: 16px; }.package-current-hint { margin: 18px 0 0; color: var(--muted); font: 11px/1.7 "Noto Serif TC", serif; } +.package-state-section { margin-top: 58px; padding-top: 2px; border-top: 2px solid var(--ink); }.package-state-section .section-heading { margin: 25px 0 26px; }.package-state-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-block: 1px solid var(--line); }.package-state-grid > div { min-width: 0; min-height: 86px; padding: 16px 17px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }.package-state-grid > div:nth-child(4n) { border-right: 0; }.package-state-grid .package-state-wide { grid-column: span 2; }.package-state-grid small { display: block; color: var(--muted); font: 8px "DM Mono"; letter-spacing: .08em; }.package-state-grid b { display: block; margin-top: 9px; overflow-wrap: anywhere; color: var(--teal-dark); font: 11px/1.5 "DM Mono"; } + +@media (max-width: 950px) { + .package-lab-grid { grid-template-columns: 1fr; }.package-control-panel { border-top: 1px solid var(--ink); }.package-state-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.package-state-grid > div:nth-child(4n) { border-right: 1px solid var(--line); }.package-state-grid > div:nth-child(2n) { border-right: 0; } +} + +@media (max-width: 720px) { + .package-editor { min-height: 220px; }.package-control-panel { padding: 24px 20px; }.package-panel-heading { display: block; }.package-lab-meta { display: block; margin-top: 14px; text-align: left; }.package-state-grid { grid-template-columns: 1fr; }.package-state-grid > div, .package-state-grid .package-state-wide { grid-column: auto; border-right: 0; } +} + +.remote-lab-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); gap: 16px; } +.remote-action-panel, .remote-context-panel { border: 1px solid var(--line); background: var(--surface); } +.remote-action-panel { padding: 30px; } +.remote-panel-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; padding-bottom: 22px; border-bottom: 1px solid var(--line); } +.remote-panel-heading h2, .remote-context-panel h2 { margin: 7px 0 0; font: 600 26px/1.2 "Newsreader", "Noto Serif TC", serif; } +.remote-lab-meta { color: var(--muted); font: 10px "DM Mono"; } +.remote-action-list { display: grid; gap: 8px; margin-top: 20px; } +.remote-action { display: grid; grid-template-columns: 42px minmax(0, 1fr) 20px; gap: 12px; align-items: center; width: 100%; padding: 15px 13px; border: 1px solid transparent; background: transparent; color: var(--ink); text-align: left; cursor: pointer; } +.remote-action:hover, .remote-action:focus-visible { border-color: var(--teal); background: #f2f7f5; outline: none; } +.remote-action.done { color: var(--teal-dark); } +.remote-action-index { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--line); color: var(--muted); font: 10px "DM Mono"; } +.remote-action.done .remote-action-index { border-color: var(--teal-dark); background: var(--teal-dark); color: var(--paper); } +.remote-action-copy { min-width: 0; } +.remote-action-copy b { display: block; font-size: 13px; } +.remote-action-copy code { display: block; overflow: hidden; margin-top: 5px; color: var(--muted); font: 10px "DM Mono"; text-overflow: ellipsis; white-space: nowrap; } +.remote-action-arrow { color: var(--muted); font-size: 18px; } +.remote-context-panel { align-self: start; padding: 30px; } +.remote-context-list { margin: 25px 0 0; border-top: 1px solid var(--line); } +.remote-context-list div { display: flex; justify-content: space-between; gap: 20px; padding: 13px 0; border-bottom: 1px solid var(--line); } +.remote-context-list dt { color: var(--muted); font: 9px "DM Mono"; letter-spacing: .12em; } +.remote-context-list dd { margin: 0; color: var(--teal-dark); font: 11px "DM Mono"; } +.remote-context-hint { margin: 25px 0 0; color: var(--muted); font-size: 12px; line-height: 1.8; } + +@media (max-width: 720px) { + .ide-editor { min-height: 205px; }.ide-control-panel { padding: 24px 20px; }.ide-state-grid { grid-template-columns: 1fr; }.ide-state-grid > div, .ide-state-grid .ide-state-wide { grid-column: auto; border-right: 0; }.ide-state-grid > div:nth-child(2n) { border-right: 0; } +} + +@media (max-width: 720px) { + .remote-lab-grid { grid-template-columns: 1fr; } + .remote-action-panel, .remote-context-panel { padding: 22px; } + .remote-panel-heading { display: block; } + .remote-lab-meta { display: block; margin-top: 14px; } +} +.guardrail-lab-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(280px, .7fr); gap: 16px; } +.guardrail-control-panel, .guardrail-result-panel { border: 1px solid var(--line); background: var(--surface); } +.guardrail-control-panel { padding: 30px; } +.guardrail-result-panel { align-self: start; padding: 30px; } +.guardrail-panel-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; padding-bottom: 22px; border-bottom: 1px solid var(--line); } +.guardrail-panel-heading h2, .guardrail-result-panel h2 { margin: 7px 0 0; font: 600 26px/1.2 "Newsreader", "Noto Serif TC", serif; } +.guardrail-stage-label { color: var(--teal-dark); font: 11px "DM Mono"; } +.guardrail-stage-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 22px; } +.guardrail-stage-button { min-height: 74px; padding: 12px; border: 1px solid var(--line); background: transparent; color: var(--ink); text-align: left; cursor: pointer; } +.guardrail-stage-button:hover, .guardrail-stage-button:focus-visible, .guardrail-stage-button.active { border-color: var(--teal-dark); background: #edf5f2; outline: none; } +.guardrail-stage-button b, .guardrail-stage-button span { display: block; } +.guardrail-stage-button b { color: var(--teal-dark); font: 11px "DM Mono"; } +.guardrail-stage-button span { margin-top: 7px; color: var(--muted); font-size: 11px; line-height: 1.4; } +.guardrail-section-heading { display: flex; justify-content: space-between; align-items: center; margin-top: 32px; } +.guardrail-section-heading > span { color: var(--muted); font-size: 11px; } +.guardrail-validator-list { display: grid; gap: 7px; margin-top: 12px; } +.guardrail-validator { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 12px; border: 1px solid var(--line); cursor: pointer; } +.guardrail-validator.enabled { border-color: var(--teal); background: #f5faf8; } +.guardrail-validator input { width: 16px; height: 16px; margin: 2px 0 0; accent-color: var(--teal-dark); } +.guardrail-validator b, .guardrail-validator small { display: block; } +.guardrail-validator b { font-size: 12px; } +.guardrail-validator small { margin-top: 4px; color: var(--muted); font-size: 11px; line-height: 1.5; } +.guardrail-validator i { color: var(--muted); font: 10px "DM Mono"; font-style: normal; } +.scenario-heading { border-top: 1px solid var(--line); padding-top: 25px; } +.guardrail-scenario-list { display: grid; gap: 7px; margin-top: 12px; } +.guardrail-scenario { display: flex; justify-content: space-between; align-items: center; gap: 16px; width: 100%; padding: 14px; border: 1px solid var(--line); background: transparent; color: var(--ink); text-align: left; cursor: pointer; } +.guardrail-scenario:hover, .guardrail-scenario:focus-visible { border-color: var(--teal-dark); background: #edf5f2; outline: none; } +.guardrail-scenario b, .guardrail-scenario small { display: block; } +.guardrail-scenario b { font-size: 12px; } +.guardrail-scenario small { max-width: 390px; overflow: hidden; margin-top: 5px; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.guardrail-scenario i { color: var(--teal-dark); font: 10px "DM Mono"; font-style: normal; } +.guardrail-result-list { display: grid; gap: 8px; margin-top: 28px; } +.guardrail-result { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 13px 0; border-bottom: 1px solid var(--line); } +.guardrail-result > span { display: grid; place-items: center; width: 23px; height: 23px; border: 1px solid var(--teal-dark); color: var(--teal-dark); font: 12px "DM Mono"; } +.guardrail-result.triggered > span { border-color: var(--coral); color: var(--coral); } +.guardrail-result b, .guardrail-result small { display: block; } +.guardrail-result b { font: 10px "DM Mono"; } +.guardrail-result small { margin-top: 4px; color: var(--muted); font-size: 11px; } +.guardrail-result i { color: var(--muted); font: 10px "DM Mono"; font-style: normal; } +.guardrail-empty { color: var(--muted); font-size: 12px; line-height: 1.8; } +.guardrail-result-note { margin: 25px 0 0; color: var(--muted); font: 10px "DM Mono"; } +.guardrail-result-note b { color: var(--teal-dark); font-weight: 400; } + +@media (max-width: 720px) { + .guardrail-lab-grid { grid-template-columns: 1fr; } + .guardrail-control-panel, .guardrail-result-panel { padding: 22px; } + .guardrail-panel-heading { display: block; } + .guardrail-stage-label { display: block; margin-top: 14px; } + .guardrail-stage-picker { grid-template-columns: 1fr; } + .guardrail-validator { grid-template-columns: 18px minmax(0, 1fr); } + .guardrail-validator i { grid-column: 2; } + .topic-lab-actions { justify-content: stretch; } + .topic-lab-actions .button { width: 100%; } + .topic-completion { margin: 34px auto; padding: 32px 22px; } +} + @media (max-width: 950px) { .auth-model { grid-template-columns: 1fr; } .auth-model > i { height: 36px; transform: rotate(90deg); } @@ -569,3 +870,106 @@ h1 em { .auth-field-list p, .auth-field-list b { grid-column: 2; } .auth-lab-grid { grid-template-columns: 1fr; } } + +/* Track-first curriculum flow */ +.nav-label-nested { margin-top: 26px; } +.track-card-link { + margin-top: 17px; + padding: 0 0 4px; + border: 0; + border-bottom: 1px solid var(--teal-dark); + color: var(--teal-dark); + background: transparent; + font: 10px "DM Mono", monospace; + letter-spacing: .05em; + transition: padding .16s ease; +} +.track-card-link span { margin-left: 16px; } +.track-card-link:hover { padding-right: 7px; } + +.track-page { max-width: 1120px; } +.track-back { + padding: 0 0 6px; + border: 0; + border-bottom: 1px solid var(--line); + color: var(--muted); + background: transparent; + font: 10px "DM Mono", monospace; + transition: color .16s ease, border-color .16s ease; +} +.track-back:hover { color: var(--teal-dark); border-color: var(--teal-dark); } +.track-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) 220px; + gap: 60px; + align-items: end; + padding: 60px 0 56px; + border-bottom: 2px solid var(--ink); +} +.track-hero h1 { + margin: 18px 0 20px; + color: var(--ink); + font: 500 clamp(52px, 6vw, 84px)/.98 "Newsreader", "Noto Serif TC", serif; + letter-spacing: -.055em; + text-wrap: pretty; +} +.track-hero > div:first-child > p:last-child { + max-width: 590px; + margin: 0; + color: var(--muted); + font: 16px/1.9 "Noto Serif TC", serif; + text-wrap: pretty; +} +.track-progress-block { padding-left: 19px; border-left: 1px solid var(--teal-dark); } +.track-progress-block > span { display: block; color: var(--teal-dark); font: 500 32px "Newsreader", serif; } +.track-progress-line { height: 3px; margin: 14px 0 10px; background: var(--line); } +.track-progress-line i { display: block; height: 100%; background: var(--teal-dark); transition: width .3s ease; } +.track-progress-block small { color: var(--muted); font: 10px "Noto Serif TC", serif; } +.track-topic-section { margin-top: 5px; } +.track-topic-heading { margin-top: 64px; } +.track-topic-list { border-top: 2px solid var(--ink); } +.track-topic-row { + display: grid; + grid-template-columns: 80px minmax(0, 1fr) 170px; + gap: 28px; + align-items: center; + min-height: 130px; + padding: 25px 0; + border-bottom: 1px solid var(--line); +} +.track-topic-row.ready { background: linear-gradient(90deg, transparent, rgba(230, 220, 196, .5)); } +.track-topic-row.planned { opacity: .58; } +.track-topic-row.complete { background: linear-gradient(90deg, rgba(230, 220, 196, .7), transparent); } +.track-topic-number { color: var(--teal-dark); font: 500 29px "Newsreader", serif; } +.track-topic-status { margin: 0 0 8px; color: var(--muted); font: 9px "DM Mono", monospace; letter-spacing: .1em; } +.track-topic-row.complete .track-topic-status { color: var(--teal-dark); } +.track-topic-copy h3 { margin: 0 0 7px; font: 600 25px "Newsreader", "Noto Serif TC", serif; letter-spacing: -.035em; } +.track-topic-copy > p:last-child { margin: 0; color: var(--muted); font: 13px/1.7 "Noto Serif TC", serif; } +.track-topic-action { justify-self: end; text-align: right; } +.track-topic-button { + padding: 0 0 6px; + border: 0; + border-bottom: 1px solid var(--teal-dark); + color: var(--teal-dark); + background: transparent; + font: 10px "DM Mono", monospace; + transition: padding .16s ease; +} +.track-topic-button:hover { padding-right: 8px; } +.track-topic-disabled { color: var(--muted); font: 10px "DM Mono", monospace; } + +@media (max-width: 950px) { + .track-hero { grid-template-columns: 1fr; gap: 32px; } + .track-progress-block { max-width: 280px; } + .track-topic-row { grid-template-columns: 62px minmax(0, 1fr); } + .track-topic-action { grid-column: 2; justify-self: start; text-align: left; } +} + +@media (max-width: 720px) { + .nav-label-nested { margin-top: 20px; } + .track-hero { padding: 42px 0 40px; } + .track-topic-heading { margin-top: 48px; } + .track-topic-row { grid-template-columns: 1fr; gap: 12px; padding: 25px 0; } + .track-topic-number { font-size: 24px; } + .track-topic-action { grid-column: auto; } +} diff --git a/frontend/src/testing/simulator.test.ts b/frontend/src/testing/simulator.test.ts new file mode 100644 index 0000000..565a1c1 --- /dev/null +++ b/frontend/src/testing/simulator.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { SimulatorDefinition, TopicTestFixture } from "../topics/types"; +import { resetSimulator, runSimulatorEvents, runTopicFixture } from "./simulator"; + +type State = { step: number }; +type Event = { type: "advance" }; + +const simulator: SimulatorDefinition = { + createInitialState: () => ({ step: 0 }), + reduce: (state, event) => event.type === "advance" ? { step: state.step + 1 } : state, + reset: () => ({ step: 0 }), +}; + +const fixture: TopicTestFixture = { + initialState: simulator.createInitialState(), + events: [{ type: "advance" }, { type: "advance" }], + expected: { completed: true, finalState: { step: 2 } }, +}; + +describe("simulator test harness", () => { + it("replays a fixture and evaluates completion", () => { + const result = runTopicFixture(simulator, fixture, (state) => state.step === 2); + + expect(result.state).toEqual(fixture.expected.finalState); + expect(result.completed).toBe(fixture.expected.completed); + }); + + it("replays the same events deterministically", () => { + const first = runSimulatorEvents(simulator, fixture.initialState, fixture.events); + const second = runSimulatorEvents(simulator, fixture.initialState, fixture.events); + + expect(second).toEqual(first); + }); + + it("resets to the simulator initial boundary", () => { + expect(resetSimulator(simulator)).toEqual(fixture.initialState); + }); +}); diff --git a/frontend/src/testing/simulator.ts b/frontend/src/testing/simulator.ts new file mode 100644 index 0000000..9091171 --- /dev/null +++ b/frontend/src/testing/simulator.ts @@ -0,0 +1,24 @@ +import type { SimulatorDefinition, TopicTestFixture } from "../topics/types"; + +export function runSimulatorEvents( + simulator: SimulatorDefinition, + initialState: State, + events: readonly Event[], +): State { + return events.reduce((state, event) => simulator.reduce(state, event), initialState); +} + +export function runTopicFixture( + simulator: SimulatorDefinition, + fixture: TopicTestFixture, + isComplete: (state: State) => boolean, +): { state: State; completed: boolean } { + const state = runSimulatorEvents(simulator, fixture.initialState, fixture.events); + return { state, completed: isComplete(state) }; +} + +export function resetSimulator( + simulator: SimulatorDefinition, +): State { + return simulator.reset(); +} diff --git a/frontend/src/topics/cli/content.test.ts b/frontend/src/topics/cli/content.test.ts new file mode 100644 index 0000000..1d0c701 --- /dev/null +++ b/frontend/src/topics/cli/content.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + cliCommandFixtures, + cliFailureFixtures, + cliLabInitialState, + cliLesson, + cliLessonSteps, +} from "./content"; + +describe("CLI topic content contract", () => { + it("provides teachable objectives and unique lesson sections", () => { + expect(cliLesson.objectives).toHaveLength(3); + expect(cliLesson.sections).toHaveLength(4); + expect(new Set(cliLesson.sections.map((section) => section.id)).size).toBe(cliLesson.sections.length); + }); + + it("maps lesson steps to the deterministic command flow", () => { + expect(cliLessonSteps.map((step) => step.id)).toEqual([ + "context", + "navigate", + "inspect", + "search", + "verify", + ]); + expect(cliLessonSteps.map((step) => step.command)).toEqual([ + "pwd", + "cd src", + "ls", + "grep TODO app.ts", + "npm test", + ]); + }); + + it("keeps successful output, event, and completion mappings fixed", () => { + expect(cliCommandFixtures.map((fixture) => fixture.eventType)).toEqual([ + "print-working-directory", + "change-directory", + "list-files", + "search-file", + "run-check", + ]); + expect(cliCommandFixtures.every((fixture) => fixture.exitCode === 0)).toBe(true); + }); + + it("defines failures that preserve the expected working directory", () => { + expect(cliFailureFixtures.map((fixture) => fixture.exitCode)).toEqual([127, 1, 2, 1]); + expect(cliFailureFixtures.every((fixture) => fixture.expectedCwd === "/workspace/project")).toBe(true); + expect(cliFailureFixtures.every((fixture) => fixture.stderr.length > 0 && fixture.message.length > 0)).toBe(true); + }); + + it("starts from a deterministic browser-only fixture", () => { + expect(cliLabInitialState).toMatchObject({ + cwd: "/workspace/project", + phase: "initial", + commandHistory: [], + stdout: [], + stderr: [], + lastStream: null, + exitCode: null, + completedStepIds: [], + canReset: true, + }); + expect(cliLabInitialState.files.map((file) => file.path)).toEqual([ + "README.md", + "package.json", + "src/app.ts", + ".env.example", + ]); + }); +}); + diff --git a/frontend/src/topics/cli/content.ts b/frontend/src/topics/cli/content.ts new file mode 100644 index 0000000..f7c7d94 --- /dev/null +++ b/frontend/src/topics/cli/content.ts @@ -0,0 +1,212 @@ +import type { LessonDefinition } from "../../topics/types"; + +export const cliLesson: LessonDefinition = { + title: "在固定工作目錄中讀懂命令列", + objectives: [ + "用 pwd 與 cd 建立目前工作目錄的 context。", + "用 ls、cat 與 grep 讀取 fixture 並找出線索。", + "區分 stdout、stderr 與 exit code,並用可重複流程完成檢查。", + ], + sections: [ + { + id: "working-directory", + title: "命令列先回答你在哪裡", + body: "pwd 顯示目前工作目錄;相對路徑會以這個 context 為起點。執行其他指令前,先確認自己位於預期的位置。", + }, + { + id: "inspect-files", + title: "先列出檔案,再讀取內容", + body: "cd 只改變目前目錄,ls 顯示該目錄的檔案。找到目標檔案後,再用 cat 或 grep 讀取內容與搜尋線索。", + }, + { + id: "read-results", + title: "輸出不只是一段文字", + body: "成功結果寫到 stdout;錯誤寫到 stderr。exit code 讓腳本能用固定規則判斷成功或失敗,而不是只看畫面顏色。", + }, + { + id: "repeatable-check", + title: "把檢查做成可重複流程", + body: "固定 fixture 與固定命令順序能讓結果可重現。若走錯路徑或流程失敗,先讀懂回饋,再 reset 回到相同起點。", + }, + ], +}; + +export interface CliLessonStep { + id: CliStepId; + title: string; + command: string; + explanation: string; + takeaway: string; +} + +export const cliLessonSteps: readonly CliLessonStep[] = [ + { + id: "context", + title: "確認目前工作目錄", + command: "pwd", + explanation: "先確認命令列目前位於固定的 project fixture,避免用錯相對路徑。", + takeaway: "先確認 context,再解讀其他命令的結果。", + }, + { + id: "navigate", + title: "進入來源目錄", + command: "cd src", + explanation: "cd 只改變目前工作目錄;成功後,後續相對路徑會從 src 開始計算。", + takeaway: "目錄狀態會影響同一個檔名是否找得到。", + }, + { + id: "inspect", + title: "列出目前檔案", + command: "ls", + explanation: "用 ls 確認來源目錄實際有哪些檔案,再決定要讀取或搜尋哪一個。", + takeaway: "先觀察檔案結構,不要猜路徑。", + }, + { + id: "search", + title: "搜尋 TODO 線索", + command: "grep TODO app.ts", + explanation: "grep 只搜尋指定檔案;相對路徑是否成立,取決於目前的 cwd。", + takeaway: "搜尋結果要連同路徑與行號一起解讀。", + }, + { + id: "verify", + title: "執行固定檢查", + command: "npm test", + explanation: "在正確的來源目錄執行 fixture 內的固定檢查,成功時會回報 stdout 與 exit code 0。", + takeaway: "可重複的檢查流程比一次性的手動觀察可靠。", + }, +] as const; + +export type CliLabPhase = "initial" | "active" | "failed" | "completed"; +export type CliStream = "stdout" | "stderr"; +export type CliStepId = "context" | "navigate" | "inspect" | "search" | "verify"; +export type CliCommandEventType = + | "print-working-directory" + | "change-directory" + | "list-files" + | "search-file" + | "run-check"; + +export interface CliFileFixture { + path: string; + content: string; +} + +export interface CliLabState { + cwd: "/workspace/project" | "/workspace/project/src"; + files: readonly CliFileFixture[]; + commandHistory: readonly string[]; + stdout: readonly string[]; + stderr: readonly string[]; + lastStream: CliStream | null; + exitCode: number | null; + phase: CliLabPhase; + completedStepIds: readonly CliStepId[]; + lastMessage: string; + canReset: true; +} + +export interface CliCommandFixture { + stepId: CliStepId; + eventType: CliCommandEventType; + command: string; + stdout: string; + exitCode: 0; +} + +export const cliCommandFixtures: readonly CliCommandFixture[] = [ + { + stepId: "context", + eventType: "print-working-directory", + command: "pwd", + stdout: "/workspace/project", + exitCode: 0, + }, + { + stepId: "navigate", + eventType: "change-directory", + command: "cd src", + stdout: "現在位於 /workspace/project/src", + exitCode: 0, + }, + { + stepId: "inspect", + eventType: "list-files", + command: "ls", + stdout: "app.ts", + exitCode: 0, + }, + { + stepId: "search", + eventType: "search-file", + command: "grep TODO app.ts", + stdout: "app.ts:1: TODO: add example", + exitCode: 0, + }, + { + stepId: "verify", + eventType: "run-check", + command: "npm test", + stdout: "Tests: 3 passed", + exitCode: 0, + }, +] as const; + +export interface CliFailureFixture { + command: string; + stderr: string; + exitCode: number; + expectedCwd: CliLabState["cwd"]; + message: string; +} + +export const cliFailureFixtures: readonly CliFailureFixture[] = [ + { + command: "unknown", + stderr: "unknown: command not found", + exitCode: 127, + expectedCwd: "/workspace/project", + message: "命令不存在;請確認拼字或回到教材中的固定指令。", + }, + { + command: "cd missing", + stderr: "cd: missing: no such directory", + exitCode: 1, + expectedCwd: "/workspace/project", + message: "目錄不存在;錯誤路徑不會改變目前 cwd。", + }, + { + command: "grep TODO app.ts", + stderr: "grep: app.ts: file not found", + exitCode: 2, + expectedCwd: "/workspace/project", + message: "相對路徑從 project root 計算;先進入 src 再搜尋 app.ts。", + }, + { + command: "npm test", + stderr: "npm test: blocked until the source directory is selected", + exitCode: 1, + expectedCwd: "/workspace/project", + message: "先完成 pwd 與 cd src,再執行固定檢查。", + }, +] as const; + +export const cliLabInitialState: CliLabState = { + cwd: "/workspace/project", + files: [ + { path: "README.md", content: "# CLI fixture" }, + { path: "package.json", content: '{"scripts":{"test":"vitest run"}}' }, + { path: "src/app.ts", content: "export const ready = true; // TODO: add example" }, + { path: ".env.example", content: "API_URL=https://example.invalid" }, + ], + commandHistory: [], + stdout: [], + stderr: [], + lastStream: null, + exitCode: null, + phase: "initial", + completedStepIds: [], + lastMessage: "準備從固定的 project fixture 開始。", + canReset: true, +}; + diff --git a/frontend/src/topics/cli/integration.test.ts b/frontend/src/topics/cli/integration.test.ts new file mode 100644 index 0000000..f8a23f7 --- /dev/null +++ b/frontend/src/topics/cli/integration.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import curriculumData from "../../../../shared/curriculum.json"; +import type { Curriculum } from "../../types"; +import { aggregateProgress } from "../../progress/aggregation"; +import type { ProgressRepository } from "../../progress/repository"; + +const curriculum = curriculumData as Curriculum; + +function memoryRepository(completedTopicIds: readonly string[]): ProgressRepository { + const completed = new Set(completedTopicIds); + return { + read: (topicId) => completed.has(topicId), + markComplete: (topicId) => completed.add(topicId), + clear: (topicId) => completed.delete(topicId), + }; +} + +describe("CLI topic integration contract", () => { + it("is ready in the Core curriculum and contributes to the ready denominator", () => { + const cli = curriculum.tracks.flatMap((track) => track.topics).find((topic) => topic.id === "cli"); + const progress = aggregateProgress(curriculum, memoryRepository([])); + + expect(cli?.status).toBe("ready"); + expect(progress.coreProgress.ready).toBe(7); + expect(progress.coreProgress.completed).toBe(0); + }); + + it("persists CLI completion as Core progress without changing the denominator", () => { + const progress = aggregateProgress(curriculum, memoryRepository(["cli"])); + + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 1 }); + expect(progress.extensionProgress.completed).toBe(0); + }); +}); diff --git a/frontend/src/topics/cli/lesson.tsx b/frontend/src/topics/cli/lesson.tsx new file mode 100644 index 0000000..0ec3c8e --- /dev/null +++ b/frontend/src/topics/cli/lesson.tsx @@ -0,0 +1,36 @@ +import { cliLesson, cliLessonSteps } from "./content"; +import { TopicLessonShell } from "../../components/TopicShell"; + +export function CliLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { + return ( + 在固定工作目錄中
讀懂命令列} + description="先確認 cwd,再用固定 fixture 讀取檔案、搜尋線索與執行檢查;錯誤不會碰到真實檔案,reset 後可以重複練習。" + completed={completed} + > +
+ {cliLesson.sections.map((section, index) => { + const step = cliLessonSteps[index]; + return ( +
+
{String(index + 1).padStart(2, "0")}CLI
+
+

{section.title}

+

{section.body}

+ {step ? $ {step.command} : null} +
+ +
+ ); + })} +
+ +
+

COMMAND LINE LAB

把命令列流程走一遍。

{cliLesson.objectives.length} 個學習目標 · 不碰真實 shell 或檔案系統

+ +
+
+ ); +} diff --git a/frontend/src/topics/cli/simulator.test.ts b/frontend/src/topics/cli/simulator.test.ts new file mode 100644 index 0000000..d5ba9a0 --- /dev/null +++ b/frontend/src/topics/cli/simulator.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { runSimulatorEvents, runTopicFixture } from "../../testing/simulator"; +import { cliLabInitialState, cliLessonSteps } from "./content"; +import { + cliCommand, + cliLabIsComplete, + cliSimulator, + createCliLabState, + runCliCommand, + type CliLabEvent, +} from "./simulator"; + +const happyPath: readonly CliLabEvent[] = cliLessonSteps.map((step) => cliCommand(step.command)); +const happyPathFixture = { + initialState: createCliLabState(), + events: happyPath, + expected: { + completed: true, + finalState: runSimulatorEvents(cliSimulator, createCliLabState(), happyPath), + }, +}; + +describe("CLI Lab simulator", () => { + it("completes the documented command flow", () => { + const result = runTopicFixture( + cliSimulator, + happyPathFixture, + cliLabIsComplete, + ); + + expect(result.completed).toBe(true); + expect(result.state).toEqual(happyPathFixture.expected.finalState); + expect(result.state.phase).toBe("completed"); + expect(result.state.completedStepIds).toEqual(["context", "navigate", "inspect", "search", "verify"]); + expect(result.state.exitCode).toBe(0); + expect(result.state.lastStream).toBe("stdout"); + }); + + it("routes failures to stderr without changing cwd or progress", () => { + const initial = createCliLabState(); + const result = runCliCommand(initial, "grep TODO app.ts"); + + expect(result.accepted).toBe(false); + expect(result.state.cwd).toBe("/workspace/project"); + expect(result.state.completedStepIds).toEqual([]); + expect(result.state.stderr).toEqual(["grep: app.ts: file not found"]); + expect(result.state.lastStream).toBe("stderr"); + expect(result.state.exitCode).toBe(2); + expect(result.state.phase).toBe("failed"); + }); + + it("supports cat without allowing it to skip acceptance steps", () => { + const result = runCliCommand(createCliLabState(), "cat README.md"); + + expect(result.accepted).toBe(true); + expect(result.output).toEqual(["# CLI fixture"]); + expect(result.state.completedStepIds).toEqual([]); + expect(result.state.exitCode).toBe(0); + }); + + it("ignores blank commands without adding history or progress", () => { + const initial = createCliLabState(); + + expect(runCliCommand(initial, " ").state).toBe(initial); + expect(initial.commandHistory).toEqual([]); + expect(initial.completedStepIds).toEqual([]); + }); + + it("resets completed and failed sessions to a deep-equal initial fixture", () => { + const completed = runSimulatorEvents(cliSimulator, createCliLabState(), happyPath); + const failed = runCliCommand(createCliLabState(), "unknown").state; + + expect(cliSimulator.reduce(completed, { type: "reset" })).toEqual(cliLabInitialState); + expect(cliSimulator.reduce(failed, { type: "reset" })).toEqual(cliLabInitialState); + }); + + it("replays the same events deterministically", () => { + const first = runSimulatorEvents(cliSimulator, createCliLabState(), happyPath); + const second = runSimulatorEvents(cliSimulator, createCliLabState(), happyPath); + + expect(second).toEqual(first); + }); +}); diff --git a/frontend/src/topics/cli/simulator.ts b/frontend/src/topics/cli/simulator.ts new file mode 100644 index 0000000..09ca7ee --- /dev/null +++ b/frontend/src/topics/cli/simulator.ts @@ -0,0 +1,193 @@ +import type { SimulatorDefinition } from "../../topics/types"; +import { + cliCommandFixtures, + cliLabInitialState, + type CliLabState, + type CliStream, + type CliStepId, +} from "./content"; + +export interface CliCommandEvent { + type: "command"; + command: string; +} + +export interface CliResetEvent { + type: "reset"; +} + +export type CliLabEvent = CliCommandEvent | CliResetEvent; + +export interface CliCommandResult { + state: CliLabState; + accepted: boolean; + stream: CliStream | null; + output: string[]; +} + +const completionStepIds = cliCommandFixtures.map((fixture) => fixture.stepId); + +function cloneState(state: CliLabState): CliLabState { + return { + ...state, + files: state.files.map((file) => ({ ...file })), + commandHistory: [...state.commandHistory], + stdout: [...state.stdout], + stderr: [...state.stderr], + completedStepIds: [...state.completedStepIds], + }; +} + +function normalize(command: string): string { + return command.trim().replace(/\s+/g, " "); +} + +function isComplete(state: CliLabState): boolean { + return completionStepIds.every((stepId) => state.completedStepIds.includes(stepId)); +} + +function appendOutput( + state: CliLabState, + stream: CliStream, + output: string, + exitCode: number, + message: string, +): CliCommandResult { + if (stream === "stdout") state.stdout = [...state.stdout, output]; + if (stream === "stderr") state.stderr = [...state.stderr, output]; + state.lastStream = stream; + state.exitCode = exitCode; + state.lastMessage = message; + state.phase = "failed"; + return { state, accepted: false, stream, output: [output] }; +} + +function appendSuccess( + state: CliLabState, + output: string, + message: string, + stepId?: CliStepId, +): CliCommandResult { + if (stepId && !state.completedStepIds.includes(stepId)) { + state.completedStepIds = [...state.completedStepIds, stepId]; + } + state.stdout = [...state.stdout, output]; + state.lastStream = "stdout"; + state.exitCode = 0; + state.lastMessage = message; + state.phase = isComplete(state) ? "completed" : "active"; + return { state, accepted: true, stream: "stdout", output: [output] }; +} + +function fileNameForCurrentDirectory(state: CliLabState): string[] { + const prefix = state.cwd === "/workspace/project/src" ? "src/" : ""; + return state.files + .filter((file) => { + if (prefix === "src/") return file.path.startsWith(prefix) && !file.path.slice(prefix.length).includes("/"); + return !file.path.includes("/"); + }) + .map((file) => file.path.slice(prefix.length)); +} + +function runKnownCommand(state: CliLabState, command: string): CliCommandResult | null { + if (command === "pwd") { + return appendSuccess(state, state.cwd, "目前 cwd 已確認。", "context"); + } + + if (command === "cd src") { + if (state.cwd !== "/workspace/project") { + return appendOutput(state, "stderr", "cd: src: no such directory from the current path", 1, "目前已在 src;不需要重複切換目錄。"); + } + state.cwd = "/workspace/project/src"; + return appendSuccess(state, "現在位於 /workspace/project/src", "已進入來源目錄。", "navigate"); + } + + if (command.startsWith("cd ")) { + return appendOutput(state, "stderr", `cd: ${command.slice(3)}: no such directory`, 1, "目錄不存在;錯誤路徑不會改變目前 cwd。"); + } + + if (command === "ls") { + const names = fileNameForCurrentDirectory(state); + const output = names.join(" "); + return appendSuccess( + state, + output, + state.cwd === "/workspace/project/src" ? "已確認來源目錄內有 app.ts。" : "已列出 project root 的固定檔案。", + state.cwd === "/workspace/project/src" ? "inspect" : undefined, + ); + } + + if (command.startsWith("cat ")) { + const requestedPath = command.slice(4); + const file = state.files.find((candidate) => { + if (state.cwd === "/workspace/project/src") return candidate.path === `src/${requestedPath}`; + return candidate.path === requestedPath; + }); + if (!file) { + return appendOutput(state, "stderr", `cat: ${requestedPath}: file not found`, 1, "檔案不存在;先用 ls 確認目前目錄的內容。"); + } + return appendSuccess(state, file.content, `已讀取 ${requestedPath}。`); + } + + if (command.startsWith("grep ")) { + const match = /^grep (.+) (.+)$/.exec(command); + if (!match) { + return appendOutput(state, "stderr", "grep: missing pattern or file", 2, "grep 需要 pattern 與檔名兩個參數。"); + } + const [, pattern, requestedPath] = match; + const file = state.files.find((candidate) => { + if (state.cwd === "/workspace/project/src") return candidate.path === `src/${requestedPath}`; + return candidate.path === requestedPath; + }); + if (!file) { + return appendOutput(state, "stderr", `grep: ${requestedPath}: file not found`, 2, "相對路徑依賴 cwd;先進入 src 再搜尋 app.ts。"); + } + const line = file.content.split("\n").find((content) => content.includes(pattern)); + if (!line) { + return appendOutput(state, "stderr", `grep: ${pattern}: no matches`, 1, "搜尋完成但沒有找到符合的線索。"); + } + const lineNumber = file.content.split("\n").indexOf(line) + 1; + return appendSuccess(state, `${requestedPath}:${lineNumber}: ${line.split("// ")[1] ?? line}`, "已找到 TODO 線索。", "search"); + } + + if (command === "npm test") { + if (state.cwd !== "/workspace/project/src") { + return appendOutput(state, "stderr", "npm test: blocked until the source directory is selected", 1, "先完成 pwd 與 cd src,再執行固定檢查。"); + } + return appendSuccess(state, "Tests: 3 passed", "固定檢查通過,CLI Lab 已完成。", "verify"); + } + + return null; +} + +export function createCliLabState(): CliLabState { + return cloneState(cliLabInitialState); +} + +export function runCliCommand(current: CliLabState, rawCommand: string): CliCommandResult { + const command = normalize(rawCommand); + if (!command) { + return { state: current, accepted: false, stream: null, output: [] }; + } + + const state = cloneState(current); + state.commandHistory = [...state.commandHistory, command]; + const knownResult = runKnownCommand(state, command); + if (knownResult) return knownResult; + + return appendOutput(state, "stderr", `${command}: command not found`, 127, "命令不存在;請確認拼字或回到教材中的固定指令。"); +} + +export const cliSimulator: SimulatorDefinition = { + createInitialState: createCliLabState, + reduce: (state, event) => event.type === "reset" ? createCliLabState() : runCliCommand(state, event.command).state, + reset: createCliLabState, +}; + +export function cliLabIsComplete(state: CliLabState): boolean { + return isComplete(state); +} + +export function cliCommand(command: string): CliCommandEvent { + return { type: "command", command }; +} diff --git a/frontend/src/topics/guardrail/content.test.ts b/frontend/src/topics/guardrail/content.test.ts new file mode 100644 index 0000000..789f983 --- /dev/null +++ b/frontend/src/topics/guardrail/content.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + guardrailLesson, + guardrailOutcomePriority, + guardrailRequiredScenarioIds, + guardrailScenarios, + guardrailValidators, +} from "./content"; + +describe("guardrail lesson and fixture contract", () => { + it("explains the module boundaries with unique sections", () => { + expect(guardrailLesson.objectives.length).toBeGreaterThanOrEqual(4); + expect(guardrailLesson.sections).toHaveLength(4); + expect(new Set(guardrailLesson.sections.map((section) => section.id)).size).toBe(guardrailLesson.sections.length); + }); + + it("covers the three stages and representative validators", () => { + expect(new Set(guardrailValidators.map((validator) => validator.stage))).toEqual(new Set(["input", "output", "tool"])); + expect(guardrailValidators.map((validator) => validator.id)).toEqual([ + "prompt-injection", + "pii-secret", + "moderation", + "off-topic", + "structured-output", + "tool-side-effect", + ]); + }); + + it("keeps deterministic scenario outcomes and completion scenarios explicit", () => { + expect(guardrailOutcomePriority).toEqual(["exception", "reask", "fix", "pass"]); + expect(guardrailRequiredScenarioIds.every((id) => guardrailScenarios.some((scenario) => scenario.id === id))).toBe(true); + expect(guardrailScenarios.find((scenario) => scenario.id === "safe-input")?.expectedOutcome).toBe("pass"); + expect(guardrailScenarios.find((scenario) => scenario.id === "pii-fix")?.expectedOutcome).toBe("fixed"); + expect(guardrailScenarios.find((scenario) => scenario.id === "tool-side-effect")?.expectedOutcome).toBe("blocked"); + }); +}); diff --git a/frontend/src/topics/guardrail/content.ts b/frontend/src/topics/guardrail/content.ts new file mode 100644 index 0000000..ff8ead8 --- /dev/null +++ b/frontend/src/topics/guardrail/content.ts @@ -0,0 +1,176 @@ +import type { LessonDefinition } from "../../topics/types"; + +export const guardrailLesson: LessonDefinition = { + title: "在模型邊界放一道可解釋的防線", + objectives: [ + "分辨 Validator、Guard、Hub 與 OnFailAction 的責任。", + "理解 input、output、tool 三個 guardrail 掛載點。", + "用固定規則與測試案例設計最小 guardrail pipeline。", + "比較 exception、fix、reask、pass 的 UX 與安全取捨。", + ], + sections: [ + { + id: "responsibilities", + title: "先分清楚每一層的責任", + body: "Validator 判斷一個具體風險;Guard 組合規則並決定何時執行;Hub 統一管理多個 Guard;OnFailAction 定義失敗後要阻擋、修正、重問或放行。", + }, + { + id: "mounting-points", + title: "三個掛載點各自守住不同風險", + body: "Input 檢查使用者請求,避免 prompt injection 或敏感資料進入模型;Output 檢查模型結果,處理 moderation、off-topic 與 structured output;Tool 檢查外部副作用,避免未授權的刪除或寫入。", + }, + { + id: "failure-actions", + title: "失敗策略是產品決策", + body: "exception 讓系統明確中止;fix 對內容做可追蹤的修正;reask 要求模型重新產生;pass 表示目前檢查沒有阻擋理由。策略必須回饋原因,不應只顯示一個紅色狀態。", + }, + { + id: "latency-defense", + title: "安全性、延遲與縱深防禦要一起看", + body: "每個 validator 都有成本。串行檢查易於追蹤但會累加 latency;平行檢查可降低等待但要處理結果彙整。高風險工具操作通常值得多一道檢查,不能只追求最快。", + }, + ], +}; + +export type GuardrailStage = "input" | "output" | "tool"; +export type ValidatorId = + | "prompt-injection" + | "pii-secret" + | "moderation" + | "off-topic" + | "structured-output" + | "tool-side-effect"; +export type GuardrailFailureAction = "exception" | "fix" | "reask" | "pass"; +export type GuardrailOutcome = "pass" | "fixed" | "reask" | "blocked"; + +export interface ValidatorFixture { + id: ValidatorId; + label: string; + stage: GuardrailStage; + purpose: string; + latencyMs: number; +} + +export const guardrailValidators: readonly ValidatorFixture[] = [ + { + id: "prompt-injection", + label: "Prompt injection", + stage: "input", + purpose: "辨識試圖改寫系統規則或繞過限制的輸入。", + latencyMs: 12, + }, + { + id: "pii-secret", + label: "PII/Secret", + stage: "input", + purpose: "辨識個資、token 與其他不應進入模型上下文的敏感資料。", + latencyMs: 14, + }, + { + id: "moderation", + label: "Moderation", + stage: "output", + purpose: "檢查輸出是否包含需要攔截或人工處理的內容。", + latencyMs: 18, + }, + { + id: "off-topic", + label: "Off-topic", + stage: "output", + purpose: "確認輸出仍然回答允許的任務範圍。", + latencyMs: 10, + }, + { + id: "structured-output", + label: "Structured output", + stage: "output", + purpose: "確認 JSON 或其他結構化結果符合必要欄位與型別。", + latencyMs: 9, + }, + { + id: "tool-side-effect", + label: "Tool side effect", + stage: "tool", + purpose: "在外部寫入、刪除或其他不可逆副作用前再次確認授權與參數。", + latencyMs: 22, + }, +] as const; + +export const guardrailOutcomePriority: readonly GuardrailFailureAction[] = [ + "exception", + "reask", + "fix", + "pass", +]; + +export type GuardrailScenarioId = "safe-input" | "pii-fix" | "ambiguous-output" | "tool-side-effect"; + +export interface GuardrailScenarioFixture { + id: GuardrailScenarioId; + title: string; + stage: GuardrailStage; + input: string; + enabledValidators: readonly ValidatorId[]; + expectedValidator: ValidatorId | null; + expectedAction: GuardrailFailureAction; + expectedOutcome: GuardrailOutcome; + latencyMs: number; + learnerTakeaway: string; +} + +export const guardrailScenarios: readonly GuardrailScenarioFixture[] = [ + { + id: "safe-input", + title: "安全的工作清單請求", + stage: "input", + input: "請整理今天的工作清單。", + enabledValidators: ["prompt-injection", "pii-secret"], + expectedValidator: null, + expectedAction: "pass", + expectedOutcome: "pass", + latencyMs: 26, + learnerTakeaway: "沒有觸發風險時,pipeline 應以可預測成本放行。", + }, + { + id: "pii-fix", + title: "移除輸入中的示範 secret", + stage: "input", + input: "請把這段設定整理好:[demo-secret]。", + enabledValidators: ["pii-secret"], + expectedValidator: "pii-secret", + expectedAction: "fix", + expectedOutcome: "fixed", + latencyMs: 14, + learnerTakeaway: "可安全修正的風險要說明修正內容,而不是假裝沒有發生。", + }, + { + id: "ambiguous-output", + title: "重新要求範圍內的回答", + stage: "output", + input: "模型輸出一段與目前課程無關的答案。", + enabledValidators: ["off-topic", "structured-output"], + expectedValidator: "off-topic", + expectedAction: "reask", + expectedOutcome: "reask", + latencyMs: 19, + learnerTakeaway: "可重新產生的問題要留下 reask 原因,不把錯誤靜默吞掉。", + }, + { + id: "tool-side-effect", + title: "攔截未確認的工具副作用", + stage: "tool", + input: "執行 delete_account()。", + enabledValidators: ["tool-side-effect"], + expectedValidator: "tool-side-effect", + expectedAction: "exception", + expectedOutcome: "blocked", + latencyMs: 22, + learnerTakeaway: "不可逆的工具操作應在副作用發生前中止並要求明確處理。", + }, +] as const; + +export const guardrailRequiredScenarioIds: readonly GuardrailScenarioId[] = [ + "safe-input", + "pii-fix", + "tool-side-effect", +]; diff --git a/frontend/src/topics/guardrail/lab.test.ts b/frontend/src/topics/guardrail/lab.test.ts new file mode 100644 index 0000000..8cb3496 --- /dev/null +++ b/frontend/src/topics/guardrail/lab.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { guardrailScenarios } from "./content"; +import { createInitialGuardrailState, runGuardrailEvents } from "./simulator"; +import { guardrailLabProgress } from "./lab"; + +describe("guardrail lab progress", () => { + it("starts empty and reaches 100 after required scenarios", () => { + const events = [ + { type: "setInput", text: guardrailScenarios[0].input }, + { type: "submitScenario", id: "safe-input" }, + { type: "setInput", text: guardrailScenarios[1].input }, + { type: "submitScenario", id: "pii-fix" }, + { type: "selectStage", stage: "tool" }, + { type: "setInput", text: guardrailScenarios[3].input }, + { type: "submitScenario", id: "tool-side-effect" }, + ] as const; + + expect(guardrailLabProgress(createInitialGuardrailState())).toBe(0); + expect(guardrailLabProgress(runGuardrailEvents(events).state)).toBe(100); + }); +}); diff --git a/frontend/src/topics/guardrail/lab.tsx b/frontend/src/topics/guardrail/lab.tsx new file mode 100644 index 0000000..d3130c3 --- /dev/null +++ b/frontend/src/topics/guardrail/lab.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { + guardrailScenarios, + guardrailValidators, + type GuardrailStage, + type ValidatorId, +} from "./content"; +import { createInitialGuardrailState, isGuardrailComplete, runGuardrailEvent, type GuardrailEvent, type GuardrailState } from "./simulator"; +import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback } from "../../components/TopicShell"; + +const STAGES: readonly { id: GuardrailStage; label: string; hint: string }[] = [ + { id: "input", label: "INPUT", hint: "請求進入模型前" }, + { id: "output", label: "OUTPUT", hint: "模型結果交付前" }, + { id: "tool", label: "TOOL", hint: "外部副作用發生前" }, +]; + +export function guardrailLabProgress(state: GuardrailState): number { + return Math.round((state.completedScenarioIds.length / 3) * 100); +} + +export function GuardrailLab({ onComplete }: { onComplete?: () => void }) { + const [state, setState] = useState(createInitialGuardrailState); + const [feedback, setFeedback] = useState({ accepted: true, message: state.lastMessage }); + const completed = isGuardrailComplete(state); + const activeValidators = guardrailValidators.filter((validator) => validator.stage === state.stage); + const activeScenarios = guardrailScenarios.filter((scenario) => scenario.stage === state.stage); + + function apply(event: GuardrailEvent) { + const result = runGuardrailEvent(state, event); + setState(result.state); + setFeedback({ accepted: result.accepted, message: result.output.join(" ") }); + if (result.accepted && isGuardrailComplete(result.state)) onComplete?.(); + } + + function submitScenario(id: (typeof guardrailScenarios)[number]["id"]) { + const scenario = guardrailScenarios.find((item) => item.id === id); + if (!scenario) return; + const inputResult = runGuardrailEvent(state, { type: "setInput", text: scenario.input }); + const result = runGuardrailEvent(inputResult.state, { type: "submitScenario", id }); + setState(result.state); + setFeedback({ accepted: result.accepted, message: result.output.join(" ") }); + if (result.accepted && isGuardrailComplete(result.state)) onComplete?.(); + } + + return ( + 讓每一次呼叫
先通過一道防線} + progressLabel={`${state.completedScenarioIds.length} / 3 SCENARIOS`} + progress={guardrailLabProgress(state)} + onReset={() => apply({ type: "reset" })} + > + {completed ? ( + apply({ type: "reset" })} + /> + ) : ( +
+
+
+
+

PIPELINE CONTROL

+

選擇檢查掛載點

+
+ {state.stage.toUpperCase()} +
+ +
+ {STAGES.map((stage) => ( + + ))} +
+ +
+

VALIDATORS

+ 可複選 +
+
+ {activeValidators.map((validator) => { + const enabled = state.enabledValidators.includes(validator.id); + return ( + + ); + })} +
+ +
+

FIXED SCENARIOS

+ 不呼叫真實模型 +
+
+ {activeScenarios.map((scenario) => ( + + ))} +
+
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/topics/guardrail/lesson.tsx b/frontend/src/topics/guardrail/lesson.tsx new file mode 100644 index 0000000..63d3b29 --- /dev/null +++ b/frontend/src/topics/guardrail/lesson.tsx @@ -0,0 +1,29 @@ +import { guardrailLesson } from "./content"; +import { TopicLessonShell } from "../../components/TopicShell"; + +export function GuardrailLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { + return ( + 在模型邊界
放一道防線} + description="從 Validator、Guard 到 OnFailAction,理解如何在 input、output 與 tool 三個掛載點建立可解釋、可重設的 deterministic guardrail pipeline。" + completed={completed} + > +
+ {guardrailLesson.sections.map((section, index) => ( +
+
{String(index + 1).padStart(2, "0")}GUARDRAIL
+

{section.title}

{section.body}

+ +
+ ))} +
+ +
+

GUARDRAIL LAB

把規則放進 pipeline。

Extension track · deterministic fixture · 不呼叫真實模型

+ +
+
+ ); +} diff --git a/frontend/src/topics/guardrail/simulator.test.ts b/frontend/src/topics/guardrail/simulator.test.ts new file mode 100644 index 0000000..7c0c306 --- /dev/null +++ b/frontend/src/topics/guardrail/simulator.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { guardrailScenarios } from "./content"; +import { + createInitialGuardrailState, + guardrailSimulator, + isGuardrailComplete, + runGuardrailEvent, + runGuardrailEvents, +} from "./simulator"; +import { resetSimulator, runSimulatorEvents } from "../../testing/simulator"; + +const requiredScenarioEvents = [ + { type: "setInput", text: guardrailScenarios[0].input }, + { type: "submitScenario", id: "safe-input" }, + { type: "setInput", text: guardrailScenarios[1].input }, + { type: "submitScenario", id: "pii-fix" }, + { type: "selectStage", stage: "tool" }, + { type: "setInput", text: guardrailScenarios[3].input }, + { type: "submitScenario", id: "tool-side-effect" }, +] as const; + +describe("guardrail simulator", () => { + it("completes the three required deterministic scenarios", () => { + const result = runGuardrailEvents(requiredScenarioEvents); + + expect(result.accepted).toBe(true); + expect(isGuardrailComplete(result.state)).toBe(true); + expect(result.state.phase).toBe("completed"); + expect(result.state.completedScenarioIds).toEqual(["safe-input", "pii-fix", "tool-side-effect"]); + expect(result.state.outcome).toBe("blocked"); + }); + + it("blocks a scenario when its triggering validator is disabled", () => { + const start = createInitialGuardrailState(); + const disabled = runGuardrailEvent(start, { type: "toggleValidator", id: "pii-secret" }); + const withInput = runGuardrailEvent(disabled.state, { type: "setInput", text: guardrailScenarios[1].input }); + const result = runGuardrailEvent(withInput.state, { type: "submitScenario", id: "pii-fix" }); + + expect(result.accepted).toBe(false); + expect(result.state.phase).toBe("failed"); + expect(result.state.completedScenarioIds).toEqual([]); + }); + + it("replays the same events deterministically through the shared harness", () => { + const first = runSimulatorEvents(guardrailSimulator, createInitialGuardrailState(), requiredScenarioEvents); + const second = runSimulatorEvents(guardrailSimulator, createInitialGuardrailState(), requiredScenarioEvents); + + expect(second).toEqual(first); + expect(isGuardrailComplete(first)).toBe(true); + }); + + it("resets all scenario progress and result details", () => { + const complete = runGuardrailEvents(requiredScenarioEvents).state; + + expect(resetSimulator(guardrailSimulator)).toEqual(createInitialGuardrailState()); + expect(complete.completedScenarioIds).not.toEqual([]); + }); +}); diff --git a/frontend/src/topics/guardrail/simulator.ts b/frontend/src/topics/guardrail/simulator.ts new file mode 100644 index 0000000..860567e --- /dev/null +++ b/frontend/src/topics/guardrail/simulator.ts @@ -0,0 +1,213 @@ +import type { SimulatorDefinition } from "../../topics/types"; +import { + guardrailRequiredScenarioIds, + guardrailScenarios, + guardrailValidators, + type GuardrailFailureAction, + type GuardrailOutcome, + type GuardrailScenarioId, + type GuardrailStage, + type ValidatorId, +} from "./content"; + +export type GuardrailPhase = "initial" | "active" | "failed" | "completed"; + +export interface ValidatorResult { + validatorId: ValidatorId; + triggered: boolean; + action: GuardrailFailureAction; + message: string; + latencyMs: number; +} + +export interface GuardrailState { + stage: GuardrailStage; + enabledValidators: ValidatorId[]; + lastInput: string; + results: ValidatorResult[]; + outcome: GuardrailOutcome; + latencyMs: number; + phase: GuardrailPhase; + completedScenarioIds: GuardrailScenarioId[]; + lastMessage: string; + canReset: true; +} + +export type GuardrailEvent = + | { type: "selectStage"; stage: GuardrailStage } + | { type: "setInput"; text: string } + | { type: "toggleValidator"; id: ValidatorId } + | { type: "submitScenario"; id: GuardrailScenarioId } + | { type: "reset" }; + +export interface GuardrailEventResult { + state: GuardrailState; + output: readonly string[]; + accepted: boolean; +} + +export interface GuardrailRunResult { + state: GuardrailState; + results: readonly GuardrailEventResult[]; + accepted: boolean; +} + +export function createInitialGuardrailState(): GuardrailState { + return { + stage: "input", + enabledValidators: ["prompt-injection", "pii-secret"], + lastInput: "", + results: [], + outcome: "pass", + latencyMs: 0, + phase: "initial", + completedScenarioIds: [], + lastMessage: "選擇一個固定情境,觀察每層 guardrail 的結果。", + canReset: true, + }; +} + +export function resetGuardrail(): GuardrailState { + return createInitialGuardrailState(); +} + +export function isGuardrailComplete(state: GuardrailState): boolean { + return guardrailRequiredScenarioIds.every((id) => state.completedScenarioIds.includes(id)); +} + +function validatorsForStage(stage: GuardrailStage): ValidatorId[] { + return guardrailValidators.filter((validator) => validator.stage === stage).map((validator) => validator.id); +} + +function accepted( + current: GuardrailState, + changes: Partial, + message: string, + output: readonly string[] = [message], +): GuardrailEventResult { + return { + state: { ...current, ...changes, phase: "active", lastMessage: message }, + output, + accepted: true, + }; +} + +function blocked(current: GuardrailState, message: string): GuardrailEventResult { + return { + state: { ...current, phase: "failed", outcome: "blocked", lastMessage: message }, + output: [message], + accepted: false, + }; +} + +function validatorMessage(validatorId: ValidatorId, triggered: boolean, action: GuardrailFailureAction): string { + if (!triggered) return `${validatorId}: pass`; + return `${validatorId}: ${action}`; +} + +export function runGuardrailEvent(current: GuardrailState, event: GuardrailEvent): GuardrailEventResult { + if (event.type === "reset") { + return { state: resetGuardrail(), output: ["Guardrail Lab 已重設。"], accepted: true }; + } + + if (current.phase === "completed") { + return blocked(current, "Guardrail Lab 已完成;如要重練,請先 reset。"); + } + + switch (event.type) { + case "selectStage": + return accepted( + current, + { + stage: event.stage, + enabledValidators: validatorsForStage(event.stage), + lastInput: "", + results: [], + outcome: "pass", + latencyMs: 0, + }, + `已切換到 ${event.stage} stage;請選擇固定情境。`, + ); + case "setInput": + return accepted(current, { lastInput: event.text, results: [], outcome: "pass", latencyMs: 0 }, "已更新待檢查內容。", [ + `待檢查內容:${event.text || "(空白)"}`, + ]); + case "toggleValidator": { + const validator = guardrailValidators.find((item) => item.id === event.id); + if (!validator || validator.stage !== current.stage) { + return blocked(current, `${event.id} 不屬於目前的 ${current.stage} stage。`); + } + const enabledValidators = current.enabledValidators.includes(event.id) + ? current.enabledValidators.filter((id) => id !== event.id) + : [...current.enabledValidators, event.id]; + return accepted(current, { enabledValidators, results: [], outcome: "pass", latencyMs: 0 }, `${event.id} validator 已${enabledValidators.includes(event.id) ? "啟用" : "停用"}。`); + } + case "submitScenario": { + const scenario = guardrailScenarios.find((item) => item.id === event.id); + if (!scenario) return blocked(current, `找不到 ${event.id} scenario fixture。`); + if (scenario.stage !== current.stage) { + return blocked(current, `${scenario.id} 需要在 ${scenario.stage} stage 執行。`); + } + if (current.lastInput !== scenario.input) { + return blocked(current, "請先把 scenario 的固定輸入載入,再送出檢查。"); + } + if (scenario.expectedValidator && !current.enabledValidators.includes(scenario.expectedValidator)) { + return blocked(current, `請先啟用 ${scenario.expectedValidator} validator,才能觀察這個風險。`); + } + + const results = current.enabledValidators.map((validatorId) => { + const triggered = validatorId === scenario.expectedValidator; + const action = triggered ? scenario.expectedAction : "pass"; + const validator = guardrailValidators.find((item) => item.id === validatorId); + return { + validatorId, + triggered, + action, + message: validatorMessage(validatorId, triggered, action), + latencyMs: validator?.latencyMs ?? 0, + }; + }); + const completedScenarioIds = guardrailRequiredScenarioIds.includes(event.id) + ? Array.from(new Set([...current.completedScenarioIds, event.id])) + : current.completedScenarioIds; + const completed = guardrailRequiredScenarioIds.every((id) => completedScenarioIds.includes(id)); + const message = `${scenario.title}:${scenario.expectedAction},結果為 ${scenario.expectedOutcome}。`; + const output = [message, ...results.map((result) => result.message), `固定 latency:${scenario.latencyMs}ms`]; + return { + state: { + ...current, + results, + outcome: scenario.expectedOutcome, + latencyMs: scenario.latencyMs, + completedScenarioIds, + phase: completed ? "completed" : scenario.expectedOutcome === "blocked" ? "failed" : "active", + lastMessage: message, + }, + output, + accepted: true, + }; + } + } +} + +export const guardrailSimulator: SimulatorDefinition = { + createInitialState: createInitialGuardrailState, + reduce: (state, event) => runGuardrailEvent(state, event).state, + reset: resetGuardrail, +}; + +export function runGuardrailEvents( + events: readonly GuardrailEvent[], + initialState: GuardrailState = createInitialGuardrailState(), +): GuardrailRunResult { + let state = { ...initialState, enabledValidators: [...initialState.enabledValidators], results: [...initialState.results], completedScenarioIds: [...initialState.completedScenarioIds] }; + const results: GuardrailEventResult[] = []; + + for (const event of events) { + const result = runGuardrailEvent(state, event); + results.push(result); + state = result.state; + } + + return { state, results, accepted: results.every((result) => result.accepted) }; +} diff --git a/frontend/src/topics/ide/content.test.ts b/frontend/src/topics/ide/content.test.ts new file mode 100644 index 0000000..9d1e634 --- /dev/null +++ b/frontend/src/topics/ide/content.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + ideCommandFixtures, + ideFileFixture, + ideInitialState, + ideLesson, + ideLessonSteps, + idePausedVariables, +} from "./content"; + +describe("IDE topic content contract", () => { + it("provides teachable objectives and unique lesson sections", () => { + expect(ideLesson.objectives).toHaveLength(3); + expect(ideLesson.sections).toHaveLength(4); + expect(new Set(ideLesson.sections.map((section) => section.id)).size).toBe(ideLesson.sections.length); + }); + + it("maps lesson steps to the deterministic debugger flow", () => { + expect(ideLessonSteps.map((step) => step.id)).toEqual([ + "open", + "breakpoint", + "run", + "inspect", + "step", + "continue", + ]); + expect(ideLessonSteps.map((step) => step.command)).toEqual([ + "open src/order.ts", + "breakpoint 3", + "run calculateTotal(10, 2, 3)", + "inspect variables", + "step over", + "continue", + ]); + }); + + it("locks the file fixture and debugger state observations", () => { + expect(ideFileFixture).toMatchObject({ path: "src/order.ts", language: "typescript" }); + expect(ideFileFixture.content.split("\n")).toHaveLength(5); + expect(idePausedVariables).toEqual({ price: "10", quantity: "2", discount: "3", subtotal: "20" }); + expect(ideCommandFixtures.map((fixture) => fixture.eventType)).toEqual([ + "open-file", + "set-breakpoint", + "run", + "inspect", + "step-over", + "continue", + ]); + }); + + it("starts from a deterministic browser-only fixture", () => { + expect(ideInitialState).toMatchObject({ + phase: "initial", + selectedFile: null, + breakpointLines: [], + currentLine: null, + callStack: [], + variables: {}, + output: [], + completedStepIds: [], + canReset: true, + }); + }); +}); + diff --git a/frontend/src/topics/ide/content.ts b/frontend/src/topics/ide/content.ts new file mode 100644 index 0000000..68ef5c8 --- /dev/null +++ b/frontend/src/topics/ide/content.ts @@ -0,0 +1,168 @@ +import type { LessonDefinition } from "../../topics/types"; + +export const ideLesson: LessonDefinition = { + title: "用除錯器看見程式正在做什麼", + objectives: [ + "在固定 editor fixture 中開啟檔案並設定 breakpoint。", + "從 paused state 閱讀 current line、call stack 與 variables。", + "用 step over 與 continue 控制執行,區分暫停、完成與非法操作。", + ], + sections: [ + { + id: "editor-context", + title: "先確認你正在看哪個檔案", + body: "Editor 顯示檔案內容;debugger 顯示程式執行位置。開啟 src/order.ts 後,才有明確的程式 context 可以設定 breakpoint。", + }, + { + id: "breakpoint-condition", + title: "Breakpoint 是暫停條件", + body: "在第 3 行設定 breakpoint,讓 calculateTotal 在 discounted 計算前暫停。Breakpoint 不會替你修正程式,也不代表程式已經失敗。", + }, + { + id: "paused-state", + title: "Paused state 提供三種線索", + body: "程式暫停時,同時讀 current line、call stack 與 variables。這些線索能說明程式走到哪裡,以及每個輸入目前的值。", + }, + { + id: "controlled-execution", + title: "一步一步縮小問題", + body: "inspect 先記錄目前 frame;step over 執行下一行但不進入其他函式;continue 則讓程式跑到下一個 breakpoint 或結束。", + }, + ], +}; + +export type IdeLabPhase = "initial" | "active" | "paused" | "failed" | "completed"; +export type IdeStream = "stdout" | "stderr"; +export type IdeStepId = "open" | "breakpoint" | "run" | "inspect" | "step" | "continue"; +export type IdeLabEventType = "open-file" | "set-breakpoint" | "run" | "inspect" | "step-over" | "continue"; + +export interface IdeFileFixture { + path: "src/order.ts"; + content: string; + language: "typescript"; +} + +export interface IdeLabState { + phase: IdeLabPhase; + selectedFile: string | null; + breakpointLines: readonly number[]; + currentLine: number | null; + callStack: readonly string[]; + variables: Readonly>; + output: readonly string[]; + lastStream: IdeStream | null; + exitCode: number | null; + completedStepIds: readonly IdeStepId[]; + lastMessage: string; + canReset: true; +} + +export interface IdeLessonStep { + id: IdeStepId; + title: string; + command: string; + explanation: string; + takeaway: string; +} + +export const ideLessonSteps: readonly IdeLessonStep[] = [ + { + id: "open", + title: "開啟固定檔案", + command: "open src/order.ts", + explanation: "先把 editor context 固定在 order.ts,後續 breakpoint 與執行位置都以這個檔案為準。", + takeaway: "先確認正在看的檔案,再解讀執行狀態。", + }, + { + id: "breakpoint", + title: "在第 3 行暫停", + command: "breakpoint 3", + explanation: "第 3 行會計算 discounted,設定 breakpoint 後,程式會在這個計算前停下來。", + takeaway: "Breakpoint 是觀察點,不是修正動作。", + }, + { + id: "run", + title: "啟動固定函式", + command: "run calculateTotal(10, 2, 3)", + explanation: "固定參數讓每次執行都可重現;有 breakpoint 時,執行會進入 paused state。", + takeaway: "先讓問題穩定重現,才有可靠線索。", + }, + { + id: "inspect", + title: "讀取目前 frame", + command: "inspect variables", + explanation: "查看 price、quantity、discount 與 subtotal,確認 debugger 顯示的是目前 frame 的值。", + takeaway: "變數值比猜測更接近原因。", + }, + { + id: "step", + title: "執行下一行", + command: "step over", + explanation: "step over 執行第 3 行但不離開目前函式;current line 會移到 return,並新增 discounted。", + takeaway: "一次只前進一行,觀察哪個值發生變化。", + }, + { + id: "continue", + title: "繼續到程式結束", + command: "continue", + explanation: "沒有下一個 breakpoint 時,continue 會完成固定函式並輸出 17。", + takeaway: "完成輸出是流程終點,不代表跳過中間證據。", + }, +] as const; + +export const ideFileFixture: IdeFileFixture = { + path: "src/order.ts", + language: "typescript", + content: [ + "export function calculateTotal(price: number, quantity: number, discount: number) {", + " const subtotal = price * quantity;", + " const discounted = subtotal - discount;", + " return discounted;", + "}", + ].join("\n"), +}; + +export const ideInitialState: IdeLabState = { + phase: "initial", + selectedFile: null, + breakpointLines: [], + currentLine: null, + callStack: [], + variables: {}, + output: [], + lastStream: null, + exitCode: null, + completedStepIds: [], + lastMessage: "準備從固定的 order.ts fixture 開始。", + canReset: true, +}; + +export const idePausedVariables = { + price: "10", + quantity: "2", + discount: "3", + subtotal: "20", +} as const; + +export const ideCompletedVariables = { + ...idePausedVariables, + discounted: "17", +} as const; + +export interface IdeCommandFixture { + stepId: IdeStepId; + eventType: IdeLabEventType; + command: string; + expectedPhase: IdeLabPhase; + expectedLine: number | null; +} + +export const ideCommandFixtures: readonly IdeCommandFixture[] = [ + { stepId: "open", eventType: "open-file", command: "open src/order.ts", expectedPhase: "active", expectedLine: null }, + { stepId: "breakpoint", eventType: "set-breakpoint", command: "breakpoint 3", expectedPhase: "active", expectedLine: null }, + { stepId: "run", eventType: "run", command: "run calculateTotal(10, 2, 3)", expectedPhase: "paused", expectedLine: 3 }, + { stepId: "inspect", eventType: "inspect", command: "inspect variables", expectedPhase: "paused", expectedLine: 3 }, + { stepId: "step", eventType: "step-over", command: "step over", expectedPhase: "paused", expectedLine: 4 }, + { stepId: "continue", eventType: "continue", command: "continue", expectedPhase: "completed", expectedLine: null }, +] as const; + diff --git a/frontend/src/topics/ide/integration.test.ts b/frontend/src/topics/ide/integration.test.ts new file mode 100644 index 0000000..d528af5 --- /dev/null +++ b/frontend/src/topics/ide/integration.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import curriculumData from "../../../../shared/curriculum.json"; +import type { Curriculum } from "../../types"; +import { aggregateProgress } from "../../progress/aggregation"; +import type { ProgressRepository } from "../../progress/repository"; + +const curriculum = curriculumData as Curriculum; + +function memoryRepository(completedTopicIds: readonly string[]): ProgressRepository { + const completed = new Set(completedTopicIds); + return { + read: (topicId) => completed.has(topicId), + markComplete: (topicId) => completed.add(topicId), + clear: (topicId) => completed.delete(topicId), + }; +} + +describe("IDE topic integration contract", () => { + it("is ready in the Core curriculum and contributes to the ready denominator", () => { + const ide = curriculum.tracks.flatMap((track) => track.topics).find((topic) => topic.id === "ide"); + const progress = aggregateProgress(curriculum, memoryRepository([])); + + expect(ide?.status).toBe("ready"); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 0 }); + }); + + it("persists IDE completion as Core progress without changing the denominator", () => { + const progress = aggregateProgress(curriculum, memoryRepository(["ide"])); + + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 1 }); + expect(progress.extensionProgress.completed).toBe(0); + }); +}); diff --git a/frontend/src/topics/ide/lesson.tsx b/frontend/src/topics/ide/lesson.tsx new file mode 100644 index 0000000..404929a --- /dev/null +++ b/frontend/src/topics/ide/lesson.tsx @@ -0,0 +1,36 @@ +import { TopicLessonShell } from "../../components/TopicShell"; +import { ideLesson, ideLessonSteps } from "./content"; + +export function IdeLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { + return ( + 用除錯器看見程式
正在做什麼} + description="先在固定的 editor fixture 建立 context,再設定 breakpoint、閱讀 paused state,最後用 step over 與 continue 觀察程式如何完成。" + completed={completed} + > +
+ {ideLesson.sections.map((section, index) => { + const step = ideLessonSteps[index]; + return ( +
+
{String(index + 1).padStart(2, "0")}IDE
+
+

{section.title}

+

{section.body}

+ {step ? {step.command} : null} +
+ +
+ ); + })} +
+ +
+

DEBUGGER LAB

把除錯流程走一遍。

{ideLesson.objectives.length} 個學習目標 · 只使用瀏覽器內固定 fixture

+ +
+
+ ); +} diff --git a/frontend/src/topics/ide/simulator.test.ts b/frontend/src/topics/ide/simulator.test.ts new file mode 100644 index 0000000..1619ddd --- /dev/null +++ b/frontend/src/topics/ide/simulator.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { runSimulatorEvents, runTopicFixture } from "../../testing/simulator"; +import { ideInitialState, ideLessonSteps } from "./content"; +import { + createIdeLabState, + ideCommand, + ideLabIsComplete, + ideSimulator, + runIdeCommand, + type IdeLabEvent, +} from "./simulator"; + +const happyPath: readonly IdeLabEvent[] = ideLessonSteps.map((step) => ideCommand(step.command)); +const happyPathFixture = { + initialState: createIdeLabState(), + events: happyPath, + expected: { + completed: true, + finalState: runSimulatorEvents(ideSimulator, createIdeLabState(), happyPath), + }, +}; + +describe("IDE Lab simulator", () => { + it("completes the documented debugger flow", () => { + const result = runTopicFixture(ideSimulator, happyPathFixture, ideLabIsComplete); + + expect(result.completed).toBe(true); + expect(result.state).toEqual(happyPathFixture.expected.finalState); + expect(result.state.phase).toBe("completed"); + expect(result.state.currentLine).toBeNull(); + expect(result.state.output.at(-1)).toBe("17"); + expect(result.state.exitCode).toBe(0); + expect(result.state.lastStream).toBe("stdout"); + }); + + it("pauses at the breakpoint with the expected frame and variables", () => { + let state = createIdeLabState(); + state = runIdeCommand(state, "open src/order.ts").state; + state = runIdeCommand(state, "breakpoint 3").state; + const result = runIdeCommand(state, "run calculateTotal(10, 2, 3)"); + + expect(result.accepted).toBe(true); + expect(result.state.phase).toBe("paused"); + expect(result.state.currentLine).toBe(3); + expect(result.state.callStack).toEqual(["calculateTotal"]); + expect(result.state.variables).toEqual({ price: "10", quantity: "2", discount: "3", subtotal: "20" }); + }); + + it("requires a breakpoint before run and preserves the editor context on failure", () => { + const opened = runIdeCommand(createIdeLabState(), "open src/order.ts").state; + const result = runIdeCommand(opened, "run calculateTotal(10, 2, 3)"); + + expect(result.accepted).toBe(false); + expect(result.state.phase).toBe("failed"); + expect(result.state.selectedFile).toBe("src/order.ts"); + expect(result.state.breakpointLines).toEqual([]); + expect(result.state.callStack).toEqual([]); + expect(result.state.lastStream).toBe("stderr"); + expect(result.state.exitCode).toBe(2); + }); + + it("does not allow step over or inspect before paused state", () => { + const initial = createIdeLabState(); + const inspect = runIdeCommand(initial, "inspect variables"); + const step = runIdeCommand(initial, "step over"); + + expect(inspect.accepted).toBe(false); + expect(step.accepted).toBe(false); + expect(inspect.state.currentLine).toBeNull(); + expect(step.state.variables).toEqual({}); + }); + + it("ignores blank commands and resets failed or completed sessions", () => { + const initial = createIdeLabState(); + expect(runIdeCommand(initial, " ").state).toBe(initial); + + const failed = runIdeCommand(initial, "unknown").state; + const completed = runSimulatorEvents(ideSimulator, createIdeLabState(), happyPath); + + expect(ideSimulator.reduce(failed, { type: "reset" })).toEqual(ideInitialState); + expect(ideSimulator.reduce(completed, { type: "reset" })).toEqual(ideInitialState); + }); + + it("replays the same debugger events deterministically", () => { + const first = runSimulatorEvents(ideSimulator, createIdeLabState(), happyPath); + const second = runSimulatorEvents(ideSimulator, createIdeLabState(), happyPath); + + expect(second).toEqual(first); + }); +}); diff --git a/frontend/src/topics/ide/simulator.ts b/frontend/src/topics/ide/simulator.ts new file mode 100644 index 0000000..d95b682 --- /dev/null +++ b/frontend/src/topics/ide/simulator.ts @@ -0,0 +1,182 @@ +import type { SimulatorDefinition } from "../../topics/types"; +import { + ideCompletedVariables, + ideFileFixture, + ideInitialState, + idePausedVariables, + type IdeLabState, + type IdeStream, + type IdeStepId, +} from "./content"; + +export interface IdeCommandEvent { + type: "command"; + command: string; +} + +export interface IdeResetEvent { + type: "reset"; +} + +export type IdeLabEvent = IdeCommandEvent | IdeResetEvent; + +export interface IdeCommandResult { + state: IdeLabState; + accepted: boolean; + stream: IdeStream | null; + output: string[]; +} + +const completionStepIds: readonly IdeStepId[] = ["open", "breakpoint", "run", "inspect", "step", "continue"]; + +function cloneState(state: IdeLabState): IdeLabState { + return { + ...state, + breakpointLines: [...state.breakpointLines], + callStack: [...state.callStack], + variables: { ...state.variables }, + output: [...state.output], + completedStepIds: [...state.completedStepIds], + }; +} + +function normalize(command: string): string { + return command.trim().replace(/\s+/g, " "); +} + +function isComplete(state: IdeLabState): boolean { + return completionStepIds.every((stepId) => state.completedStepIds.includes(stepId)); +} + +function markStep(state: IdeLabState, stepId: IdeStepId) { + if (!state.completedStepIds.includes(stepId)) { + state.completedStepIds = [...state.completedStepIds, stepId]; + } +} + +function appendSuccess(state: IdeLabState, output: string, message: string, stepId?: IdeStepId): IdeCommandResult { + if (stepId) markStep(state, stepId); + state.output = [...state.output, output]; + state.lastStream = "stdout"; + state.exitCode = 0; + state.lastMessage = message; + state.phase = isComplete(state) ? "completed" : state.phase === "paused" ? "paused" : "active"; + return { state, accepted: true, stream: "stdout", output: [output] }; +} + +function appendFailure(state: IdeLabState, output: string, message: string, exitCode: number): IdeCommandResult { + state.output = [...state.output, output]; + state.lastStream = "stderr"; + state.exitCode = exitCode; + state.lastMessage = message; + state.phase = "failed"; + return { state, accepted: false, stream: "stderr", output: [output] }; +} + +function runKnownCommand(state: IdeLabState, command: string): IdeCommandResult | null { + if (command.startsWith("open ")) { + const path = command.slice(5); + if (path !== ideFileFixture.path) { + return appendFailure(state, `open: ${path}: file not found`, "檔案不存在;請開啟固定的 src/order.ts。", 1); + } + state.selectedFile = ideFileFixture.path; + return appendSuccess(state, `Opened ${ideFileFixture.path}`, "已開啟固定的 order.ts fixture。", "open"); + } + + if (command.startsWith("breakpoint ")) { + const line = Number(command.slice("breakpoint ".length)); + if (!state.selectedFile) { + return appendFailure(state, "breakpoint: no file is open", "先開啟 src/order.ts,再設定 breakpoint。", 2); + } + if (!Number.isInteger(line) || line < 1 || line > ideFileFixture.content.split("\n").length) { + return appendFailure(state, `breakpoint: invalid line ${command.slice("breakpoint ".length)}`, "Breakpoint 必須落在 order.ts 的有效行號。", 1); + } + state.breakpointLines = [line]; + if (line !== 3) { + return appendSuccess(state, `Breakpoint set at line ${line}`, "Breakpoint 已設定;happy path 需要第 3 行才能觀察 discounted。", undefined); + } + return appendSuccess(state, "Breakpoint set at line 3", "已在 discounted 計算前設定 breakpoint。", "breakpoint"); + } + + if (command === "run calculateTotal(10, 2, 3)") { + if (state.selectedFile !== ideFileFixture.path) { + return appendFailure(state, "run: no file is open", "先開啟 src/order.ts,再啟動固定函式。", 2); + } + if (!state.breakpointLines.includes(3)) { + return appendFailure(state, "run: no breakpoint at line 3", "先在第 3 行設定 breakpoint,再執行函式。", 2); + } + state.phase = "paused"; + state.currentLine = 3; + state.callStack = ["calculateTotal"]; + state.variables = { ...idePausedVariables }; + return appendSuccess(state, "Paused at src/order.ts:3", "程式已在第 3 行 paused;現在可以 inspect variables。", "run"); + } + + if (command === "inspect variables") { + if (state.phase !== "paused" || state.currentLine === null) { + return appendFailure(state, "inspect: no active frame", "只有 paused state 才有可讀取的 active frame。", 2); + } + const variables = Object.entries(state.variables).map(([name, value]) => `${name}=${value}`).join(" "); + markStep(state, "inspect"); + return appendSuccess(state, variables, "已讀取目前 frame 的 variables。"); + } + + if (command === "step over") { + if (state.phase !== "paused" || state.currentLine === null) { + return appendFailure(state, "step over: debugger is not paused", "先 run 並停在 breakpoint,才能逐行執行。", 2); + } + if (!state.completedStepIds.includes("inspect")) { + return appendFailure(state, "step over: inspect the current frame first", "先 inspect variables,再執行下一行。", 2); + } + if (state.currentLine !== 3) { + return appendFailure(state, `step over: no step from line ${state.currentLine}`, "目前已在 return 行;可直接 continue。", 2); + } + state.currentLine = 4; + state.variables = { ...ideCompletedVariables }; + return appendSuccess(state, "Stepped to src/order.ts:4", "step over 已執行 discounted 計算。", "step"); + } + + if (command === "continue") { + if (state.phase !== "paused" || state.currentLine === null) { + return appendFailure(state, "continue: program has not started", "先 run 固定函式,再 continue 到下一個 breakpoint 或結束。", 2); + } + if (!state.completedStepIds.includes("step")) { + return appendFailure(state, "continue: step over the paused line first", "先完成 inspect 與 step over,再 continue。", 2); + } + state.phase = "completed"; + state.currentLine = null; + state.callStack = []; + return appendSuccess(state, "17", "固定函式已完成,debug 流程完成。", "continue"); + } + + return null; +} + +export function createIdeLabState(): IdeLabState { + return cloneState(ideInitialState); +} + +export function runIdeCommand(current: IdeLabState, rawCommand: string): IdeCommandResult { + const command = normalize(rawCommand); + if (!command) return { state: current, accepted: false, stream: null, output: [] }; + + const state = cloneState(current); + const knownResult = runKnownCommand(state, command); + if (knownResult) return knownResult; + + return appendFailure(state, `${command}: command not found`, "命令不存在;請使用教材中的固定 debugger 指令。", 127); +} + +export const ideSimulator: SimulatorDefinition = { + createInitialState: createIdeLabState, + reduce: (state, event) => event.type === "reset" ? createIdeLabState() : runIdeCommand(state, event.command).state, + reset: createIdeLabState, +}; + +export function ideCommand(command: string): IdeCommandEvent { + return { type: "command", command }; +} + +export function ideLabIsComplete(state: IdeLabState): boolean { + return isComplete(state); +} diff --git a/frontend/src/topics/package/content.test.ts b/frontend/src/topics/package/content.test.ts new file mode 100644 index 0000000..9de1ff9 --- /dev/null +++ b/frontend/src/topics/package/content.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + packageFailureFixtures, + packageLabHappyPath, + packageLabInitialState, + packageLesson, + packageLessonSteps, + packageLockFixture, + packageManifestFixture, + packageRegistryFixtures, +} from "./content"; + +describe("package topic content contract", () => { + it("provides teachable objectives and unique lesson sections", () => { + expect(packageLesson.objectives).toHaveLength(4); + expect(packageLesson.sections).toHaveLength(4); + expect(new Set(packageLesson.sections.map((section) => section.id)).size).toBe(packageLesson.sections.length); + }); + + it("maps lesson steps to the manifest, lockfile, and clean install flow", () => { + expect(packageLessonSteps.map((step) => step.id)).toEqual([ + "inspect-manifest", + "add-dependency", + "install", + "inspect-lockfile", + "clean-install", + ]); + expect(packageLessonSteps.map((step) => step.command)).toContain("npm install @workshop/format@^1.2.0"); + expect(packageLessonSteps.map((step) => step.command)).toContain("npm ci"); + }); + + it("starts from the acceptance fixture and exposes deterministic resolutions", () => { + expect(packageManifestFixture).toMatchObject({ + name: "workshop-package-lab", + packageManager: "npm@10.8.2", + dependencies: {}, + }); + expect(packageRegistryFixtures.map((fixture) => fixture.name)).toEqual([ + "@workshop/format", + "@workshop/shared", + ]); + expect(packageLockFixture.packages["node_modules/@workshop/format"]).toMatchObject({ version: "1.3.0", resolvedFrom: "fixture-registry" }); + expect(packageLockFixture.packages["node_modules/@workshop/shared"]).toMatchObject({ version: "1.0.0", resolvedFrom: "fixture-registry" }); + expect(packageLabInitialState).toMatchObject({ phase: "initial", lockfile: null, lockfileState: "missing", installState: "empty" }); + }); + + it("keeps the happy path and failure boundaries explicit", () => { + expect(packageLabHappyPath.map((event) => event.type)).toEqual([ + "inspect-manifest", + "add-dependency", + "install", + "inspect-lockfile", + "clean-install", + ]); + expect(packageLabHappyPath[1]).toEqual({ type: "add-dependency", packageSpec: "@workshop/format@^1.2.0" }); + expect(packageFailureFixtures.map((fixture) => fixture.expectedPhase)).toEqual(["blocked", "failed", "failed"]); + }); +}); diff --git a/frontend/src/topics/package/content.ts b/frontend/src/topics/package/content.ts new file mode 100644 index 0000000..f31956e --- /dev/null +++ b/frontend/src/topics/package/content.ts @@ -0,0 +1,220 @@ +import type { LessonDefinition } from "../../topics/types"; + +export const packageLesson: LessonDefinition = { + title: "讓依賴可重現,而不是碰運氣", + objectives: [ + "分辨 package.json、package-lock.json 與 installed modules 各自負責的狀態。", + "用版本範圍新增依賴,理解 manifest spec 與 lockfile exact version 的差異。", + "用 npm install 解析 fixture registry,再用 npm ci 從 lockfile 重建相同結果。", + "在依賴狀態不一致時,知道應該更新、檢查或停止,而不是留下半套安裝。", + ], + sections: [ + { + id: "three-dependency-layers", + title: "依賴管理有三層狀態", + body: "package.json 宣告你想要的版本範圍;package-lock.json 記錄這次解析出的 exact versions;installed modules 則是目前工作目錄實際可用的結果。三者要一起理解,才知道專案是否可重現。", + }, + { + id: "range-and-exact", + title: "版本範圍不等於實際版本", + body: "^1.2.0 是允許更新的依賴範圍,lockfile 中的 1.3.0 才是這次安裝固定採用的版本。不要只看 manifest 就假設每台機器都會得到相同 dependency graph。", + }, + { + id: "install-and-lock", + title: "npm install 會更新解析結果", + body: "新增依賴後,npm install 會依照 fixture registry 解析 direct dependency 與 transitive dependency,並同步更新 lockfile。這一步應留下可 review 的 manifest 與 lockfile 變更。", + }, + { + id: "clean-rebuild", + title: "npm ci 用 lockfile 重建", + body: "npm ci 不是另一種猜版本的安裝方式;它要求 manifest 與 lockfile 一致,再從 lockfile 重建乾淨結果。若兩者不一致,應該先修正狀態而不是忽略錯誤。", + }, + ], +}; + +export type PackageStepId = + | "inspect-manifest" + | "add-dependency" + | "install" + | "inspect-lockfile" + | "clean-install"; + +export interface PackageLessonStep { + id: PackageStepId; + title: string; + command: string; + explanation: string; + takeaway: string; +} + +export const packageLessonSteps: readonly PackageLessonStep[] = [ + { + id: "inspect-manifest", + title: "先看依賴宣告", + command: "cat package.json", + explanation: "先確認專案名稱、package manager 版本與目前 dependencies,再決定要新增什麼。", + takeaway: "manifest 說明需求,不保證 exact resolution。", + }, + { + id: "add-dependency", + title: "新增版本範圍", + command: "npm install @workshop/format@^1.2.0", + explanation: "用版本範圍新增 direct dependency;fixture 會讓 manifest 更新,並標記 lockfile 尚未同步。", + takeaway: "改 manifest 後,先承認 lockfile 是 stale。", + }, + { + id: "install", + title: "解析並安裝依賴", + command: "npm install", + explanation: "從固定 registry fixture 解析 @workshop/format@1.3.0 與它的 transitive dependency。", + takeaway: "安裝結果要能被 lockfile 解釋。", + }, + { + id: "inspect-lockfile", + title: "檢查 exact resolution", + command: "cat package-lock.json", + explanation: "確認 lockfile 記下 direct dependency、transitive dependency、exact version 與 fixture 來源。", + takeaway: "lockfile 是可重現安裝的證據。", + }, + { + id: "clean-install", + title: "用 lockfile 乾淨重建", + command: "npm ci", + explanation: "清空既有安裝結果,再只依照 lockfile 重建相同 dependency graph。", + takeaway: "可重現不是安裝一次成功,而是清空後仍得到同一組結果。", + }, +] as const; + +export type PackageLabPhase = "initial" | "active" | "blocked" | "failed" | "completed"; +export type PackageManifestState = "unchanged" | "updated"; +export type PackageLockfileState = "missing" | "stale" | "synced"; +export type PackageInstallState = "empty" | "installed" | "clean-installed"; +export type PackageEventType = PackageStepId | "reset"; + +export interface PackageManifestFixture { + name: "workshop-package-lab"; + private: true; + packageManager: "npm@10.8.2"; + dependencies: Readonly>; +} + +export interface PackageRegistryFixture { + name: "@workshop/format" | "@workshop/shared"; + versions: Readonly> }>>; +} + +export interface PackageLockPackageFixture { + version: string; + resolvedFrom: "fixture-registry"; + dependencies?: Readonly>; +} + +export interface PackageLockFixture { + lockfileVersion: 3; + packages: Readonly>; +} + +export interface PackageLabState { + phase: PackageLabPhase; + packageManager: "npm"; + manifest: PackageManifestFixture; + manifestState: PackageManifestState; + lockfile: PackageLockFixture | null; + lockfileState: PackageLockfileState; + installedModules: readonly string[]; + installState: PackageInstallState; + completedStepIds: readonly PackageStepId[]; + lastCommand: string | null; + lastMessage: string; + canReset: true; +} + +export interface PackageLabEvent { + type: PackageEventType; + packageSpec?: string; +} + +export const packageManifestFixture: PackageManifestFixture = { + name: "workshop-package-lab", + private: true, + packageManager: "npm@10.8.2", + dependencies: {}, +}; + +export const packageRegistryFixtures: readonly PackageRegistryFixture[] = [ + { + name: "@workshop/format", + versions: { + "1.2.0": { dependencies: { "@workshop/shared": "^1.0.0" } }, + "1.3.0": { dependencies: { "@workshop/shared": "^1.0.0" } }, + }, + }, + { + name: "@workshop/shared", + versions: { + "1.0.0": { dependencies: {} }, + }, + }, +] as const; + +export const packageLockFixture: PackageLockFixture = { + lockfileVersion: 3, + packages: { + "node_modules/@workshop/format": { + version: "1.3.0", + resolvedFrom: "fixture-registry", + dependencies: { "@workshop/shared": "^1.0.0" }, + }, + "node_modules/@workshop/shared": { + version: "1.0.0", + resolvedFrom: "fixture-registry", + }, + }, +}; + +export const packageLabInitialState: PackageLabState = { + phase: "initial", + packageManager: "npm", + manifest: packageManifestFixture, + manifestState: "unchanged", + lockfile: null, + lockfileState: "missing", + installedModules: [], + installState: "empty", + completedStepIds: [], + lastCommand: null, + lastMessage: "準備從固定 package.json fixture 開始。", + canReset: true, +}; + +export const packageLabHappyPath: readonly PackageLabEvent[] = [ + { type: "inspect-manifest" }, + { type: "add-dependency", packageSpec: "@workshop/format@^1.2.0" }, + { type: "install" }, + { type: "inspect-lockfile" }, + { type: "clean-install" }, +]; + +export interface PackageFailureFixture { + command: string; + message: string; + expectedPhase: "blocked" | "failed"; +} + +export const packageFailureFixtures: readonly PackageFailureFixture[] = [ + { + command: "npm ci", + message: "npm ci blocked until package.json and package-lock.json are consistent.", + expectedPhase: "blocked", + }, + { + command: "npm install @workshop/unknown@^1.0.0", + message: "package not found in fixture registry.", + expectedPhase: "failed", + }, + { + command: "npm ci", + message: "lockfile does not match the declared dependency graph.", + expectedPhase: "failed", + }, +] as const; diff --git a/frontend/src/topics/package/integration.test.ts b/frontend/src/topics/package/integration.test.ts new file mode 100644 index 0000000..5eaa9c1 --- /dev/null +++ b/frontend/src/topics/package/integration.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import curriculumData from "../../../../shared/curriculum.json"; +import type { Curriculum } from "../../types"; +import { aggregateProgress } from "../../progress/aggregation"; +import type { ProgressRepository } from "../../progress/repository"; + +const curriculum = curriculumData as Curriculum; + +function memoryRepository(completedTopicIds: readonly string[]): ProgressRepository { + const completed = new Set(completedTopicIds); + return { + read: (topicId) => completed.has(topicId), + markComplete: (topicId) => completed.add(topicId), + clear: (topicId) => completed.delete(topicId), + }; +} + +describe("Package topic integration contract", () => { + it("is ready in the Core curriculum and contributes to the ready denominator", () => { + const packageTopic = curriculum.tracks.flatMap((track) => track.topics).find((topic) => topic.id === "package"); + const progress = aggregateProgress(curriculum, memoryRepository([])); + + expect(packageTopic?.status).toBe("ready"); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 0 }); + }); + + it("persists Package completion as Core progress without changing the denominator", () => { + const progress = aggregateProgress(curriculum, memoryRepository(["package"])); + + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 1 }); + expect(progress.extensionProgress.completed).toBe(0); + }); +}); diff --git a/frontend/src/topics/package/lab.test.ts b/frontend/src/topics/package/lab.test.ts new file mode 100644 index 0000000..5297fc6 --- /dev/null +++ b/frontend/src/topics/package/lab.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { packageLabHappyPath } from "./content"; +import { createInitialPackageState, runPackageEvents } from "./simulator"; +import { packageLabProgress } from "./lab"; + +describe("package lab progress", () => { + it("starts empty and reaches 100 after the happy path", () => { + expect(packageLabProgress(createInitialPackageState())).toBe(0); + expect(packageLabProgress(runPackageEvents(packageLabHappyPath).state)).toBe(100); + }); +}); diff --git a/frontend/src/topics/package/lab.tsx b/frontend/src/topics/package/lab.tsx new file mode 100644 index 0000000..d04b54b --- /dev/null +++ b/frontend/src/topics/package/lab.tsx @@ -0,0 +1,220 @@ +import { type FormEvent, useMemo, useState } from "react"; +import { + packageLabHappyPath, + packageLessonSteps, + type PackageLabEvent, + type PackageLabState, + type PackageStepId, +} from "./content"; +import { createInitialPackageState, isPackageLabComplete, runPackageEvent } from "./simulator"; +import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell"; + +interface PackageHistoryEntry { + command?: string; + lines: readonly string[]; + accepted?: boolean; +} + +type PackageFile = "package.json" | "package-lock.json" | "node_modules"; + +const PACKAGE_FILES: readonly PackageFile[] = ["package.json", "package-lock.json", "node_modules"]; +const INITIAL_HISTORY: readonly PackageHistoryEntry[] = [ + { lines: ["Package Management sandbox v1", "固定 fixture registry 已準備好。選擇一個 package action 開始。"] }, +]; + +function eventForStep(stepId: PackageStepId): PackageLabEvent { + return packageLabHappyPath.find((event) => event.type === stepId) ?? { type: stepId }; +} + +function eventForCommand(rawCommand: string): PackageLabEvent | null { + const command = rawCommand.trim().replace(/\s+/g, " "); + if (command === "cat package.json") return { type: "inspect-manifest" }; + if (command === "npm install @workshop/format@^1.2.0") return { type: "add-dependency", packageSpec: "@workshop/format@^1.2.0" }; + if (command.startsWith("npm install ")) return { type: "add-dependency", packageSpec: command.slice("npm install ".length) }; + if (command === "npm install") return { type: "install" }; + if (command === "cat package-lock.json") return { type: "inspect-lockfile" }; + if (command === "npm ci") return { type: "clean-install" }; + return null; +} + +function statusTone(state: PackageLabState): TopicStatusTone { + if (state.phase === "completed") return "success"; + if (state.phase === "blocked" || state.phase === "failed") return "error"; + return "neutral"; +} + +function stepDone(state: PackageLabState, stepId: PackageStepId): boolean { + return state.completedStepIds.includes(stepId); +} + +function packageFileLines(state: PackageLabState, file: PackageFile): readonly string[] { + if (file === "package.json") { + return JSON.stringify(state.manifest, null, 2).split("\n"); + } + if (file === "package-lock.json") { + return state.lockfile ? JSON.stringify(state.lockfile, null, 2).split("\n") : ["// package-lock.json 尚未產生", "// 執行 npm install 後再檢查"]; + } + return state.installedModules.length + ? ["node_modules/", ...state.installedModules.map((module) => ` ${module}`)] + : ["node_modules/", " // 尚未安裝任何依賴"]; +} + +export function packageLabProgress(state: PackageLabState): number { + return Math.round((state.completedStepIds.length / packageLessonSteps.length) * 100); +} + +export function PackageLab({ onComplete }: { onComplete?: () => void }) { + const [state, setState] = useState(createInitialPackageState); + const [command, setCommand] = useState(""); + const [selectedFile, setSelectedFile] = useState("package.json"); + const [history, setHistory] = useState(INITIAL_HISTORY); + const completed = isPackageLabComplete(state); + const completedCount = state.completedStepIds.length; + const currentStep = useMemo( + () => packageLessonSteps.find((step) => !stepDone(state, step.id)) ?? packageLessonSteps[packageLessonSteps.length - 1], + [state], + ); + + function dispatch(event: PackageLabEvent, rawCommand?: string) { + if (completed) return; + const result = runPackageEvent(state, event); + setState(result.state); + setHistory((items) => [ + ...items, + { command: rawCommand ?? packageLessonSteps.find((step) => step.id === event.type)?.command, lines: result.output, accepted: result.accepted }, + ]); + if (result.accepted && isPackageLabComplete(result.state)) onComplete?.(); + } + + function submit(event: FormEvent) { + event.preventDefault(); + const rawCommand = command.trim(); + if (!rawCommand || completed) return; + const packageEvent = eventForCommand(rawCommand); + if (packageEvent) { + dispatch(packageEvent, rawCommand); + } else { + setHistory((items) => [...items, { command: rawCommand, lines: [`${rawCommand}: command not found`], accepted: false }]); + } + setCommand(""); + } + + function reset() { + setState(createInitialPackageState()); + setCommand(""); + setSelectedFile("package.json"); + setHistory([{ lines: ["Package Lab 已重設。從 cat package.json 重新開始。"] }]); + } + + return ( + 讓依賴變得可重現
而不是碰運氣} + progressLabel={`${completedCount} / ${packageLessonSteps.length} STEPS`} + progress={packageLabProgress(state)} + onReset={reset} + > + + + {completed ? ( + + ) : ( +
+
+
+ + workshop-package-lab + {state.phase} +
+
+ {PACKAGE_FILES.map((file) => ( + + ))} +
+
+ {packageFileLines(state, selectedFile).map((line, index) => ( +
+ {String(index + 1).padStart(2, "0")}{line || " "} +
+ ))} +
+
+ {history.map((entry, index) => ( +
+ {entry.command ?

{entry.command}

: null} + {entry.lines.map((line, lineIndex) => {line})} +
+ ))} +
+
+ + setCommand(event.target.value)} + placeholder="輸入 npm 指令…" + autoComplete="off" + spellCheck={false} + aria-describedby="package-command-help" + /> + +
+

可輸入教材中的 package 指令,或使用右側 action buttons。

+
+ + +
+ )} + +
+

LIVE PACKAGE STATE

目前的依賴線索

只顯示 simulator 狀態,不連線真實 registry。

+
+
MANIFEST{state.manifestState}
+
LOCKFILE{state.lockfileState}
+
INSTALL{state.installState}
+
MODULES{state.installedModules.length || "—"}
+
DECLARED DEPENDENCIES{Object.entries(state.manifest.dependencies).map(([name, version]) => `${name}@${version}`).join(" · ") || "—"}
+
LAST COMMAND{state.lastCommand ?? "—"}
+
+
+
+ ); +} diff --git a/frontend/src/topics/package/lesson.tsx b/frontend/src/topics/package/lesson.tsx new file mode 100644 index 0000000..2c21c46 --- /dev/null +++ b/frontend/src/topics/package/lesson.tsx @@ -0,0 +1,36 @@ +import { TopicLessonShell } from "../../components/TopicShell"; +import { packageLesson, packageLessonSteps } from "./content"; + +export function PackageLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { + return ( + 讓依賴可重現
而不是碰運氣} + description="從 package.json、lockfile 到 clean install,理解版本解析與可重現依賴;所有操作都在固定 fixture 中完成。" + completed={completed} + > +
+ {packageLesson.sections.map((section, index) => { + const step = packageLessonSteps[index]; + return ( +
+
{String(index + 1).padStart(2, "0")}PACKAGE
+
+

{section.title}

+

{section.body}

+ {step ? $ {step.command} : null} +
+ +
+ ); + })} +
+ +
+

PACKAGE MANAGEMENT LAB

把依賴裝得可重現。

{packageLesson.objectives.length} 個學習目標 · 不連線真實 registry

+ +
+
+ ); +} diff --git a/frontend/src/topics/package/simulator.test.ts b/frontend/src/topics/package/simulator.test.ts new file mode 100644 index 0000000..ce57bc9 --- /dev/null +++ b/frontend/src/topics/package/simulator.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + packageLabHappyPath, + packageLabInitialState, + packageLockFixture, + type PackageLabState, +} from "./content"; +import { + createInitialPackageState, + isPackageLabComplete, + packageSimulator, + resetPackageLab, + runPackageEvent, + runPackageEvents, +} from "./simulator"; + +describe("package deterministic simulator", () => { + it("starts from the acceptance fixture without installed modules", () => { + expect(createInitialPackageState()).toEqual(packageLabInitialState); + expect(createInitialPackageState().installedModules).toEqual([]); + expect(createInitialPackageState().lockfile).toBeNull(); + }); + + it("completes the manifest, lockfile, and clean install happy path", () => { + const result = runPackageEvents(packageLabHappyPath); + + expect(result.accepted).toBe(true); + expect(result.state.phase).toBe("completed"); + expect(result.state.manifest.dependencies).toEqual({ "@workshop/format": "^1.2.0" }); + expect(result.state.lockfile).toEqual(packageLockFixture); + expect(result.state.installedModules).toEqual(["@workshop/format@1.3.0", "@workshop/shared@1.0.0"]); + expect(result.state.installState).toBe("clean-installed"); + expect(isPackageLabComplete(result.state)).toBe(true); + }); + + it("blocks npm ci until the manifest and lockfile flow is ready", () => { + const initialResult = runPackageEvent(createInitialPackageState(), { type: "clean-install" }); + expect(initialResult.accepted).toBe(false); + expect(initialResult.state.phase).toBe("blocked"); + expect(initialResult.state.installedModules).toEqual([]); + + const inspected = runPackageEvent(createInitialPackageState(), { type: "inspect-manifest" }).state; + const addResult = runPackageEvent(inspected, { type: "add-dependency", packageSpec: "@workshop/format@^1.2.0" }); + const staleResult = runPackageEvent(addResult.state, { type: "clean-install" }); + expect(staleResult.accepted).toBe(false); + expect(staleResult.state.phase).toBe("blocked"); + expect(staleResult.state.lockfileState).toBe("stale"); + }); + + it("rejects unknown packages without changing the manifest", () => { + const inspected = runPackageEvent(createInitialPackageState(), { type: "inspect-manifest" }).state; + const result = runPackageEvent(inspected, { type: "add-dependency", packageSpec: "@workshop/unknown@^1.0.0" }); + + expect(result.accepted).toBe(false); + expect(result.state.phase).toBe("failed"); + expect(result.state.manifest.dependencies).toEqual({}); + expect(result.state.lockfile).toBeNull(); + }); + + it("fails clean install when the lockfile no longer matches the manifest", () => { + const installed = runPackageEvents(packageLabHappyPath.slice(0, 4)).state; + const mismatchedState: PackageLabState = { + ...installed, + manifest: { ...installed.manifest, dependencies: { "@workshop/format": "^9.0.0" } }, + }; + const result = runPackageEvent(mismatchedState, { type: "clean-install" }); + + expect(result.accepted).toBe(false); + expect(result.state.phase).toBe("failed"); + expect(result.state.installState).toBe("installed"); + expect(result.state.installedModules).toEqual(["@workshop/format@1.3.0", "@workshop/shared@1.0.0"]); + }); + + it("resets and remains deterministic", () => { + const first = runPackageEvents(packageLabHappyPath); + const second = runPackageEvents(packageLabHappyPath); + + expect(first.state).toEqual(second.state); + expect(first.results).toEqual(second.results); + expect(resetPackageLab()).toEqual(createInitialPackageState()); + expect(packageSimulator.reset()).toEqual(createInitialPackageState()); + }); +}); diff --git a/frontend/src/topics/package/simulator.ts b/frontend/src/topics/package/simulator.ts new file mode 100644 index 0000000..e106d6d --- /dev/null +++ b/frontend/src/topics/package/simulator.ts @@ -0,0 +1,233 @@ +import type { SimulatorDefinition } from "../../topics/types"; +import { + packageLabInitialState, + packageLabHappyPath, + packageLockFixture, + type PackageEventType, + type PackageLabEvent, + type PackageLabState, + type PackageStepId, +} from "./content"; + +export interface PackageEventResult { + state: PackageLabState; + output: readonly string[]; + accepted: boolean; +} + +export interface PackageRunResult { + state: PackageLabState; + results: readonly PackageEventResult[]; + accepted: boolean; +} + +const completionStepIds: readonly PackageStepId[] = packageLabHappyPath.map((event) => event.type as PackageStepId); + +const installedModules = ["@workshop/format@1.3.0", "@workshop/shared@1.0.0"] as const; + +function cloneLockfile(state: PackageLabState["lockfile"]): PackageLabState["lockfile"] { + if (!state) return null; + return { + ...state, + packages: Object.fromEntries( + Object.entries(state.packages).map(([path, fixture]) => [path, { ...fixture, dependencies: fixture.dependencies ? { ...fixture.dependencies } : undefined }]), + ), + }; +} + +function cloneState(state: PackageLabState): PackageLabState { + return { + ...state, + manifest: { ...state.manifest, dependencies: { ...state.manifest.dependencies } }, + lockfile: cloneLockfile(state.lockfile), + installedModules: [...state.installedModules], + completedStepIds: [...state.completedStepIds], + }; +} + +function hasCompleted(state: PackageLabState, stepId: PackageStepId): boolean { + return state.completedStepIds.includes(stepId); +} + +function withStep(state: PackageLabState, stepId: PackageStepId): PackageStepId[] { + return hasCompleted(state, stepId) ? [...state.completedStepIds] : [...state.completedStepIds, stepId]; +} + +function isComplete(state: PackageLabState): boolean { + return ( + state.phase === "completed" && + completionStepIds.every((stepId) => hasCompleted(state, stepId)) && + state.lockfileState === "synced" && + state.installState === "clean-installed" && + state.installedModules.join("|") === installedModules.join("|") + ); +} + +function accepted( + current: PackageLabState, + changes: Partial, + message: string, + output: readonly string[] = [message], +): PackageEventResult { + return { + state: { ...current, ...changes, phase: "active", lastMessage: message }, + output, + accepted: true, + }; +} + +function blocked(current: PackageLabState, command: string, message: string): PackageEventResult { + return { + state: { ...current, phase: "blocked", lastCommand: command, lastMessage: message }, + output: [message], + accepted: false, + }; +} + +function failed(current: PackageLabState, command: string, message: string): PackageEventResult { + return { + state: { ...current, phase: "failed", lastCommand: command, lastMessage: message }, + output: [message], + accepted: false, + }; +} + +function manifestAndLockfileMatch(state: PackageLabState): boolean { + return ( + state.manifest.dependencies["@workshop/format"] === "^1.2.0" && + state.lockfile?.packages["node_modules/@workshop/format"]?.version === "1.3.0" && + state.lockfile.packages["node_modules/@workshop/shared"]?.version === "1.0.0" + ); +} + +export function createInitialPackageState(): PackageLabState { + return cloneState(packageLabInitialState); +} + +export function resetPackageLab(): PackageLabState { + return createInitialPackageState(); +} + +export function isPackageLabComplete(state: PackageLabState): boolean { + return isComplete(state); +} + +export function runPackageEvent(current: PackageLabState, event: PackageLabEvent): PackageEventResult { + if (event.type === "reset") { + return { state: resetPackageLab(), output: ["Package Lab 已重設,可以重新開始。"], accepted: true }; + } + + if (current.phase === "completed") { + return blocked(current, event.type, "Package Lab 已完成;如要重練,請先 reset。"); + } + + const state = cloneState(current); + + switch (event.type) { + case "inspect-manifest": + return accepted( + state, + { completedStepIds: withStep(state, "inspect-manifest"), lastCommand: "cat package.json" }, + "已確認 package.json 與 npm@10.8.2 fixture。", + ["workshop-package-lab", "dependencies: (empty)", "packageManager: npm@10.8.2"], + ); + case "add-dependency": + if (!hasCompleted(state, "inspect-manifest")) { + return blocked(state, "npm install @workshop/format@^1.2.0", "請先檢查 package.json,再新增依賴。"); + } + if (event.packageSpec !== "@workshop/format@^1.2.0") { + return failed(state, "npm install", "package not found in fixture registry."); + } + if (state.manifest.dependencies["@workshop/format"]) { + return blocked(state, "npm install @workshop/format@^1.2.0", "@workshop/format 已存在;請繼續檢查目前 lockfile 狀態。"); + } + return accepted( + state, + { + manifest: { ...state.manifest, dependencies: { ...state.manifest.dependencies, "@workshop/format": "^1.2.0" } }, + manifestState: "updated", + lockfileState: "stale", + lastCommand: "npm install @workshop/format@^1.2.0", + completedStepIds: withStep(state, "add-dependency"), + }, + "已新增 @workshop/format@^1.2.0;lockfile 目前尚未同步。", + ["package.json updated", "lockfile: stale"], + ); + case "install": + if (!hasCompleted(state, "add-dependency") || state.lockfileState !== "stale") { + return blocked(state, "npm install", "請先新增依賴;npm install 會負責產生與 manifest 一致的 lockfile。"); + } + return accepted( + state, + { + lockfile: cloneLockfile(packageLockFixture), + lockfileState: "synced", + installState: "installed", + installedModules: [...installedModules], + lastCommand: "npm install", + completedStepIds: withStep(state, "install"), + }, + "已從 fixture registry 解析 direct 與 transitive dependencies。", + ["@workshop/format@1.3.0", "@workshop/shared@1.0.0", "lockfile: synced"], + ); + case "inspect-lockfile": + if (state.lockfileState !== "synced" || !state.lockfile) { + return blocked(state, "cat package-lock.json", "請先執行 npm install,現在沒有可檢查的 synced lockfile。"); + } + return accepted( + state, + { lastCommand: "cat package-lock.json", completedStepIds: withStep(state, "inspect-lockfile") }, + "已確認 exact versions、transitive dependency 與 fixture registry 來源。", + ["@workshop/format: 1.3.0", "@workshop/shared: 1.0.0", "resolvedFrom: fixture-registry"], + ); + case "clean-install": + if (!hasCompleted(state, "inspect-lockfile")) { + return blocked(state, "npm ci", "請先檢查 package-lock.json,再用 npm ci 重建安裝結果。"); + } + if (state.lockfileState !== "synced" || !state.lockfile || !manifestAndLockfileMatch(state)) { + return failed(state, "npm ci", "lockfile does not match the declared dependency graph."); + } + if (isComplete({ ...state, phase: "completed", installState: "clean-installed" })) { + return accepted(state, {}, "Package Lab 已完成;不需要重複 clean install。"); + } + { + const completedStepIds = withStep(state, "clean-install"); + const nextState: PackageLabState = { + ...state, + phase: "completed", + installState: "clean-installed", + installedModules: [...installedModules], + lastCommand: "npm ci", + completedStepIds, + lastMessage: "已從 lockfile 乾淨重建相同 dependency graph;PACKAGE Lab 完成。", + }; + return { state: nextState, output: ["node_modules cleared", ...installedModules, "PACKAGE Lab completed"], accepted: true }; + } + default: { + const unknownEvent = event.type as PackageEventType; + return blocked(state, unknownEvent, `不支援的 package event:${unknownEvent}。`); + } + } +} + +export const packageSimulator: SimulatorDefinition = { + createInitialState: createInitialPackageState, + reduce: (state, event) => runPackageEvent(state, event).state, + reset: resetPackageLab, +}; + +export function runPackageEvents( + events: readonly PackageLabEvent[], + initialState: PackageLabState = createInitialPackageState(), +): PackageRunResult { + let state = cloneState(initialState); + const results: PackageEventResult[] = []; + + for (const event of events) { + const result = runPackageEvent(state, event); + results.push(result); + state = result.state; + } + + return { state, results, accepted: results.every((result) => result.accepted) }; +} diff --git a/frontend/src/topics/registry.test.ts b/frontend/src/topics/registry.test.ts new file mode 100644 index 0000000..9f165b4 --- /dev/null +++ b/frontend/src/topics/registry.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import curriculumData from "../../../shared/curriculum.json"; +import type { Curriculum } from "../types"; +import { getTopicViewModule, TOPIC_MODULE_IDS } from "./registry"; + +const curriculum = curriculumData as Curriculum; + +describe("topic view registry", () => { + it("registers exactly every ready curriculum topic", () => { + const readyTopicIds = curriculum.tracks + .flatMap((track) => track.topics) + .filter((topic) => topic.status === "ready") + .map((topic) => topic.id) + .sort(); + + expect([...TOPIC_MODULE_IDS].sort()).toEqual(readyTopicIds); + readyTopicIds.forEach((topicId) => expect(getTopicViewModule(topicId)).toBeDefined()); + }); + + it("does not expose a module for planned topics", () => { + expect(getTopicViewModule("env")).toBeUndefined(); + }); +}); diff --git a/frontend/src/topics/registry.tsx b/frontend/src/topics/registry.tsx new file mode 100644 index 0000000..b61babe --- /dev/null +++ b/frontend/src/topics/registry.tsx @@ -0,0 +1,49 @@ +import type { ComponentType } from "react"; +import { AuthLab } from "../components/AuthLab"; +import { AuthLesson } from "../components/AuthLesson"; +import { GitLab } from "../components/GitLab"; +import { GitLesson } from "../components/GitLesson"; +import { CliLab } from "../components/CliLab"; +import { CliLesson } from "./cli/lesson"; +import { IdeLab } from "../components/IdeLab"; +import { IdeLesson } from "./ide/lesson"; +import { PackageLab } from "./package/lab"; +import { PackageLesson } from "./package/lesson"; +import { RemoteLab } from "./remote/lab"; +import { RemoteLesson } from "./remote/lesson"; +import { GuardrailLab } from "./guardrail/lab"; +import { GuardrailLesson } from "./guardrail/lesson"; +import { RestLab } from "./rest/lab"; +import { RestLesson } from "./rest/lesson"; + +export interface TopicLessonViewProps { + completed: boolean; + onOpenLab: () => void; +} + +export interface TopicLabViewProps { + onComplete: () => void; +} + +export interface TopicViewModule { + id: string; + lesson: ComponentType; + lab: ComponentType; +} + +export const TOPIC_MODULE_REGISTRY: Readonly> = { + git: { id: "git", lesson: GitLesson, lab: GitLab }, + auth: { id: "auth", lesson: AuthLesson, lab: AuthLab }, + remote: { id: "remote", lesson: RemoteLesson, lab: RemoteLab }, + cli: { id: "cli", lesson: CliLesson, lab: CliLab }, + ide: { id: "ide", lesson: IdeLesson, lab: IdeLab }, + package: { id: "package", lesson: PackageLesson, lab: PackageLab }, + guardrail: { id: "guardrail", lesson: GuardrailLesson, lab: GuardrailLab }, + rest: { id: "rest", lesson: RestLesson, lab: RestLab }, +}; + +export const TOPIC_MODULE_IDS: ReadonlySet = new Set(Object.keys(TOPIC_MODULE_REGISTRY)); + +export function getTopicViewModule(topicId: string | undefined): TopicViewModule | undefined { + return topicId ? TOPIC_MODULE_REGISTRY[topicId] : undefined; +} diff --git a/frontend/src/topics/remote/content.test.ts b/frontend/src/topics/remote/content.test.ts new file mode 100644 index 0000000..3ec4e4f --- /dev/null +++ b/frontend/src/topics/remote/content.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + remoteLabHappyPath, + remoteLabInitialState, + remoteLesson, + remoteLessonSteps, +} from "./content"; + +describe("remote topic content contract", () => { + it("provides teachable objectives and unique lesson sections", () => { + expect(remoteLesson.objectives).toHaveLength(3); + expect(remoteLesson.sections).toHaveLength(4); + expect(new Set(remoteLesson.sections.map((section) => section.id)).size).toBe(remoteLesson.sections.length); + }); + + it("maps the lesson examples to the remote collaboration flow", () => { + expect(remoteLessonSteps.map((step) => step.id)).toEqual([ + "branch", + "commit", + "sync", + "rebase", + "publish", + ]); + expect(remoteLessonSteps.map((step) => step.command)).toContain("git fetch origin dev"); + expect(remoteLessonSteps.map((step) => step.command)).toContain("git rebase origin/dev"); + }); + + it("starts the lab from the acceptance fixture and exposes the happy path", () => { + expect(remoteLabInitialState).toMatchObject({ + phase: "initial", + localBranch: "dev", + syncState: "stale", + remoteBranch: "absent", + pullRequest: "none", + checks: "not-run", + }); + expect(remoteLabHappyPath.map((event) => event.type)).toEqual([ + "inspect", + "branch", + "commit", + "fetch", + "rebase", + "push", + "open-pr", + "checks-pass", + "merge", + ]); + }); +}); diff --git a/frontend/src/topics/remote/content.ts b/frontend/src/topics/remote/content.ts new file mode 100644 index 0000000..72d9f03 --- /dev/null +++ b/frontend/src/topics/remote/content.ts @@ -0,0 +1,145 @@ +import type { LessonDefinition } from "../../topics/types"; + +export const remoteLesson: LessonDefinition = { + title: "把本地分支送上遠端協作", + objectives: [ + "分辨 local branch、remote-tracking branch 與 origin。", + "用 fetch 與 rebase 對齊遠端 dev,再發布功能分支。", + "理解 PR、CI、review 與 merge/auto-merge 的檢查順序。", + ], + sections: [ + { + id: "remote-model", + title: "origin 不是另一個工作目錄", + body: "local branch 是你目前前進的分支;origin/dev 是本地記住的遠端 dev 狀態。git fetch 只更新遠端參照,不會替你修改工作目錄。", + }, + { + id: "sync-before-publish", + title: "先更新認知,再整理歷史", + body: "在發布功能分支前執行 git fetch origin dev,再用 git rebase origin/dev 將自己的 commit 放到最新基線上。這能提早發現衝突,也讓 PR 的基礎清楚。", + }, + { + id: "publish-for-review", + title: "push 是把分支交給協作流程", + body: "git push -u origin feature/remote-work 會建立遠端分支與 upstream 關係。之後才能開 PR,讓 CI 與 review 對這組變更提供可追蹤的回饋。", + }, + { + id: "pr-lifecycle", + title: "PR 不是最後一步", + body: "PR 建立後要確認 CI、review 與 branch protection 條件。只有檢查通過,才可 merge;auto-merge 是符合規則後由平台代為執行,不是跳過檢查。", + }, + ], +}; + +export interface RemoteLessonStep { + id: string; + title: string; + command: string; + explanation: string; + takeaway: string; +} + +export const remoteLessonSteps: readonly RemoteLessonStep[] = [ + { + id: "branch", + title: "從 dev 切出功能分支", + command: "git switch -c feature/remote-work", + explanation: "把這次變更隔離在自己的分支,讓 dev 保持可整合。", + takeaway: "先隔離工作,再開始累積歷史。", + }, + { + id: "commit", + title: "留下可 review 的 commit", + command: 'git commit -m "add remote lesson"', + explanation: "commit 是可以被命名、比較與回復的變更快照。", + takeaway: "一個 commit 只回答一件事。", + }, + { + id: "sync", + title: "同步遠端基線", + command: "git fetch origin dev", + explanation: "fetch 更新本地的 origin/dev 參照,不會偷偷改動目前分支。", + takeaway: "先知道遠端發生什麼,再決定怎麼整理。", + }, + { + id: "rebase", + title: "把功能分支對齊最新 dev", + command: "git rebase origin/dev", + explanation: "rebase 將自己的 commit 接到最新遠端基線後面,讓待審查歷史保持清楚。", + takeaway: "rebase 是整理自己的分支,不是覆寫遠端 dev。", + }, + { + id: "publish", + title: "發布分支並建立 PR", + command: "git push -u origin feature/remote-work", + explanation: "遠端分支發布後,PR 才有可供 CI 與 review 使用的來源。", + takeaway: "push 交付分支,PR 交付協作上下文。", + }, +] as const; + +export type RemoteLabPhase = "initial" | "active" | "blocked" | "completed"; +export type WorkingTree = "clean" | "dirty"; +export type RemoteBranch = "absent" | "published"; +export type PullRequest = "none" | "open" | "merged"; +export type Checks = "not-run" | "pending" | "passed" | "failed"; +export type SyncState = "stale" | "fetched" | "rebased"; + +export interface RemoteLabState { + phase: RemoteLabPhase; + remoteName: "origin"; + baseBranch: "dev"; + localBranch: "dev" | "feature/remote-work"; + workingTree: WorkingTree; + localCommitCount: number; + localBaseRef: "dev" | "origin/dev"; + syncState: SyncState; + remoteBranch: RemoteBranch; + pullRequest: PullRequest; + checks: Checks; + lastMessage: string; + canReset: true; +} + +export type RemoteLabEventType = + | "inspect" + | "branch" + | "commit" + | "fetch" + | "rebase" + | "push" + | "open-pr" + | "checks-pass" + | "merge" + | "reset"; + +export interface RemoteLabEvent { + type: RemoteLabEventType; +} + +export const remoteLabInitialState: RemoteLabState = { + phase: "initial", + remoteName: "origin", + baseBranch: "dev", + localBranch: "dev", + workingTree: "clean", + localCommitCount: 0, + localBaseRef: "dev", + syncState: "stale", + remoteBranch: "absent", + pullRequest: "none", + checks: "not-run", + lastMessage: "準備建立第一個遠端協作分支。", + canReset: true, +}; + +export const remoteLabHappyPath: readonly RemoteLabEvent[] = [ + { type: "inspect" }, + { type: "branch" }, + { type: "commit" }, + { type: "fetch" }, + { type: "rebase" }, + { type: "push" }, + { type: "open-pr" }, + { type: "checks-pass" }, + { type: "merge" }, +]; diff --git a/frontend/src/topics/remote/lab.test.ts b/frontend/src/topics/remote/lab.test.ts new file mode 100644 index 0000000..9733aef --- /dev/null +++ b/frontend/src/topics/remote/lab.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { remoteLabHappyPath } from "./content"; +import { createInitialRemoteState, runRemoteEvents } from "./simulator"; +import { remoteLabProgress } from "./lab"; + +describe("remote lab progress", () => { + it("starts at zero and reaches 100 after the happy path", () => { + expect(remoteLabProgress(createInitialRemoteState())).toBe(0); + expect(remoteLabProgress(runRemoteEvents(remoteLabHappyPath).state)).toBe(100); + }); +}); diff --git a/frontend/src/topics/remote/lab.tsx b/frontend/src/topics/remote/lab.tsx new file mode 100644 index 0000000..9ab8b0b --- /dev/null +++ b/frontend/src/topics/remote/lab.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { + remoteLabHappyPath, + type RemoteLabEvent, + type RemoteLabEventType, + type RemoteLabState, +} from "./content"; +import { createInitialRemoteState, isRemoteLabComplete, runRemoteEvent } from "./simulator"; +import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback } from "../../components/TopicShell"; + +const REMOTE_ACTIONS: readonly { event: RemoteLabEventType; label: string; command: string }[] = [ + { event: "inspect", label: "檢查目前狀態", command: "git status" }, + { event: "branch", label: "建立功能分支", command: "git switch -c feature/remote-work" }, + { event: "commit", label: "建立本地 commit", command: 'git commit -m "add remote lesson"' }, + { event: "fetch", label: "更新遠端基線", command: "git fetch origin dev" }, + { event: "rebase", label: "對齊 origin/dev", command: "git rebase origin/dev" }, + { event: "push", label: "發布功能分支", command: "git push -u origin feature/remote-work" }, + { event: "open-pr", label: "開啟 Pull Request", command: "PR: feature/remote-work → dev" }, + { event: "checks-pass", label: "通過 CI checks", command: "CI checks pass" }, + { event: "merge", label: "合併至 dev", command: "Merge PR" }, +]; + +const REQUIRED_STEPS = remoteLabHappyPath.filter((event) => event.type !== "inspect").map((event) => event.type); + +function stepComplete(state: RemoteLabState, event: RemoteLabEventType): boolean { + switch (event) { + case "branch": + return state.localBranch === "feature/remote-work"; + case "commit": + return state.localCommitCount > 0; + case "fetch": + return state.syncState === "fetched" || state.syncState === "rebased"; + case "rebase": + return state.syncState === "rebased"; + case "push": + return state.remoteBranch === "published"; + case "open-pr": + return state.pullRequest !== "none"; + case "checks-pass": + return state.checks === "passed"; + case "merge": + return state.pullRequest === "merged"; + default: + return false; + } +} + +export function remoteLabProgress(state: RemoteLabState): number { + const completedSteps = REQUIRED_STEPS.filter((event) => stepComplete(state, event)).length; + return Math.round((completedSteps / REQUIRED_STEPS.length) * 100); +} + +export function RemoteLab({ onComplete }: { onComplete?: () => void }) { + const [state, setState] = useState(createInitialRemoteState); + const [feedback, setFeedback] = useState({ accepted: true, message: state.lastMessage }); + const completed = isRemoteLabComplete(state); + + function dispatch(event: RemoteLabEvent) { + const result = runRemoteEvent(state, event); + setState(result.state); + setFeedback({ accepted: result.accepted, message: result.output.join(" ") }); + if (result.accepted && isRemoteLabComplete(result.state)) onComplete?.(); + } + + return ( + 把變更送上
遠端協作流程} + progressLabel={`${Math.round(remoteLabProgress(state) / 100 * REQUIRED_STEPS.length)} / ${REQUIRED_STEPS.length} STEPS`} + progress={remoteLabProgress(state)} + onReset={() => dispatch({ type: "reset" })} + > + {completed ? ( + dispatch({ type: "reset" })} + /> + ) : ( +
+
+
+
+

MISSION CONTROL

+

依序完成協作檢查

+
+ {state.localBranch} · {state.remoteName} +
+
+ {REMOTE_ACTIONS.map((action, index) => { + const done = stepComplete(state, action.event); + return ( + + ); + })} +
+
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/topics/remote/lesson.tsx b/frontend/src/topics/remote/lesson.tsx new file mode 100644 index 0000000..afba4aa --- /dev/null +++ b/frontend/src/topics/remote/lesson.tsx @@ -0,0 +1,36 @@ +import { remoteLesson, remoteLessonSteps } from "./content"; +import { TopicLessonShell } from "../../components/TopicShell"; + +export function RemoteLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { + return ( + 把本地工作
送進協作流程} + description="理解 origin、fetch、rebase、push 與 PR 的責任邊界,再用 deterministic Lab 走完一次可 review、可合併的遠端工作流。" + completed={completed} + > +
+ {remoteLesson.sections.map((section, index) => { + const step = remoteLessonSteps[index]; + return ( +
+
{String(index + 1).padStart(2, "0")}REMOTE
+
+

{section.title}

+

{section.body}

+ {step ? $ {step.command} : null} +
+ +
+ ); + })} +
+ +
+

REMOTE COLLABORATION LAB

把分支送上 review。

{remoteLesson.objectives.length} 個學習目標 · 不連線真實 provider

+ +
+
+ ); +} diff --git a/frontend/src/topics/remote/simulator.test.ts b/frontend/src/topics/remote/simulator.test.ts new file mode 100644 index 0000000..ba65bae --- /dev/null +++ b/frontend/src/topics/remote/simulator.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { remoteLabHappyPath } from "./content"; +import { + createInitialRemoteState, + isRemoteLabComplete, + resetRemoteLab, + runRemoteEvent, + runRemoteEvents, +} from "./simulator"; + +describe("remote collaboration simulator", () => { + it("completes the deterministic happy path", () => { + const result = runRemoteEvents(remoteLabHappyPath); + + expect(result.accepted).toBe(true); + expect(result.results).toHaveLength(remoteLabHappyPath.length); + expect(isRemoteLabComplete(result.state)).toBe(true); + expect(result.state.pullRequest).toBe("merged"); + }); + + it("blocks rebase before fetch without mutating the prior state", () => { + const start = createInitialRemoteState(); + const branched = runRemoteEvent(start, { type: "branch" }).state; + const committed = runRemoteEvent(branched, { type: "commit" }).state; + const result = runRemoteEvent(committed, { type: "rebase" }); + + expect(result.accepted).toBe(false); + expect(result.state.phase).toBe("blocked"); + expect(result.state.syncState).toBe("stale"); + expect(committed.phase).toBe("active"); + expect(committed.syncState).toBe("stale"); + }); + + it("requires passed checks before merge and supports recovery", () => { + const beforeMerge = runRemoteEvents([ + { type: "branch" }, + { type: "commit" }, + { type: "fetch" }, + { type: "rebase" }, + { type: "push" }, + { type: "open-pr" }, + ]); + const blockedMerge = runRemoteEvent(beforeMerge.state, { type: "merge" }); + const passedChecks = runRemoteEvent(blockedMerge.state, { type: "checks-pass" }); + const merged = runRemoteEvent(passedChecks.state, { type: "merge" }); + + expect(blockedMerge.accepted).toBe(false); + expect(passedChecks.accepted).toBe(true); + expect(isRemoteLabComplete(merged.state)).toBe(true); + }); + + it("resets to the exact initial fixture and remains deterministic", () => { + const first = runRemoteEvents(remoteLabHappyPath).state; + const second = runRemoteEvents(remoteLabHappyPath).state; + + expect(second).toEqual(first); + expect(resetRemoteLab()).toEqual(createInitialRemoteState()); + }); +}); diff --git a/frontend/src/topics/remote/simulator.ts b/frontend/src/topics/remote/simulator.ts new file mode 100644 index 0000000..8476123 --- /dev/null +++ b/frontend/src/topics/remote/simulator.ts @@ -0,0 +1,135 @@ +import { + remoteLabInitialState, + type RemoteLabEvent, + type RemoteLabState, +} from "./content"; + +export interface RemoteCommandResult { + state: RemoteLabState; + output: readonly string[]; + accepted: boolean; +} + +export interface RemoteRunResult { + state: RemoteLabState; + results: readonly RemoteCommandResult[]; + accepted: boolean; +} + +export function createInitialRemoteState(): RemoteLabState { + return { ...remoteLabInitialState }; +} + +export function resetRemoteLab(): RemoteLabState { + return createInitialRemoteState(); +} + +export function isRemoteLabComplete(state: RemoteLabState): boolean { + return ( + state.phase === "completed" && + state.localCommitCount > 0 && + state.syncState === "rebased" && + state.remoteBranch === "published" && + state.pullRequest === "merged" && + state.checks === "passed" + ); +} + +function accepted( + current: RemoteLabState, + changes: Partial, + message: string, + output: readonly string[] = [message], +): RemoteCommandResult { + return { + state: { ...current, ...changes, phase: "active", lastMessage: message }, + output, + accepted: true, + }; +} + +function blocked(current: RemoteLabState, message: string): RemoteCommandResult { + return { + state: { ...current, phase: "blocked", lastMessage: message }, + output: [message], + accepted: false, + }; +} + +export function runRemoteEvent(current: RemoteLabState, event: RemoteLabEvent): RemoteCommandResult { + if (event.type === "reset") { + return { state: resetRemoteLab(), output: ["Lab 已重設,可以重新開始。"], accepted: true }; + } + + if (current.phase === "completed") { + return blocked(current, "Lab 已完成;如要重練,請先 reset。"); + } + + switch (event.type) { + case "inspect": + return accepted(current, {}, `目前在 ${current.localBranch};遠端為 ${current.remoteName}。`); + case "branch": + if (current.localBranch !== "dev") { + return blocked(current, "功能分支已建立;下一步請先提交變更。"); + } + return accepted(current, { localBranch: "feature/remote-work", workingTree: "dirty" }, "已切換到 feature/remote-work,準備提交變更。"); + case "commit": + if (current.localBranch !== "feature/remote-work" || current.workingTree !== "dirty") { + return blocked(current, "請先在 feature/remote-work 建立工作變更,再建立 commit。"); + } + return accepted(current, { workingTree: "clean", localCommitCount: current.localCommitCount + 1 }, "已建立本地 commit;接著更新遠端 dev 的參照。"); + case "fetch": + if (current.localBranch !== "feature/remote-work") { + return blocked(current, "請先從 dev 切出 feature/remote-work,再 fetch 遠端基線。"); + } + return accepted(current, { localBaseRef: "origin/dev", syncState: "fetched" }, "已更新 origin/dev;目前工作目錄沒有被 fetch 直接改動。"); + case "rebase": + if (current.syncState !== "fetched") { + return blocked(current, "請先執行 git fetch origin dev,再 rebase origin/dev。"); + } + if (current.localCommitCount === 0) { + return blocked(current, "目前沒有可整理的本地 commit;請先建立功能變更。"); + } + return accepted(current, { syncState: "rebased", localBaseRef: "origin/dev" }, "已將功能 commit 對齊最新 origin/dev。"); + case "push": + if (current.syncState !== "rebased") { + return blocked(current, "請先 rebase origin/dev;未確認基線前不可發布分支。"); + } + return accepted(current, { remoteBranch: "published" }, "已發布 feature/remote-work,現在可以開 PR。"); + case "open-pr": + if (current.remoteBranch !== "published") { + return blocked(current, "請先 push 功能分支,PR 才有可供 review 的遠端來源。"); + } + return accepted(current, { pullRequest: "open", checks: "pending" }, "PR 已開啟;等待 CI checks 與 review。"); + case "checks-pass": + if (current.pullRequest !== "open") { + return blocked(current, "請先開啟 PR,再等待 CI checks 結果。"); + } + return accepted(current, { checks: "passed" }, "CI checks 已通過;現在符合 merge 條件。"); + case "merge": + if (current.pullRequest !== "open" || current.checks !== "passed") { + return blocked(current, "PR 必須已開啟且 CI checks 通過,才能 merge 至 dev。"); + } + return { + state: { ...current, phase: "completed", pullRequest: "merged", lastMessage: "PR 已合併至 dev;REMOTE Lab 完成。" }, + output: ["PR 已合併至 dev;REMOTE Lab 完成。"], + accepted: true, + }; + } +} + +export function runRemoteEvents( + events: readonly RemoteLabEvent[], + initialState: RemoteLabState = createInitialRemoteState(), +): RemoteRunResult { + let state = { ...initialState }; + const results: RemoteCommandResult[] = []; + + for (const event of events) { + const result = runRemoteEvent(state, event); + results.push(result); + state = result.state; + } + + return { state, results, accepted: results.every((result) => result.accepted) }; +} diff --git a/frontend/src/topics/rest/content.test.ts b/frontend/src/topics/rest/content.test.ts new file mode 100644 index 0000000..ad2a7f4 --- /dev/null +++ b/frontend/src/topics/rest/content.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { restCodeFiles, restScenarios, restTraceStages } from "./content"; + +describe("REST teaching content", () => { + it("annotates every displayed non-empty code line", () => { + const lines = restCodeFiles.flatMap((file) => file.lines); + + expect(lines.length).toBeGreaterThan(50); + expect(new Set(lines.map((line) => line.id)).size).toBe(lines.length); + lines.forEach((line) => { + expect(line.code.trim()).not.toBe(""); + expect(line.explanation.trim()).not.toBe(""); + expect(line.timing.trim()).not.toBe(""); + expect(line.connection.trim()).not.toBe(""); + expect(line.consequence.trim()).not.toBe(""); + expect(line.stages.length).toBeGreaterThan(0); + }); + }); + + it("maps every lifecycle stage to at least one real code line", () => { + const lines = restCodeFiles.flatMap((file) => file.lines); + + restTraceStages.forEach((stage) => { + expect(lines.some((line) => line.stages.includes(stage.id))).toBe(true); + }); + }); + + it("defines the four required HTTP outcomes", () => { + expect(restScenarios.map((scenario) => scenario.status)).toEqual([ + "201 Created", + "200 OK", + "404 Not Found", + "422 Unprocessable Entity", + ]); + expect(restScenarios.find((scenario) => scenario.id === "validation-error")?.terminalStageId).toBe("validation"); + }); +}); diff --git a/frontend/src/topics/rest/content.ts b/frontend/src/topics/rest/content.ts new file mode 100644 index 0000000..49e6761 --- /dev/null +++ b/frontend/src/topics/rest/content.ts @@ -0,0 +1,285 @@ +import type { LessonDefinition } from "../types"; + +export type RestTraceStageId = + | "browser" + | "cors" + | "routing" + | "validation" + | "dependency" + | "database" + | "response"; + +export interface RestTraceStage { + id: RestTraceStageId; + label: string; + actor: string; + summary: string; + fileId: RestCodeFileId; +} + +export type RestCodeFileId = "api.ts" | "database.py" | "models.py" | "main.py"; + +export interface RestCodeLine { + id: string; + code: string; + explanation: string; + timing: string; + connection: string; + consequence: string; + stages: readonly RestTraceStageId[]; +} + +export interface RestCodeFile { + id: RestCodeFileId; + path: string; + language: "TypeScript" | "Python"; + role: string; + lines: readonly RestCodeLine[]; +} + +export type RestScenarioId = "create-success" | "read-success" | "not-found" | "validation-error"; + +export interface RestItem { + id: number; + name: string; + price: number; +} + +export interface RestScenario { + id: RestScenarioId; + label: string; + method: "GET" | "POST"; + url: string; + requestBody: string; + responseBody: string; + status: string; + sql: string; + terminalStageId: RestTraceStageId; + tone: "success" | "error"; +} + +export type RestLabPhase = "initial" | "tracing" | "error" | "completed"; + +export interface RestLabState { + selectedScenarioId: RestScenarioId; + requestStarted: boolean; + activeStageId: RestTraceStageId; + currentVisitedStageIds: readonly RestTraceStageId[]; + learnedStageIds: readonly RestTraceStageId[]; + completedScenarioIds: readonly RestScenarioId[]; + databaseItems: readonly RestItem[]; + responseReady: boolean; + phase: RestLabPhase; + lastMessage: string; +} + +export type RestLabEvent = + | { type: "select-scenario"; scenarioId: RestScenarioId } + | { type: "start-request" } + | { type: "inspect-stage"; stageId: RestTraceStageId } + | { type: "next-stage" } + | { type: "reset" }; + +export const restLesson: LessonDefinition = { + title: "FastAPI request lifecycle", + objectives: [ + "追蹤前端 fetch 如何形成 HTTP request。", + "理解 FastAPI routing、dependency injection 與 Pydantic validation 的執行順序。", + "理解 Session、ORM 與 database engine 各自負責什麼。", + "看懂 response model 如何把資料轉成安全的 JSON response。", + ], + sections: [ + { id: "client", title: "前端不是直接呼叫 Python", body: "React 透過 HTTP 傳送 method、URL、headers 與 JSON body;FastAPI 接到的是一個 request。" }, + { id: "framework", title: "FastAPI 負責協調", body: "FastAPI 比對 route、解析參數、執行 dependencies、驗證資料,再呼叫 path operation。" }, + { id: "database", title: "FastAPI 不直接執行 SQL", body: "Path operation 使用注入的 Session,ORM 透過 engine 與資料庫溝通。" }, + { id: "response", title: "回傳前還有一道契約", body: "Response model 驗證與過濾輸出,FastAPI 再序列化成 JSON。" }, + ], +}; + +export const restTraceStages: readonly RestTraceStage[] = [ + { id: "browser", label: "01", actor: "React", summary: "fetch 組成 HTTP request", fileId: "api.ts" }, + { id: "cors", label: "02", actor: "CORS", summary: "瀏覽器確認 origin 是否被允許", fileId: "main.py" }, + { id: "routing", label: "03", actor: "Router", summary: "FastAPI 比對 method 與 path", fileId: "main.py" }, + { id: "validation", label: "04", actor: "Pydantic", summary: "JSON body 轉成有型別的資料", fileId: "models.py" }, + { id: "dependency", label: "05", actor: "Depends", summary: "建立這次 request 使用的 Session", fileId: "database.py" }, + { id: "database", label: "06", actor: "SQLModel", summary: "ORM 將物件操作轉成 SQL", fileId: "main.py" }, + { id: "response", label: "07", actor: "Response", summary: "輸出經過 model 過濾後成為 JSON", fileId: "main.py" }, +]; + +export const restDatabaseFixture: readonly RestItem[] = [ + { id: 1, name: "Notebook", price: 180 }, + { id: 2, name: "Mouse", price: 650 }, +]; + +export const restScenarios: readonly RestScenario[] = [ + { + id: "create-success", + label: "建立商品 · 201", + method: "POST", + url: "http://localhost:8000/items", + requestBody: '{\n "name": "Keyboard",\n "price": 1200\n}', + responseBody: '{\n "id": 3,\n "name": "Keyboard",\n "price": 1200\n}', + status: "201 Created", + sql: "INSERT INTO item (name, price) VALUES ('Keyboard', 1200);", + terminalStageId: "response", + tone: "success", + }, + { + id: "read-success", + label: "讀取商品 · 200", + method: "GET", + url: "http://localhost:8000/items/1", + requestBody: "— GET request 沒有 body —", + responseBody: '{\n "id": 1,\n "name": "Notebook",\n "price": 180\n}', + status: "200 OK", + sql: "SELECT id, name, price FROM item WHERE id = 1;", + terminalStageId: "response", + tone: "success", + }, + { + id: "not-found", + label: "找不到商品 · 404", + method: "GET", + url: "http://localhost:8000/items/99", + requestBody: "— GET request 沒有 body —", + responseBody: '{\n "detail": "Item not found"\n}', + status: "404 Not Found", + sql: "SELECT id, name, price FROM item WHERE id = 99;", + terminalStageId: "response", + tone: "error", + }, + { + id: "validation-error", + label: "驗證失敗 · 422", + method: "POST", + url: "http://localhost:8000/items", + requestBody: '{\n "name": "Keyboard",\n "price": -1\n}', + responseBody: '{\n "detail": [{\n "loc": ["body", "price"],\n "msg": "Input should be greater than 0"\n }]\n}', + status: "422 Unprocessable Entity", + sql: "— validation 失敗,沒有執行 SQL —", + terminalStageId: "validation", + tone: "error", + }, +]; + +export const restRequiredScenarioIds: readonly RestScenarioId[] = restScenarios.map((scenario) => scenario.id); + +export function findRestScenario(scenarioId: RestScenarioId): RestScenario { + return restScenarios.find((scenario) => scenario.id === scenarioId) ?? restScenarios[0]; +} + +export const restCodeFiles: readonly RestCodeFile[] = [ + { + id: "api.ts", + path: "frontend/src/api.ts", + language: "TypeScript", + role: "Browser client:建立 request 並處理 response。", + lines: [ + { id: "api-1", code: "export interface ItemCreate {", explanation: "宣告前端送出商品時使用的資料形狀。", timing: "TypeScript 編譯時檢查。", connection: "它要和後端 ItemCreate schema 對齊。", consequence: "欄位漂移時,前端可能送出後端不接受的 JSON。", stages: ["browser"] }, + { id: "api-2", code: " name: string;", explanation: "商品名稱在前端必須是字串。", timing: "開發者建立 payload 時。", connection: "會成為 JSON body 的 name。", consequence: "刪除後,前端型別不再提醒缺少 name。", stages: ["browser"] }, + { id: "api-3", code: " price: number;", explanation: "商品價格在前端必須是數字。", timing: "開發者建立 payload 時。", connection: "後端會再用 Pydantic 驗證一次。", consequence: "只靠前端型別不足以保護 API。", stages: ["browser"] }, + { id: "api-4", code: "}", explanation: "結束 ItemCreate 型別範圍。", timing: "TypeScript 解析型別時。", connection: "後面的函式可以引用這個型別。", consequence: "缺少結尾會造成語法錯誤。", stages: ["browser"] }, + { id: "api-5", code: "export async function createItem(item: ItemCreate) {", explanation: "建立一個非同步函式,接收符合 ItemCreate 的物件。", timing: "UI 呼叫 createItem 時。", connection: "這是 React 與 FastAPI 之間的 client adapter。", consequence: "沒有 async 就不能直接 await fetch。", stages: ["browser"] }, + { id: "api-6", code: " const response = await fetch(\"http://localhost:8000/items\", {", explanation: "瀏覽器向 FastAPI server 的 /items 發出 request,並等待 response。", timing: "使用者按下建立商品時。", connection: "不同 port 代表不同 origin,因此會牽涉 CORS。", consequence: "URL 或 port 錯誤會得到 network error。", stages: ["browser", "cors"] }, + { id: "api-7", code: " method: \"POST\",", explanation: "指定 HTTP method 為 POST。", timing: "fetch 建立 request 時。", connection: "FastAPI router 會用 POST + /items 尋找 path operation。", consequence: "改成 GET 就不會命中 POST route。", stages: ["browser", "routing"] }, + { id: "api-8", code: " headers: { \"Content-Type\": \"application/json\" },", explanation: "告訴後端 body 使用 JSON 格式。", timing: "request 送出前。", connection: "FastAPI 依 content type 解析 body。", consequence: "錯誤格式可能讓 body 無法被正確解析。", stages: ["browser", "validation"] }, + { id: "api-9", code: " body: JSON.stringify(item),", explanation: "把 JavaScript object 序列化成 HTTP 可以傳送的 JSON 字串。", timing: "request 送出前。", connection: "Pydantic 會把這段 JSON 驗證成 ItemCreate。", consequence: "直接傳 object 不是有效的 fetch body。", stages: ["browser", "validation"] }, + { id: "api-10", code: " });", explanation: "結束 fetch 設定並真正送出 request。", timing: "await 開始等待網路結果時。", connection: "控制權暫時交回瀏覽器 event loop。", consequence: "請求失敗時 fetch 可能拋出例外。", stages: ["browser"] }, + { id: "api-11", code: " if (!response.ok) throw new Error(`HTTP ${response.status}`);", explanation: "將 4xx 或 5xx response 轉成前端可處理的錯誤。", timing: "收到 HTTP response 後。", connection: "422、404、500 都會走這個分支。", consequence: "若忽略 response.ok,錯誤 JSON 可能被當成成功資料。", stages: ["response"] }, + { id: "api-12", code: " return response.json();", explanation: "讀取 response body,將 JSON 轉回 JavaScript object。", timing: "成功 response 抵達後。", connection: "資料來自 FastAPI 的 response serialization。", consequence: "少了它,呼叫端拿到的只是 Response 物件。", stages: ["response"] }, + { id: "api-13", code: "}", explanation: "結束 createItem 函式。", timing: "函式定義完成時。", connection: "UI 可以 import 並呼叫它。", consequence: "缺少結尾會造成語法錯誤。", stages: ["browser", "response"] }, + { id: "api-14", code: "export async function readItem(itemId: number) {", explanation: "宣告讀取單一商品的 client 函式,itemId 必須是數字。", timing: "UI 要顯示商品明細時。", connection: "itemId 會被放進 FastAPI route 的 path parameter。", consequence: "未限制型別時可能組出無效 URL。", stages: ["browser"] }, + { id: "api-15", code: " const response = await fetch(`http://localhost:8000/items/${itemId}`);", explanation: "使用 GET 呼叫商品資源 URL;fetch 預設 method 就是 GET。", timing: "readItem 被呼叫時。", connection: "FastAPI 會把 URL 尾端解析成 item_id。", consequence: "路徑不符合 route 時會得到 404 route not found。", stages: ["browser", "cors", "routing"] }, + { id: "api-16", code: " if (!response.ok) throw new Error(`HTTP ${response.status}`);", explanation: "把找不到商品等 HTTP error 交給前端錯誤流程。", timing: "GET response 抵達後。", connection: "資料庫查無 row 時,FastAPI route 會回傳 404。", consequence: "忽略它會把錯誤內容當作 Item。", stages: ["response"] }, + { id: "api-17", code: " return response.json();", explanation: "把成功的 JSON response 解析成前端物件。", timing: "確認 response.ok 後。", connection: "內容已經過 ItemPublic response model。", consequence: "呼叫端只能拿到未解析的 Response。", stages: ["response"] }, + { id: "api-18", code: "}", explanation: "結束 readItem 函式範圍。", timing: "函式定義完成時。", connection: "UI 可以 import 並重用它。", consequence: "缺少結尾會造成語法錯誤。", stages: ["browser", "response"] }, + ], + }, + { + id: "database.py", + path: "backend/database.py", + language: "Python", + role: "Database adapter:建立 engine,並管理每個 request 的 Session。", + lines: [ + { id: "db-1", code: "from typing import Annotated", explanation: "匯入 Annotated,讓型別同時攜帶 FastAPI dependency metadata。", timing: "Python 載入 module 時。", connection: "稍後用來建立 SessionDep。", consequence: "沒有它就要在每個 route 重複 Depends 寫法。", stages: ["dependency"] }, + { id: "db-2", code: "from fastapi import Depends", explanation: "匯入 FastAPI 的 dependency 宣告工具。", timing: "Python 載入 module 時。", connection: "FastAPI 看到 Depends 後會先執行 get_session。", consequence: "route 不會自動取得 database session。", stages: ["dependency"] }, + { id: "db-3", code: "from sqlmodel import SQLModel, Session, create_engine", explanation: "匯入 metadata、ORM session 與 database engine 工具。", timing: "Python 載入 module 時。", connection: "SQLModel 底層透過 engine 與 SQLite 溝通。", consequence: "FastAPI 本身不提供 ORM 或 database driver。", stages: ["dependency", "database"] }, + { id: "db-3b", code: "from .models import Item", explanation: "匯入對應 item table 的 ORM model,供 workshop 建表與 seed 使用。", timing: "Python 載入 database module 時。", connection: "Item class 會把 table metadata 註冊到 SQLModel。", consequence: "未載入 table model 時,create_all 不知道要建立哪張表。", stages: ["database"] }, + { id: "db-4", code: "engine = create_engine(\"sqlite:///workshop.db\")", explanation: "建立可連到 workshop.db 的 database engine。", timing: "應用程式啟動並載入 module 時。", connection: "所有 Session 會透過這個 engine 發送 SQL。", consequence: "連線字串錯誤會讓 database 操作失敗。", stages: ["database"] }, + { id: "db-4b", code: "def create_db_and_tables():", explanation: "宣告 workshop 啟動時建立缺少資料表的 helper。", timing: "FastAPI lifespan 啟動階段呼叫。", connection: "正式專案通常改由 migration 管理 schema。", consequence: "全新 SQLite 檔沒有 table 時,第一個 query 會失敗。", stages: ["dependency", "database"] }, + { id: "db-4c", code: " SQLModel.metadata.create_all(engine)", explanation: "依所有 table models 的 metadata 建立尚不存在的資料表。", timing: "server 開始接受 request 前。", connection: "engine 把 CREATE TABLE 送到 SQLite。", consequence: "它不會取代正式 migration,也不會安全修改既有 schema。", stages: ["database"] }, + { id: "db-4d", code: " with Session(engine) as session:", explanation: "開啟短生命週期 Session,準備 workshop 的固定初始資料。", timing: "建表完成後、server 接受 request 前。", connection: "讓 GET 情境在全新 database 也有 deterministic rows。", consequence: "沒有 seed 時,全新 database 的 GET /items/1 只會得到 404。", stages: ["database"] }, + { id: "db-4e", code: " if session.get(Item, 1) is None:", explanation: "只在 id=1 不存在時 seed,避免每次啟動都重複插入。", timing: "每次 application startup。", connection: "Session.get 以 primary key 查詢 Item。", consequence: "無條件 seed 會造成 primary-key conflict 或重複資料。", stages: ["database"] }, + { id: "db-4f", code: " session.add_all([Item(id=1, name=\"Notebook\", price=180), Item(id=2, name=\"Mouse\", price=650)])", explanation: "加入兩筆與 browser simulator 相同的 deterministic fixture。", timing: "第一次啟動空 database 時。", connection: "這兩筆 row 支援 200 與 404 查詢對照。", consequence: "fixture 與 simulator 漂移會讓教材展示和實際程式結果不同。", stages: ["database"] }, + { id: "db-4g", code: " session.commit()", explanation: "提交 seed transaction,讓兩筆 fixture 永久寫入 SQLite。", timing: "確認 database 尚未 seed 後。", connection: "後續每個 request 的 Session 都能查到它們。", consequence: "沒有 commit,Session 關閉後 seed 不會保存。", stages: ["database"] }, + { id: "db-5", code: "def get_session():", explanation: "宣告 FastAPI dependency,用來提供一個 request 專用 Session。", timing: "每次 route 需要 SessionDep 時。", connection: "FastAPI dependency system 負責呼叫它。", consequence: "自行在 route 建 Session 容易忘記關閉。", stages: ["dependency"] }, + { id: "db-6", code: " with Session(engine) as session:", explanation: "從 engine 開啟 Session,並用 context manager 確保最後關閉。", timing: "path operation 執行前。", connection: "Session 追蹤 ORM objects 與 transaction。", consequence: "未關閉 Session 可能耗盡連線資源。", stages: ["dependency", "database"] }, + { id: "db-7", code: " yield session", explanation: "把 Session 注入 route,並暫停 dependency;route 結束後才離開 with。", timing: "path operation 執行前到 response 建立期間。", connection: "create_item 的 session 參數會收到這個物件。", consequence: "改成 return 仍可提供值,但失去 yield 後的清理生命週期。", stages: ["dependency"] }, + { id: "db-8", code: "SessionDep = Annotated[Session, Depends(get_session)]", explanation: "建立可重用型別:需要 Session,也要求 FastAPI 透過 get_session 提供。", timing: "FastAPI 分析 route signature 時。", connection: "route 只寫 session: SessionDep 就能取得連線。", consequence: "拿掉 Depends 後,FastAPI 會把它誤解成一般參數。", stages: ["dependency", "routing"] }, + ], + }, + { + id: "models.py", + path: "backend/models.py", + language: "Python", + role: "Data contracts:分開 database model、request schema 與 response schema。", + lines: [ + { id: "model-1", code: "from sqlmodel import Field, SQLModel", explanation: "匯入 SQLModel base class 與欄位設定工具。", timing: "Python 載入 module 時。", connection: "同一套型別可產生 validation schema 與 ORM mapping。", consequence: "FastAPI 不會替你定義資料表。", stages: ["validation", "database"] }, + { id: "model-2", code: "class ItemBase(SQLModel):", explanation: "宣告 request、table 與 response 共用的商品欄位。", timing: "應用程式啟動時建立 class。", connection: "子類別會繼承 name 與 price。", consequence: "重複欄位容易讓 API schema 漂移。", stages: ["validation"] }, + { id: "model-3", code: " name: str = Field(min_length=1, max_length=80)", explanation: "要求 name 是 1 到 80 字元的字串。", timing: "FastAPI 驗證 request body 時。", connection: "規則會出現在 JSON Schema 與 /docs。", consequence: "空字串或過長名稱會得到 422。", stages: ["validation"] }, + { id: "model-4", code: " price: int = Field(gt=0)", explanation: "要求 price 是大於 0 的整數。", timing: "FastAPI 驗證 request body 時。", connection: "不合法資料會在進入 route 前被拒絕。", consequence: "沒有條件時可能寫入負數價格。", stages: ["validation"] }, + { id: "model-5", code: "class Item(ItemBase, table=True):", explanation: "宣告真正對應 database table 的 ORM model。", timing: "SQLModel 建立 metadata 時。", connection: "Session 會用它產生 INSERT 與 SELECT。", consequence: "沒有 table=True 就只是一個資料 schema。", stages: ["database"] }, + { id: "model-6", code: " id: int | None = Field(default=None, primary_key=True)", explanation: "宣告 database primary key;新增前可以是 None,由 SQLite 產生。", timing: "寫入與讀取資料時。", connection: "commit 後 refresh 會取得資料庫生成的 id。", consequence: "沒有 primary key,ORM 無法穩定識別 row。", stages: ["database"] }, + { id: "model-7", code: "class ItemCreate(ItemBase):", explanation: "宣告建立商品時允許 client 傳入的 request schema。", timing: "FastAPI 建立 OpenAPI 與驗證 body 時。", connection: "不包含 id,避免 client 指定主鍵。", consequence: "直接用 table model 會混淆輸入與儲存責任。", stages: ["validation"] }, + { id: "model-8", code: " pass", explanation: "表示目前不需要在 ItemBase 之外增加輸入欄位。", timing: "Python 建立 class 時。", connection: "仍會繼承 name 與 price。", consequence: "空 class body 沒有 pass 會造成語法錯誤。", stages: ["validation"] }, + { id: "model-9", code: "class ItemPublic(ItemBase):", explanation: "宣告 API 對外輸出的 response schema。", timing: "FastAPI 序列化 response 時。", connection: "它決定 client 最終能看到哪些欄位。", consequence: "直接回傳 database model 可能洩漏內部欄位。", stages: ["response"] }, + { id: "model-10", code: " id: int", explanation: "公開資料必須包含 database 已產生的 id。", timing: "response validation 時。", connection: "refresh 後的 ORM object 應具備這個值。", consequence: "若缺少 id,FastAPI 會視為 server response 錯誤。", stages: ["response"] }, + ], + }, + { + id: "main.py", + path: "backend/main.py", + language: "Python", + role: "FastAPI application:組合 middleware、route、validation、dependency 與 response。", + lines: [ + { id: "main-0", code: "from contextlib import asynccontextmanager", explanation: "匯入建立 async lifespan context manager 的 decorator。", timing: "Python 載入 module 時。", connection: "FastAPI 會在接受 request 前後進出 lifespan。", consequence: "缺少生命週期管理時,啟動資源只能散落在 module scope。", stages: ["dependency"] }, + { id: "main-1", code: "from fastapi import FastAPI, HTTPException", explanation: "匯入 FastAPI application class 與可轉成 HTTP error response 的例外。", timing: "Python 載入 module 時。", connection: "app 註冊 routes;HTTPException 用於 404 等可預期錯誤。", consequence: "一般例外通常會變成 500,而不是清楚的 client error。", stages: ["routing", "response"] }, + { id: "main-2", code: "from fastapi.middleware.cors import CORSMiddleware", explanation: "匯入處理跨 origin request 的 middleware。", timing: "Python 載入 module 時。", connection: "允許 Vite frontend 呼叫不同 port 的 API。", consequence: "瀏覽器可能封鎖跨 origin response。", stages: ["cors"] }, + { id: "main-3", code: "from .database import SessionDep, create_db_and_tables", explanation: "匯入 Session dependency 與 workshop 建表 helper。", timing: "Python 載入 module 時。", connection: "route 取得 Session;lifespan 確保 SQLite table 已存在。", consequence: "缺少任一部分都可能讓 database request 失敗。", stages: ["dependency", "database"] }, + { id: "main-4", code: "from .models import Item, ItemCreate, ItemPublic", explanation: "匯入 database、request 與 response 三種資料模型。", timing: "Python 載入 module 時。", connection: "讓輸入、儲存與輸出契約保持分離。", consequence: "混用模型可能讓 client 控制或看到不該出現的欄位。", stages: ["validation", "database", "response"] }, + { id: "main-4b", code: "from sqlmodel import select", explanation: "匯入建立 SELECT statement 的 SQLModel helper。", timing: "Python 載入 module 時。", connection: "Session 會把 statement 交給 engine 執行。", consequence: "FastAPI 不會自己產生 database query。", stages: ["database"] }, + { id: "main-5a", code: "@asynccontextmanager", explanation: "把下一個 async generator 轉成 FastAPI 可使用的 lifespan context manager。", timing: "Python 建立 lifespan 函式時。", connection: "yield 前是 startup,yield 後是 shutdown。", consequence: "普通 async generator 不能直接作為 lifespan。", stages: ["dependency"] }, + { id: "main-5b", code: "async def lifespan(app: FastAPI):", explanation: "宣告應用程式生命週期;app 參數代表目前 FastAPI instance。", timing: "server startup 與 shutdown 各進入一次。", connection: "用來準備 request 需要的 database schema。", consequence: "長期資源初始化若放在每個 request 會浪費成本。", stages: ["dependency", "database"] }, + { id: "main-5c", code: " create_db_and_tables()", explanation: "在 server 接受第一個 request 前建立 workshop SQLite tables。", timing: "lifespan startup 階段。", connection: "呼叫 database.py 中依 metadata 建表的 helper。", consequence: "全新環境可能得到 no such table 錯誤。", stages: ["database"] }, + { id: "main-5d", code: " yield", explanation: "把控制權交給 FastAPI 開始服務 requests;離開時進入 shutdown。", timing: "startup 完成後、shutdown 開始前。", connection: "這行分隔資源建立與清理階段。", consequence: "lifespan context manager 必須 yield 一次。", stages: ["dependency"] }, + { id: "main-5", code: "app = FastAPI(title=\"Workshop API\", lifespan=lifespan)", explanation: "建立 ASGI application,並掛上啟動生命週期;title 會進入 OpenAPI schema。", timing: "server import main:app 時。", connection: "Uvicorn 會啟動 lifespan,再把 HTTP request 交給 app。", consequence: "entrypoint 找不到 app 或 lifespan 啟動失敗時 server 無法服務。", stages: ["routing", "dependency"] }, + { id: "main-6", code: "app.add_middleware(", explanation: "開始註冊會包住每個 request 的 CORS middleware。", timing: "應用程式啟動時。", connection: "middleware 在 route 前後處理 headers。", consequence: "設定不完整會造成跨 origin 問題。", stages: ["cors"] }, + { id: "main-7", code: " CORSMiddleware,", explanation: "指定使用 Starlette 提供、FastAPI 重新匯出的 CORS middleware。", timing: "應用程式啟動時。", connection: "它會處理 preflight OPTIONS 與 response headers。", consequence: "少了它,frontend 與 backend 不同 origin 時可能無法通訊。", stages: ["cors"] }, + { id: "main-8", code: " allow_origins=[\"http://localhost:5173\"],", explanation: "只允許本機 Vite frontend 的 origin。", timing: "middleware 判斷 request Origin 時。", connection: "protocol、host、port 必須全部相同。", consequence: "使用萬用 * 會限制帶 credentials 的請求。", stages: ["cors"] }, + { id: "main-8b", code: " allow_methods=[\"GET\", \"POST\"],", explanation: "允許 workshop frontend 使用 GET 與 POST。", timing: "CORS preflight 檢查 Access-Control-Request-Method 時。", connection: "POST JSON request 通常會先送 OPTIONS preflight。", consequence: "未允許 POST 時,瀏覽器不會送出真正的 create request。", stages: ["cors"] }, + { id: "main-8c", code: " allow_headers=[\"Content-Type\"],", explanation: "允許 frontend 在跨 origin request 使用 Content-Type header。", timing: "CORS preflight 檢查 requested headers 時。", connection: "fetch 用它宣告 JSON body。", consequence: "preflight 會拒絕 application/json request。", stages: ["cors", "validation"] }, + { id: "main-9", code: ")", explanation: "完成 middleware 設定。", timing: "應用程式啟動時。", connection: "之後進入 app 的 request 都套用此設定。", consequence: "括號未關閉會造成語法錯誤。", stages: ["cors"] }, + { id: "main-10", code: "@app.post(\"/items\", response_model=ItemPublic, status_code=201)", explanation: "註冊 POST /items,並宣告成功 status 與公開 response schema。", timing: "應用程式啟動時註冊;request 到達時用來比對。", connection: "OpenAPI /docs 也從這行產生 endpoint 契約。", consequence: "method 或 path 不同時,request 不會進入下方函式。", stages: ["routing", "response"] }, + { id: "main-11", code: "def create_item(item: ItemCreate, session: SessionDep):", explanation: "宣告 path operation;FastAPI 會驗證 item,並注入 session。", timing: "route 命中且 validation 成功後。", connection: "函式 signature 就是 request 與 dependency 契約。", consequence: "驗證失敗時這個函式完全不會執行。", stages: ["routing", "validation", "dependency"] }, + { id: "main-12", code: " db_item = Item.model_validate(item)", explanation: "把已驗證的 input schema 轉成可寫入 table 的 ORM object。", timing: "進入 path operation 後。", connection: "明確跨越 request model 到 database model 的邊界。", consequence: "直接混用 model 會模糊哪些欄位可由 client 控制。", stages: ["database"] }, + { id: "main-13", code: " session.add(db_item)", explanation: "將 ORM object 加入目前 Session;此時尚未永久寫入。", timing: "transaction 準備階段。", connection: "Session 開始追蹤這個新物件。", consequence: "沒有 add,commit 不會插入這筆商品。", stages: ["database"] }, + { id: "main-14", code: " session.commit()", explanation: "提交 transaction,ORM 透過 engine 對 SQLite 執行 INSERT。", timing: "資料驗證與物件建立完成後。", connection: "這是資料真正持久化的時點。", consequence: "失敗時應 rollback;完整版本會加入錯誤情境。", stages: ["database"] }, + { id: "main-15", code: " session.refresh(db_item)", explanation: "重新讀取該 row,取得 database 生成的 id 等欄位。", timing: "commit 成功後。", connection: "讓 db_item 與 database 的最新狀態同步。", consequence: "response 可能拿不到 database 產生的值。", stages: ["database", "response"] }, + { id: "main-16", code: " return db_item", explanation: "把 ORM object 交回 FastAPI;ItemPublic 會驗證、過濾並序列化它。", timing: "path operation 最後。", connection: "client 最終只收到 response model 允許的 JSON 欄位。", consequence: "回傳不符合 ItemPublic 時會被視為 server error。", stages: ["response"] }, + { id: "main-17", code: "@app.get(\"/items/{item_id}\", response_model=ItemPublic)", explanation: "註冊帶有 path parameter 的 GET route,預設成功 status 是 200。", timing: "啟動時註冊;GET request 到達時比對。", connection: "OpenAPI 會把 item_id 與 ItemPublic 都記入契約。", consequence: "若 path 名稱與函式參數不同,FastAPI 無法正確注入值。", stages: ["routing", "response"] }, + { id: "main-18", code: "def read_item(item_id: int, session: SessionDep):", explanation: "要求 FastAPI 把 URL 值驗證成 int,並注入 database Session。", timing: "GET route 命中後。", connection: "path validation 與 dependency 都在函式執行前完成。", consequence: "例如 /items/abc 會在查詢前得到 422。", stages: ["routing", "validation", "dependency"] }, + { id: "main-19", code: " item = session.exec(select(Item).where(Item.id == item_id)).first()", explanation: "建立 SELECT、透過 Session 執行,並取得第一個符合的 ORM object。", timing: "validation 與 dependency 成功後。", connection: "Session → engine → SQLite,再把 row 轉回 Item。", consequence: "沒有 where 條件可能讀到錯誤商品。", stages: ["database"] }, + { id: "main-20", code: " if item is None:", explanation: "判斷 database 是否完全找不到對應 row。", timing: "SELECT 執行完成後。", connection: "把 database 的空結果轉成 HTTP 語意。", consequence: "忽略 None 會讓 response validation 失敗並形成 500。", stages: ["database", "response"] }, + { id: "main-21", code: " raise HTTPException(status_code=404, detail=\"Item not found\")", explanation: "中止 route,要求 FastAPI 建立結構化的 404 JSON response。", timing: "查無商品時。", connection: "前端 response.ok 會變成 false。", consequence: "回傳 None 不等於清楚的 resource-not-found 契約。", stages: ["response"] }, + { id: "main-22", code: " return item", explanation: "找到商品時交給 ItemPublic 驗證與 JSON serialization。", timing: "GET happy path 最後。", connection: "前端 readItem 會解析這個 JSON。", consequence: "輸出不符合 response model 時代表 server contract 壞掉。", stages: ["response"] }, + ], + }, +]; + +export function findRestCodeFile(fileId: RestCodeFileId): RestCodeFile { + return restCodeFiles.find((file) => file.id === fileId) ?? restCodeFiles[0]; +} + +export function findRestCodeLine(lineId: string): RestCodeLine { + return restCodeFiles.flatMap((file) => file.lines).find((line) => line.id === lineId) ?? restCodeFiles[0].lines[0]; +} diff --git a/frontend/src/topics/rest/integration.test.ts b/frontend/src/topics/rest/integration.test.ts new file mode 100644 index 0000000..8c376d2 --- /dev/null +++ b/frontend/src/topics/rest/integration.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import curriculumData from "../../../../shared/curriculum.json"; +import { aggregateProgress } from "../../progress/aggregation"; +import { completionKeyFor, type ProgressRepository } from "../../progress/repository"; +import { resolveRoute } from "../../routes/registry"; +import type { Curriculum } from "../../types"; +import { TOPIC_MODULE_IDS, getTopicViewModule } from "../registry"; + +const curriculum = curriculumData as Curriculum; + +function memoryRepository(completedTopicIds: readonly string[]): ProgressRepository { + const completed = new Set(completedTopicIds); + return { + read: (topicId) => completed.has(topicId), + markComplete: (topicId) => completed.add(topicId), + clear: (topicId) => completed.delete(topicId), + }; +} + +describe("REST topic integration", () => { + it("is a ready Core topic with lesson and Lab routes", () => { + const topic = curriculum.tracks.flatMap((track) => track.topics).find((candidate) => candidate.id === "rest"); + + expect(topic?.status).toBe("ready"); + expect(getTopicViewModule("rest")).toBeDefined(); + expect(resolveRoute("#/rest", curriculum, TOPIC_MODULE_IDS)).toMatchObject({ kind: "lesson", topicId: "rest" }); + expect(resolveRoute("#/rest-lab", curriculum, TOPIC_MODULE_IDS)).toMatchObject({ kind: "lab", topicId: "rest" }); + }); + + it("uses an independent completion key and contributes to Core progress", () => { + const progress = aggregateProgress(curriculum, memoryRepository(["rest"])); + + expect(completionKeyFor("rest")).toBe("se-workshop-rest-complete"); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 1 }); + expect(progress.extensionProgress.completed).toBe(0); + }); +}); diff --git a/frontend/src/topics/rest/lab.test.ts b/frontend/src/topics/rest/lab.test.ts new file mode 100644 index 0000000..9c22ea5 --- /dev/null +++ b/frontend/src/topics/rest/lab.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { restScenarios, restTraceStages } from "./content"; +import { restLabProgress } from "./lab"; +import { createInitialRestState, runRestEvents } from "./simulator"; + +describe("REST Lab progress", () => { + it("starts at zero and reaches 100 only with all scenarios and stages", () => { + const completed = { + ...createInitialRestState(), + completedScenarioIds: restScenarios.map((scenario) => scenario.id), + learnedStageIds: restTraceStages.map((stage) => stage.id), + }; + + expect(restLabProgress(createInitialRestState())).toBe(0); + expect(restLabProgress(completed)).toBe(100); + }); + + it("keeps partial work visible", () => { + const partial = runRestEvents([{ type: "start-request" }, { type: "next-stage" }]); + expect(restLabProgress(partial)).toBeGreaterThan(0); + expect(restLabProgress(partial)).toBeLessThan(100); + }); +}); diff --git a/frontend/src/topics/rest/lab.tsx b/frontend/src/topics/rest/lab.tsx new file mode 100644 index 0000000..4e68cf4 --- /dev/null +++ b/frontend/src/topics/rest/lab.tsx @@ -0,0 +1,190 @@ +import { useMemo, useState } from "react"; +import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell"; +import { + findRestCodeFile, + findRestCodeLine, + findRestScenario, + restCodeFiles, + restScenarios, + restTraceStages, + type RestCodeFileId, + type RestLabEvent, + type RestLabState, + type RestScenarioId, + type RestTraceStageId, +} from "./content"; +import { createInitialRestState, isRestLabComplete, runRestEvent } from "./simulator"; + +type RestCodeMode = "annotated" | "source"; + +function stageIndex(stageId: RestTraceStageId): number { + return restTraceStages.findIndex((stage) => stage.id === stageId); +} + +function statusTone(state: RestLabState): TopicStatusTone { + if (state.phase === "completed") return "success"; + if (state.phase === "error") return "error"; + return "neutral"; +} + +function relatedLine(fileId: RestCodeFileId, stageId: RestTraceStageId): string { + const file = findRestCodeFile(fileId); + return (file.lines.find((line) => line.stages.includes(stageId)) ?? file.lines[0]).id; +} + +export function restLabProgress(state: RestLabState): number { + const scenarioWeight = state.completedScenarioIds.length / restScenarios.length; + const stageWeight = state.learnedStageIds.length / restTraceStages.length; + return Math.round((scenarioWeight * .7 + stageWeight * .3) * 100); +} + +export function RestLab({ onComplete }: { onComplete?: () => void }) { + const [state, setState] = useState(createInitialRestState); + const [selectedFileId, setSelectedFileId] = useState("api.ts"); + const [selectedLineId, setSelectedLineId] = useState("api-6"); + const [codeMode, setCodeMode] = useState("annotated"); + const scenario = findRestScenario(state.selectedScenarioId); + const selectedFile = findRestCodeFile(selectedFileId); + const selectedLine = findRestCodeLine(selectedLineId); + const terminalIndex = stageIndex(scenario.terminalStageId); + const currentIndex = stageIndex(state.activeStageId); + const completed = isRestLabComplete(state); + const activeLineIds = useMemo( + () => selectedFile.lines.filter((line) => line.stages.includes(state.activeStageId)).map((line) => line.id), + [selectedFile, state.activeStageId], + ); + + function dispatch(event: RestLabEvent) { + const result = runRestEvent(state, event); + if (!isRestLabComplete(state) && isRestLabComplete(result.state)) onComplete?.(); + setState(result.state); + if (event.type === "start-request" || event.type === "inspect-stage" || event.type === "next-stage") { + const nextStage = restTraceStages.find((stage) => stage.id === result.state.activeStageId) ?? restTraceStages[0]; + setSelectedFileId(nextStage.fileId); + setSelectedLineId(relatedLine(nextStage.fileId, nextStage.id)); + } + } + + function reset() { + setState(createInitialRestState()); + setSelectedFileId("api.ts"); + setSelectedLineId("api-6"); + setCodeMode("annotated"); + } + + function selectScenario(scenarioId: RestScenarioId) { + dispatch({ type: "select-scenario", scenarioId }); + setSelectedFileId("api.ts"); + setSelectedLineId(scenarioId === "create-success" || scenarioId === "validation-error" ? "api-6" : "api-15"); + } + + function changeFile(fileId: RestCodeFileId) { + setSelectedFileId(fileId); + setSelectedLineId(relatedLine(fileId, state.activeStageId)); + } + + return ( + 追蹤一次 request
讀懂整個 full stack} + progressLabel={`${state.completedScenarioIds.length} / ${restScenarios.length} REQUESTS`} + progress={restLabProgress(state)} + onReset={reset} + > + + + {completed ? ( + + ) : null} + +
+ +
{scenario.method}{scenario.url}
+ + +
+ +
+ {restScenarios.map((item) =>
{state.completedScenarioIds.includes(item.id) ? "✓" : "○"}{item.method}{new URL(item.url).pathname}{item.status.split(" ")[0]}
)} +
+ +
+ {restTraceStages.map((stage, index) => { + const isBlocked = index > terminalIndex; + const isVisited = state.currentVisitedStageIds.includes(stage.id); + return ( + + ); + })} +
+ +
+
+
+
+ {restCodeFiles.map((file) => ( + + ))} +
+
+ + +
+
+
{selectedFile.path}{selectedFile.language} · {selectedFile.role}
+
+ {selectedFile.lines.map((line, index) => { + const isRelated = activeLineIds.includes(line.id); + return ( + + ); + })} +
+
+ + +
+ +
+
REQUEST BODY{scenario.method === "POST" ? "application/json" : "none"}
{scenario.requestBody}
+
DATABASE LOG{state.databaseItems.length} rows · SQLite fixture
{state.requestStarted && (currentIndex >= stageIndex("database") || scenario.terminalStageId === "validation") ? scenario.sql : "— waiting for database stage —"}
+
HTTP RESPONSE{state.responseReady ? scenario.status : "Pending"}
{state.responseReady ? scenario.responseBody : "— follow the request to see the response —"}
+
+
+ ); +} diff --git a/frontend/src/topics/rest/lesson.tsx b/frontend/src/topics/rest/lesson.tsx new file mode 100644 index 0000000..e727745 --- /dev/null +++ b/frontend/src/topics/rest/lesson.tsx @@ -0,0 +1,28 @@ +import { TopicLessonShell } from "../../components/TopicShell"; +import { restLesson } from "./content"; + +export function RestLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { + return ( + 一個 request
穿過哪些程式碼?} + description="從 React fetch、FastAPI routing 與 Pydantic validation,一路追到 ORM、SQLite 與 JSON response。" + completed={completed} + > +
+ {restLesson.sections.map((section, index) => ( +
+
{String(index + 1).padStart(2, "0")}REQUEST
+

{section.title}

{section.body}

+ +
+ ))} +
+
+

FASTAPI REQUEST LAB

讓 request 自己帶你讀程式。

逐行解說 · deterministic simulator · 不連線真實 backend

+ +
+
+ ); +} diff --git a/frontend/src/topics/rest/simulator.test.ts b/frontend/src/topics/rest/simulator.test.ts new file mode 100644 index 0000000..311460f --- /dev/null +++ b/frontend/src/topics/rest/simulator.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { restDatabaseFixture, restScenarios, restTraceStages, type RestLabEvent, type RestScenarioId } from "./content"; +import { createInitialRestState, isRestLabComplete, runRestEvent, runRestEvents } from "./simulator"; + +function completeScenarioEvents(scenarioId: RestScenarioId): RestLabEvent[] { + const scenario = restScenarios.find((item) => item.id === scenarioId) ?? restScenarios[0]; + const terminalIndex = restTraceStages.findIndex((stage) => stage.id === scenario.terminalStageId); + return [ + { type: "select-scenario", scenarioId }, + { type: "start-request" }, + ...Array.from({ length: terminalIndex }, () => ({ type: "next-stage" } as const)), + ]; +} + +describe("REST simulator", () => { + it("runs POST through validation, database and a 201 response", () => { + const state = runRestEvents(completeScenarioEvents("create-success")); + + expect(state.responseReady).toBe(true); + expect(state.completedScenarioIds).toContain("create-success"); + expect(state.databaseItems).toContainEqual({ id: 3, name: "Keyboard", price: 1200 }); + }); + + it("keeps repeated create requests deterministic instead of duplicating fixture rows", () => { + const events = [...completeScenarioEvents("create-success"), ...completeScenarioEvents("create-success")]; + const state = runRestEvents(events); + + expect(state.databaseItems.filter((item) => item.id === 3)).toHaveLength(1); + }); + + it("stops invalid input at validation without changing the database", () => { + const state = runRestEvents(completeScenarioEvents("validation-error")); + + expect(state.activeStageId).toBe("validation"); + expect(state.phase).toBe("error"); + expect(state.databaseItems).toEqual(restDatabaseFixture); + expect(state.learnedStageIds).not.toContain("database"); + }); + + it("distinguishes a successful SELECT from a missing resource", () => { + const success = runRestEvents(completeScenarioEvents("read-success")); + const missing = runRestEvents(completeScenarioEvents("not-found")); + + expect(success.lastMessage).toContain("200"); + expect(missing.lastMessage).toContain("404"); + expect(missing.databaseItems).toEqual(restDatabaseFixture); + }); + + it("rejects stages beyond a validation error terminal", () => { + const started = runRestEvents([ + { type: "select-scenario", scenarioId: "validation-error" }, + { type: "start-request" }, + ]); + const result = runRestEvent(started, { type: "inspect-stage", stageId: "database" }); + + expect(result.accepted).toBe(false); + expect(result.state.activeStageId).toBe("browser"); + expect(result.state.databaseItems).toEqual(restDatabaseFixture); + }); + + it("completes only after all requests and lifecycle stages", () => { + const events = restScenarios.flatMap((scenario) => completeScenarioEvents(scenario.id)); + const state = runRestEvents(events); + + expect(isRestLabComplete(state)).toBe(true); + expect(state.phase).toBe("completed"); + expect(state.completedScenarioIds).toHaveLength(4); + expect(state.learnedStageIds).toHaveLength(7); + }); + + it("is deterministic and reset returns a deep-equal initial state", () => { + const events = completeScenarioEvents("create-success"); + const first = runRestEvents(events); + const second = runRestEvents(events); + const reset = runRestEvent(first, { type: "reset" }).state; + + expect(first).toEqual(second); + expect(reset).toEqual(createInitialRestState()); + }); +}); diff --git a/frontend/src/topics/rest/simulator.ts b/frontend/src/topics/rest/simulator.ts new file mode 100644 index 0000000..ec2e801 --- /dev/null +++ b/frontend/src/topics/rest/simulator.ts @@ -0,0 +1,180 @@ +import type { SimulatorDefinition } from "../types"; +import { + findRestScenario, + restDatabaseFixture, + restRequiredScenarioIds, + restTraceStages, + type RestItem, + type RestLabEvent, + type RestLabState, + type RestScenarioId, + type RestTraceStageId, +} from "./content"; + +export interface RestEventResult { + state: RestLabState; + accepted: boolean; +} + +function appendUnique(values: readonly T[], value: T): T[] { + return values.includes(value) ? [...values] : [...values, value]; +} + +function cloneItems(items: readonly RestItem[]): RestItem[] { + return items.map((item) => ({ ...item })); +} + +function cloneState(state: RestLabState): RestLabState { + return { + ...state, + currentVisitedStageIds: [...state.currentVisitedStageIds], + learnedStageIds: [...state.learnedStageIds], + completedScenarioIds: [...state.completedScenarioIds], + databaseItems: cloneItems(state.databaseItems), + }; +} + +function stageIndex(stageId: RestTraceStageId): number { + return restTraceStages.findIndex((stage) => stage.id === stageId); +} + +function isKnownStage(stageId: RestTraceStageId): boolean { + return stageIndex(stageId) >= 0; +} + +function isComplete(state: RestLabState): boolean { + return ( + restRequiredScenarioIds.every((scenarioId) => state.completedScenarioIds.includes(scenarioId)) && + restTraceStages.every((stage) => state.learnedStageIds.includes(stage.id)) + ); +} + +function terminalMessage(scenarioId: RestScenarioId): string { + if (scenarioId === "create-success") return "201:資料通過 validation,ORM 完成 INSERT,response model 輸出公開欄位。"; + if (scenarioId === "read-success") return "200:path parameter 進入 SELECT,找到的 ORM object 已序列化為 JSON。"; + if (scenarioId === "not-found") return "404:SQL 正常執行,但查無 resource;route 將空結果轉成 HTTPException。"; + return "422:Pydantic 在 path operation 與 database 之前拒絕了不合法 body。"; +} + +function withStage(current: RestLabState, stageId: RestTraceStageId): RestLabState { + const state = cloneState(current); + const scenario = findRestScenario(state.selectedScenarioId); + const terminalIndex = stageIndex(scenario.terminalStageId); + const nextIndex = stageIndex(stageId); + + if (!state.requestStarted || !isKnownStage(stageId) || nextIndex > terminalIndex) { + return { ...state, lastMessage: "這個 stage 不會在目前 request 執行;請依 lifecycle 前進。" }; + } + + state.activeStageId = stageId; + state.currentVisitedStageIds = appendUnique(state.currentVisitedStageIds, stageId); + state.learnedStageIds = appendUnique(state.learnedStageIds, stageId); + + if (stageId === "database" && state.selectedScenarioId === "create-success" && !state.databaseItems.some((item) => item.id === 3)) { + state.databaseItems = [...state.databaseItems, { id: 3, name: "Keyboard", price: 1200 }]; + } + + if (nextIndex === terminalIndex) { + state.responseReady = true; + state.completedScenarioIds = appendUnique(state.completedScenarioIds, state.selectedScenarioId); + state.phase = isComplete(state) ? "completed" : scenario.tone === "error" ? "error" : "tracing"; + state.lastMessage = isComplete(state) ? "四個 request 情境與七個 lifecycle stages 都已完成。" : terminalMessage(state.selectedScenarioId); + } else { + state.responseReady = false; + state.phase = "tracing"; + const stage = restTraceStages[nextIndex]; + state.lastMessage = `${stage.actor}:${stage.summary}`; + } + + return state; +} + +export function createInitialRestState(): RestLabState { + return { + selectedScenarioId: "create-success", + requestStarted: false, + activeStageId: "browser", + currentVisitedStageIds: [], + learnedStageIds: [], + completedScenarioIds: [], + databaseItems: cloneItems(restDatabaseFixture), + responseReady: false, + phase: "initial", + lastMessage: "選擇情境,送出 request,接著逐站追蹤實際程式碼。", + }; +} + +export function isRestLabComplete(state: RestLabState): boolean { + return isComplete(state); +} + +export function runRestEvent(current: RestLabState, event: RestLabEvent): RestEventResult { + if (event.type === "reset") { + return { state: createInitialRestState(), accepted: true }; + } + + const state = cloneState(current); + + if (event.type === "select-scenario") { + const scenario = findRestScenario(event.scenarioId); + return { + accepted: true, + state: { + ...state, + selectedScenarioId: scenario.id, + requestStarted: false, + activeStageId: "browser", + currentVisitedStageIds: [], + responseReady: false, + phase: isComplete(state) ? "completed" : "initial", + lastMessage: `${scenario.method} ${new URL(scenario.url).pathname} 已準備好;送出 request 開始追蹤。`, + }, + }; + } + + if (event.type === "start-request") { + const started: RestLabState = { + ...state, + requestStarted: true, + activeStageId: "browser", + currentVisitedStageIds: [], + responseReady: false, + phase: "tracing", + lastMessage: "React:fetch 正在組成 HTTP request。", + }; + return { state: withStage(started, "browser"), accepted: true }; + } + + if (!state.requestStarted) { + return { state: { ...state, lastMessage: "請先送出 request。" }, accepted: false }; + } + + if (event.type === "inspect-stage") { + const scenario = findRestScenario(state.selectedScenarioId); + const accepted = isKnownStage(event.stageId) && stageIndex(event.stageId) <= stageIndex(scenario.terminalStageId); + return { state: withStage(state, event.stageId), accepted }; + } + + const scenario = findRestScenario(state.selectedScenarioId); + const currentIndex = stageIndex(state.activeStageId); + const terminalIndex = stageIndex(scenario.terminalStageId); + if (currentIndex >= terminalIndex) { + return { state: { ...state, lastMessage: "這次 request 已結束;請切換情境或重新送出。" }, accepted: false }; + } + + return { state: withStage(state, restTraceStages[currentIndex + 1].id), accepted: true }; +} + +export function resetRestLab(): RestLabState { + return createInitialRestState(); +} + +export const restSimulator: SimulatorDefinition = { + createInitialState: createInitialRestState, + reduce: (state, event) => runRestEvent(state, event).state, + reset: resetRestLab, +}; + +export function runRestEvents(events: readonly RestLabEvent[], initialState = createInitialRestState()): RestLabState { + return events.reduce((state, event) => runRestEvent(state, event).state, cloneState(initialState)); +} diff --git a/frontend/src/topics/types.test.ts b/frontend/src/topics/types.test.ts new file mode 100644 index 0000000..bf8255f --- /dev/null +++ b/frontend/src/topics/types.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import type { TopicModule, TopicTestFixture } from "./types"; + +type FixtureState = { + phase: "initial" | "completed"; + steps: number; +}; + +type FixtureEvent = { type: "complete-step" }; + +const fixtureModule: TopicModule = { + id: "fixture-topic", + trackKind: "extension", + lesson: { + title: "Fixture topic", + objectives: ["驗證 TopicModule 的最小欄位"], + sections: [{ id: "intro", title: "Intro", body: "Fixture content" }], + }, + lab: { + title: "Fixture lab", + completionRule: (state) => state.phase === "completed", + }, + simulator: { + createInitialState: () => ({ phase: "initial", steps: 0 }), + reduce: (state, event) => + event.type === "complete-step" + ? { phase: "completed", steps: state.steps + 1 } + : state, + reset: () => ({ phase: "initial", steps: 0 }), + }, + progress: { + completionKey: "se-workshop-fixture-topic-complete", + isComplete: (state) => state.phase === "completed", + }, +}; + +const fixture: TopicTestFixture = { + initialState: fixtureModule.simulator.createInitialState(), + events: [{ type: "complete-step" }], + expected: { + completed: true, + finalState: { phase: "completed", steps: 1 }, + }, +}; + +describe("TopicModule contract", () => { + it("supports the minimum lesson, lab, simulator and progress boundaries", () => { + const finalState = fixture.events.reduce( + (state, event) => fixtureModule.simulator.reduce(state, event), + fixture.initialState, + ); + + expect(finalState).toEqual(fixture.expected.finalState); + expect(fixtureModule.lab.completionRule(finalState)).toBe(fixture.expected.completed); + expect(fixtureModule.progress.isComplete(finalState)).toBe(fixture.expected.completed); + expect(fixtureModule.trackKind).toBe("extension"); + }); + + it("provides a deterministic reset boundary", () => { + expect(fixtureModule.simulator.reset()).toEqual(fixture.initialState); + }); +}); diff --git a/frontend/src/topics/types.ts b/frontend/src/topics/types.ts new file mode 100644 index 0000000..835ee5f --- /dev/null +++ b/frontend/src/topics/types.ts @@ -0,0 +1,49 @@ +export type TrackKind = "core" | "extension"; + +export type TopicStatus = "ready" | "planned"; + +export interface LessonSection { + id: string; + title: string; + body: string; +} + +export interface LessonDefinition { + title: string; + objectives: readonly string[]; + sections: readonly LessonSection[]; +} + +export interface LabDefinition { + title: string; + completionRule: (state: State) => boolean; +} + +export interface SimulatorDefinition { + createInitialState: () => State; + reduce: (state: State, event: Event) => State; + reset: () => State; +} + +export interface ProgressDefinition { + completionKey: string; + isComplete: (state: State) => boolean; +} + +export interface TopicModule { + id: string; + trackKind: TrackKind; + lesson: LessonDefinition; + lab: LabDefinition; + simulator: SimulatorDefinition; + progress: ProgressDefinition; +} + +export interface TopicTestFixture { + initialState: State; + events: readonly Event[]; + expected: { + completed: boolean; + finalState: State; + }; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b77cd2c..d912218 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,3 +1,5 @@ +import type { TrackKind } from "./topics/types"; + export type TopicStatus = "ready" | "planned"; export interface Topic { @@ -11,6 +13,8 @@ export interface Track { id: string; title: string; description: string; + /** Existing curriculum data defaults to core until extension metadata is added. */ + kind?: TrackKind; topics: Topic[]; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5402a6d..ab8b809 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,15 +1,19 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +const frontendRoot = path.dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ base: process.env.VITE_BASE ?? "/", plugins: [react()], resolve: { alias: { - "@shared": "../shared", + "@shared": path.resolve(frontendRoot, "../shared"), }, }, server: { - fs: { allow: [".."] }, + fs: { allow: [path.resolve(frontendRoot, "..")] }, }, }); diff --git a/shared/curriculum.json b/shared/curriculum.json index d16a34f..15b2747 100644 --- a/shared/curriculum.json +++ b/shared/curriculum.json @@ -7,10 +7,10 @@ "description": "工具與可重複流程", "topics": [ { "id": "git", "title": "Git", "summary": "版本控制與安全協作", "status": "ready" }, - { "id": "remote", "title": "GitHub / GitLab", "summary": "遠端協作平台", "status": "planned" }, - { "id": "cli", "title": "命令列", "summary": "可重複的工作流入口", "status": "planned" }, - { "id": "ide", "title": "IDE/除錯器", "summary": "快速定位問題", "status": "planned" }, - { "id": "package", "title": "套件管理", "summary": "可重現的依賴", "status": "planned" }, + { "id": "remote", "title": "GitHub / GitLab", "summary": "遠端協作平台", "status": "ready" }, + { "id": "cli", "title": "命令列", "summary": "可重複的工作流入口", "status": "ready" }, + { "id": "ide", "title": "IDE/除錯器", "summary": "快速定位問題", "status": "ready" }, + { "id": "package", "title": "套件管理", "summary": "可重現的依賴", "status": "ready" }, { "id": "env", "title": "環境變數", "summary": "設定與秘密", "status": "planned" }, { "id": "build", "title": "建置工具", "summary": "從原始碼到產品", "status": "planned" } ] @@ -20,7 +20,7 @@ "title": "Web 與 API", "description": "請求、契約與權限", "topics": [ - { "id": "rest", "title": "REST API", "summary": "服務契約", "status": "planned" }, + { "id": "rest", "title": "REST API", "summary": "FastAPI request lifecycle 與服務契約", "status": "ready" }, { "id": "auth", "title": "身分驗證與授權", "summary": "Entra ID、OIDC 與 SSO", "status": "ready" } ] }, @@ -54,6 +54,15 @@ { "id": "cicd", "title": "CI/CD", "summary": "自動檢查與交付", "status": "planned" }, { "id": "deploy", "title": "部署", "summary": "版本、回滾與觀測", "status": "planned" } ] + }, + { + "id": "ai-engineering", + "title": "AI/LLM Engineering", + "description": "安全、可靠的模型工作流", + "kind": "extension", + "topics": [ + { "id": "guardrail", "title": "Guardrails", "summary": "模型輸入、輸出與工具防線", "status": "ready" } + ] } ] } From a5be4612e557516f50242ee20e42bf671c53b482 Mon Sep 17 00:00:00 2001 From: Yu-Tsen Wei <59054102+frobel0520@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:02:08 +0800 Subject: [PATCH 02/15] release: promote build and environment workshops Release validated with 120 tests and production build. --- .gitignore | 3 + README.md | 4 +- docs/build-acceptance.md | 36 +++ docs/env-acceptance.md | 36 +++ docs/project-plan.md | 6 +- docs/rest-acceptance.md | 10 +- docs/task-breakdown.md | 6 +- frontend/.env.example | 4 + frontend/src/App.tsx | 2 + frontend/src/progress/aggregation.test.ts | 2 +- frontend/src/styles.css | 42 ++++ frontend/src/topics/build/content.test.ts | 26 +++ frontend/src/topics/build/content.ts | 183 +++++++++++++++ frontend/src/topics/build/integration.test.ts | 33 +++ frontend/src/topics/build/lab.test.ts | 11 + frontend/src/topics/build/lab.tsx | 215 ++++++++++++++++++ frontend/src/topics/build/lesson.tsx | 36 +++ frontend/src/topics/build/simulator.test.ts | 70 ++++++ frontend/src/topics/build/simulator.ts | 186 +++++++++++++++ frontend/src/topics/cli/integration.test.ts | 4 +- frontend/src/topics/env/content.test.ts | 26 +++ frontend/src/topics/env/content.ts | 185 +++++++++++++++ frontend/src/topics/env/integration.test.ts | 33 +++ frontend/src/topics/env/lab.test.ts | 11 + frontend/src/topics/env/lab.tsx | 214 +++++++++++++++++ frontend/src/topics/env/lesson.tsx | 36 +++ frontend/src/topics/env/simulator.test.ts | 70 ++++++ frontend/src/topics/env/simulator.ts | 201 ++++++++++++++++ frontend/src/topics/ide/integration.test.ts | 4 +- .../src/topics/package/integration.test.ts | 4 +- frontend/src/topics/registry.test.ts | 2 +- frontend/src/topics/registry.tsx | 6 + frontend/src/topics/rest/content.test.ts | 13 ++ frontend/src/topics/rest/content.ts | 21 +- frontend/src/topics/rest/integration.test.ts | 2 +- frontend/src/topics/rest/lab.tsx | 2 +- frontend/src/topics/rest/simulator.test.ts | 3 +- frontend/src/topics/rest/simulator.ts | 2 +- shared/curriculum.json | 4 +- 39 files changed, 1719 insertions(+), 35 deletions(-) create mode 100644 docs/build-acceptance.md create mode 100644 docs/env-acceptance.md create mode 100644 frontend/.env.example create mode 100644 frontend/src/topics/build/content.test.ts create mode 100644 frontend/src/topics/build/content.ts create mode 100644 frontend/src/topics/build/integration.test.ts create mode 100644 frontend/src/topics/build/lab.test.ts create mode 100644 frontend/src/topics/build/lab.tsx create mode 100644 frontend/src/topics/build/lesson.tsx create mode 100644 frontend/src/topics/build/simulator.test.ts create mode 100644 frontend/src/topics/build/simulator.ts create mode 100644 frontend/src/topics/env/content.test.ts create mode 100644 frontend/src/topics/env/content.ts create mode 100644 frontend/src/topics/env/integration.test.ts create mode 100644 frontend/src/topics/env/lab.test.ts create mode 100644 frontend/src/topics/env/lab.tsx create mode 100644 frontend/src/topics/env/lesson.tsx create mode 100644 frontend/src/topics/env/simulator.test.ts create mode 100644 frontend/src/topics/env/simulator.ts diff --git a/.gitignore b/.gitignore index 6c2ce48..1af6aee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ frontend/dist/ frontend/coverage/ *.tsbuildinfo Thumbs.db +.env +.env.* +!.env.example diff --git a/README.md b/README.md index 46b8010..9d52c15 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 目前部署來源:`frobel0520/software-engineering-workshop`。 -目前可操作的 Core 主題有 7 / 19 個:**Git**、**GitHub/GitLab 遠端協作**、**命令列**、**IDE/除錯器**、**套件管理**、**REST API/FastAPI**、**身分驗證/授權**;另有 1 個不計入 Core 進度的 Extension:**Guardrails**。其餘 Core 主題保留在課程路線圖。 +目前可操作的 Core 主題有 9 / 19 個:**Git**、**GitHub/GitLab 遠端協作**、**命令列**、**IDE/除錯器**、**套件管理**、**環境變數**、**建置工具**、**REST API/FastAPI**、**身分驗證/授權**;另有 1 個不計入 Core 進度的 Extension:**Guardrails**。其餘 Core 主題保留在課程路線圖。 ## Git 單元 @@ -26,6 +26,8 @@ - 命令列:在固定 fixture 中練習工作目錄、檔案讀取、搜尋與檢查流程。 - IDE/除錯器:模擬 breakpoint、paused frame、variables、step over 與 continue。 - 套件管理:練習 manifest、lockfile、registry 與 deterministic install 狀態。 +- 環境變數:練習 `.env.example`、`.env.local`、Vite 公開邊界、fail-fast 驗證與 git 保護。 +- 建置工具:練習 TypeScript gate、Vite production bundle、GitHub Pages base path、dist artifact 與 preview。 - REST API/FastAPI:逐行追蹤 React fetch、routing、validation、dependency、SQLModel/SQLite 與 JSON response。 - Guardrails Extension:模擬輸入、輸出與工具呼叫的安全防線,不連線真實模型。 diff --git a/docs/build-acceptance.md b/docs/build-acceptance.md new file mode 100644 index 0000000..c5c0ba5 --- /dev/null +++ b/docs/build-acceptance.md @@ -0,0 +1,36 @@ +# BUILD-01:建置工具驗收 + +## 教學邊界 + +本主題只用固定 fixture,不執行真實部署、不修改 GitHub Pages,也不把 `dist/` 當成 source。學習重點是 TypeScript gate、Vite production bundle、公開 base path、artifact 檢查與 preview。 + +本專案的 build script 是 `tsc -b && vite build`。TypeScript 的 `tsc -b` 會依 build mode 處理專案建置;Vite 的 `vite build` 會產出可由 static hosting 服務的 bundle。參考:[TypeScript Build Mode](https://www.typescriptlang.org/docs/handbook/project-references#build-mode-for-typescript) 與 [Vite Building for Production](https://vite.dev/guide/build.html)。 + +## Happy path + +```text +cat package.json + → npm run lint + → VITE_BASE=/software-engineering-workshop/ npm run build + → ls dist + → npm run preview + → BUILD complete +``` + +## 必須看見的狀態 + +- `lint` 是 TypeScript gate;先擋住 source 層的型別錯誤。 +- `build` 產出 `dist/index.html` 與 hashed assets。 +- `VITE_BASE=/software-engineering-workshop/` 對應 GitHub Pages project site 的 nested path。 +- `preview` 服務 `dist/`,不是 dev server,也不是 production server。 +- `dist/` 是可發布 artifact,不應被當成手寫 source。 + +## 失敗情境 + +- 尚未檢查 script 就執行 `npm run lint`:阻擋並提示先確認 build 契約。 +- 尚未通過 TypeScript gate 就執行 build:阻擋並提示先完成 gate。 +- 尚未產生 dist 就檢查或 preview:阻擋並提示先完成 production build。 + +## 完成條件 + +所有 5 個步驟完成,`typecheckState === "passed"`、`bundleState === "created"`、`artifactState === "verified"`、`previewState === "running"`,並將 `build` 標記為 Core ready。 diff --git a/docs/env-acceptance.md b/docs/env-acceptance.md new file mode 100644 index 0000000..0fd961e --- /dev/null +++ b/docs/env-acceptance.md @@ -0,0 +1,36 @@ +# ENV-01:環境變數驗收 + +## 教學邊界 + +本主題只用固定 fixture,不讀取使用者真實的 `.env`,也不把任何值送到外部服務。學習重點是設定來源、Vite mode、client bundle 公開邊界、fail-fast 驗證與 git 保護。 + +Vite 官方規則:`VITE_` 變數會在 bundling 後暴露到 client source,所有值會以字串注入;因此 `VITE_*` 不得放 API key、token 或密碼。參考:[Vite Env Variables and Modes](https://vite.dev/guide/env-and-mode)。 + +## Happy path + +```text +cat .env.example + → cp .env.example .env.local + → npm run check-config + → npm run check-exposure + → git check-ignore .env.local + → ENV complete +``` + +## 必須看見的狀態 + +- `.env.example` 是可提交的設定名稱範本。 +- `.env.local` 是本地覆寫,修改後需要重啟 Vite。 +- `VITE_API_BASE_URL`、`VITE_FEATURE_FLAG` 屬 client 可見設定。 +- `DATABASE_PASSWORD` 屬 server-only,不能進 client bundle。 +- `.env.local` 被 `.gitignore` 排除。 + +## 失敗情境 + +- 尚未載入 `.env.local` 就執行 `npm run check-config`:阻擋並提示先載入。 +- 尚未通過 validate 就執行 `npm run check-exposure`:阻擋並提示先檢查必要 key。 +- 尚未確認 bundle 邊界就執行 `git check-ignore .env.local`:阻擋並提示先完成公開邊界檢查。 + +## 完成條件 + +所有 5 個步驟完成,`configState === "valid"`、`exposureState === "verified"`、`localIgnored === true`,並將 `env` 標記為 Core ready。 diff --git a/docs/project-plan.md b/docs/project-plan.md index 127188d..4a836c5 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -18,7 +18,7 @@ ## 2. 現況基線 - 課程清單:`shared/curriculum.json`,共 19 個主題 -- Core 已開放:Git、GitHub/GitLab 遠端協作、命令列、IDE/除錯器、套件管理、REST API/FastAPI、身分驗證與授權,共 7 / 19 +- Core 已開放:Git、GitHub/GitLab 遠端協作、命令列、IDE/除錯器、套件管理、環境變數、建置工具、REST API/FastAPI、身分驗證與授權,共 9 / 19 - Git v1 release gate:已完成 cowork/pipeline、keyboard、mobile、200% zoom 與 reduced-motion 驗收,`GIT-REVIEW` 已於 2026-08-16 通過。 - Extension 已開放:Guardrails,共 1 個;不計入 Core 19 的完成分母 - 學習者完成數:依瀏覽器 `localStorage` 個別計算,不在專案文件中固定寫死 @@ -86,8 +86,8 @@ - 命令列(已完成) - IDE/除錯器(已完成) - 套件管理(已完成) -- 環境變數 -- 建置工具 +- 環境變數(已完成) +- 建置工具(已完成) - REST API/FastAPI(已完成) - 身分驗證與授權(已完成) diff --git a/docs/rest-acceptance.md b/docs/rest-acceptance.md index 45db6a4..a6f2351 100644 --- a/docs/rest-acceptance.md +++ b/docs/rest-acceptance.md @@ -10,7 +10,7 @@ 完成本主題後,學習者應能: 1. 從 React `fetch` 追蹤一個 HTTP request 如何進入 FastAPI。 -2. 說明 CORS、routing、Pydantic validation 與 dependency injection 的執行順序。 +2. 說明 CORS/preflight、routing、dependency injection 與 Pydantic validation 的執行順序。 3. 分辨 FastAPI、SQLModel Session、database engine 與 SQLite 的責任。 4. 說明 request model、table model 與 response model 為何要分離。 5. 從實際程式碼判斷 `201`、`404` 與 `422` 在哪一層產生。 @@ -23,7 +23,7 @@ REST Lesson → REST Lab → 選擇 request scenario → 送出 deterministic request - → 逐站追蹤 React/FastAPI/Pydantic/Session/SQLite/response + → 逐站追蹤 React/CORS-preflight/FastAPI/Depends/Pydantic/Session/SQLite/response → 點選每一行閱讀執行時機、連接關係與錯誤後果 → 完成四個 required scenarios → 標記 REST topic complete @@ -36,11 +36,13 @@ REST Lesson | `create-success` | `POST /items` → `201` | request body、validation、Session、INSERT、response model | | `read-success` | `GET /items/1` → `200` | path parameter、SELECT、serialization | | `not-found` | `GET /items/99` → `404` | 查詢成功執行,但 resource 不存在 | -| `validation-error` | invalid `POST /items` → `422` | validation 在 route 與 database 前拒絕 request | +| `validation-error` | invalid `POST /items` → `422` | dependency 可能已啟動;validation 會在 path operation 前拒絕 request,且不執行 SQL | ## 4. 程式碼教學契約 - 顯示 `frontend/src/api.ts`、`backend/database.py`、`backend/models.py`、`backend/main.py`。 +- SQLite engine 範例必須保留 `check_same_thread=False`,並說明它不等於跨 request 共用 Session。 +- SQL panel 顯示參數化的示意 SQL,不冒充實際 database log。 - 每個非空白程式碼行都有對應說明。 - 每行說明至少包含:做什麼、何時執行、如何連到相鄰層、刪除或寫錯的後果。 - 「學習模式」顯示逐行摘要;「原始碼模式」保留可直接複製的乾淨程式碼。 @@ -78,7 +80,7 @@ events: - 四個 required scenarios 全部執行到各自的 terminal stage。 - 學習者至少跨情境走過七個 lifecycle stages。 -- `validation-error` 不得產生 SQL 或改變 database fixture。 +- `validation-error` 不得產生 SQL 或改變 database fixture;它可以先建立並在 request 結束時清理 dependency。 - `create-success` 只能產生 deterministic item `id=3`;重跑不重複新增。 - 完成後使用 `se-workshop-rest-complete` 保存進度。 diff --git a/docs/task-breakdown.md b/docs/task-breakdown.md index 0497b88..fd62fe1 100644 --- a/docs/task-breakdown.md +++ b/docs/task-breakdown.md @@ -122,8 +122,8 @@ CORE-007(獨立) | cli | 命令列 | CLI | ready | | ide | IDE/除錯器 | IDE | ready | | package | 套件管理 | PACKAGE | ready | -| env | 環境變數 | ENV | planned | -| build | 建置工具 | BUILD | planned | +| env | 環境變數 | ENV | ready | +| build | 建置工具 | BUILD | ready | | rest | REST API/FastAPI | REST | ready | 例如第一個主題會產生:`REMOTE-01` acceptance、`REMOTE-02` lesson、`REMOTE-03` simulator、`REMOTE-04` Lab、`REMOTE-05` integration/QA。 @@ -230,4 +230,4 @@ M6 的 task 依賴分成兩層:`RELEASE-001`、`RELEASE-002`、`RELEASE-003` ## 15. 目前下一個可開工 task -下一個可開工的是尚未 ready 的 `ENV-01` 或 `BUILD-01`,兩者只依賴 M1,可平行進行。`REST-01` 到 `REST-05` 已依 `docs/rest-acceptance.md` 完成。若先處理架構債務,則開 `CORE-008`;它不阻塞上述教材 task。M6 的 release audit 要等所有 Core topic integration 完成後再收斂。 +下一個可開工的是尚未 ready 的後續 Core topic;`ENV-01` 與 `BUILD-01` 已完成。`REST-01` 到 `REST-05` 已依 `docs/rest-acceptance.md` 完成。若先處理架構債務,則開 `CORE-008`;它不阻塞上述教材 task。M6 的 release audit 要等所有 Core topic integration 完成後再收斂。 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..66a2286 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +# Safe template only: commit names, never real secrets. +VITE_API_BASE_URL=https://api.example.test +VITE_FEATURE_FLAG=env-lab +DATABASE_PASSWORD=replace-me-on-server diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e497737..d9b3e1a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -91,6 +91,8 @@ export default function App() { + + diff --git a/frontend/src/progress/aggregation.test.ts b/frontend/src/progress/aggregation.test.ts index 27f3575..e668e67 100644 --- a/frontend/src/progress/aggregation.test.ts +++ b/frontend/src/progress/aggregation.test.ts @@ -81,7 +81,7 @@ describe("progress aggregation", () => { }); it("ignores completion keys for planned topics", () => { - const repository = new MemoryProgressRepository(new Set(["git", "env"])); + const repository = new MemoryProgressRepository(new Set(["git", "sql"])); expect(completedReadyTopicIds(curriculum, repository)).toEqual(["git"]); }); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5875a10..40614fe 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -765,6 +765,19 @@ h1 em { .package-action-list { display: grid; gap: 1px; margin-top: 27px; border-block: 1px solid var(--line); }.package-action { min-width: 0; padding: 14px 0; display: grid; grid-template-columns: 29px minmax(0, 1fr) 20px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--line); color: var(--ink); background: transparent; text-align: left; }.package-action:last-child { border-bottom: 0; }.package-action:hover:not(:disabled) { color: var(--teal-dark); }.package-action:focus-visible { outline: 2px solid var(--teal-dark); outline-offset: -2px; }.package-action:disabled { cursor: default; }.package-action.done { color: var(--teal-dark); }.package-action-index { display: grid; place-items: center; width: 25px; height: 25px; color: var(--paper); background: var(--teal-dark); font: 9px "DM Mono"; }.package-action:not(.done):disabled .package-action-index { color: var(--muted); background: var(--line); }.package-action-copy { min-width: 0; }.package-action-copy b { display: block; font: 600 12px "Noto Serif TC", serif; }.package-action-copy code { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--muted); font: 9px "DM Mono"; }.package-action-arrow { color: var(--teal-dark); font-size: 16px; }.package-current-hint { margin: 18px 0 0; color: var(--muted); font: 11px/1.7 "Noto Serif TC", serif; } .package-state-section { margin-top: 58px; padding-top: 2px; border-top: 2px solid var(--ink); }.package-state-section .section-heading { margin: 25px 0 26px; }.package-state-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-block: 1px solid var(--line); }.package-state-grid > div { min-width: 0; min-height: 86px; padding: 16px 17px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }.package-state-grid > div:nth-child(4n) { border-right: 0; }.package-state-grid .package-state-wide { grid-column: span 2; }.package-state-grid small { display: block; color: var(--muted); font: 8px "DM Mono"; letter-spacing: .08em; }.package-state-grid b { display: block; margin-top: 9px; overflow-wrap: anywhere; color: var(--teal-dark); font: 11px/1.5 "DM Mono"; } +/* ENV lab extension */ +.env-lab-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); gap: 16px; } +.env-workspace-panel, .env-control-panel { min-width: 0; border: 1px solid var(--ink); } +.env-workspace-panel { color: #dce9e6; background: #172729; } +.env-workspace-top { min-height: 47px; padding: 0 17px; display: flex; align-items: center; gap: 13px; color: #9ab0ad; background: #203638; border-bottom: 1px solid #496063; font: 10px "DM Mono"; } +.env-workspace-top b { overflow: hidden; color: #d1dfdc; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }.env-window-dots { display: flex; gap: 5px; }.env-window-dots i { width: 7px; height: 7px; border-radius: 50%; background: #a6725b; }.env-window-dots i:nth-child(2) { background: #b8a873; }.env-window-dots i:nth-child(3) { background: #7ca7a3; }.env-phase { margin-left: auto; color: #a7cac6; text-transform: uppercase; } +.env-file-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #334e50; }.env-file-tabs button { padding: 12px 15px; border: 0; border-right: 1px solid #334e50; color: #799490; background: transparent; font: 10px "DM Mono"; white-space: nowrap; }.env-file-tabs button:hover, .env-file-tabs button:focus-visible, .env-file-tabs button.active { color: #dce9e6; background: #294446; outline: none; } +.env-editor { min-height: 260px; max-height: 320px; padding: 16px 0; overflow: auto; font: 11px/1.85 "DM Mono"; }.env-code-line { min-height: 22px; padding-right: 17px; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 16px; color: #bfd2d1; }.env-code-line > span { color: #6c8886; text-align: right; }.env-code-line code { min-width: 0; overflow-wrap: anywhere; white-space: pre; } +.env-terminal-output { max-height: 180px; min-height: 108px; padding: 15px 19px; overflow-y: auto; border-top: 1px solid #334e50; }.env-terminal-output .terminal-entry { margin-bottom: 11px; }.env-terminal-output .terminal-entry:last-child { margin-bottom: 0; }.env-terminal-output small { color: #9cb5b1; }.env-command-form { border-top-color: #334e50; }.env-command-form button { border-color: #668582; color: #c3dbd7; background: transparent; } +.env-control-panel { align-self: start; padding: 27px; color: var(--ink); background: var(--surface); }.env-panel-heading { display: flex; justify-content: space-between; align-items: start; gap: 15px; }.env-panel-heading h2 { margin: 8px 0 0; font: 600 23px/1.35 "Newsreader", "Noto Serif TC", serif; }.env-lab-meta { max-width: 125px; overflow-wrap: anywhere; color: var(--muted); font: 9px/1.6 "DM Mono"; text-align: right; } +.env-action-list { display: grid; gap: 1px; margin-top: 27px; border-block: 1px solid var(--line); }.env-action { min-width: 0; padding: 14px 0; display: grid; grid-template-columns: 29px minmax(0, 1fr) 20px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--line); color: var(--ink); background: transparent; text-align: left; }.env-action:last-child { border-bottom: 0; }.env-action:hover:not(:disabled) { color: var(--teal-dark); }.env-action:focus-visible { outline: 2px solid var(--teal-dark); outline-offset: -2px; }.env-action:disabled { cursor: default; }.env-action.done { color: var(--teal-dark); }.env-action-index { display: grid; place-items: center; width: 25px; height: 25px; color: var(--paper); background: var(--teal-dark); font: 9px "DM Mono"; }.env-action:not(.done):disabled .env-action-index { color: var(--muted); background: var(--line); }.env-action-copy { min-width: 0; }.env-action-copy b { display: block; font: 600 12px "Noto Serif TC", serif; }.env-action-copy code { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--muted); font: 9px "DM Mono"; }.env-action-arrow { color: var(--teal-dark); font-size: 16px; }.env-current-hint { margin: 18px 0 0; color: var(--muted); font: 11px/1.7 "Noto Serif TC", serif; } +.env-state-section { margin-top: 58px; padding-top: 2px; border-top: 2px solid var(--ink); }.env-state-section .section-heading { margin: 25px 0 26px; }.env-state-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-block: 1px solid var(--line); }.env-state-grid > div { min-width: 0; min-height: 86px; padding: 16px 17px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }.env-state-grid > div:nth-child(4n) { border-right: 0; }.env-state-grid .env-state-wide { grid-column: span 2; }.env-state-grid small { display: block; color: var(--muted); font: 8px "DM Mono"; letter-spacing: .08em; }.env-state-grid b { display: block; margin-top: 9px; overflow-wrap: anywhere; color: var(--teal-dark); font: 11px/1.5 "DM Mono"; } + @media (max-width: 950px) { .package-lab-grid { grid-template-columns: 1fr; }.package-control-panel { border-top: 1px solid var(--ink); }.package-state-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.package-state-grid > div:nth-child(4n) { border-right: 1px solid var(--line); }.package-state-grid > div:nth-child(2n) { border-right: 0; } } @@ -773,6 +786,35 @@ h1 em { .package-editor { min-height: 220px; }.package-control-panel { padding: 24px 20px; }.package-panel-heading { display: block; }.package-lab-meta { display: block; margin-top: 14px; text-align: left; }.package-state-grid { grid-template-columns: 1fr; }.package-state-grid > div, .package-state-grid .package-state-wide { grid-column: auto; border-right: 0; } } +@media (max-width: 950px) { + .env-lab-grid { grid-template-columns: 1fr; }.env-control-panel { border-top: 1px solid var(--ink); }.env-state-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.env-state-grid > div:nth-child(4n) { border-right: 1px solid var(--line); }.env-state-grid > div:nth-child(2n) { border-right: 0; } +} + +@media (max-width: 720px) { + .env-editor { min-height: 220px; }.env-control-panel { padding: 24px 20px; }.env-panel-heading { display: block; }.env-lab-meta { display: block; margin-top: 14px; text-align: left; }.env-state-grid { grid-template-columns: 1fr; }.env-state-grid > div, .env-state-grid .env-state-wide { grid-column: auto; border-right: 0; } +} + +/* BUILD lab extension */ +.build-lab-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); gap: 16px; } +.build-workspace-panel, .build-control-panel { min-width: 0; border: 1px solid var(--ink); } +.build-workspace-panel { color: #dce9e6; background: #172729; } +.build-workspace-top { min-height: 47px; padding: 0 17px; display: flex; align-items: center; gap: 13px; color: #9ab0ad; background: #203638; border-bottom: 1px solid #496063; font: 10px "DM Mono"; } +.build-workspace-top b { overflow: hidden; color: #d1dfdc; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }.build-window-dots { display: flex; gap: 5px; }.build-window-dots i { width: 7px; height: 7px; border-radius: 50%; background: #a6725b; }.build-window-dots i:nth-child(2) { background: #b8a873; }.build-window-dots i:nth-child(3) { background: #7ca7a3; }.build-phase { margin-left: auto; color: #a7cac6; text-transform: uppercase; } +.build-file-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #334e50; }.build-file-tabs button { padding: 12px 15px; border: 0; border-right: 1px solid #334e50; color: #799490; background: transparent; font: 10px "DM Mono"; white-space: nowrap; }.build-file-tabs button:hover, .build-file-tabs button:focus-visible, .build-file-tabs button.active { color: #dce9e6; background: #294446; outline: none; } +.build-editor { min-height: 260px; max-height: 320px; padding: 16px 0; overflow: auto; font: 11px/1.85 "DM Mono"; }.build-code-line { min-height: 22px; padding-right: 17px; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 16px; color: #bfd2d1; }.build-code-line > span { color: #6c8886; text-align: right; }.build-code-line code { min-width: 0; overflow-wrap: anywhere; white-space: pre; } +.build-terminal-output { max-height: 180px; min-height: 108px; padding: 15px 19px; overflow-y: auto; border-top: 1px solid #334e50; }.build-terminal-output .terminal-entry { margin-bottom: 11px; }.build-terminal-output .terminal-entry:last-child { margin-bottom: 0; }.build-terminal-output small { color: #9cb5b1; }.build-command-form { border-top-color: #334e50; }.build-command-form button { border-color: #668582; color: #c3dbd7; background: transparent; } +.build-control-panel { align-self: start; padding: 27px; color: var(--ink); background: var(--surface); }.build-panel-heading { display: flex; justify-content: space-between; align-items: start; gap: 15px; }.build-panel-heading h2 { margin: 8px 0 0; font: 600 23px/1.35 "Newsreader", "Noto Serif TC", serif; }.build-lab-meta { max-width: 125px; overflow-wrap: anywhere; color: var(--muted); font: 9px/1.6 "DM Mono"; text-align: right; } +.build-action-list { display: grid; gap: 1px; margin-top: 27px; border-block: 1px solid var(--line); }.build-action { min-width: 0; padding: 14px 0; display: grid; grid-template-columns: 29px minmax(0, 1fr) 20px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--line); color: var(--ink); background: transparent; text-align: left; }.build-action:last-child { border-bottom: 0; }.build-action:hover:not(:disabled) { color: var(--teal-dark); }.build-action:focus-visible { outline: 2px solid var(--teal-dark); outline-offset: -2px; }.build-action:disabled { cursor: default; }.build-action.done { color: var(--teal-dark); }.build-action-index { display: grid; place-items: center; width: 25px; height: 25px; color: var(--paper); background: var(--teal-dark); font: 9px "DM Mono"; }.build-action:not(.done):disabled .build-action-index { color: var(--muted); background: var(--line); }.build-action-copy { min-width: 0; }.build-action-copy b { display: block; font: 600 12px "Noto Serif TC", serif; }.build-action-copy code { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--muted); font: 9px "DM Mono"; }.build-action-arrow { color: var(--teal-dark); font-size: 16px; }.build-current-hint { margin: 18px 0 0; color: var(--muted); font: 11px/1.7 "Noto Serif TC", serif; } +.build-state-section { margin-top: 58px; padding-top: 2px; border-top: 2px solid var(--ink); }.build-state-section .section-heading { margin: 25px 0 26px; }.build-state-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-block: 1px solid var(--line); }.build-state-grid > div { min-width: 0; min-height: 86px; padding: 16px 17px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }.build-state-grid > div:nth-child(4n) { border-right: 0; }.build-state-grid .build-state-wide { grid-column: span 2; }.build-state-grid small { display: block; color: var(--muted); font: 8px "DM Mono"; letter-spacing: .08em; }.build-state-grid b { display: block; margin-top: 9px; overflow-wrap: anywhere; color: var(--teal-dark); font: 11px/1.5 "DM Mono"; } + +@media (max-width: 950px) { + .build-lab-grid { grid-template-columns: 1fr; }.build-control-panel { border-top: 1px solid var(--ink); }.build-state-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.build-state-grid > div:nth-child(4n) { border-right: 1px solid var(--line); }.build-state-grid > div:nth-child(2n) { border-right: 0; } +} + +@media (max-width: 720px) { + .build-editor { min-height: 220px; }.build-control-panel { padding: 24px 20px; }.build-panel-heading { display: block; }.build-lab-meta { display: block; margin-top: 14px; text-align: left; }.build-state-grid { grid-template-columns: 1fr; }.build-state-grid > div, .build-state-grid .build-state-wide { grid-column: auto; border-right: 0; } +} + .remote-lab-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); gap: 16px; } .remote-action-panel, .remote-context-panel { border: 1px solid var(--line); background: var(--surface); } .remote-action-panel { padding: 30px; } diff --git a/frontend/src/topics/build/content.test.ts b/frontend/src/topics/build/content.test.ts new file mode 100644 index 0000000..90de231 --- /dev/null +++ b/frontend/src/topics/build/content.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildFileFixtures, buildLesson, buildLessonSteps } from "./content"; + +describe("build lesson content", () => { + it("explains source, gates, base paths, and preview", () => { + expect(buildLesson.sections.map((section) => section.id)).toEqual([ + "source-to-artifact", + "gates-before-bundle", + "base-path", + "preview-artifact", + ]); + expect(buildLesson.objectives).toHaveLength(4); + }); + + it("keeps every lab step mapped to a build fixture", () => { + expect(buildLessonSteps).toHaveLength(5); + expect(buildLessonSteps.map((step) => step.command)).toEqual([ + "cat package.json", + "npm run lint", + "VITE_BASE=/software-engineering-workshop/ npm run build", + "ls dist", + "npm run preview", + ]); + expect(buildFileFixtures.map((file) => file.name)).toEqual(["package.json", "vite.config.ts", "dist/"]); + }); +}); diff --git a/frontend/src/topics/build/content.ts b/frontend/src/topics/build/content.ts new file mode 100644 index 0000000..ad9c33b --- /dev/null +++ b/frontend/src/topics/build/content.ts @@ -0,0 +1,183 @@ +import type { LessonDefinition } from "../../topics/types"; + +export const buildLesson: LessonDefinition = { + title: "把 source 變成可以交付的產品", + objectives: [ + "分辨 source code、typecheck、bundle 與 dist artifact 各自負責的階段。", + "理解 npm script 如何把 TypeScript gate 與 Vite production build 串成可重複指令。", + "知道 GitHub Pages 的 nested base path 為什麼會影響產出的 asset URL。", + "用 preview 檢查正式 artifact,而不是把 dev server 當成部署驗證。", + ], + sections: [ + { + id: "source-to-artifact", + title: "Source 不是交付物", + body: "src/ 裡的 TypeScript、React 與 CSS 是開發輸入;使用者真正下載的是 dist/ 裡的 HTML、JavaScript 與 CSS。建置工具負責把前者轉成靜態 artifact。", + }, + { + id: "gates-before-bundle", + title: "先過 gate,再產 bundle", + body: "TypeScript 檢查先抓住型別與 import 問題,Vite 再負責解析模組、處理 JSX/CSS 並產出 production bundle。把錯誤留在 CI,不要留到部署後。", + }, + { + id: "base-path", + title: "部署位置會改變 asset URL", + body: "根網域可以使用 /,但 GitHub Pages project site 通常位於 /repository-name/。Vite 的 base 必須與公開路徑一致,否則 index.html 會指向錯誤的 asset URL。", + }, + { + id: "preview-artifact", + title: "預覽正式輸出", + body: "npm run preview 服務的是已產出的 dist,而不是即時編譯的 source。它適合在本機檢查 production artifact;真正上線仍交給靜態 hosting。", + }, + ], +}; + +export type BuildStepId = "inspect-scripts" | "typecheck" | "bundle" | "inspect-dist" | "preview"; + +export interface BuildLessonStep { + id: BuildStepId; + title: string; + command: string; + explanation: string; + takeaway: string; +} + +export const buildLessonSteps: readonly BuildLessonStep[] = [ + { + id: "inspect-scripts", + title: "先看建置契約", + command: "cat package.json", + explanation: "確認 build script 會先跑 tsc -b,再交給 vite build;指令鏈就是 CI 的可讀契約。", + takeaway: "先知道 pipeline 做什麼,再相信它產出的檔案。", + }, + { + id: "typecheck", + title: "通過 TypeScript gate", + command: "npm run lint", + explanation: "先做不產出 bundle 的型別檢查,讓錯誤在 source 階段停止。", + takeaway: "lint 在這裡是 build 前的安全閘門,不只是排版工具。", + }, + { + id: "bundle", + title: "產出 production bundle", + command: "VITE_BASE=/software-engineering-workshop/ npm run build", + explanation: "用 Pages 的公開 base path 執行正式建置,將 source 轉成可部署的 dist artifact。", + takeaway: "部署路徑是 build input,不能等上線後才猜。", + }, + { + id: "inspect-dist", + title: "檢查 artifact", + command: "ls dist", + explanation: "確認 dist 至少包含 index.html 與 hashed assets;這些才是 static host 要發布的內容。", + takeaway: "Build 成功的證據是 artifact 可檢查,不是終端機只顯示綠色。", + }, + { + id: "preview", + title: "預覽正式輸出", + command: "npm run preview", + explanation: "啟動 dist 的本機 preview,檢查正式輸出是否能載入;它不是 production server。", + takeaway: "用 preview 驗證交付物,用 hosting 負責正式服務。", + }, +] as const; + +export type BuildLabPhase = "initial" | "active" | "blocked" | "failed" | "completed"; +export type BuildTypecheckState = "unknown" | "passed"; +export type BuildBundleState = "missing" | "created"; +export type BuildArtifactState = "unknown" | "verified"; +export type BuildPreviewState = "stopped" | "running"; +export type BuildFileId = "package-json" | "vite-config" | "dist"; +export type BuildEventType = BuildStepId | "reset"; + +export interface BuildFileFixture { + id: BuildFileId; + name: string; + lines: readonly string[]; +} + +export interface BuildLabState { + phase: BuildLabPhase; + typecheckState: BuildTypecheckState; + bundleState: BuildBundleState; + artifactState: BuildArtifactState; + previewState: BuildPreviewState; + basePath: "/" | "/software-engineering-workshop/" | "unknown"; + selectedFile: BuildFileId; + completedStepIds: readonly BuildStepId[]; + lastCommand: string | null; + lastMessage: string; + canReset: true; +} + +export interface BuildLabEvent { + type: BuildEventType; +} + +export const buildFileFixtures: readonly BuildFileFixture[] = [ + { + id: "package-json", + name: "package.json", + lines: [ + '"lint": "tsc --noEmit",', + '"build": "tsc -b && vite build",', + '"preview": "vite preview"', + ], + }, + { + id: "vite-config", + name: "vite.config.ts", + lines: [ + "export default defineConfig({", + ' base: process.env.VITE_BASE ?? "/",', + " plugins: [react()],", + "});", + ], + }, + { + id: "dist", + name: "dist/", + lines: [ + "dist/ 尚未產生", + "// 執行 production build 後檢查 index.html 與 assets/", + ], + }, +] as const; + +export const buildLabInitialState: BuildLabState = { + phase: "initial", + typecheckState: "unknown", + bundleState: "missing", + artifactState: "unknown", + previewState: "stopped", + basePath: "unknown", + selectedFile: "package-json", + completedStepIds: [], + lastCommand: null, + lastMessage: "準備從 package.json 的 build script 開始。", + canReset: true, +}; + +export const buildLabHappyPath: readonly BuildLabEvent[] = buildLessonSteps.map((step) => ({ type: step.id })); + +export interface BuildFailureFixture { + command: string; + message: string; + expectedPhase: "blocked" | "failed"; +} + +export const buildFailureFixtures: readonly BuildFailureFixture[] = [ + { + command: "npm run build", + message: "請先通過 TypeScript gate,再產出 production bundle。", + expectedPhase: "blocked", + }, + { + command: "ls dist", + message: "dist 尚未產生;請先執行 production build。", + expectedPhase: "blocked", + }, + { + command: "npm run preview", + message: "請先檢查 dist artifact,再啟動 preview。", + expectedPhase: "blocked", + }, +] as const; diff --git a/frontend/src/topics/build/integration.test.ts b/frontend/src/topics/build/integration.test.ts new file mode 100644 index 0000000..b8b0db9 --- /dev/null +++ b/frontend/src/topics/build/integration.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import curriculumData from "../../../../shared/curriculum.json"; +import type { Curriculum } from "../../types"; +import { aggregateProgress } from "../../progress/aggregation"; +import type { ProgressRepository } from "../../progress/repository"; + +const curriculum = curriculumData as Curriculum; + +function memoryRepository(completedTopicIds: readonly string[]): ProgressRepository { + const completed = new Set(completedTopicIds); + return { + read: (topicId) => completed.has(topicId), + markComplete: (topicId) => completed.add(topicId), + clear: (topicId) => completed.delete(topicId), + }; +} + +describe("Build topic integration contract", () => { + it("is ready in the Core curriculum and contributes to the ready denominator", () => { + const buildTopic = curriculum.tracks.flatMap((track) => track.topics).find((topic) => topic.id === "build"); + const progress = aggregateProgress(curriculum, memoryRepository([])); + + expect(buildTopic?.status).toBe("ready"); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 0 }); + }); + + it("persists Build completion as Core progress without changing the denominator", () => { + const progress = aggregateProgress(curriculum, memoryRepository(["build"])); + + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 1 }); + expect(progress.extensionProgress.completed).toBe(0); + }); +}); diff --git a/frontend/src/topics/build/lab.test.ts b/frontend/src/topics/build/lab.test.ts new file mode 100644 index 0000000..42fcb2f --- /dev/null +++ b/frontend/src/topics/build/lab.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { buildLabHappyPath } from "./content"; +import { createInitialBuildState, runBuildEvents } from "./simulator"; +import { buildLabProgress } from "./lab"; + +describe("build lab progress", () => { + it("starts empty and reaches 100 after the happy path", () => { + expect(buildLabProgress(createInitialBuildState())).toBe(0); + expect(buildLabProgress(runBuildEvents(buildLabHappyPath).state)).toBe(100); + }); +}); diff --git a/frontend/src/topics/build/lab.tsx b/frontend/src/topics/build/lab.tsx new file mode 100644 index 0000000..3b3c5ef --- /dev/null +++ b/frontend/src/topics/build/lab.tsx @@ -0,0 +1,215 @@ +import { type FormEvent, useMemo, useState } from "react"; +import { + buildFileFixtures, + buildLabHappyPath, + buildLessonSteps, + type BuildFileId, + type BuildLabEvent, + type BuildLabState, + type BuildStepId, +} from "./content"; +import { createInitialBuildState, isBuildLabComplete, runBuildEvent } from "./simulator"; +import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell"; + +interface BuildHistoryEntry { + command?: string; + lines: readonly string[]; + accepted?: boolean; +} + +const INITIAL_HISTORY: readonly BuildHistoryEntry[] = [ + { lines: ["Build sandbox v1", "固定 package、Vite config 與 dist fixture 已準備好。"] }, +]; + +function eventForStep(stepId: BuildStepId): BuildLabEvent { + return buildLabHappyPath.find((event) => event.type === stepId) ?? { type: stepId }; +} + +function eventForCommand(rawCommand: string): BuildLabEvent | null { + const command = rawCommand.trim().replace(/\s+/g, " "); + if (command === "cat package.json") return { type: "inspect-scripts" }; + if (command === "npm run lint") return { type: "typecheck" }; + if (command === "VITE_BASE=/software-engineering-workshop/ npm run build") return { type: "bundle" }; + if (command === "npm run build") return { type: "bundle" }; + if (command === "ls dist") return { type: "inspect-dist" }; + if (command === "npm run preview") return { type: "preview" }; + return null; +} + +function statusTone(state: BuildLabState): TopicStatusTone { + if (state.phase === "completed") return "success"; + if (state.phase === "blocked" || state.phase === "failed") return "error"; + return "neutral"; +} + +function stepDone(state: BuildLabState, stepId: BuildStepId): boolean { + return state.completedStepIds.includes(stepId); +} + +function buildFileLines(state: BuildLabState, fileId: BuildFileId): readonly string[] { + if (fileId === "dist" && state.bundleState === "created") { + return ["dist/", "├── index.html", "└── assets/", " ├── index-[hash].js", " └── index-[hash].css"]; + } + return buildFileFixtures.find((file) => file.id === fileId)?.lines ?? []; +} + +export function buildLabProgress(state: BuildLabState): number { + return Math.round((state.completedStepIds.length / buildLessonSteps.length) * 100); +} + +export function BuildLab({ onComplete }: { onComplete?: () => void }) { + const [state, setState] = useState(createInitialBuildState); + const [command, setCommand] = useState(""); + const [selectedFile, setSelectedFile] = useState("package-json"); + const [history, setHistory] = useState(INITIAL_HISTORY); + const completed = isBuildLabComplete(state); + const completedCount = state.completedStepIds.length; + const currentStep = useMemo( + () => buildLessonSteps.find((step) => !stepDone(state, step.id)) ?? buildLessonSteps[buildLessonSteps.length - 1], + [state], + ); + + function dispatch(event: BuildLabEvent, rawCommand?: string) { + if (completed) return; + const result = runBuildEvent({ ...state, selectedFile }, event); + setState(result.state); + setSelectedFile(result.state.selectedFile); + setHistory((items) => [ + ...items, + { command: rawCommand ?? buildLessonSteps.find((step) => step.id === event.type)?.command, lines: result.output, accepted: result.accepted }, + ]); + if (result.accepted && isBuildLabComplete(result.state)) onComplete?.(); + } + + function submit(event: FormEvent) { + event.preventDefault(); + const rawCommand = command.trim(); + if (!rawCommand || completed) return; + const buildEvent = eventForCommand(rawCommand); + if (buildEvent) { + dispatch(buildEvent, rawCommand); + } else { + setHistory((items) => [...items, { command: rawCommand, lines: [`${rawCommand}: command not found`], accepted: false }]); + } + setCommand(""); + } + + function reset() { + setState(createInitialBuildState()); + setCommand(""); + setSelectedFile("package-json"); + setHistory([{ lines: ["BUILD Lab 已重設。從 cat package.json 重新開始。"] }]); + } + + return ( + 把 source 變成
可以交付的產品} + progressLabel={`${completedCount} / ${buildLessonSteps.length} STEPS`} + progress={buildLabProgress(state)} + onReset={reset} + > + + + {completed ? ( + + ) : ( +
+
+
+ + workshop-build-lab + {state.phase} +
+
+ {buildFileFixtures.map((file) => ( + + ))} +
+
+ {buildFileLines(state, selectedFile).map((line, index) => ( +
+ {String(index + 1).padStart(2, "0")}{line || " "} +
+ ))} +
+
+ {history.map((entry, index) => ( +
+ {entry.command ?

{entry.command}

: null} + {entry.lines.map((line, lineIndex) => {line})} +
+ ))} +
+
+ + setCommand(event.target.value)} + placeholder="輸入 build 指令…" + autoComplete="off" + spellCheck={false} + aria-describedby="build-command-help" + /> + +
+

可輸入教材中的 build 指令,或使用右側 action buttons。

+
+ + +
+ )} + +
+

LIVE BUILD STATE

目前的交付線索

只顯示 simulator 狀態,不執行真實 build。

+
+
TYPECHECK{state.typecheckState}
+
BUNDLE{state.bundleState}
+
ARTIFACT{state.artifactState}
+
PREVIEW{state.previewState}
+
BASE PATH{state.basePath}
+
LAST COMMAND{state.lastCommand ?? "—"}
+
+
+
+ ); +} diff --git a/frontend/src/topics/build/lesson.tsx b/frontend/src/topics/build/lesson.tsx new file mode 100644 index 0000000..9023d4f --- /dev/null +++ b/frontend/src/topics/build/lesson.tsx @@ -0,0 +1,36 @@ +import { TopicLessonShell } from "../../components/TopicShell"; +import { buildLesson, buildLessonSteps } from "./content"; + +export function BuildLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { + return ( + 把 source 變成
可以交付的產品} + description="從 TypeScript gate、Vite production bundle 到 dist preview,理解 source、artifact、base path 與靜態部署的關係。" + completed={completed} + > +
+ {buildLesson.sections.map((section, index) => { + const step = buildLessonSteps[index]; + return ( +
+
{String(index + 1).padStart(2, "0")}BUILD
+
+

{section.title}

+

{section.body}

+ {step ? $ {step.command} : null} +
+ +
+ ); + })} +
+ +
+

BUILD TOOLING LAB

把 artifact 交到正確位置。

{buildLesson.objectives.length} 個學習目標 · 不執行真實部署

+ +
+
+ ); +} diff --git a/frontend/src/topics/build/simulator.test.ts b/frontend/src/topics/build/simulator.test.ts new file mode 100644 index 0000000..9ec1faa --- /dev/null +++ b/frontend/src/topics/build/simulator.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { buildFailureFixtures, buildLabHappyPath, buildLabInitialState } from "./content"; +import { + buildSimulator, + createInitialBuildState, + isBuildLabComplete, + resetBuildLab, + runBuildEvent, + runBuildEvents, +} from "./simulator"; + +describe("build deterministic simulator", () => { + it("starts from source scripts without an artifact", () => { + expect(createInitialBuildState()).toEqual(buildLabInitialState); + expect(createInitialBuildState().bundleState).toBe("missing"); + expect(createInitialBuildState().previewState).toBe("stopped"); + }); + + it("completes the typecheck, bundle, artifact, and preview happy path", () => { + const result = runBuildEvents(buildLabHappyPath); + + expect(result.accepted).toBe(true); + expect(result.state.phase).toBe("completed"); + expect(result.state.typecheckState).toBe("passed"); + expect(result.state.bundleState).toBe("created"); + expect(result.state.artifactState).toBe("verified"); + expect(result.state.previewState).toBe("running"); + expect(result.state.basePath).toBe("/software-engineering-workshop/"); + expect(isBuildLabComplete(result.state)).toBe(true); + }); + + it("blocks build until the TypeScript gate has passed", () => { + const result = runBuildEvent(createInitialBuildState(), { type: "bundle" }); + + expect(result.accepted).toBe(false); + expect(result.state.phase).toBe("blocked"); + expect(result.state.bundleState).toBe("missing"); + }); + + it("keeps artifact inspection and preview in order", () => { + const built = runBuildEvents(buildLabHappyPath.slice(0, 3)).state; + const previewBeforeInspect = runBuildEvent(built, { type: "preview" }); + expect(previewBeforeInspect.accepted).toBe(false); + expect(previewBeforeInspect.state.previewState).toBe("stopped"); + + const inspect = runBuildEvent(built, { type: "inspect-dist" }); + expect(inspect.accepted).toBe(true); + expect(inspect.state.artifactState).toBe("verified"); + }); + + it("documents the main out-of-order failure fixtures", () => { + expect(buildFailureFixtures.map((fixture) => fixture.command)).toEqual([ + "npm run build", + "ls dist", + "npm run preview", + ]); + expect(runBuildEvent(createInitialBuildState(), { type: "inspect-dist" }).state.phase).toBe("blocked"); + expect(runBuildEvent(createInitialBuildState(), { type: "preview" }).state.phase).toBe("blocked"); + }); + + it("resets and remains deterministic", () => { + const first = runBuildEvents(buildLabHappyPath); + const second = runBuildEvents(buildLabHappyPath); + + expect(first.state).toEqual(second.state); + expect(first.results).toEqual(second.results); + expect(resetBuildLab()).toEqual(createInitialBuildState()); + expect(buildSimulator.reset()).toEqual(createInitialBuildState()); + }); +}); diff --git a/frontend/src/topics/build/simulator.ts b/frontend/src/topics/build/simulator.ts new file mode 100644 index 0000000..d82c77c --- /dev/null +++ b/frontend/src/topics/build/simulator.ts @@ -0,0 +1,186 @@ +import type { SimulatorDefinition } from "../../topics/types"; +import { + buildLabHappyPath, + buildLabInitialState, + type BuildEventType, + type BuildLabEvent, + type BuildLabState, + type BuildStepId, +} from "./content"; + +export interface BuildEventResult { + state: BuildLabState; + output: readonly string[]; + accepted: boolean; +} + +export interface BuildRunResult { + state: BuildLabState; + results: readonly BuildEventResult[]; + accepted: boolean; +} + +const completionStepIds: readonly BuildStepId[] = buildLabHappyPath.map((event) => event.type as BuildStepId); + +function cloneState(state: BuildLabState): BuildLabState { + return { ...state, completedStepIds: [...state.completedStepIds] }; +} + +function hasCompleted(state: BuildLabState, stepId: BuildStepId): boolean { + return state.completedStepIds.includes(stepId); +} + +function withStep(state: BuildLabState, stepId: BuildStepId): BuildStepId[] { + return hasCompleted(state, stepId) ? [...state.completedStepIds] : [...state.completedStepIds, stepId]; +} + +function isComplete(state: BuildLabState): boolean { + return ( + state.phase === "completed" && + completionStepIds.every((stepId) => hasCompleted(state, stepId)) && + state.typecheckState === "passed" && + state.bundleState === "created" && + state.artifactState === "verified" && + state.previewState === "running" && + state.basePath === "/software-engineering-workshop/" + ); +} + +function accepted( + current: BuildLabState, + changes: Partial, + message: string, + output: readonly string[] = [message], +): BuildEventResult { + return { + state: { ...current, ...changes, phase: "active", lastMessage: message }, + output, + accepted: true, + }; +} + +function blocked(current: BuildLabState, command: string, message: string): BuildEventResult { + return { + state: { ...current, phase: "blocked", lastCommand: command, lastMessage: message }, + output: [message], + accepted: false, + }; +} + +function failed(current: BuildLabState, command: string, message: string): BuildEventResult { + return { + state: { ...current, phase: "failed", lastCommand: command, lastMessage: message }, + output: [message], + accepted: false, + }; +} + +export function createInitialBuildState(): BuildLabState { + return cloneState(buildLabInitialState); +} + +export function resetBuildLab(): BuildLabState { + return createInitialBuildState(); +} + +export function isBuildLabComplete(state: BuildLabState): boolean { + return isComplete(state); +} + +export function runBuildEvent(current: BuildLabState, event: BuildLabEvent): BuildEventResult { + if (event.type === "reset") { + return { state: resetBuildLab(), output: ["BUILD Lab 已重設,可以重新開始。"], accepted: true }; + } + + if (current.phase === "completed") { + return blocked(current, event.type, "BUILD Lab 已完成;如要重練,請先 reset。"); + } + + const state = cloneState(current); + + switch (event.type) { + case "inspect-scripts": + return accepted( + state, + { selectedFile: "package-json", completedStepIds: withStep(state, "inspect-scripts"), lastCommand: "cat package.json" }, + "已確認 lint、build、preview 三個 script 的責任分界。", + ["lint: tsc --noEmit", "build: tsc -b && vite build", "preview: vite preview"], + ); + case "typecheck": + if (!hasCompleted(state, "inspect-scripts")) { + return blocked(state, "npm run lint", "請先檢查 package.json,再執行 TypeScript gate。 "); + } + return accepted( + state, + { typecheckState: "passed", completedStepIds: withStep(state, "typecheck"), lastCommand: "npm run lint" }, + "TypeScript gate 已通過;可以進入 production bundle。", + ["tsc --noEmit", "type errors: 0", "gate: passed"], + ); + case "bundle": + if (!hasCompleted(state, "typecheck") || state.typecheckState !== "passed") { + return blocked(state, "npm run build", "請先通過 TypeScript gate,再產出 production bundle。 "); + } + return accepted( + state, + { + selectedFile: "dist", + bundleState: "created", + basePath: "/software-engineering-workshop/", + completedStepIds: withStep(state, "bundle"), + lastCommand: "VITE_BASE=/software-engineering-workshop/ npm run build", + }, + "production bundle 已產生,並套用 GitHub Pages base path。", + ["tsc -b: passed", "vite build: passed", "base: /software-engineering-workshop/", "output: dist/"], + ); + case "inspect-dist": + if (!hasCompleted(state, "bundle") || state.bundleState !== "created") { + return blocked(state, "ls dist", "dist 尚未產生;請先執行 production build。 "); + } + return accepted( + state, + { artifactState: "verified", completedStepIds: withStep(state, "inspect-dist"), lastCommand: "ls dist" }, + "已確認 dist artifact 包含入口與 hashed assets,可以交給 static host。", + ["index.html", "assets/index-[hash].js", "assets/index-[hash].css", "artifact: verified"], + ); + case "preview": { + if (!hasCompleted(state, "inspect-dist") || state.artifactState !== "verified") { + return blocked(state, "npm run preview", "請先檢查 dist artifact,再啟動 preview。 "); + } + const nextState: BuildLabState = { + ...state, + phase: "completed", + previewState: "running", + lastCommand: "npm run preview", + completedStepIds: withStep(state, "preview"), + lastMessage: "正式 artifact 已在 preview 中載入;BUILD Lab 完成。", + }; + return { state: nextState, output: ["vite preview", "serving dist/", "BUILD Lab completed"], accepted: true }; + } + default: { + const unknownEvent = event.type as BuildEventType; + return failed(state, unknownEvent, `不支援的 build event:${unknownEvent}。`); + } + } +} + +export const buildSimulator: SimulatorDefinition = { + createInitialState: createInitialBuildState, + reduce: (state, event) => runBuildEvent(state, event).state, + reset: resetBuildLab, +}; + +export function runBuildEvents( + events: readonly BuildLabEvent[], + initialState: BuildLabState = createInitialBuildState(), +): BuildRunResult { + let state = cloneState(initialState); + const results: BuildEventResult[] = []; + + for (const event of events) { + const result = runBuildEvent(state, event); + results.push(result); + state = result.state; + } + + return { state, results, accepted: results.every((result) => result.accepted) }; +} diff --git a/frontend/src/topics/cli/integration.test.ts b/frontend/src/topics/cli/integration.test.ts index f8a23f7..27763d1 100644 --- a/frontend/src/topics/cli/integration.test.ts +++ b/frontend/src/topics/cli/integration.test.ts @@ -21,14 +21,14 @@ describe("CLI topic integration contract", () => { const progress = aggregateProgress(curriculum, memoryRepository([])); expect(cli?.status).toBe("ready"); - expect(progress.coreProgress.ready).toBe(7); + expect(progress.coreProgress.ready).toBe(9); expect(progress.coreProgress.completed).toBe(0); }); it("persists CLI completion as Core progress without changing the denominator", () => { const progress = aggregateProgress(curriculum, memoryRepository(["cli"])); - expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 1 }); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 1 }); expect(progress.extensionProgress.completed).toBe(0); }); }); diff --git a/frontend/src/topics/env/content.test.ts b/frontend/src/topics/env/content.test.ts new file mode 100644 index 0000000..7f96c61 --- /dev/null +++ b/frontend/src/topics/env/content.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { envLesson, envLessonSteps, envFileFixtures } from "./content"; + +describe("environment lesson content", () => { + it("explains source, mode, public boundary, and local protection", () => { + expect(envLesson.sections.map((section) => section.id)).toEqual([ + "config-is-input", + "load-by-mode", + "public-boundary", + "validate-and-ignore", + ]); + expect(envLesson.objectives).toHaveLength(4); + }); + + it("keeps every lab step mapped to a fixture concept", () => { + expect(envLessonSteps).toHaveLength(5); + expect(envLessonSteps.map((step) => step.command)).toEqual([ + "cat .env.example", + "cp .env.example .env.local", + "npm run check-config", + "npm run check-exposure", + "git check-ignore .env.local", + ]); + expect(envFileFixtures.map((file) => file.name)).toEqual([".env.example", ".env.local", "src/config.ts"]); + }); +}); diff --git a/frontend/src/topics/env/content.ts b/frontend/src/topics/env/content.ts new file mode 100644 index 0000000..a844dae --- /dev/null +++ b/frontend/src/topics/env/content.ts @@ -0,0 +1,185 @@ +import type { LessonDefinition } from "../../topics/types"; + +export const envLesson: LessonDefinition = { + title: "讓設定跟著環境走,但別把秘密打包", + objectives: [ + "分辨可提交的 .env.example、機器本地的 .env.local 與程式讀取設定的邊界。", + "理解 Vite 的 VITE_ 變數會進入瀏覽器 bundle,不能放 API key 或密碼。", + "在啟動前驗證必要設定,讓缺值變成清楚的失敗,而不是埋成 runtime bug。", + "用 .gitignore 保護本地設定,同時留下可複製的安全範本。", + ], + sections: [ + { + id: "config-is-input", + title: "設定是輸入,不是程式碼", + body: ".env.example 描述團隊需要哪些設定;.env.local 放每台機器自己的值;程式只負責讀取與驗證。把環境差異從 source code 抽離,部署時才不用改程式重打包。", + }, + { + id: "load-by-mode", + title: "檔名決定載入情境", + body: "Vite 會依 mode 載入 .env、.env.local 與 .env.[mode]。這些值會在 Vite 啟動時載入;修改後要重啟 dev server,才能看到新的設定。", + }, + { + id: "public-boundary", + title: "VITE_ 是公開邊界", + body: "VITE_API_BASE_URL 可以是瀏覽器需要知道的公開設定,但 VITE_ 變數會被打進 client bundle。API key、資料庫密碼與 token 必須留在 server 或 serverless function。", + }, + { + id: "validate-and-ignore", + title: "先驗證,再保護本地檔", + body: "啟動時檢查必要 key,缺值就 fail fast;把 .env.local 加進 .gitignore,並提交 .env.example。這樣團隊得到的是可重現的契約,不是某個人的秘密。", + }, + ], +}; + +export type EnvStepId = "inspect-example" | "load-local" | "validate-config" | "check-exposure" | "check-ignore"; + +export interface EnvLessonStep { + id: EnvStepId; + title: string; + command: string; + explanation: string; + takeaway: string; +} + +export const envLessonSteps: readonly EnvLessonStep[] = [ + { + id: "inspect-example", + title: "檢查安全範本", + command: "cat .env.example", + explanation: "先看專案要求哪些設定;範本可以提交,但不應含有真實密碼。", + takeaway: "先定義契約,再讓每個環境提供自己的值。", + }, + { + id: "load-local", + title: "建立本地覆寫", + command: "cp .env.example .env.local", + explanation: "本地檔承接開發機的值,和可提交的範本分開;Vite 會在啟動時載入它。", + takeaway: ".env.local 是工作目錄的輸入,不是版本控制的輸出。", + }, + { + id: "validate-config", + title: "啟動前驗證", + command: "npm run check-config", + explanation: "模擬 config loader 檢查必要的 VITE_API_BASE_URL,缺值時立刻停止。", + takeaway: "越早失敗,越容易知道是哪個環境沒有準備好。", + }, + { + id: "check-exposure", + title: "檢查 bundle 邊界", + command: "npm run check-exposure", + explanation: "確認 VITE_ 設定是 client 可見值,而 DATABASE_PASSWORD 仍然是 server-only。", + takeaway: "前端拿得到的值,就不能當成秘密。", + }, + { + id: "check-ignore", + title: "確認不會誤提交", + command: "git check-ignore .env.local", + explanation: "最後確認本地檔被 .gitignore 排除;團隊仍以 .env.example 交換設定名稱。", + takeaway: "保護秘密靠邊界與流程,不靠記憶。", + }, +] as const; + +export type EnvLabPhase = "initial" | "active" | "blocked" | "failed" | "completed"; +export type EnvConfigState = "unknown" | "loaded" | "valid"; +export type EnvExposureState = "unknown" | "verified"; +export type EnvFileId = "env-example" | "env-local" | "config-ts"; +export type EnvEventType = EnvStepId | "reset"; + +export interface EnvFileFixture { + id: EnvFileId; + name: string; + lines: readonly string[]; +} + +export interface EnvLabState { + phase: EnvLabPhase; + configState: EnvConfigState; + exposureState: EnvExposureState; + selectedFile: EnvFileId; + loadedFiles: readonly EnvFileId[]; + publicKeys: readonly string[]; + serverOnlyKeys: readonly string[]; + localIgnored: boolean; + completedStepIds: readonly EnvStepId[]; + lastCommand: string | null; + lastMessage: string; + canReset: true; +} + +export interface EnvLabEvent { + type: EnvEventType; +} + +export const envFileFixtures: readonly EnvFileFixture[] = [ + { + id: "env-example", + name: ".env.example", + lines: [ + "# safe template: commit names, never real secrets", + "VITE_API_BASE_URL=https://api.example.test", + "VITE_FEATURE_FLAG=env-lab", + "DATABASE_PASSWORD=replace-me-on-server", + ], + }, + { + id: "env-local", + name: ".env.local", + lines: [ + "# local-only: ignored by git", + "VITE_API_BASE_URL=http://127.0.0.1:8000", + "VITE_FEATURE_FLAG=env-lab", + "DATABASE_PASSWORD=local-placeholder", + ], + }, + { + id: "config-ts", + name: "src/config.ts", + lines: [ + "const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;", + "if (!apiBaseUrl) throw new Error(\"Missing VITE_API_BASE_URL\");", + "export const config = { apiBaseUrl };", + ], + }, +] as const; + +export const envLabInitialState: EnvLabState = { + phase: "initial", + configState: "unknown", + exposureState: "unknown", + selectedFile: "env-example", + loadedFiles: [], + publicKeys: [], + serverOnlyKeys: [], + localIgnored: false, + completedStepIds: [], + lastCommand: null, + lastMessage: "準備從安全的 .env.example 範本開始。", + canReset: true, +}; + +export const envLabHappyPath: readonly EnvLabEvent[] = envLessonSteps.map((step) => ({ type: step.id })); + +export interface EnvFailureFixture { + command: string; + message: string; + expectedPhase: "blocked" | "failed"; +} + +export const envFailureFixtures: readonly EnvFailureFixture[] = [ + { + command: "npm run check-config", + message: "請先載入 .env.local,再驗證必要設定。", + expectedPhase: "blocked", + }, + { + command: "npm run check-exposure", + message: "設定尚未通過 validate;先確認必要 key 存在。", + expectedPhase: "blocked", + }, + { + command: "git check-ignore .env.local", + message: "請先檢查 bundle 邊界,再確認本地檔的 git 保護。", + expectedPhase: "blocked", + }, +] as const; diff --git a/frontend/src/topics/env/integration.test.ts b/frontend/src/topics/env/integration.test.ts new file mode 100644 index 0000000..3d3ff0f --- /dev/null +++ b/frontend/src/topics/env/integration.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import curriculumData from "../../../../shared/curriculum.json"; +import type { Curriculum } from "../../types"; +import { aggregateProgress } from "../../progress/aggregation"; +import type { ProgressRepository } from "../../progress/repository"; + +const curriculum = curriculumData as Curriculum; + +function memoryRepository(completedTopicIds: readonly string[]): ProgressRepository { + const completed = new Set(completedTopicIds); + return { + read: (topicId) => completed.has(topicId), + markComplete: (topicId) => completed.add(topicId), + clear: (topicId) => completed.delete(topicId), + }; +} + +describe("Environment topic integration contract", () => { + it("is ready in the Core curriculum and contributes to the ready denominator", () => { + const envTopic = curriculum.tracks.flatMap((track) => track.topics).find((topic) => topic.id === "env"); + const progress = aggregateProgress(curriculum, memoryRepository([])); + + expect(envTopic?.status).toBe("ready"); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 0 }); + }); + + it("persists Environment completion as Core progress without changing the denominator", () => { + const progress = aggregateProgress(curriculum, memoryRepository(["env"])); + + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 1 }); + expect(progress.extensionProgress.completed).toBe(0); + }); +}); diff --git a/frontend/src/topics/env/lab.test.ts b/frontend/src/topics/env/lab.test.ts new file mode 100644 index 0000000..ff1af12 --- /dev/null +++ b/frontend/src/topics/env/lab.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { envLabHappyPath } from "./content"; +import { createInitialEnvState, runEnvEvents } from "./simulator"; +import { envLabProgress } from "./lab"; + +describe("environment lab progress", () => { + it("starts empty and reaches 100 after the happy path", () => { + expect(envLabProgress(createInitialEnvState())).toBe(0); + expect(envLabProgress(runEnvEvents(envLabHappyPath).state)).toBe(100); + }); +}); diff --git a/frontend/src/topics/env/lab.tsx b/frontend/src/topics/env/lab.tsx new file mode 100644 index 0000000..02dbda6 --- /dev/null +++ b/frontend/src/topics/env/lab.tsx @@ -0,0 +1,214 @@ +import { type FormEvent, useMemo, useState } from "react"; +import { + envFileFixtures, + envLabHappyPath, + envLessonSteps, + type EnvFileId, + type EnvLabEvent, + type EnvLabState, + type EnvStepId, +} from "./content"; +import { createInitialEnvState, isEnvLabComplete, runEnvEvent } from "./simulator"; +import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell"; + +interface EnvHistoryEntry { + command?: string; + lines: readonly string[]; + accepted?: boolean; +} + +const INITIAL_HISTORY: readonly EnvHistoryEntry[] = [ + { lines: ["Environment sandbox v1", "固定 .env fixture 已準備好。請依序檢查設定來源與公開邊界。"] }, +]; + +function eventForStep(stepId: EnvStepId): EnvLabEvent { + return envLabHappyPath.find((event) => event.type === stepId) ?? { type: stepId }; +} + +function eventForCommand(rawCommand: string): EnvLabEvent | null { + const command = rawCommand.trim().replace(/\s+/g, " "); + if (command === "cat .env.example") return { type: "inspect-example" }; + if (command === "cp .env.example .env.local") return { type: "load-local" }; + if (command === "npm run check-config") return { type: "validate-config" }; + if (command === "npm run check-exposure") return { type: "check-exposure" }; + if (command === "git check-ignore .env.local") return { type: "check-ignore" }; + return null; +} + +function statusTone(state: EnvLabState): TopicStatusTone { + if (state.phase === "completed") return "success"; + if (state.phase === "blocked" || state.phase === "failed") return "error"; + return "neutral"; +} + +function stepDone(state: EnvLabState, stepId: EnvStepId): boolean { + return state.completedStepIds.includes(stepId); +} + +function envFileLines(state: EnvLabState, fileId: EnvFileId): readonly string[] { + if (fileId === "env-local" && !state.loadedFiles.includes("env-local")) { + return ["// .env.local 尚未建立", "// 執行 cp .env.example .env.local"]; + } + return envFileFixtures.find((file) => file.id === fileId)?.lines ?? []; +} + +export function envLabProgress(state: EnvLabState): number { + return Math.round((state.completedStepIds.length / envLessonSteps.length) * 100); +} + +export function EnvLab({ onComplete }: { onComplete?: () => void }) { + const [state, setState] = useState(createInitialEnvState); + const [command, setCommand] = useState(""); + const [selectedFile, setSelectedFile] = useState("env-example"); + const [history, setHistory] = useState(INITIAL_HISTORY); + const completed = isEnvLabComplete(state); + const completedCount = state.completedStepIds.length; + const currentStep = useMemo( + () => envLessonSteps.find((step) => !stepDone(state, step.id)) ?? envLessonSteps[envLessonSteps.length - 1], + [state], + ); + + function dispatch(event: EnvLabEvent, rawCommand?: string) { + if (completed) return; + const result = runEnvEvent({ ...state, selectedFile }, event); + setState(result.state); + setSelectedFile(result.state.selectedFile); + setHistory((items) => [ + ...items, + { command: rawCommand ?? envLessonSteps.find((step) => step.id === event.type)?.command, lines: result.output, accepted: result.accepted }, + ]); + if (result.accepted && isEnvLabComplete(result.state)) onComplete?.(); + } + + function submit(event: FormEvent) { + event.preventDefault(); + const rawCommand = command.trim(); + if (!rawCommand || completed) return; + const envEvent = eventForCommand(rawCommand); + if (envEvent) { + dispatch(envEvent, rawCommand); + } else { + setHistory((items) => [...items, { command: rawCommand, lines: [`${rawCommand}: command not found`], accepted: false }]); + } + setCommand(""); + } + + function reset() { + setState(createInitialEnvState()); + setCommand(""); + setSelectedFile("env-example"); + setHistory([{ lines: ["ENV Lab 已重設。從 cat .env.example 重新開始。"] }]); + } + + return ( + 讓設定跟著環境走
但別把秘密打包} + progressLabel={`${completedCount} / ${envLessonSteps.length} STEPS`} + progress={envLabProgress(state)} + onReset={reset} + > + + + {completed ? ( + + ) : ( +
+
+
+ + workshop-env-lab + {state.phase} +
+
+ {envFileFixtures.map((file) => ( + + ))} +
+
+ {envFileLines(state, selectedFile).map((line, index) => ( +
+ {String(index + 1).padStart(2, "0")}{line || " "} +
+ ))} +
+
+ {history.map((entry, index) => ( +
+ {entry.command ?

{entry.command}

: null} + {entry.lines.map((line, lineIndex) => {line})} +
+ ))} +
+
+ + setCommand(event.target.value)} + placeholder="輸入環境設定指令…" + autoComplete="off" + spellCheck={false} + aria-describedby="env-command-help" + /> + +
+

可輸入教材中的環境設定指令,或使用右側 action buttons。

+
+ + +
+ )} + +
+

LIVE ENV STATE

目前的設定線索

只顯示 simulator 狀態,不讀取真實 .env。

+
+
SOURCE{state.configState}
+
EXPOSURE{state.exposureState}
+
LOCAL FILE{state.localIgnored ? "ignored" : "unverified"}
+
PUBLIC KEYS{state.publicKeys.length || "—"}
+
CLIENT BUNDLE{state.publicKeys.join(" · ") || "—"}
+
SERVER ONLY{state.serverOnlyKeys.join(" · ") || "—"}
+
+
+
+ ); +} diff --git a/frontend/src/topics/env/lesson.tsx b/frontend/src/topics/env/lesson.tsx new file mode 100644 index 0000000..44952cd --- /dev/null +++ b/frontend/src/topics/env/lesson.tsx @@ -0,0 +1,36 @@ +import { TopicLessonShell } from "../../components/TopicShell"; +import { envLesson, envLessonSteps } from "./content"; + +export function EnvLesson({ completed, onOpenLab }: { completed: boolean; onOpenLab: () => void }) { + return ( + 讓設定跟著環境走
但別把秘密打包} + description="從 .env.example、.env.local 到 client bundle,理解設定來源、公開邊界與 fail-fast 驗證;所有操作都在固定 fixture 中完成。" + completed={completed} + > +
+ {envLesson.sections.map((section, index) => { + const step = envLessonSteps[index]; + return ( +
+
{String(index + 1).padStart(2, "0")}ENV
+
+

{section.title}

+

{section.body}

+ {step ? $ {step.command} : null} +
+ +
+ ); + })} +
+ +
+

ENVIRONMENT LAB

把設定放到正確的邊界。

{envLesson.objectives.length} 個學習目標 · 不讀取真實 .env

+ +
+
+ ); +} diff --git a/frontend/src/topics/env/simulator.test.ts b/frontend/src/topics/env/simulator.test.ts new file mode 100644 index 0000000..f554d6c --- /dev/null +++ b/frontend/src/topics/env/simulator.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + envFailureFixtures, + envLabHappyPath, + envLabInitialState, +} from "./content"; +import { + createInitialEnvState, + envSimulator, + isEnvLabComplete, + resetEnvLab, + runEnvEvent, + runEnvEvents, +} from "./simulator"; + +describe("environment deterministic simulator", () => { + it("starts from the safe example fixture", () => { + expect(createInitialEnvState()).toEqual(envLabInitialState); + expect(createInitialEnvState().loadedFiles).toEqual([]); + expect(createInitialEnvState().localIgnored).toBe(false); + }); + + it("completes the source, validation, exposure, and ignore happy path", () => { + const result = runEnvEvents(envLabHappyPath); + + expect(result.accepted).toBe(true); + expect(result.state.phase).toBe("completed"); + expect(result.state.configState).toBe("valid"); + expect(result.state.exposureState).toBe("verified"); + expect(result.state.publicKeys).toEqual(["VITE_API_BASE_URL", "VITE_FEATURE_FLAG"]); + expect(result.state.serverOnlyKeys).toEqual(["DATABASE_PASSWORD"]); + expect(result.state.localIgnored).toBe(true); + expect(isEnvLabComplete(result.state)).toBe(true); + }); + + it("blocks validation until local settings are loaded", () => { + const result = runEnvEvent(createInitialEnvState(), { type: "validate-config" }); + + expect(result.accepted).toBe(false); + expect(result.state.phase).toBe("blocked"); + expect(result.state.configState).toBe("unknown"); + }); + + it("keeps the public and server-only boundary explicit", () => { + const loaded = runEnvEvents(envLabHappyPath.slice(0, 3)).state; + const result = runEnvEvent(loaded, { type: "check-exposure" }); + + expect(result.accepted).toBe(true); + expect(result.state.publicKeys).not.toContain("DATABASE_PASSWORD"); + expect(result.state.serverOnlyKeys).toContain("DATABASE_PASSWORD"); + }); + + it("blocks out-of-order safety checks described by the failure fixtures", () => { + const commands = envFailureFixtures.map((fixture) => fixture.command); + expect(commands).toEqual(["npm run check-config", "npm run check-exposure", "git check-ignore .env.local"]); + + expect(runEnvEvent(createInitialEnvState(), { type: "check-exposure" }).state.phase).toBe("blocked"); + expect(runEnvEvent(createInitialEnvState(), { type: "check-ignore" }).state.phase).toBe("blocked"); + }); + + it("resets and remains deterministic", () => { + const first = runEnvEvents(envLabHappyPath); + const second = runEnvEvents(envLabHappyPath); + + expect(first.state).toEqual(second.state); + expect(first.results).toEqual(second.results); + expect(resetEnvLab()).toEqual(createInitialEnvState()); + expect(envSimulator.reset()).toEqual(createInitialEnvState()); + }); +}); diff --git a/frontend/src/topics/env/simulator.ts b/frontend/src/topics/env/simulator.ts new file mode 100644 index 0000000..6645683 --- /dev/null +++ b/frontend/src/topics/env/simulator.ts @@ -0,0 +1,201 @@ +import type { SimulatorDefinition } from "../../topics/types"; +import { + envLabHappyPath, + envLabInitialState, + type EnvEventType, + type EnvLabEvent, + type EnvLabState, + type EnvStepId, +} from "./content"; + +export interface EnvEventResult { + state: EnvLabState; + output: readonly string[]; + accepted: boolean; +} + +export interface EnvRunResult { + state: EnvLabState; + results: readonly EnvEventResult[]; + accepted: boolean; +} + +const completionStepIds: readonly EnvStepId[] = envLabHappyPath.map((event) => event.type as EnvStepId); +const publicKeys = ["VITE_API_BASE_URL", "VITE_FEATURE_FLAG"] as const; +const serverOnlyKeys = ["DATABASE_PASSWORD"] as const; + +function cloneState(state: EnvLabState): EnvLabState { + return { + ...state, + loadedFiles: [...state.loadedFiles], + publicKeys: [...state.publicKeys], + serverOnlyKeys: [...state.serverOnlyKeys], + completedStepIds: [...state.completedStepIds], + }; +} + +function hasCompleted(state: EnvLabState, stepId: EnvStepId): boolean { + return state.completedStepIds.includes(stepId); +} + +function withStep(state: EnvLabState, stepId: EnvStepId): EnvStepId[] { + return hasCompleted(state, stepId) ? [...state.completedStepIds] : [...state.completedStepIds, stepId]; +} + +function isComplete(state: EnvLabState): boolean { + return ( + state.phase === "completed" && + completionStepIds.every((stepId) => hasCompleted(state, stepId)) && + state.configState === "valid" && + state.exposureState === "verified" && + state.localIgnored && + state.publicKeys.join("|") === publicKeys.join("|") && + state.serverOnlyKeys.join("|") === serverOnlyKeys.join("|") + ); +} + +function accepted( + current: EnvLabState, + changes: Partial, + message: string, + output: readonly string[] = [message], +): EnvEventResult { + return { + state: { ...current, ...changes, phase: "active", lastMessage: message }, + output, + accepted: true, + }; +} + +function blocked(current: EnvLabState, command: string, message: string): EnvEventResult { + return { + state: { ...current, phase: "blocked", lastCommand: command, lastMessage: message }, + output: [message], + accepted: false, + }; +} + +function failed(current: EnvLabState, command: string, message: string): EnvEventResult { + return { + state: { ...current, phase: "failed", lastCommand: command, lastMessage: message }, + output: [message], + accepted: false, + }; +} + +export function createInitialEnvState(): EnvLabState { + return cloneState(envLabInitialState); +} + +export function resetEnvLab(): EnvLabState { + return createInitialEnvState(); +} + +export function isEnvLabComplete(state: EnvLabState): boolean { + return isComplete(state); +} + +export function runEnvEvent(current: EnvLabState, event: EnvLabEvent): EnvEventResult { + if (event.type === "reset") { + return { state: resetEnvLab(), output: ["ENV Lab 已重設,可以重新開始。"], accepted: true }; + } + + if (current.phase === "completed") { + return blocked(current, event.type, "ENV Lab 已完成;如要重練,請先 reset。"); + } + + const state = cloneState(current); + + switch (event.type) { + case "inspect-example": + return accepted( + state, + { selectedFile: "env-example", completedStepIds: withStep(state, "inspect-example"), lastCommand: "cat .env.example" }, + "已確認 .env.example 只描述設定契約,不應放真實秘密。", + [".env.example found", "public keys: VITE_API_BASE_URL, VITE_FEATURE_FLAG", "server-only key: DATABASE_PASSWORD"], + ); + case "load-local": + if (!hasCompleted(state, "inspect-example")) { + return blocked(state, "cp .env.example .env.local", "請先檢查 .env.example,再建立本地覆寫檔。 "); + } + return accepted( + state, + { + selectedFile: "env-local", + loadedFiles: ["env-example", "env-local"], + configState: "loaded", + completedStepIds: withStep(state, "load-local"), + lastCommand: "cp .env.example .env.local", + }, + "已建立 .env.local;修改後要重啟 Vite 才會重新載入。", + [".env.local created", "mode: development", "restart required after edits"], + ); + case "validate-config": + if (!hasCompleted(state, "load-local") || state.configState !== "loaded") { + return blocked(state, "npm run check-config", "請先載入 .env.local,再驗證必要設定。 "); + } + return accepted( + state, + { selectedFile: "config-ts", configState: "valid", completedStepIds: withStep(state, "validate-config"), lastCommand: "npm run check-config" }, + "必要設定已通過驗證;缺值會在啟動時立即失敗。", + ["VITE_API_BASE_URL: present", "config: valid", "startup guard: passed"], + ); + case "check-exposure": + if (!hasCompleted(state, "validate-config") || state.configState !== "valid") { + return blocked(state, "npm run check-exposure", "設定尚未通過 validate;先確認必要 key 存在。 "); + } + return accepted( + state, + { + exposureState: "verified", + publicKeys: [...publicKeys], + serverOnlyKeys: [...serverOnlyKeys], + completedStepIds: withStep(state, "check-exposure"), + lastCommand: "npm run check-exposure", + }, + "已確認 VITE_ 會進入 client bundle;DATABASE_PASSWORD 保持 server-only。", + ["client bundle: VITE_API_BASE_URL, VITE_FEATURE_FLAG", "server only: DATABASE_PASSWORD", "secret leak: prevented"], + ); + case "check-ignore": { + if (!hasCompleted(state, "check-exposure") || state.exposureState !== "verified") { + return blocked(state, "git check-ignore .env.local", "請先檢查 bundle 邊界,再確認本地檔的 git 保護。 "); + } + const completedStepIds = withStep(state, "check-ignore"); + const nextState: EnvLabState = { + ...state, + phase: "completed", + localIgnored: true, + lastCommand: "git check-ignore .env.local", + completedStepIds, + lastMessage: "本地設定已被 git 忽略;ENV Lab 完成。", + }; + return { state: nextState, output: [".env.local", "ignored by .gitignore", "ENV Lab completed"], accepted: true }; + } + default: { + const unknownEvent = event.type as EnvEventType; + return failed(state, unknownEvent, `不支援的 env event:${unknownEvent}。`); + } + } +} + +export const envSimulator: SimulatorDefinition = { + createInitialState: createInitialEnvState, + reduce: (state, event) => runEnvEvent(state, event).state, + reset: resetEnvLab, +}; + +export function runEnvEvents( + events: readonly EnvLabEvent[], + initialState: EnvLabState = createInitialEnvState(), +): EnvRunResult { + let state = cloneState(initialState); + const results: EnvEventResult[] = []; + + for (const event of events) { + const result = runEnvEvent(state, event); + results.push(result); + state = result.state; + } + + return { state, results, accepted: results.every((result) => result.accepted) }; +} diff --git a/frontend/src/topics/ide/integration.test.ts b/frontend/src/topics/ide/integration.test.ts index d528af5..f56a7fd 100644 --- a/frontend/src/topics/ide/integration.test.ts +++ b/frontend/src/topics/ide/integration.test.ts @@ -21,13 +21,13 @@ describe("IDE topic integration contract", () => { const progress = aggregateProgress(curriculum, memoryRepository([])); expect(ide?.status).toBe("ready"); - expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 0 }); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 0 }); }); it("persists IDE completion as Core progress without changing the denominator", () => { const progress = aggregateProgress(curriculum, memoryRepository(["ide"])); - expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 1 }); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 1 }); expect(progress.extensionProgress.completed).toBe(0); }); }); diff --git a/frontend/src/topics/package/integration.test.ts b/frontend/src/topics/package/integration.test.ts index 5eaa9c1..88b228e 100644 --- a/frontend/src/topics/package/integration.test.ts +++ b/frontend/src/topics/package/integration.test.ts @@ -21,13 +21,13 @@ describe("Package topic integration contract", () => { const progress = aggregateProgress(curriculum, memoryRepository([])); expect(packageTopic?.status).toBe("ready"); - expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 0 }); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 0 }); }); it("persists Package completion as Core progress without changing the denominator", () => { const progress = aggregateProgress(curriculum, memoryRepository(["package"])); - expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 1 }); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 1 }); expect(progress.extensionProgress.completed).toBe(0); }); }); diff --git a/frontend/src/topics/registry.test.ts b/frontend/src/topics/registry.test.ts index 9f165b4..1082a0b 100644 --- a/frontend/src/topics/registry.test.ts +++ b/frontend/src/topics/registry.test.ts @@ -18,6 +18,6 @@ describe("topic view registry", () => { }); it("does not expose a module for planned topics", () => { - expect(getTopicViewModule("env")).toBeUndefined(); + expect(getTopicViewModule("sql")).toBeUndefined(); }); }); diff --git a/frontend/src/topics/registry.tsx b/frontend/src/topics/registry.tsx index b61babe..530dd6d 100644 --- a/frontend/src/topics/registry.tsx +++ b/frontend/src/topics/registry.tsx @@ -15,6 +15,10 @@ import { GuardrailLab } from "./guardrail/lab"; import { GuardrailLesson } from "./guardrail/lesson"; import { RestLab } from "./rest/lab"; import { RestLesson } from "./rest/lesson"; +import { EnvLab } from "./env/lab"; +import { EnvLesson } from "./env/lesson"; +import { BuildLab } from "./build/lab"; +import { BuildLesson } from "./build/lesson"; export interface TopicLessonViewProps { completed: boolean; @@ -40,6 +44,8 @@ export const TOPIC_MODULE_REGISTRY: Readonly> = package: { id: "package", lesson: PackageLesson, lab: PackageLab }, guardrail: { id: "guardrail", lesson: GuardrailLesson, lab: GuardrailLab }, rest: { id: "rest", lesson: RestLesson, lab: RestLab }, + env: { id: "env", lesson: EnvLesson, lab: EnvLab }, + build: { id: "build", lesson: BuildLesson, lab: BuildLab }, }; export const TOPIC_MODULE_IDS: ReadonlySet = new Set(Object.keys(TOPIC_MODULE_REGISTRY)); diff --git a/frontend/src/topics/rest/content.test.ts b/frontend/src/topics/rest/content.test.ts index ad2a7f4..063b743 100644 --- a/frontend/src/topics/rest/content.test.ts +++ b/frontend/src/topics/rest/content.test.ts @@ -33,5 +33,18 @@ describe("REST teaching content", () => { "422 Unprocessable Entity", ]); expect(restScenarios.find((scenario) => scenario.id === "validation-error")?.terminalStageId).toBe("validation"); + expect(restScenarios.find((scenario) => scenario.id === "validation-error")?.responseBody).toContain('"type": "greater_than"'); + }); + + it("models dependency resolution before request validation", () => { + expect(restTraceStages.map((stage) => stage.id)).toEqual([ + "browser", + "cors", + "routing", + "dependency", + "validation", + "database", + "response", + ]); }); }); diff --git a/frontend/src/topics/rest/content.ts b/frontend/src/topics/rest/content.ts index 49e6761..c69631e 100644 --- a/frontend/src/topics/rest/content.ts +++ b/frontend/src/topics/rest/content.ts @@ -90,7 +90,7 @@ export const restLesson: LessonDefinition = { ], sections: [ { id: "client", title: "前端不是直接呼叫 Python", body: "React 透過 HTTP 傳送 method、URL、headers 與 JSON body;FastAPI 接到的是一個 request。" }, - { id: "framework", title: "FastAPI 負責協調", body: "FastAPI 比對 route、解析參數、執行 dependencies、驗證資料,再呼叫 path operation。" }, + { id: "framework", title: "FastAPI 負責協調", body: "FastAPI 先比對 route,再解析並執行 dependencies,接著驗證 path、query 與 body;只有沒有驗證錯誤時才呼叫 path operation。" }, { id: "database", title: "FastAPI 不直接執行 SQL", body: "Path operation 使用注入的 Session,ORM 透過 engine 與資料庫溝通。" }, { id: "response", title: "回傳前還有一道契約", body: "Response model 驗證與過濾輸出,FastAPI 再序列化成 JSON。" }, ], @@ -98,10 +98,10 @@ export const restLesson: LessonDefinition = { export const restTraceStages: readonly RestTraceStage[] = [ { id: "browser", label: "01", actor: "React", summary: "fetch 組成 HTTP request", fileId: "api.ts" }, - { id: "cors", label: "02", actor: "CORS", summary: "瀏覽器確認 origin 是否被允許", fileId: "main.py" }, + { id: "cors", label: "02", actor: "CORS", summary: "可能先處理 OPTIONS preflight,再放行跨 origin request", fileId: "main.py" }, { id: "routing", label: "03", actor: "Router", summary: "FastAPI 比對 method 與 path", fileId: "main.py" }, - { id: "validation", label: "04", actor: "Pydantic", summary: "JSON body 轉成有型別的資料", fileId: "models.py" }, - { id: "dependency", label: "05", actor: "Depends", summary: "建立這次 request 使用的 Session", fileId: "database.py" }, + { id: "dependency", label: "04", actor: "Depends", summary: "建立這次 request 使用的 Session", fileId: "database.py" }, + { id: "validation", label: "05", actor: "Pydantic", summary: "驗證 path、query 或 JSON body", fileId: "models.py" }, { id: "database", label: "06", actor: "SQLModel", summary: "ORM 將物件操作轉成 SQL", fileId: "main.py" }, { id: "response", label: "07", actor: "Response", summary: "輸出經過 model 過濾後成為 JSON", fileId: "main.py" }, ]; @@ -120,7 +120,7 @@ export const restScenarios: readonly RestScenario[] = [ requestBody: '{\n "name": "Keyboard",\n "price": 1200\n}', responseBody: '{\n "id": 3,\n "name": "Keyboard",\n "price": 1200\n}', status: "201 Created", - sql: "INSERT INTO item (name, price) VALUES ('Keyboard', 1200);", + sql: "INSERT INTO item (name, price) VALUES (?, ?); 參數:('Keyboard', 1200)", terminalStageId: "response", tone: "success", }, @@ -132,7 +132,7 @@ export const restScenarios: readonly RestScenario[] = [ requestBody: "— GET request 沒有 body —", responseBody: '{\n "id": 1,\n "name": "Notebook",\n "price": 180\n}', status: "200 OK", - sql: "SELECT id, name, price FROM item WHERE id = 1;", + sql: "SELECT id, name, price FROM item WHERE id = ?; 參數:(1)", terminalStageId: "response", tone: "success", }, @@ -144,7 +144,7 @@ export const restScenarios: readonly RestScenario[] = [ requestBody: "— GET request 沒有 body —", responseBody: '{\n "detail": "Item not found"\n}', status: "404 Not Found", - sql: "SELECT id, name, price FROM item WHERE id = 99;", + sql: "SELECT id, name, price FROM item WHERE id = ?; 參數:(99)", terminalStageId: "response", tone: "error", }, @@ -154,7 +154,7 @@ export const restScenarios: readonly RestScenario[] = [ method: "POST", url: "http://localhost:8000/items", requestBody: '{\n "name": "Keyboard",\n "price": -1\n}', - responseBody: '{\n "detail": [{\n "loc": ["body", "price"],\n "msg": "Input should be greater than 0"\n }]\n}', + responseBody: '{\n "detail": [{\n "type": "greater_than",\n "loc": ["body", "price"],\n "msg": "Input should be greater than 0",\n "input": -1,\n "ctx": {"gt": 0}\n }]\n}', status: "422 Unprocessable Entity", sql: "— validation 失敗,沒有執行 SQL —", terminalStageId: "validation", @@ -205,7 +205,8 @@ export const restCodeFiles: readonly RestCodeFile[] = [ { id: "db-2", code: "from fastapi import Depends", explanation: "匯入 FastAPI 的 dependency 宣告工具。", timing: "Python 載入 module 時。", connection: "FastAPI 看到 Depends 後會先執行 get_session。", consequence: "route 不會自動取得 database session。", stages: ["dependency"] }, { id: "db-3", code: "from sqlmodel import SQLModel, Session, create_engine", explanation: "匯入 metadata、ORM session 與 database engine 工具。", timing: "Python 載入 module 時。", connection: "SQLModel 底層透過 engine 與 SQLite 溝通。", consequence: "FastAPI 本身不提供 ORM 或 database driver。", stages: ["dependency", "database"] }, { id: "db-3b", code: "from .models import Item", explanation: "匯入對應 item table 的 ORM model,供 workshop 建表與 seed 使用。", timing: "Python 載入 database module 時。", connection: "Item class 會把 table metadata 註冊到 SQLModel。", consequence: "未載入 table model 時,create_all 不知道要建立哪張表。", stages: ["database"] }, - { id: "db-4", code: "engine = create_engine(\"sqlite:///workshop.db\")", explanation: "建立可連到 workshop.db 的 database engine。", timing: "應用程式啟動並載入 module 時。", connection: "所有 Session 會透過這個 engine 發送 SQL。", consequence: "連線字串錯誤會讓 database 操作失敗。", stages: ["database"] }, + { id: "db-4a", code: "connect_args = {\"check_same_thread\": False}", explanation: "允許 FastAPI 在不同 thread 間使用 SQLite;這不代表可以跨 request 共用同一個 Session。", timing: "建立 SQLite engine 前。", connection: "同步 dependency 與 path operation 可能由不同 thread 執行。", consequence: "省略它時,SQLite 可能因 thread 不同而拒絕使用 connection。", stages: ["database", "dependency"] }, + { id: "db-4", code: "engine = create_engine(\"sqlite:///workshop.db\", connect_args=connect_args)", explanation: "建立可連到 workshop.db 的 database engine,並套用 SQLite 的 thread 設定。", timing: "應用程式啟動並載入 module 時。", connection: "所有 Session 會透過這個 engine 發送 SQL。", consequence: "連線字串或 connect_args 錯誤會讓 database 操作失敗。", stages: ["database"] }, { id: "db-4b", code: "def create_db_and_tables():", explanation: "宣告 workshop 啟動時建立缺少資料表的 helper。", timing: "FastAPI lifespan 啟動階段呼叫。", connection: "正式專案通常改由 migration 管理 schema。", consequence: "全新 SQLite 檔沒有 table 時,第一個 query 會失敗。", stages: ["dependency", "database"] }, { id: "db-4c", code: " SQLModel.metadata.create_all(engine)", explanation: "依所有 table models 的 metadata 建立尚不存在的資料表。", timing: "server 開始接受 request 前。", connection: "engine 把 CREATE TABLE 送到 SQLite。", consequence: "它不會取代正式 migration,也不會安全修改既有 schema。", stages: ["database"] }, { id: "db-4d", code: " with Session(engine) as session:", explanation: "開啟短生命週期 Session,準備 workshop 的固定初始資料。", timing: "建表完成後、server 接受 request 前。", connection: "讓 GET 情境在全新 database 也有 deterministic rows。", consequence: "沒有 seed 時,全新 database 的 GET /items/1 只會得到 404。", stages: ["database"] }, @@ -214,7 +215,7 @@ export const restCodeFiles: readonly RestCodeFile[] = [ { id: "db-4g", code: " session.commit()", explanation: "提交 seed transaction,讓兩筆 fixture 永久寫入 SQLite。", timing: "確認 database 尚未 seed 後。", connection: "後續每個 request 的 Session 都能查到它們。", consequence: "沒有 commit,Session 關閉後 seed 不會保存。", stages: ["database"] }, { id: "db-5", code: "def get_session():", explanation: "宣告 FastAPI dependency,用來提供一個 request 專用 Session。", timing: "每次 route 需要 SessionDep 時。", connection: "FastAPI dependency system 負責呼叫它。", consequence: "自行在 route 建 Session 容易忘記關閉。", stages: ["dependency"] }, { id: "db-6", code: " with Session(engine) as session:", explanation: "從 engine 開啟 Session,並用 context manager 確保最後關閉。", timing: "path operation 執行前。", connection: "Session 追蹤 ORM objects 與 transaction。", consequence: "未關閉 Session 可能耗盡連線資源。", stages: ["dependency", "database"] }, - { id: "db-7", code: " yield session", explanation: "把 Session 注入 route,並暫停 dependency;route 結束後才離開 with。", timing: "path operation 執行前到 response 建立期間。", connection: "create_item 的 session 參數會收到這個物件。", consequence: "改成 return 仍可提供值,但失去 yield 後的清理生命週期。", stages: ["dependency"] }, + { id: "db-7", code: " yield session", explanation: "把 Session 注入 route,並暫停 dependency;request 結束後才離開 with。", timing: "path operation 執行前,到 response 完成後清理。", connection: "create_item 的 session 參數會收到這個物件。", consequence: "改成 return 仍可提供值,但失去 yield 後的清理生命週期。", stages: ["dependency"] }, { id: "db-8", code: "SessionDep = Annotated[Session, Depends(get_session)]", explanation: "建立可重用型別:需要 Session,也要求 FastAPI 透過 get_session 提供。", timing: "FastAPI 分析 route signature 時。", connection: "route 只寫 session: SessionDep 就能取得連線。", consequence: "拿掉 Depends 後,FastAPI 會把它誤解成一般參數。", stages: ["dependency", "routing"] }, ], }, diff --git a/frontend/src/topics/rest/integration.test.ts b/frontend/src/topics/rest/integration.test.ts index 8c376d2..77d137f 100644 --- a/frontend/src/topics/rest/integration.test.ts +++ b/frontend/src/topics/rest/integration.test.ts @@ -31,7 +31,7 @@ describe("REST topic integration", () => { const progress = aggregateProgress(curriculum, memoryRepository(["rest"])); expect(completionKeyFor("rest")).toBe("se-workshop-rest-complete"); - expect(progress.coreProgress).toMatchObject({ total: 19, ready: 7, completed: 1 }); + expect(progress.coreProgress).toMatchObject({ total: 19, ready: 9, completed: 1 }); expect(progress.extensionProgress.completed).toBe(0); }); }); diff --git a/frontend/src/topics/rest/lab.tsx b/frontend/src/topics/rest/lab.tsx index 4e68cf4..8b2e975 100644 --- a/frontend/src/topics/rest/lab.tsx +++ b/frontend/src/topics/rest/lab.tsx @@ -182,7 +182,7 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) {
REQUEST BODY{scenario.method === "POST" ? "application/json" : "none"}
{scenario.requestBody}
-
DATABASE LOG{state.databaseItems.length} rows · SQLite fixture
{state.requestStarted && (currentIndex >= stageIndex("database") || scenario.terminalStageId === "validation") ? scenario.sql : "— waiting for database stage —"}
+
SQL 示意(非實際 log){state.databaseItems.length} rows · SQLite fixture
{state.requestStarted && (currentIndex >= stageIndex("database") || scenario.terminalStageId === "validation") ? scenario.sql : "— waiting for database stage —"}
HTTP RESPONSE{state.responseReady ? scenario.status : "Pending"}
{state.responseReady ? scenario.responseBody : "— follow the request to see the response —"}
diff --git a/frontend/src/topics/rest/simulator.test.ts b/frontend/src/topics/rest/simulator.test.ts index 311460f..5a7b2d5 100644 --- a/frontend/src/topics/rest/simulator.test.ts +++ b/frontend/src/topics/rest/simulator.test.ts @@ -28,12 +28,13 @@ describe("REST simulator", () => { expect(state.databaseItems.filter((item) => item.id === 3)).toHaveLength(1); }); - it("stops invalid input at validation without changing the database", () => { + it("stops invalid input at validation without SQL or database changes", () => { const state = runRestEvents(completeScenarioEvents("validation-error")); expect(state.activeStageId).toBe("validation"); expect(state.phase).toBe("error"); expect(state.databaseItems).toEqual(restDatabaseFixture); + expect(state.learnedStageIds).toContain("dependency"); expect(state.learnedStageIds).not.toContain("database"); }); diff --git a/frontend/src/topics/rest/simulator.ts b/frontend/src/topics/rest/simulator.ts index ec2e801..a66c4ba 100644 --- a/frontend/src/topics/rest/simulator.ts +++ b/frontend/src/topics/rest/simulator.ts @@ -53,7 +53,7 @@ function terminalMessage(scenarioId: RestScenarioId): string { if (scenarioId === "create-success") return "201:資料通過 validation,ORM 完成 INSERT,response model 輸出公開欄位。"; if (scenarioId === "read-success") return "200:path parameter 進入 SELECT,找到的 ORM object 已序列化為 JSON。"; if (scenarioId === "not-found") return "404:SQL 正常執行,但查無 resource;route 將空結果轉成 HTTPException。"; - return "422:Pydantic 在 path operation 與 database 之前拒絕了不合法 body。"; + return "422:dependency 已準備但沒有執行 SQL;Pydantic body validation 失敗,因此 path operation 不會執行。"; } function withStage(current: RestLabState, stageId: RestTraceStageId): RestLabState { diff --git a/shared/curriculum.json b/shared/curriculum.json index 15b2747..6f50ecc 100644 --- a/shared/curriculum.json +++ b/shared/curriculum.json @@ -11,8 +11,8 @@ { "id": "cli", "title": "命令列", "summary": "可重複的工作流入口", "status": "ready" }, { "id": "ide", "title": "IDE/除錯器", "summary": "快速定位問題", "status": "ready" }, { "id": "package", "title": "套件管理", "summary": "可重現的依賴", "status": "ready" }, - { "id": "env", "title": "環境變數", "summary": "設定與秘密", "status": "planned" }, - { "id": "build", "title": "建置工具", "summary": "從原始碼到產品", "status": "planned" } + { "id": "env", "title": "環境變數", "summary": "設定與秘密", "status": "ready" }, + { "id": "build", "title": "建置工具", "summary": "從原始碼到產品", "status": "ready" } ] }, { From 2838d342abdc5d84ce6abe3c440db8aae6ee015d Mon Sep 17 00:00:00 2001 From: Yu-Tsen Wei <59054102+frobel0520@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:59:43 +0800 Subject: [PATCH 03/15] release: ship Core 19/19 to main (#90) * refactor: align project code with coding standards * feat: add index and transaction topic * feat: add problem-solving extension workshop Add the Problem-solving Extension Lesson and Lab with deterministic nine-check simulator, progress integration, responsive UI, tests, and docs. * feat: add unit testing workshop Unit Testing lesson, deterministic six-check Lab, routes, navigation, curriculum readiness, and integration coverage. Local validation: 59 test files / 181 tests, lint, build, and browser QA passed. * feat: add PostgreSQL topic (#61) * docs: define integration testing acceptance (#64)docs: * feat: add integration lesson fixtures (#65) * feat: add integration simulator (#66) * feat: add integration lab UI (#67) * feat: add integration testing topic (#68) * docs: add logs acceptance contract (#69) * feat: add logs lesson fixtures (#70) * feat: add logs simulator (#71) * feat: add logs lab UI (#72) * feat: open logs topic (#73) * docs: sync curriculum status and phase questions (#74) * docs: define Docker workshop acceptance (#75) * feat: add Docker lesson fixtures (#76) * feat: add Docker deterministic simulator (#77) * feat: add Docker interactive lab (#78) * feat: integrate Docker topic (#79) * docs: define CI/CD workshop acceptance (#80) * feat: add CI/CD lesson fixtures (#81) * feat: add CI/CD deterministic simulator (#82) * feat: add CI/CD interactive lab (#83) * feat: integrate CI/CD topic (#84) * docs: define deployment acceptance contract (#85) * feat: add deployment lesson fixtures (#86) * feat: add deployment deterministic simulator (#87) * feat: add deployment interactive lab (#88) * feat: integrate deployment topic (#89) * docs: record M6 release audit --- AGENTS.md | 8 + README.md | 8 +- docs/accessibility-checklist.md | 8 + docs/cicd-acceptance.md | 239 +++++++++ docs/deploy-acceptance.md | 241 +++++++++ docs/docker-acceptance.md | 199 ++++++++ docs/integration-acceptance.md | 160 ++++++ docs/logs-acceptance.md | 224 ++++++++ docs/project-plan.md | 13 +- docs/project-sa.md | 14 +- docs/release-audit.md | 59 +++ docs/task-breakdown.md | 40 +- frontend/src/App.tsx | 14 +- frontend/src/components/CurriculumMap.tsx | 4 +- frontend/src/components/GitLab.tsx | 12 +- frontend/src/curriculum.test.ts | 30 ++ frontend/src/curriculum.ts | 104 ++++ frontend/src/git/release-simulator.test.ts | 7 + frontend/src/git/release-simulator.ts | 11 +- frontend/src/main.tsx | 9 +- frontend/src/progress/aggregation.test.ts | 33 +- frontend/src/progress/aggregation.ts | 2 +- frontend/src/progress/repository.ts | 11 +- frontend/src/routes/registry.test.ts | 17 + frontend/src/routes/registry.ts | 10 +- frontend/src/styles.css | 89 ++++ frontend/src/testing/progress.ts | 13 + frontend/src/topics/build/integration.test.ts | 24 +- frontend/src/topics/build/lab.tsx | 6 +- frontend/src/topics/build/simulator.ts | 8 +- frontend/src/topics/cicd/content.test.ts | 105 ++++ frontend/src/topics/cicd/content.ts | 283 +++++++++++ frontend/src/topics/cicd/integration.test.ts | 42 ++ frontend/src/topics/cicd/lab.test.ts | 10 + frontend/src/topics/cicd/lab.tsx | 266 ++++++++++ frontend/src/topics/cicd/lesson.tsx | 38 ++ frontend/src/topics/cicd/simulator.test.ts | 190 +++++++ frontend/src/topics/cicd/simulator.ts | 435 ++++++++++++++++ frontend/src/topics/cicd/styles.css | 96 ++++ frontend/src/topics/cli/integration.test.ts | 24 +- frontend/src/topics/deploy/content.test.ts | 54 ++ frontend/src/topics/deploy/content.ts | 271 ++++++++++ .../src/topics/deploy/integration.test.ts | 42 ++ frontend/src/topics/deploy/lab.test.ts | 10 + frontend/src/topics/deploy/lab.tsx | 268 ++++++++++ frontend/src/topics/deploy/lesson.tsx | 38 ++ frontend/src/topics/deploy/simulator.test.ts | 175 +++++++ frontend/src/topics/deploy/simulator.ts | 457 +++++++++++++++++ frontend/src/topics/deploy/styles.css | 96 ++++ frontend/src/topics/docker/content.test.ts | 109 ++++ frontend/src/topics/docker/content.ts | 263 ++++++++++ .../src/topics/docker/integration.test.ts | 42 ++ frontend/src/topics/docker/lab.test.ts | 11 + frontend/src/topics/docker/lab.tsx | 270 ++++++++++ frontend/src/topics/docker/lesson.tsx | 38 ++ frontend/src/topics/docker/simulator.test.ts | 184 +++++++ frontend/src/topics/docker/simulator.ts | 472 +++++++++++++++++ frontend/src/topics/docker/styles.css | 96 ++++ frontend/src/topics/env/integration.test.ts | 24 +- frontend/src/topics/env/lab.tsx | 6 +- frontend/src/topics/env/simulator.ts | 8 +- frontend/src/topics/guardrail/lab.tsx | 8 +- frontend/src/topics/ide/integration.test.ts | 24 +- frontend/src/topics/index/content.test.ts | 39 ++ frontend/src/topics/index/content.ts | 237 +++++++++ frontend/src/topics/index/integration.test.ts | 23 + frontend/src/topics/index/lab.test.ts | 11 + frontend/src/topics/index/lab.tsx | 181 +++++++ frontend/src/topics/index/lesson.tsx | 46 ++ frontend/src/topics/index/simulator.test.ts | 69 +++ frontend/src/topics/index/simulator.ts | 187 +++++++ frontend/src/topics/index/styles.css | 78 +++ .../src/topics/integration/content.test.ts | 87 ++++ frontend/src/topics/integration/content.ts | 408 +++++++++++++++ .../topics/integration/integration.test.ts | 34 ++ frontend/src/topics/integration/lab.test.ts | 43 ++ frontend/src/topics/integration/lab.tsx | 284 +++++++++++ frontend/src/topics/integration/lesson.tsx | 46 ++ .../src/topics/integration/simulator.test.ts | 113 +++++ frontend/src/topics/integration/simulator.ts | 323 ++++++++++++ frontend/src/topics/logs/content.test.ts | 117 +++++ frontend/src/topics/logs/content.ts | 456 +++++++++++++++++ frontend/src/topics/logs/integration.test.ts | 34 ++ frontend/src/topics/logs/lab.test.ts | 33 ++ frontend/src/topics/logs/lab.tsx | 258 ++++++++++ frontend/src/topics/logs/lesson.tsx | 46 ++ frontend/src/topics/logs/simulator.test.ts | 153 ++++++ frontend/src/topics/logs/simulator.ts | 477 ++++++++++++++++++ frontend/src/topics/logs/styles.css | 104 ++++ .../src/topics/package/integration.test.ts | 24 +- frontend/src/topics/package/simulator.ts | 8 +- .../src/topics/postgresql/content.test.ts | 44 ++ frontend/src/topics/postgresql/content.ts | 249 +++++++++ .../src/topics/postgresql/integration.test.ts | 31 ++ frontend/src/topics/postgresql/lab.test.ts | 11 + frontend/src/topics/postgresql/lab.tsx | 181 +++++++ frontend/src/topics/postgresql/lesson.tsx | 46 ++ .../src/topics/postgresql/simulator.test.ts | 68 +++ frontend/src/topics/postgresql/simulator.ts | 192 +++++++ frontend/src/topics/postgresql/styles.css | 78 +++ .../topics/problem-solving/content.test.ts | 39 ++ .../src/topics/problem-solving/content.ts | 334 ++++++++++++ .../problem-solving/integration.test.ts | 23 + .../src/topics/problem-solving/lab.test.ts | 11 + frontend/src/topics/problem-solving/lab.tsx | 174 +++++++ .../src/topics/problem-solving/lesson.tsx | 50 ++ .../topics/problem-solving/simulator.test.ts | 63 +++ .../src/topics/problem-solving/simulator.ts | 201 ++++++++ .../src/topics/problem-solving/styles.css | 87 ++++ frontend/src/topics/registry.test.ts | 9 +- frontend/src/topics/registry.tsx | 40 ++ frontend/src/topics/rest/content.test.ts | 8 +- frontend/src/topics/rest/content.ts | 26 +- frontend/src/topics/rest/integration.test.ts | 21 +- frontend/src/topics/rest/lab.tsx | 27 +- frontend/src/topics/rest/simulator.test.ts | 5 +- frontend/src/topics/schema/content.test.ts | 34 ++ frontend/src/topics/schema/content.ts | 253 ++++++++++ .../src/topics/schema/integration.test.ts | 23 + frontend/src/topics/schema/lab.test.ts | 11 + frontend/src/topics/schema/lab.tsx | 183 +++++++ frontend/src/topics/schema/lesson.tsx | 38 ++ frontend/src/topics/schema/simulator.test.ts | 62 +++ frontend/src/topics/schema/simulator.ts | 163 ++++++ frontend/src/topics/schema/styles.css | 89 ++++ frontend/src/topics/sql/integration.test.ts | 20 +- frontend/src/topics/sql/lab.tsx | 3 +- frontend/src/topics/sql/simulator.ts | 4 +- frontend/src/topics/unit/content.test.ts | 34 ++ frontend/src/topics/unit/content.ts | 245 +++++++++ frontend/src/topics/unit/integration.test.ts | 23 + frontend/src/topics/unit/lab.test.ts | 11 + frontend/src/topics/unit/lab.tsx | 183 +++++++ frontend/src/topics/unit/lesson.tsx | 46 ++ frontend/src/topics/unit/simulator.test.ts | 68 +++ frontend/src/topics/unit/simulator.ts | 190 +++++++ frontend/src/topics/unit/styles.css | 79 +++ frontend/src/types.ts | 7 +- shared/curriculum.json | 32 +- 139 files changed, 13584 insertions(+), 226 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/cicd-acceptance.md create mode 100644 docs/deploy-acceptance.md create mode 100644 docs/docker-acceptance.md create mode 100644 docs/integration-acceptance.md create mode 100644 docs/logs-acceptance.md create mode 100644 docs/release-audit.md create mode 100644 frontend/src/curriculum.test.ts create mode 100644 frontend/src/curriculum.ts create mode 100644 frontend/src/testing/progress.ts create mode 100644 frontend/src/topics/cicd/content.test.ts create mode 100644 frontend/src/topics/cicd/content.ts create mode 100644 frontend/src/topics/cicd/integration.test.ts create mode 100644 frontend/src/topics/cicd/lab.test.ts create mode 100644 frontend/src/topics/cicd/lab.tsx create mode 100644 frontend/src/topics/cicd/lesson.tsx create mode 100644 frontend/src/topics/cicd/simulator.test.ts create mode 100644 frontend/src/topics/cicd/simulator.ts create mode 100644 frontend/src/topics/cicd/styles.css create mode 100644 frontend/src/topics/deploy/content.test.ts create mode 100644 frontend/src/topics/deploy/content.ts create mode 100644 frontend/src/topics/deploy/integration.test.ts create mode 100644 frontend/src/topics/deploy/lab.test.ts create mode 100644 frontend/src/topics/deploy/lab.tsx create mode 100644 frontend/src/topics/deploy/lesson.tsx create mode 100644 frontend/src/topics/deploy/simulator.test.ts create mode 100644 frontend/src/topics/deploy/simulator.ts create mode 100644 frontend/src/topics/deploy/styles.css create mode 100644 frontend/src/topics/docker/content.test.ts create mode 100644 frontend/src/topics/docker/content.ts create mode 100644 frontend/src/topics/docker/integration.test.ts create mode 100644 frontend/src/topics/docker/lab.test.ts create mode 100644 frontend/src/topics/docker/lab.tsx create mode 100644 frontend/src/topics/docker/lesson.tsx create mode 100644 frontend/src/topics/docker/simulator.test.ts create mode 100644 frontend/src/topics/docker/simulator.ts create mode 100644 frontend/src/topics/docker/styles.css create mode 100644 frontend/src/topics/index/content.test.ts create mode 100644 frontend/src/topics/index/content.ts create mode 100644 frontend/src/topics/index/integration.test.ts create mode 100644 frontend/src/topics/index/lab.test.ts create mode 100644 frontend/src/topics/index/lab.tsx create mode 100644 frontend/src/topics/index/lesson.tsx create mode 100644 frontend/src/topics/index/simulator.test.ts create mode 100644 frontend/src/topics/index/simulator.ts create mode 100644 frontend/src/topics/index/styles.css create mode 100644 frontend/src/topics/integration/content.test.ts create mode 100644 frontend/src/topics/integration/content.ts create mode 100644 frontend/src/topics/integration/integration.test.ts create mode 100644 frontend/src/topics/integration/lab.test.ts create mode 100644 frontend/src/topics/integration/lab.tsx create mode 100644 frontend/src/topics/integration/lesson.tsx create mode 100644 frontend/src/topics/integration/simulator.test.ts create mode 100644 frontend/src/topics/integration/simulator.ts create mode 100644 frontend/src/topics/logs/content.test.ts create mode 100644 frontend/src/topics/logs/content.ts create mode 100644 frontend/src/topics/logs/integration.test.ts create mode 100644 frontend/src/topics/logs/lab.test.ts create mode 100644 frontend/src/topics/logs/lab.tsx create mode 100644 frontend/src/topics/logs/lesson.tsx create mode 100644 frontend/src/topics/logs/simulator.test.ts create mode 100644 frontend/src/topics/logs/simulator.ts create mode 100644 frontend/src/topics/logs/styles.css create mode 100644 frontend/src/topics/postgresql/content.test.ts create mode 100644 frontend/src/topics/postgresql/content.ts create mode 100644 frontend/src/topics/postgresql/integration.test.ts create mode 100644 frontend/src/topics/postgresql/lab.test.ts create mode 100644 frontend/src/topics/postgresql/lab.tsx create mode 100644 frontend/src/topics/postgresql/lesson.tsx create mode 100644 frontend/src/topics/postgresql/simulator.test.ts create mode 100644 frontend/src/topics/postgresql/simulator.ts create mode 100644 frontend/src/topics/postgresql/styles.css create mode 100644 frontend/src/topics/problem-solving/content.test.ts create mode 100644 frontend/src/topics/problem-solving/content.ts create mode 100644 frontend/src/topics/problem-solving/integration.test.ts create mode 100644 frontend/src/topics/problem-solving/lab.test.ts create mode 100644 frontend/src/topics/problem-solving/lab.tsx create mode 100644 frontend/src/topics/problem-solving/lesson.tsx create mode 100644 frontend/src/topics/problem-solving/simulator.test.ts create mode 100644 frontend/src/topics/problem-solving/simulator.ts create mode 100644 frontend/src/topics/problem-solving/styles.css create mode 100644 frontend/src/topics/schema/content.test.ts create mode 100644 frontend/src/topics/schema/content.ts create mode 100644 frontend/src/topics/schema/integration.test.ts create mode 100644 frontend/src/topics/schema/lab.test.ts create mode 100644 frontend/src/topics/schema/lab.tsx create mode 100644 frontend/src/topics/schema/lesson.tsx create mode 100644 frontend/src/topics/schema/simulator.test.ts create mode 100644 frontend/src/topics/schema/simulator.ts create mode 100644 frontend/src/topics/schema/styles.css create mode 100644 frontend/src/topics/unit/content.test.ts create mode 100644 frontend/src/topics/unit/content.ts create mode 100644 frontend/src/topics/unit/integration.test.ts create mode 100644 frontend/src/topics/unit/lab.test.ts create mode 100644 frontend/src/topics/unit/lab.tsx create mode 100644 frontend/src/topics/unit/lesson.tsx create mode 100644 frontend/src/topics/unit/simulator.test.ts create mode 100644 frontend/src/topics/unit/simulator.ts create mode 100644 frontend/src/topics/unit/styles.css diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6526c70 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Repository Agent Rules + +## Task branch naming + +- All task branches must use the `feature/` format. +- Curriculum task examples: `feature/integration-04` and `feature/logs-01`. +- Do not use the `codex/` prefix for task branches unless the user explicitly requests it. +- Before starting implementation, verify the active branch with `git branch --show-current`. diff --git a/README.md b/README.md index 9d52c15..ebaddd6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 目前部署來源:`frobel0520/software-engineering-workshop`。 -目前可操作的 Core 主題有 9 / 19 個:**Git**、**GitHub/GitLab 遠端協作**、**命令列**、**IDE/除錯器**、**套件管理**、**環境變數**、**建置工具**、**REST API/FastAPI**、**身分驗證/授權**;另有 1 個不計入 Core 進度的 Extension:**Guardrails**。其餘 Core 主題保留在課程路線圖。 +目前可操作的 Core 主題有 19 / 19 個:**Git**、**GitHub/GitLab 遠端協作**、**命令列**、**IDE/除錯器**、**套件管理**、**環境變數**、**建置工具**、**REST API/FastAPI**、**身分驗證/授權**、**SQL**、**資料庫設計**、**索引與交易**、**PostgreSQL**、**單元測試**、**整合測試**、**日誌**、**Docker 基礎**、**CI/CD**、**部署**;另有 2 個不計入 Core 進度的 Extension:**Guardrails**、**問題處理方法**。Core 19 個主題全部開放。 ## Git 單元 @@ -29,7 +29,13 @@ - 環境變數:練習 `.env.example`、`.env.local`、Vite 公開邊界、fail-fast 驗證與 git 保護。 - 建置工具:練習 TypeScript gate、Vite production bundle、GitHub Pages base path、dist artifact 與 preview。 - REST API/FastAPI:逐行追蹤 React fetch、routing、validation、dependency、SQLModel/SQLite 與 JSON response。 +- 單元測試:用 unit boundary、Arrange/Act/Assert、red/green、edge case 與 regression suite 建立快速回饋。 +- 整合測試:用 module contract、deterministic fixture、success/failure scenario 與 boundary evidence 驗證模組協作。 +- 日誌:用結構化事件、severity、correlationId、safe context 與 redaction 留下可追蹤線索。 +- CI/CD:用固定 workflow、trigger/ref、ordered gates、failure boundary、required check 與 merge gate 重跑交付檢查。 +- 部署:用 main release、frontend/dist、GitHub Pages、live probe、release record 與 rollback 驗證可觀測交付。 - Guardrails Extension:模擬輸入、輸出與工具呼叫的安全防線,不連線真實模型。 +- 問題處理 Extension:從問題定義、重現、蒐證、假設、錯誤邊界到驗證與預防復發。 ## 本機啟動 diff --git a/docs/accessibility-checklist.md b/docs/accessibility-checklist.md index 7404ca0..a099d5e 100644 --- a/docs/accessibility-checklist.md +++ b/docs/accessibility-checklist.md @@ -25,6 +25,14 @@ - `styles.css` 已確認含 `max-width: 720px` responsive rules 與 `prefers-reduced-motion` rules。 - 手動驗收已通過:使用實體鍵盤確認 `Tab`/`Enter`/`Space` 可操作 Lesson/Lab controls,terminal submit、reset、錯誤回饋與返回課程地圖流程可完成;reduced-motion 偏好下內容與操作仍完整。`GIT-REVIEW` 已通過。 +### 2026-08-23:M6 delivery topic browser smoke + +- CI/CD Lesson → Lab route 與 Deploy Lesson → Lab route 可達;兩個 Lab 的 native buttons、command input、`aria-live` feedback、progressbar 與 reset 都存在。 +- CI/CD 已驗證 green pipeline 與 test failure;failure state 保留 `lint`/`build: not-run`、required check failed 與 merge gate blocked。 +- Deploy 已驗證 green release、artifact blocked、probe failure → rollback、release record、Pages pointer 與 reset/replay completion。 +- CI/CD、Deploy 在 390×844 viewport 下 document width 為 375,mobile menu 可開關,command input 可取得 focus;兩個 topic styles 都含 `prefers-reduced-motion` rule。 +- M6 full regression:83 test files / 265 tests、TypeScript lint、GitHub Pages base-path build 與 `git diff --check` 通過。完整 release 結論見 [`release-audit.md`](./release-audit.md)。 + ## 1. Keyboard 與焦點 - [ ] 所有互動控制項都能以 `Tab`、`Shift+Tab`、`Enter` 或 `Space` 操作。 diff --git a/docs/cicd-acceptance.md b/docs/cicd-acceptance.md new file mode 100644 index 0000000..67de100 --- /dev/null +++ b/docs/cicd-acceptance.md @@ -0,0 +1,239 @@ +# CICD-01:CI/CD 基礎驗收規格 + +> 狀態:Accepted for implementation +> 日期:2026-08-23 +> Task:CICD-01 +> 上位契約:`docs/project-sa.md`、`docs/project-sd.md` + +本文件鎖定 CI/CD 主題的 learning boundary、workflow fixture、trigger/ref、job step、required check、merge gate、失敗回饋與完成邊界。後續 task 可以調整 TypeScript type 或視覺分段名稱,但不可改變本文件定義的 observable semantics。 + +## 1. 學習目標 + +完成本主題後,學習者應能: + +1. 分辨 CI 的自動檢查與 CD 的可重複交付流程,不把「有 workflow」當成「所有步驟都成功」。 +2. 讀懂 workflow trigger,知道 push、pull request 與手動 dispatch 會在什麼 ref 上啟動哪些 fixture job。 +3. 理解 checkout、Node setup/cache、install、test、lint、build 的順序與 gate 責任。 +4. 以 required check 判斷 pull request 是否可以進入 merge,而不是只看某個單獨 step 的綠色結果。 +5. 分辨 test failure 與 build failure 的 evidence,知道下游 step 在 job failure 後不應被偽造為已執行。 +6. 用固定的 success/failure scenario 重跑 pipeline,理解 reset、retry 與 deterministic feedback 的關係;本 Lab 不連接真實 GitHub Actions。 + +## 2. 教學邊界 + +本主題只呈現 deterministic workflow fixture,不啟動 GitHub Actions runner,不呼叫 GitHub API,不建立真實 pull request,不推送 branch,不讀取 repository secrets,也不執行網路安裝。command、job status、check result 與 merge status 都是可測試的教學資料。 + +fixture 以目前 repository 的 `.github/workflows/ci.yml` 為輸入快照:pull request 目標為 `dev` 或 `main` 時執行 frontend job;push 與 workflow dispatch 也可啟動同一個檢查 job。教學會顯示 workflow evidence,但不把目前遠端 CI 的即時狀態當成 Lab 結果。 + +本主題只教 CI gate 與 CD pipeline 的可觀察順序。GitHub Pages 的正式 publish、release branch、rollback 與部署後觀測留給 `DEPLOY-01`,不在本 contract 偷換範圍。 + +## 3. Lesson/Lab Pageflow + +```text +CI/CD Lesson + → CI、CD 與 pipeline gate 的責任 + → 讀懂 workflow trigger 與 branch ref + → checkout、Node setup、cache 與 npm ci + → test → lint → build 的 fail-fast 順序 + → required check 與 pull request merge gate + → CICD Lab + → 選擇固定 trigger/failure scenario + → inspect workflow + → checkout source + → install dependencies + → run test + → run lint + → run build + → publish required check + → 判斷 mergeable/blocked + → 完成 success、test failure、build failure 三個 scenarios + → reset 後重跑 green pipeline regression + → 標記 CI/CD topic complete +``` + +## 4. Lesson outline + +| Section | 要回答的問題 | 必須留下的判斷線索 | +| --- | --- | --- | +| CI/CD boundary | CI 與 CD 各自保證什麼? | CI 驗證變更可合併;CD 將已驗證版本交給可追蹤的交付流程,不等於每次都直接 production deploy。 | +| Trigger/ref | 為什麼同一份 workflow 需要知道 event 與 branch? | `pull_request` 的 base ref、`push` 的 branch ref 與 `workflow_dispatch` 都是 pipeline input,不能混為單一目前分支。 | +| Checkout/setup | runner 怎樣取得一致的輸入? | checkout source、Node 22、npm cache 與 `frontend/package-lock.json` 共同固定安裝 context。 | +| Install | `npm ci` 為什麼不是可省略的暖身步驟? | lockfile 驅動可重現依賴;install failure 應在 test 前停止,不能用上一輪 node_modules 假裝成功。 | +| Test/lint/build | 三個 gate 的順序如何解讀? | test 先驗證行為,lint 驗證 TypeScript,build 驗證 production artifact;前一步失敗時下游維持 not-run。 | +| Required check | 綠色 job 為什麼會影響 merge? | `frontend` required check 必須 passed;failure 會讓 merge gate blocked,即使 branch 本身沒有 conflict。 | +| Retry/feedback | 失敗後怎樣安全重跑? | 保留失敗 boundary、reset fixture、修正輸入後重跑;不可累加舊 job output 或把 failure 改寫成 success。 | + +## 5. Required scenarios + +| Scenario | Fixture input | Fixture outcome | 教學重點 | +| --- | --- | --- | --- | +| `pull-request-green` | event `pull_request`、base `dev`、test/lint/build 全部 pass | `frontend` required check passed,merge gate `mergeable` | 完整 CI gate 通過才表示 pull request 可以進入 merge。 | +| `pull-request-test-failure` | event `pull_request`、base `dev`、test fixture failed | test `failed`;lint/build `not-run`;required check failed;merge gate blocked | 不把下游未執行的 steps 畫成綠色,先保留第一個 failure boundary。 | +| `pull-request-build-failure` | event `pull_request`、base `dev`、test/lint pass、build fixture failed | build `failed`;required check failed;merge gate blocked | test 與 lint 綠色不能掩蓋 production build failure。 | + +每個 scenario 都必須讓學習者看見:trigger event、target ref、workflow step、job status、每個 step 的 passed/failed/not-run、required check 與 merge gate;失敗時要看見下游沒有產生的結果。 + +## 6. Deterministic workflow fixture + +### 6.1 Workflow inputs + +```ts +const cicdFixture = { + workflowPath: ".github/workflows/ci.yml", + workflowName: "CI", + jobId: "frontend", + requiredCheck: "frontend", + nodeVersion: "22", + cacheDependencyPath: "frontend/package-lock.json", + workingDirectory: "frontend", + targetRefs: ["dev", "main"], +} as const; +``` + +固定 workflow evidence 如下;Lab 不執行它,只把每個設定當成可觀察 fixture: + +```yaml +on: + push: + pull_request: + branches: [dev, main] + workflow_dispatch: + +jobs: + frontend: + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + working-directory: frontend + - run: npm test + working-directory: frontend + - run: npm run lint + working-directory: frontend + - run: npm run build + working-directory: frontend +``` + +### 6.2 Observable pipeline stages + +建議 simulator stage 順序為: + +```text +inspect-workflow + → select-trigger + → checkout-source + → install-dependencies + → run-test + → run-lint + → run-build + → publish-required-check + → evaluate-merge-gate +``` + +固定 command/evidence contract: + +| Stage | Command/input | Success evidence | Failure/not-run evidence | +| --- | --- | --- | --- | +| inspect workflow | `cat .github/workflows/ci.yml` | workflow `CI`、job `frontend`、required check `frontend` | 未檢查 workflow 時不能選擇 pipeline input | +| select trigger | `pull_request → dev` | event、base ref、target branch 可見 | 未支援的 event 或 ref 要被阻擋 | +| checkout source | `actions/checkout@v4` | source ref `fixture/feature` 已取得 | checkout 未完成時 install 為 not-run | +| install dependencies | `npm ci` in `frontend` | lockfile、Node 22、cache key 可見 | install failure 時 test/lint/build 都是 not-run | +| run test | `npm test` | test status `passed` | test failure 停在行為 gate,下游維持 not-run | +| run lint | `npm run lint` | TypeScript lint status `passed` | lint failure 停在型別 gate,build 維持 not-run | +| run build | `npm run build` | production build status `passed`、artifact `dist/` | build failure 保留 artifact `missing`,required check failed | +| publish check | `frontend` | required check `passed` | 任一 gate failure → required check `failed` | +| merge gate | `evaluate merge gate` | `mergeable` | required check failure → `blocked` | + +Fixture rules: + +- 相同 initial state 加上相同 event sequence,必須得到相同 trigger、ref、step status、check result、merge status 與 feedback。 +- `pull-request-test-failure` 只能改變 test fixture outcome;不可同時改變 lint/build 的輸入或偽造下游 output。 +- `pull-request-build-failure` 只能改變 build fixture outcome;test/lint 必須保留 passed,build artifact 必須保持 missing。 +- `npm ci`、`npm test`、`npm run lint`、`npm run build` 都是固定文字證據,不可執行使用者電腦上的 shell、Node、網路或 GitHub runner。 +- cache 只表達可重複安裝的 metadata,不產生跨 scenario 的隱藏 state;reset 後 cache evidence 應回到 fixture 起點。 + +## 7. Lab state boundary + +後續 `CICD-03` simulator 應能表達下列概念;欄位名稱可微調,但不可移除其可觀察語意: + +```text +CicdLabState { + phase: initial | inspecting | running | blocked | completed + selectedScenarioId: pull-request-green | pull-request-test-failure | pull-request-build-failure | null + activeStageId: inspect-workflow | select-trigger | checkout-source | install-dependencies + | run-test | run-lint | run-build | publish-required-check + | evaluate-merge-gate | null + completedStageIds: readonly string[] + triggerEvent: pull_request | push | workflow_dispatch | null + targetRef: dev | main | null + workflowState: unknown | inspected + installState: not-run | passed | failed + testState: not-run | passed | failed + lintState: not-run | passed | failed + buildState: not-run | passed | failed + artifactState: missing | created + requiredCheck: pending | passed | failed + mergeGate: pending | mergeable | blocked + completedScenarioIds: readonly string[] + regressionVerified: boolean + lastFeedback: string + lastCommand: string | null + reset(): void +} +``` + +失敗時保留已完成 stage 與第一個 failure boundary;不可把 `not-run` 變成 `failed`,也不可在 required check failed 後顯示 `mergeable`。 + +## 8. Failure feedback contract + +- 未 inspect workflow 就選 trigger:阻擋,提示先確認 workflow、job 與 required check。 +- 沒有選擇支援的 trigger/target ref:阻擋,列出 `pull_request → dev/main`、`push` 或 `workflow_dispatch` 的 fixture boundary。 +- 未 checkout 就 install:阻擋,指出 source ref 尚未取得。 +- install failure:保留 lockfile/Node/cache evidence,test、lint、build 都是 not-run。 +- `pull-request-test-failure`:保留 test failure,lint/build 不顯示成功,required check 為 failed、merge gate 為 blocked。 +- 未通過 test 就執行 lint 或 build:阻擋,指出 pipeline 順序與目前第一個 failure。 +- `pull-request-build-failure`:保留 test/lint passed、build failed 與 artifact missing,required check 為 failed。 +- required check failed:merge gate 只能是 blocked;branch 無 conflict 也不能越過 required check。 +- 已完成 scenario 再次操作:提示先 reset,避免 job output 與 stage state 重複累加。 +- reset:清除目前 scenario 的 trigger、ref、step status、check、merge gate 與 feedback;保留已完成 scenario audit 與 regression baseline。 + +## 9. Completion contract + +只有下列條件全部成立時,CI/CD Lab 才算完成: + +- 三個 required scenarios 都完成各自的 terminal outcome。 +- `pull-request-green` 顯示完整 stage、required check passed 與 mergeable。 +- `pull-request-test-failure` 顯示 test failed、lint/build not-run、required check failed 與 merge gate blocked。 +- `pull-request-build-failure` 顯示 test/lint passed、build failed、artifact missing、required check failed 與 merge gate blocked。 +- reset 後重跑 green pipeline,trigger、ref、stage status、check、merge gate 與 feedback 與第一次一致。 +- 完成後使用 `se-workshop-cicd-complete` 保存進度。 +- 本 task 只定義 contract;在 `CICD-05` 前不得把 `cicd` 改成 curriculum `ready`。 + +單獨看見 workflow 存在、test passed、required check passed 或 branch 沒有 conflict,都不得單獨標記 CI/CD topic 完成。 + +## 10. Out of scope + +- 真實 GitHub Actions runner、GitHub API、pull request、branch push、merge、review、secrets、OIDC token 或第三方 action execution。 +- 真實 npm registry、網路安裝、Node process、shell command、cache directory 或使用者 repository state。 +- GitHub Pages publish、release branch、rollback、production deploy、domain/DNS、post-deploy monitoring 與 incident response;這些屬於 `DEPLOY-01`。 +- Docker/Compose、Kubernetes、artifact registry、image signing、SBOM 與 vulnerability scanner;這些不屬於 CI/CD 基礎 workflow fixture。 +- 修改既有 `.github/workflows/ci.yml`、`.github/workflows/deploy-pages.yml`、branch protection 或共用 progress/route harness。 + +## 11. CICD-01 驗收 + +- 文件明確描述 CI/CD boundary、trigger/ref、workflow fixture、九個 observable stages、三個 scenarios、failure feedback、completion 與 out-of-scope。 +- `CICD-02` 可依本文件撰寫 lesson 與 workflow fixture,不需要重新決定 job step、required check 或 failure semantics。 +- `CICD-03` 可依本文件建立純 simulator;不需要真實 GitHub Actions、runner、network、secret 或 shell。 +- `CICD-04` 可依本文件設計 Lab 的 trigger selector、step evidence、required check、merge gate、reset、keyboard、mobile 與 reduced-motion interaction。 +- `CICD-05` 可使用固定 completion key、route 與 progress contract 接入,且不改變既有 Docker 或其他 topic 的完成統計。 +- 文件檢查與 `git diff --check` 通過;本 task 不要求重新執行完整 frontend test suite。 + +## 12. 已知 rework 風險 + +- 若未來要教 matrix build、parallel jobs、approval environment 或 reusable workflow,應新增 acceptance contract,不偷換目前的單一 frontend required-check boundary。 +- 目前 cache 只作為可觀察 metadata;若未來產品要求量測 cache hit/miss 或成本,新增 fixture 欄位,不把真實 runner cache 帶入 Phase 1。 +- 若 workflow 的 Node version、working directory 或 required check 名稱改變,應更新 fixture snapshot 與 integration contract;不可只更新畫面文字。 +- 若 Deploy topic 要展示 Pages publish 的正式 pipeline,應透過 `DEPLOY-01` contract 定義 release/artifact/rollback boundary,不在本 topic 擴張。 diff --git a/docs/deploy-acceptance.md b/docs/deploy-acceptance.md new file mode 100644 index 0000000..5fc6b64 --- /dev/null +++ b/docs/deploy-acceptance.md @@ -0,0 +1,241 @@ +# DEPLOY-01:部署、版本與回滾驗收規格 + +> 狀態:Accepted for implementation +> 日期:2026-08-23 +> Task:DEPLOY-01 +> 上位契約:`docs/project-sa.md`、`docs/project-sd.md` + +本文件鎖定部署主題的 release source、CI artifact、GitHub Pages publish、版本紀錄、回滾、部署後觀測與 deterministic feedback。後續 task 可以調整 TypeScript type 或視覺分段名稱,但不可改變本文件定義的 observable semantics。 + +## 1. 學習目標 + +完成本主題後,學習者應能: + +1. 分辨 production artifact、Pages publish 與部署後可觀測狀態,不把「build 成功」當成「網站已上線」。 +2. 讀懂部署 workflow 的 `main` trigger、`workflow_dispatch`、`frontend/dist` 與 `gh-pages` publish branch。 +3. 理解 release source、版本識別、Pages base path 與 artifact provenance 的關係。 +4. 在 artifact 缺失或部署驗證失敗時,保留 blocked/failed evidence,不把失敗版本誤標成 live。 +5. 以固定 release scenario 練習成功發布、artifact 缺失與 rollback 到上一個可用版本。 +6. 用部署狀態、live probe、release record 與 rollback evidence 判斷一次交付是否真的完成。 + +## 2. 教學邊界 + +本主題只呈現 deterministic deployment fixture,不啟動 GitHub Actions runner,不呼叫 GitHub API,不推送 `main` 或 `gh-pages`,不讀取 secrets,不連線真實 GitHub Pages,也不執行網路安裝。workflow、artifact、branch、URL、release version 與 probe result 都是可測試的教學資料。 + +fixture 以目前 repository 的 `.github/workflows/deploy-pages.yml` 為輸入快照:`main` push 或手動 dispatch 會在 `frontend` 安裝依賴、執行 test/build,將 `frontend/dist` 發布到 `gh-pages`。教學會顯示 workflow evidence,但不把遠端 Pages 即時狀態當成 Lab 結果。 + +本主題不重新定義 CI 的 test/lint/build gate;它只接收一個固定的 CI passed/artifact state,專注 release、publish、verify、record 與 rollback。真實 domain、DNS、CDN、帳號權限與維運告警留給後續產品決策。 + +## 3. Lesson/Lab Pageflow + +```text +Deploy Lesson + → release source、artifact 與 live site 的責任 + → 讀懂 Pages workflow、main trigger 與 gh-pages branch + → 驗證 CI result、dist artifact 與 Pages base path + → publish、live probe 與 release record + → deployment failure 與上一個版本的 rollback + → Deploy Lab + → 選擇固定 release scenario + → inspect deploy workflow + → select main release + → verify CI artifact + → verify Pages base path + → publish gh-pages + → verify deployment + → record release + → evaluate release/rollback + → 完成 success、missing artifact、rollback 三個 scenarios + → reset 後重跑 green release regression + → 標記 Deploy topic complete +``` + +## 4. Lesson outline + +| Section | 要回答的問題 | 必須留下的判斷線索 | +| --- | --- | --- | +| Release boundary | build、publish、live verify 各保證什麼? | build 產生 artifact;publish 更新部署來源;probe 才能證明指定版本可觀測。 | +| Workflow input | 部署為什麼只接受 release source? | `main` push 或手動 dispatch 是 fixture input;學習 Lab 不把任意本機 branch 當 production source。 | +| Artifact provenance | `dist` 為什麼要和 base path 一起驗證? | artifact 必須存在、來自通過 CI 的 source,且 Pages base path 與 repository path 一致。 | +| Publish boundary | gh-pages 更新代表什麼? | publish 只表示部署分支被更新;仍需 live probe 與 release record,不可直接宣稱使用者可用。 | +| Rollback | 失敗版本怎樣退回? | 保留失敗 release、指向上一個 verified version,記錄 rollback reason;不改寫失敗歷史。 | +| Observe and record | 怎樣知道交付完成? | deployment status、live URL、probe、release version 與 branch pointer 共同形成 evidence。 | + +## 5. Required scenarios + +| Scenario | Fixture input | Fixture outcome | 教學重點 | +| --- | --- | --- | --- | +| `main-pages-success` | source `main`、CI passed、`dist` verified、Pages base path verified | `gh-pages` updated to `release-2026.08.23`;live probe 200;release `verified` | artifact、publish 與 live verification 必須全部完成,才是成功部署。 | +| `missing-artifact-blocked` | source `main`、CI passed、`dist` missing | publish blocked;`gh-pages` 保持上一個 verified version;release `blocked` | build/artifact 缺失時不可更新 Pages,也不可假裝 live。 | +| `rollback-after-probe-failure` | source `main`、new artifact verified、publish succeeded、live probe failed | release `release-2026.08.23` failed;rollback 到 `release-2026.08.16`;live probe 200 | rollback 指向上一個可用版本,保留失敗版本與原因。 | + +每個 scenario 都必須讓學習者看見:release source、version、CI result、artifact、base path、Pages branch pointer、publish result、deployment status、live URL/probe、release record 與 rollback evidence;失敗時要看見未更新或已回復的邊界。 + +## 6. Deterministic deployment fixture + +### 6.1 Workflow inputs + +```ts +const deployFixture = { + workflowPath: ".github/workflows/deploy-pages.yml", + workflowName: "Publish workshop to gh-pages", + releaseSource: "main", + manualDispatch: true, + artifactPath: "frontend/dist", + pagesBranch: "gh-pages", + repositoryBasePath: "/software-engineering-workshop/", + currentVerifiedRelease: "release-2026.08.16", + candidateRelease: "release-2026.08.23", +} as const; +``` + +固定 workflow evidence 如下;Lab 不執行它,只把每個設定當成可觀察 fixture: + +```yaml +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + publish: + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + working-directory: frontend + - run: npm test && npm run build + working-directory: frontend + env: + VITE_BASE: /${{ github.event.repository.name }}/ + - uses: peaceiris/actions-gh-pages@v4 + with: + publish_dir: ./frontend/dist + publish_branch: gh-pages +``` + +### 6.2 Observable deployment stages + +建議 simulator stage 順序為: + +```text +inspect-workflow + → select-release + → verify-ci-artifact + → verify-pages-base + → publish-pages + → verify-deployment + → record-release + → evaluate-release +``` + +固定 command/evidence contract: + +| Stage | Command/input | Success evidence | Failure/not-run evidence | +| --- | --- | --- | --- | +| inspect workflow | `cat .github/workflows/deploy-pages.yml` | main trigger、workflow_dispatch、dist、gh-pages 可見 | 未 inspect 時不可選擇 release source | +| select release | `main → release-2026.08.23` | source、candidate version 與 previous verified version 可見 | 非 main source 應被阻擋 | +| verify CI artifact | `artifact: frontend/dist` | CI passed、dist exists、artifact provenance 可見 | artifact missing 時 publish 不可更新 gh-pages | +| verify Pages base | `VITE_BASE=/software-engineering-workshop/` | base path 與 repository path 一致 | base path mismatch 時 deployment blocked | +| publish Pages | `publish → gh-pages` | gh-pages pointer 指向 candidate version | artifact/base path 不完整時 branch 保持 previous version | +| verify deployment | `probe /software-engineering-workshop/` | live status 200、candidate version 可觀測 | probe failure 要保留 failed version,不宣稱 live | +| record release | `record release` | version、source、artifact、URL、status 完整 | 沒有 deployment evidence 時不可寫 verified record | +| evaluate release | `evaluate release / rollback` | verified 或 rolled-back 結論可見 | failed release 沒有 rollback evidence 時維持 blocked | + +Fixture rules: + +- 相同 initial state 加上相同 event sequence,必須得到相同 source、version、artifact、Pages pointer、deployment status、probe、release record 與 feedback。 +- `missing-artifact-blocked` 只能改變 artifact outcome;不可同時改變 source、base path 或上一個 verified version。 +- `rollback-after-probe-failure` 只能讓 candidate probe failed,並要求 rollback 指向 `currentVerifiedRelease`;不可刪除 failed release record。 +- workflow、release command、URL、version 與 probe 都是固定文字/數值證據,不可執行 shell、網路、GitHub Actions 或 Pages API。 +- reset 清除目前 candidate 的 publish/probe/record state,但保留 completed scenario audit 與 regression baseline。 + +## 7. Lab state boundary + +後續 `DEPLOY-03` simulator 應能表達下列概念;欄位名稱可微調,但不可移除其可觀察語意: + +```text +DeployLabState { + phase: initial | inspecting | releasing | blocked | completed + selectedScenarioId: main-pages-success | missing-artifact-blocked | rollback-after-probe-failure | null + activeStageId: inspect-workflow | select-release | verify-ci-artifact | verify-pages-base + | publish-pages | verify-deployment | record-release | evaluate-release | null + completedStageIds: readonly string[] + releaseSource: main | null + candidateVersion: string | null + previousVerifiedVersion: string + workflowState: unknown | inspected + ciState: pending | passed | failed + artifactState: missing | verified + basePathState: unknown | verified | mismatch + publishState: pending | published | blocked + pagesBranchVersion: string + deploymentState: pending | live | failed | rolled-back + liveStatus: number | null + liveUrl: string | null + releaseRecord: none | verified | failed | rolled-back + rollbackVersion: string | null + completedScenarioIds: readonly string[] + regressionVerified: boolean + lastFeedback: string + lastCommand: string | null + reset(): void +} +``` + +失敗時保留已完成 stage 與第一個 failure boundary;不可把 artifact `missing` 變成 verified、把 `gh-pages` 指標移到未驗證版本,或在 probe failed 後直接顯示 live。 + +## 8. Failure feedback contract + +- 未 inspect workflow 就選 release:阻擋,提示先確認 main trigger、artifact path 與 gh-pages branch。 +- release source 不是 main:阻擋,指出 production fixture 只接受 `main` 或手動 dispatch 的 main release。 +- CI 尚未 passed:阻擋,指出 Deploy 只接收 CI gate 結果,不重新偽造 test/build。 +- artifact missing:保留 CI evidence,publish 與後續 live state 不得顯示成功,Pages pointer 保持 previous verified version。 +- Pages base path mismatch:阻擋 publish,指出 repository path 與 Vite base path 必須一致。 +- `rollback-after-probe-failure`:保留 candidate publish 與 probe failure,rollback 後才可將 Pages pointer 與 live status 指向 previous verified version。 +- 沒有 release record 就 evaluate:阻擋,指出 version、source、artifact、URL 與 status 必須先被記錄。 +- failed release 沒有 rollback:release 維持 blocked,不能宣稱 deployment complete。 +- 已完成 scenario 再次操作:提示先 reset,避免 release output 與 branch pointer 重複累加。 +- reset:清除目前 candidate 的 source、artifact、publish、probe、record 與 rollback state;保留 completed scenario audit 與 regression baseline。 + +## 9. Completion contract + +只有下列條件全部成立時,Deploy Lab 才算完成: + +- 三個 required scenarios 都完成各自的 terminal outcome。 +- `main-pages-success` 顯示 candidate artifact、gh-pages publish、live status 200 與 verified release record。 +- `missing-artifact-blocked` 顯示 artifact missing、publish blocked、Pages pointer 保持 previous verified version 與 blocked record。 +- `rollback-after-probe-failure` 顯示 candidate probe failed、failed release record、rollback version、Pages pointer 回到 previous verified version 與 rolled-back record。 +- reset 後重跑 green release,source、version、artifact、Pages pointer、probe、record 與 feedback 與第一次一致。 +- 完成後使用 `se-workshop-deploy-complete` 保存進度。 +- 本 task 只定義 contract;在 `DEPLOY-05` 前不得把 `deploy` 改成 curriculum `ready`。 + +單獨看見 `dist` 存在、gh-pages branch 更新、live URL 可開啟或 release record 存在,都不得單獨標記 Deploy topic 完成。 + +## 10. Out of scope + +- 真實 GitHub Actions runner、GitHub API、GitHub Pages、branch push、release PR、review、secret、OIDC token 或第三方 action execution。 +- 真實網路、DNS、CDN、domain、TLS、hosting quota、production traffic、monitoring provider 或 incident notification。 +- 真實 npm registry、Node process、shell command、cache directory、Git history 或使用者 repository state。 +- Docker、Kubernetes、artifact registry、container rollout、database migration 與 infrastructure-as-code;這些不屬於 Pages 基礎部署 fixture。 +- 修改既有 `.github/workflows/deploy-pages.yml`、`.github/workflows/ci.yml`、branch protection 或共用 progress/route harness。 + +## 11. DEPLOY-01 驗收 + +- 文件明確描述 release/artifact/Pages/live probe/rollback boundary、workflow fixture、八個 observable stages、三個 scenarios、failure feedback、completion 與 out-of-scope。 +- `DEPLOY-02` 可依本文件撰寫 lesson 與 workflow/artifact fixture,不需要重新決定 release source、version 或 Pages semantics。 +- `DEPLOY-03` 可依本文件建立純 simulator;不需要真實 GitHub Pages、network、secret、shell 或 runner。 +- `DEPLOY-04` 可依本文件設計 Lab 的 release selector、stage evidence、live probe、rollback、reset、keyboard、mobile 與 reduced-motion interaction。 +- `DEPLOY-05` 可使用固定 completion key、route 與 progress contract 接入,且不改變既有 Docker、CI/CD 或其他 topic 的完成統計。 +- 文件檢查與 `git diff --check` 通過;本 task 不要求重新執行完整 frontend test suite。 + +## 12. 已知 rework 風險 + +- 若未來要教 preview environment、canary、blue/green、multi-region 或非 Pages provider,應新增 acceptance contract,不偷換目前的單一 gh-pages branch boundary。 +- 目前 live probe 只作為固定 status/URL evidence;若未來產品要求真實 uptime、RUM 或 alerting,需另立 provider 與隱私契約。 +- 若 workflow 的 artifact path、base path、release branch 或 publish action 改變,應更新 fixture snapshot 與 integration contract;不可只更新畫面文字。 +- 若 rollback 需要資料庫 migration、schema compatibility 或外部資源協調,應新增跨系統 release contract,不把真實 rollback side effect 帶入 Phase 1。 diff --git a/docs/docker-acceptance.md b/docs/docker-acceptance.md new file mode 100644 index 0000000..b0a7387 --- /dev/null +++ b/docs/docker-acceptance.md @@ -0,0 +1,199 @@ +# DOCKER-01:Docker 基礎驗收規格 + +> 狀態:Accepted for implementation +> 日期:2026-08-23 +> Task:DOCKER-01 +> 上位契約:`docs/project-sa.md`、`docs/project-sd.md` + +本文件鎖定 Docker 主題的學習目標、Lesson/Lab pageflow、static-site fixture、image/container/port boundary、失敗回饋與完成邊界。後續 task 可以調整 TypeScript type 或視覺分段名稱,但不可改變本文件定義的 observable semantics。 + +## 1. 學習目標 + +完成本主題後,學習者應能: + +1. 分辨 image、container、Dockerfile 與 build context 的責任,不把 image 當成正在執行的 process。 +2. 讀懂一個最小 static-site Dockerfile,知道 `COPY`、`EXPOSE` 與 runtime command 各自在什麼 boundary 生效。 +3. 用固定 build context 建立可重現的 image,並知道 source artifact 缺少時 build 應在哪個步驟失敗。 +4. 用 `docker run -p host:container` 發布 container port,分辨 container 內服務正常與 host 端可以連線是兩個檢查。 +5. 以 `docker ps`、固定 HTTP probe 與 container state 判斷結果,不把「container 啟動」誤當成「服務可從 host 存取」。 +6. 在完成後停止並移除 fixture container,理解 local cleanup 是可重複工作流的一部分;本 Lab 不連接真實 Docker daemon。 + +## 2. 教學邊界 + +本主題只呈現 deterministic fixture,不執行使用者電腦上的 Docker,不啟動真實 container,不下載 image,不連接 registry,也不修改 host port。Docker command、image digest 與 HTTP response 都是可測試的教學資料,不代表目前環境已安裝 Docker。 + +本主題的 fixture 假設上一個 Build topic 已產出 `frontend/dist/index.html`;Lab 以固定的 `dist/` artifact snapshot 表達這個輸入,不要求學習者真的先完成另一個 topic。 + +## 3. Lesson/Lab Pageflow + +```text +Docker Lesson + → image、container、Dockerfile 與 build context 的邊界 + → 讀懂 static-site Dockerfile + → build cache 與可重現 tag + → container port 與 host port mapping + → runtime verification 與 cleanup + → Docker Lab + → 選擇 deterministic scenario + → 檢查 context 與 Dockerfile + → build image + → run container + → 驗證固定 HTTP probe 與 port mapping + → stop/remove container + → 完成 success、missing artifact、unpublished port 三個 scenarios + → reset 後重跑固定 regression flow + → 標記 Docker topic complete +``` + +## 4. Lesson outline + +| Section | 要回答的問題 | 必須留下的判斷線索 | +| --- | --- | --- | +| Responsibility | image 與 container 有什麼不同? | image 是可建立與標記的不可變輸入;container 是從 image 建立的執行個體,具有 running/stopped state。 | +| Dockerfile | Dockerfile 每一行如何影響結果? | `FROM` 提供 base、`COPY` 需要 build context 中存在的 artifact、`EXPOSE` 描述 container port、runtime command 決定服務是否啟動。 | +| Context | 為什麼 build context 不是任意整個 repository? | build 只能讀取 context 內的檔案;固定 context 與 `.dockerignore` 可縮小輸入並避免把不必要檔案帶入 build。 | +| Reproducibility | 怎樣知道 image 是哪一版? | 使用固定 image tag、固定 fixture digest 與固定 build result;不可用目前時間或 random tag 作為驗收依據。 | +| Port mapping | container 內的 `80` 為什麼不等於 host 的 `8080`? | `EXPOSE 80` 只是 metadata;`-p 8080:80` 才建立 host 到 container 的可觀察 mapping。 | +| Verification | container running 是否足以證明成功? | 必須同時看到 running state、正確 mapping 與固定 HTTP probe success。 | +| Cleanup | 為什麼要 stop/remove? | cleanup 讓相同 fixture 可重跑,避免殘留 container 或 port state 變成下一次的隱藏輸入。 | + +## 5. Required scenarios + +| Scenario | Fixture outcome | 教學重點 | +| --- | --- | --- | +| `static-site-success` | `dist/index.html` 存在;image build 成功;container 以 `8080:80` 啟動;HTTP probe 回傳固定 `200` | image、container、port mapping 與 runtime verification 必須全部成立,不能只看 build 成功。 | +| `missing-build-artifact` | Dockerfile 的 `COPY dist/ /usr/share/nginx/html/` 找不到 `dist/index.html`,build 在 context/copy boundary 失敗 | source artifact 缺失時應保留明確的 build failure,不建立成功 image,也不啟動 container。 | +| `unpublished-container-port` | image build 成功、container running,但 run 沒有 `8080:80` mapping;固定 HTTP probe 無法從 host 存取 | container 內 process running 不代表 host 可連線;應修正 run command,不偽造 HTTP success。 | + +每個 scenario 都必須讓學習者看見:目前 stage、Dockerfile/command evidence、image state、container state、port mapping、probe result,以及失敗時沒有產生的 artifact 或副作用。 + +## 6. Deterministic fixture + +### 6.1 Static-site inputs + +```ts +const dockerFixture = { + contextPath: ".", + dockerfilePath: "Dockerfile", + sourceArtifact: "dist/index.html", + imageTag: "workshop-web:1", + imageDigest: "sha256:docker-fixture-001", + containerName: "workshop-web", + containerPort: 80, + hostPort: 8080, + probePath: "/", + probeStatus: 200, +} as const; +``` + +固定 Dockerfile 顯示內容如下;Lab 不執行它,只把每一行當成可觀察的 fixture evidence: + +```dockerfile +FROM nginx:alpine +COPY dist/ /usr/share/nginx/html/ +EXPOSE 80 +``` + +固定 happy-path command sequence: + +```text +docker build -t workshop-web:1 . +docker run --name workshop-web -p 8080:80 workshop-web:1 +curl http://localhost:8080/ +docker stop workshop-web +docker rm workshop-web +``` + +Fixture rules: + +- `dist/index.html`、image tag、digest、container name、port 與 HTTP status 都是固定值;不可使用目前時間、random id、真實 host 或網路回應。 +- `docker build` 只在 `contextPath` 內尋找 `Dockerfile` 與 `sourceArtifact`;不可讀取使用者真實 repository 以外的資料。 +- `EXPOSE 80` 不會自動建立 host mapping;只有 run event 提供 `8080:80` 時,probe 才能通過。 +- `missing-build-artifact` 只能改變 source artifact presence;不得同時改變 port mapping 或 runtime command。 +- `unpublished-container-port` 只能改變 port mapping;image build 必須成功,container 必須保持 running,probe 才能顯示不可達。 +- 相同 initial state 加上相同 event sequence,必須得到相同 image、container、mapping、probe、feedback 與 completion 結果。 + +### 6.2 Observable command evidence + +每個 command event 至少要呈現 command、boundary、結果與下一步;不可只顯示顏色或 `failed`: + +| Command | Boundary | Success evidence | Failure evidence | +| --- | --- | --- | --- | +| `docker build -t workshop-web:1 .` | build context/image | fixed tag 與 digest 出現,image state 為 `built` | `COPY dist/` 找不到 source artifact,image state 保持 `absent` | +| `docker run --name workshop-web -p 8080:80 workshop-web:1` | container/port | container 為 `running`,mapping 為 `8080→80` | image 尚未 built,或 mapping 未發布;不得假裝 container 可用 | +| `curl http://localhost:8080/` | host runtime probe | status `200`、body marker `SE_WORKSHOP_HOME` | mapping 不存在時 status 為 `unreachable`,指出要檢查 host/container port | +| `docker stop workshop-web` | runtime cleanup | container 為 `stopped` | 未有 running container 時阻擋,指出先選擇正確 scenario state | +| `docker rm workshop-web` | local cleanup | container state 為 `removed` | running container 不可直接移除,指出先 stop | + +## 7. Lab state boundary + +後續 `DOCKER-03` simulator 應能表達下列概念;欄位名稱可微調,但不可移除其可觀察語意: + +```text +DockerLabState { + phase: initial | inspecting | building | running | blocked | completed + selectedScenarioId: static-site-success | missing-build-artifact | unpublished-container-port | null + activeStepId: inspect-context | build-image | run-container | verify-probe | cleanup-container | null + completedStepIds: readonly string[] + imageState: absent | built + containerState: absent | running | stopped | removed + portMapping: absent | published | mismatched + probeState: pending | success | unreachable + completedScenarioIds: readonly string[] + lastFeedback: none | success | blocked + lastMessage: string + canReset: true +} +``` + +建議的 observable stage 順序為 `inspect-context → build-image → run-container → verify-probe → cleanup-container`。視覺上可以合併 command card,但必須保留 build boundary、container state、port mapping、probe result 與 cleanup evidence。 + +## 8. Failure feedback contract + +- 未選擇 scenario 就執行 inspect:阻擋,提示先選擇一個 deterministic fixture。 +- 未檢查 context/Dockerfile 就 build:阻擋,指出尚未確認的 source artifact 與下一步。 +- 尚未有 image 就 run:阻擋,指出 image tag 尚未建立,不能直接啟動 container。 +- `missing-build-artifact` 找不到 `dist/index.html`:保留 `COPY` boundary evidence,說明 build 未產生 image,也未啟動 container。 +- `unpublished-container-port` 的 container 雖 running 但 probe unreachable:保留 running evidence,指出 `EXPOSE` 不等於 host publish,下一步是修正 `-p 8080:80`。 +- 未完成 cleanup 就宣告 scenario 完成:阻擋,指出 container 仍為 running 或 stopped,下一步是 stop/remove。 +- 已完成的 scenario 再次操作:提示先 reset,避免重複累加 container 或 image state。 +- reset:回到固定 initial fixture,清除 selected scenario、steps、image、container、mapping、probe、feedback 與 completion state。 + +## 9. Completion contract + +只有下列條件全部成立時,Docker Lab 才算完成: + +- 三個 required scenarios 都完成各自的 terminal outcome。 +- `static-site-success` 顯示固定 image tag/digest、`running` container、`8080→80` mapping、HTTP `200` 與 cleanup complete。 +- `missing-build-artifact` 在 build context/copy boundary 停止,沒有成功 image、running container 或 probe success。 +- `unpublished-container-port` 保留 running container evidence,但 probe 為 unreachable,並完成修正後的 regression flow。 +- reset 後重跑固定 happy path,image、container、mapping、probe、feedback 與 cleanup 結果與第一次一致。 +- 完成後使用 `se-workshop-docker-complete` 保存進度。 +- 本 task 只定義 contract;在 `DOCKER-05` 前不得把 `docker` 改成 curriculum `ready`。 + +單獨看見 Dockerfile、build success、container running 或 port metadata,都不得單獨標記 topic 完成。 + +## 10. Out of scope + +- 真實 Docker daemon、host filesystem、container runtime、kernel isolation 或 local port binding。 +- Docker Hub/registry login、image push/pull、private registry、credentials、SBOM、signing、vulnerability scanner 與 production hardening。 +- Docker Compose、Kubernetes、swarm、service mesh、multi-container network 與 volume persistence。 +- 真實 nginx、HTTP server、browser request、CI runner 或 GitHub Actions container execution。 +- 以 Docker container 取代 GitHub Pages;Phase 1 Pages 仍只發布 `frontend/dist` 的 static artifact。 +- 修改既有 route registry、ProgressRepository、共用 simulator harness 或 `CORE-008` dispatcher。 + +## 11. DOCKER-01 驗收 + +- 文件明確描述 image/container/Dockerfile/context/port 的責任邊界、Lesson/Lab pageflow、三個 scenarios、deterministic fixture、failure feedback、completion 與 out-of-scope。 +- `DOCKER-02` 可依本文件撰寫教材與 Dockerfile fixture,不需要重新決定 static-site input、port mapping 或 cleanup semantics。 +- `DOCKER-03` 可依本文件建立純 simulator;不需要真實 Docker、host port、network 或 registry。 +- `DOCKER-04` 可依本文件設計 Lab 的 command evidence、reset、錯誤回饋、keyboard、mobile 與 reduced-motion interaction。 +- `DOCKER-05` 可使用固定 completion key、route 與 progress contract 接入,且不改變既有 topic 的完成統計。 +- 文件檢查與 `git diff --check` 通過;本 task 不要求重新執行完整 frontend test suite。 + +## 12. 已知 rework 風險 + +- 若未來產品要教多階段 build、Compose 或 production image hardening,應新增 acceptance contract,不偷換本主題的 single-container static-site boundary。 +- `nginx:alpine` 與固定 digest 目前是教學 fixture,不代表 production base image policy;若未來要求 supply-chain pinning,新增欄位與驗收即可,不改變 image/container/port 核心語意。 +- 若 GitHub Pages 的 artifact layout 改變,`dist/index.html` 可調整為新的固定 source artifact,但 build boundary 與 host/container port boundary 必須保留。 +- 若共用 simulator harness 需要增加 command replay 或 resource cleanup primitive,改動應由共用契約 task 處理,不把 framework change 偷塞進 DOCKER topic。 diff --git a/docs/integration-acceptance.md b/docs/integration-acceptance.md new file mode 100644 index 0000000..58dece2 --- /dev/null +++ b/docs/integration-acceptance.md @@ -0,0 +1,160 @@ +# INTEGRATION-01:整合測試驗收規格 + +> 狀態:Accepted for implementation +> 日期:2026-08-22 +> Task:INTEGRATION-01 +> 上位契約:`docs/project-sa.md`、`docs/project-sd.md` + +## 1. 學習目標 + +完成本主題後,學習者應能: + +1. 分辨 unit boundary 與整合測試 boundary;整合測試要驗證多個真實模組如何透過公開契約協作。 +2. 用一個小而完整的 fixture,追蹤輸入如何經過 client adapter、service 與 repository adapter。 +3. 說明整合測試何時保留真實模組,何時在外部服務邊界使用 deterministic fake。 +4. 從錯誤位置判斷是資料契約不一致、依賴失敗,還是測試 fixture 沒有表達必要條件。 +5. 分辨整合測試與 unit、E2E、load test 的責任,不用一種測試取代所有層級。 + +## 2. Lesson/Lab Pageflow + +```text +Integration Lesson + → unit boundary 與 integration boundary 的對照 + → 讀懂 checkout fixture 的 module contract + → Integration Lab + → 選擇 deterministic scenario + → 依序觀察 client adapter → service → repository adapter → response + → 檢查每個邊界的輸入、輸出與 failure consequence + → 完成 success、contract failure、dependency failure 三個 scenarios + → 重跑固定 regression flow + → 標記 Integration topic complete +``` + +Lab 只呈現可重現的狀態轉換,不連線真實 HTTP server、database、queue 或第三方服務。 + +## 3. 核心概念與 Lesson outline + +| Section | 要回答的問題 | 必須留下的判斷線索 | +| --- | --- | --- | +| Boundary | 哪些模組必須一起測? | `checkoutClient`、`orderService` 與 `orderRepository` 的公開 contract 是整合邊界;純計算仍可由 unit test 保護。 | +| Fixture | 測試需要準備什麼? | 小型 order input、固定 repository rows、明確的 expected response,以及可控制的 dependency outcome。 | +| Success trace | 正常協作如何完成? | input 經過 adapter、service、repository 後,回傳同一個可驗證的 `orderId`、`total` 與 `status`。 | +| Contract failure | 契約不一致如何被看見? | 缺少必要 response 欄位或型別不符時,在模組邊界失敗;測試不可自行補預設值掩蓋問題。 | +| Dependency failure | 外部依賴失敗如何傳遞? | repository 的 deterministic failure 必須成為可讀的 service error,且不得假裝訂單已建立。 | +| Regression | 為什麼要重跑整組流程? | 修正一個 adapter contract 後,success 與兩個 failure behaviors 都仍然成立。 | + +## 4. Required scenarios + +| Scenario | Fixture outcome | 教學重點 | +| --- | --- | --- | +| `create-order-success` | `checkout` input 經過三個模組後回傳 `201`、`orderId: "ord-001"`、`total: 90` | 多個真實模組以公開 contract 協作;不是只測一個函式的 return value。 | +| `response-contract-error` | response 缺少 `orderId` 時在 response boundary 失敗,結果為 `contract-error` | 不可用 `|| "unknown"` 或其他預設值掩蓋 producer/consumer 契約漂移。 | +| `repository-unavailable` | repository 回傳固定 `dependency-unavailable`,service 回傳可讀錯誤且不建立 order | 整合測試要驗證錯誤傳遞與副作用邊界,不只驗證 happy path。 | + +每個 scenario 都必須顯示:目前 stage、已通過的 module boundary、下一個可觀察的輸入/輸出,以及失敗時沒有發生的副作用。 + +## 5. Fixture contract + +### 5.1 Module boundaries + +```text +checkoutClient + → orderService + → orderRepository + → orderResponse +``` + +| Module | Input contract | Output contract | 整合測試觀察點 | +| --- | --- | --- | --- | +| `checkoutClient` | `{ items: [{ sku, quantity, unitPrice }], discount }` | `CreateOrderRequest` | request mapping 不遺漏 item、quantity 或 discount。 | +| `orderService` | `CreateOrderRequest` | `OrderDraft` 或 typed error | business rule 使用已驗證資料;不直接讀取 UI state。 | +| `orderRepository` | `OrderDraft` | `{ orderId, total, status }` 或 `dependency-unavailable` | repository 是可控制的外部邊界;failure 不得被吞掉。 | +| `orderResponse` | repository result | `{ orderId, total, status }` | consumer 驗證必要欄位與型別,再宣告 success。 | + +### 5.2 Deterministic data + +```ts +const createOrderInput = { + items: [ + { sku: "book", quantity: 2, unitPrice: 50 }, + ], + discount: 10, +}; + +const successfulRepositoryResult = { + orderId: "ord-001", + total: 90, + status: "created", +}; +``` + +Fixture rules: + +- `subtotal` 是 `2 × 50 = 100`,`discount` 是 `10`,因此成功結果的 `total` 固定為 `90`。 +- `orderId`、`status` 與 error code 都是固定值;不可使用目前時間、random UUID 或真實資料。 +- `response-contract-error` 只改變 response contract,不能同時改變輸入或 repository outcome。 +- `repository-unavailable` 只改變 dependency outcome;它不得產生 `orderId` 或寫入成功狀態。 +- 相同 initial state 與 event sequence 必須得到相同 stage、output、error 與 completion 結果。 + +### 5.3 Lab state boundary + +後續 `INTEGRATION-03` simulator 應能表達下列概念;欄位名稱可微調,但不可移除其可觀察語意: + +```text +IntegrationLabState { + selectedScenarioId + activeStageId + completedScenarioIds + visitedBoundaryIds + phase: initial | tracing | blocked | completed + response: success | contract-error | dependency-unavailable | null + sideEffects: none | order-created + lastMessage + canReset: true +} +``` + +建議 stage 順序為 `input → client → service → repository → response`。後續 implementation 可以合併或拆分視覺 stage,但必須保留 module boundary、failure location 與 side-effect 結果。 + +## 6. Failure feedback contract + +- 在未選擇 scenario 前執行 trace:阻擋並提示先選擇 fixture。 +- 未完成前一個 boundary 就跳到後面:阻擋並指出 required boundary,不直接完成。 +- response 缺少 `orderId`:顯示 contract error,保留失敗位置,不產生 `order-created`。 +- repository 回傳 `dependency-unavailable`:顯示 dependency failure,保留 error code,不產生成功 response。 +- 已完成後再次操作:提示先 reset,不偷偷累加 scenario 或 side effect。 +- reset:回到固定 initial fixture,清除 visited boundaries、responses、errors 與 completion state。 + +錯誤訊息必須說明「哪個 boundary 失敗、看見什麼證據、下一步要檢查什麼」,不可只顯示 `failed`。 + +## 7. 完成條件 + +- 三個 required scenarios 都完成各自的 terminal outcome。 +- Success scenario 走過 `client → service → repository → response`,並產生固定 `ord-001`。 +- 兩個 failure scenarios 都在正確 boundary 停止,且 side effect 與 response 狀態正確。 +- Regression flow 能重跑三個 scenarios,結果與第一次一致。 +- 完成後使用 `se-workshop-integration-complete` 保存進度。 +- 本 task 只定義 contract;在 `INTEGRATION-05` 前不得把 `integration` 改成 curriculum `ready`。 + +## 8. Out of scope + +- 真實 HTTP server、database、message queue、container 或第三方 API。 +- E2E browser automation、snapshot testing、load testing、contract testing platform 或 CI provider 整合。 +- 重複 `UNIT` 已驗證的純函式 edge cases;本主題只觀察模組協作與 boundary behavior。 +- 修改既有 Git/Auth persistence key、共用 route registry、ProgressRepository 或 `CORE-008` dispatcher。 +- 在本 task 建立 backend、migration 或正式 production adapter。 + +## 9. INTEGRATION-01 驗收 + +- 文件明確描述學習目標、Lesson/Lab pageflow、lesson outline、三個 scenarios、module contract、deterministic fixture、failure feedback、completion 與 out-of-scope。 +- `INTEGRATION-02` 可依本文件撰寫文案與 fixture,不需要重新決定 success/failure semantics。 +- `INTEGRATION-03` 可依本文件建立純 simulator;不需要真實 network 或 database。 +- `INTEGRATION-04` 可依本文件設計 Lab feedback、reset、keyboard 與 mobile interaction。 +- 後續 `INTEGRATION-05` 可用固定 completion key、route 與 progress contract 接入,且不改變既有 topic 的完成統計。 +- 文件檢查與 `git diff --check` 通過;本 task 不要求重新執行完整 frontend test suite。 + +## 10. 已知 rework 風險 + +- 後續 UI 可能把五個 trace stages 合併成較少的視覺步驟;只要保留四個 module boundary、failure location 與 side-effect semantics,不視為契約破壞。 +- 若 `INTEGRATION-02` 發現更適合的 domain 名稱,可調整文案與 TypeScript type 名稱,但應維持三個 scenario 的 observable outcomes 與 completion key。 +- 若既有共用 simulator harness 增加欄位,應由共用契約 task 處理,不把 framework change 偷塞進本 topic。 diff --git a/docs/logs-acceptance.md b/docs/logs-acceptance.md new file mode 100644 index 0000000..422e178 --- /dev/null +++ b/docs/logs-acceptance.md @@ -0,0 +1,224 @@ +# LOGS-01:結構化日誌與可追蹤線索驗收規格 + +> 狀態:Accepted for implementation +> 日期:2026-08-22 +> Task:LOGS-01 +> 上位契約:`docs/project-sa.md`、`docs/project-sd.md` + +本文件鎖定 Logs 主題的學習目標、Lesson/Lab pageflow、fixture、結構化 log event、失敗回饋與完成邊界。後續 task 可以調整 TypeScript type 或視覺分段名稱,但不可改變本文件定義的 observable semantics。 + +## 1. 學習目標 + +完成本主題後,學習者應能: + +1. 分辨 logs、metrics 與 traces 的用途;Logs Lab 只練習以事件留下可讀、可搜尋的執行線索。 +2. 讀懂結構化 log event,知道穩定的 machine-readable 欄位與人類可讀的 message 如何並存。 +3. 依事件嚴重性選擇 `debug`、`info`、`warn` 或 `error`,不把所有事件都記成 error。 +4. 用固定的 `correlationId` 把同一個 request 的事件串成一條可追蹤的時間線。 +5. 在格式化與輸出前移除 authorization、password、token、cookie 與 PII,不依賴 UI 遮罩假裝完成 redaction。 +6. 用 deterministic fixture 比較正常、輸入被拒絕與依賴逾時三條路徑,從 log 證據判斷結果而不是猜測根因。 + +## 2. Lesson/Lab Pageflow + +```text +Logs Lesson + → logs、metrics、traces 的責任邊界 + → 結構化 log event 的欄位 + → severity 與 event naming + → correlationId 與 request timeline + → redaction 與 safe context + → Logs Lab + → 選擇 deterministic scenario + → 逐筆檢視 log events + → 驗證 level、correlationId、terminal outcome 與 redaction + → 完成 success、validation rejection、dependency timeout 三個 scenarios + → reset 後重跑固定 regression flow + → 標記 Logs topic complete +``` + +Lab 只呈現可重現的狀態轉換,不寫入檔案、不連線 logging backend、不使用目前時間或 random UUID,也不把真實帳號、token 或 request body 傳出瀏覽器。 + +## 3. Lesson outline + +| Section | 要回答的問題 | 必須留下的判斷線索 | +| --- | --- | --- | +| Responsibility | Logs、metrics、traces 各自看什麼? | Logs 保存事件與上下文;metrics 聚合數值;traces 串起跨邊界的執行路徑。本主題只實作前者。 | +| Event schema | 一筆 log 怎樣既能搜尋又能閱讀? | `level`、`event`、`source`、`correlationId`、`outcome` 是穩定欄位;`message` 不能取代結構化欄位。 | +| Severity | 什麼值得 warn,什麼才是 error? | 可預期的輸入拒絕是 `warn`;依賴逾時造成請求失敗是 `error`;正常完成是 `info`。 | +| Correlation | 如何把同一個 request 的事件連起來? | 同一 scenario 的每筆 event 都使用同一個 `correlationId`,不可靠 message 文字或事件順序猜測。 | +| Redaction | 如何避免 log 洩漏秘密? | 使用 safe context allowlist;敏感欄位在格式化前被移除,raw value 不得出現在 `message`、`context` 或 serialized output。 | +| Evidence | 日誌能證明什麼,不能證明什麼? | Log 能證明事件、時間線與輸出結果;不能單獨證明根因、效能趨勢或跨服務完整 trace。 | +| Regression | 修正 log mapping 後如何避免倒退? | reset 後用同一 event sequence 重跑三條路徑,結果、欄位與 redaction 必須一致。 | + +## 4. Required scenarios + +| Scenario | Fixture outcome | 教學重點 | +| --- | --- | --- | +| `request-success` | `POST /orders` 完成,回傳固定 `201`,terminal event 為 `info`/`success` | 正常完成不是 error;同一個 `correlationId` 可以連起 received 與 completed events。 | +| `validation-rejected` | 缺少固定欄位 `amount`,回傳固定 `400`,terminal event 為 `warn`/`rejected` | 可預期的輸入問題要保留可修正的證據,不應升級成 dependency error,也不得記錄整個 request body。 | +| `dependency-timeout` | `payment-provider` 固定逾時 `3000ms`,回傳固定 `503`,terminal event 為 `error`/`failed` | 依賴失敗要留下 dependency、timeout 與 correlation 證據,不得偽造成功或產生付款副作用。 | + +每個 scenario 都必須讓學習者看見:目前 event、`source`、`level`、`correlationId`、安全的 `context`、terminal `outcome`,以及敏感欄位已被 redacted 的證據。 + +## 5. Log event contract + +### 5.1 Event schema + +後續 simulator 應維持下列語意;欄位名稱可以配合既有 shared types 調整,但不得移除必要資訊。 + +```ts +type LogLevel = "debug" | "info" | "warn" | "error"; +type LogOutcome = "started" | "success" | "rejected" | "failed"; +type LogSource = "api" | "validation" | "dependency"; + +interface LogEvent { + sequence: number; + timestamp: string; + level: LogLevel; + event: string; + message: string; + source: LogSource; + correlationId: string; + context: Readonly>; + outcome: LogOutcome; + redactedFields: readonly string[]; +} +``` + +欄位規則: + +- `sequence` 是從 `1` 開始的固定整數;Lab 依它顯示順序,不依賴陣列排序以外的副作用。 +- `timestamp` 使用 fixture 提供的 ISO 8601 字串;不得呼叫 `Date.now()`、讀取使用者時鐘或在每次重跑時產生新值。 +- `level` 只能是四種固定值;`validation-rejected` 必須是 `warn`,`dependency-timeout` 必須是 `error`。 +- `event` 是穩定的 machine-readable 名稱,例如 `request.received`、`request.completed`、`request.validation_rejected`、`dependency.timeout`;不可只留下任意例外文字。 +- `message` 說明人類可讀的事實與下一個判斷線索;不可把唯一重要資訊藏在 message,亦不可包含 raw secret。 +- `source` 指出事件來自 API boundary、validation boundary 或 dependency boundary。 +- `correlationId` 在同一 scenario 的所有 events 中必須完全相同;不同 scenario 要有不同固定值。 +- `context` 只能放 safe context allowlist 內的欄位,例如 route、method、statusCode、field、dependency、timeoutMs、durationMs、reason;不得直接塞入整個 request 或 response。 +- `outcome` 表示事件在流程中的結果,不等同於 HTTP status;terminal event 才能使用 `success`、`rejected` 或 `failed`。 +- `redactedFields` 是 deterministic 的驗收證據,列出被移除的 input keys;清單排序固定,且不得包含被移除的 raw value。 + +### 5.2 Deterministic fixture + +三個 scenarios 共用下列測試輸入。值是教學用 placeholder,但必須視為敏感資料處理;它們不應出現在任何輸出的 `message` 或 `context`。 + +```ts +const baseRequest = { + method: "POST", + route: "/orders", + authorization: "Bearer test-secret-001", + email: "learner@example.test", + payload: { sku: "book", quantity: 1 }, +}; + +const expectedCorrelationIds = { + "request-success": "req-logs-001", + "validation-rejected": "req-logs-002", + "dependency-timeout": "req-logs-003", +} as const; +``` + +Terminal outcome 固定如下: + +| Scenario | Terminal event | `source` | `level` | `context` 必須包含 | `outcome` | +| --- | --- | --- | --- | --- | --- | +| `request-success` | `request.completed` | `api` | `info` | `statusCode: 201`、`durationMs: 42` | `success` | +| `validation-rejected` | `request.validation_rejected` | `validation` | `warn` | `statusCode: 400`、`field: "amount"` | `rejected` | +| `dependency-timeout` | `dependency.timeout` | `dependency` | `error` | `dependency: "payment-provider"`、`timeoutMs: 3000`、`statusCode: 503` | `failed` | + +Fixture rules: + +- 每個 scenario 至少先產生 `request.received`,再產生一筆固定 terminal event;兩筆 event 的 `correlationId` 必須相同。 +- `request.received` 的 `level` 固定為 `debug`、`source` 固定為 `api`、`outcome` 固定為 `started`。 +- `request-success` 只能改變 terminal success outcome;不得同時加入 timeout 或 validation failure。 +- `validation-rejected` 只能因缺少 `amount` 被拒絕;不得輸出 `authorization`、`email` 或完整 payload。 +- `dependency-timeout` 只能因固定 `payment-provider` 在 `3000ms` 逾時失敗;不得產生成功付款或訂單副作用。 +- 相同 initial state 加上相同 event sequence,必須得到相同的 events、feedback、terminal outcome 與 completion 結果。 +- 不可使用目前時間、random UUID、網路回應、真實 provider、瀏覽器 local state 以外的外部輸入。 + +### 5.3 Redaction contract + +Redaction 必須在 event 格式化前完成,而不是只在畫面上用 CSS 或字串替換遮住結果: + +- `authorization`、`password`、`accessToken`、`cookie` 與 `email` 的 raw value 不得出現在 `message`、`context`、`redactedFields` 或 serialized event output。 +- `redactedFields` 必須至少記錄本 fixture 中被移除的 `authorization` 與 `email`,並以固定順序輸出。 +- 安全輸出只能使用 allowlist 欄位;不得透過 `JSON.stringify(baseRequest)` 再事後刪除一部分文字。 +- 任一 raw sensitive value 被找到時,scenario 必須進入 `redaction-failed` feedback,不能完成,也不能把該 event 當作有效證據。 +- reset 後 redaction 結果必須與第一次執行完全一致。 + +### 5.4 Lab state boundary + +後續 `LOGS-03` simulator 應能表達下列概念;欄位名稱可微調,但不可移除其可觀察語意: + +```text +LogsLabState { + phase: initial | inspecting | blocked | completed + selectedScenarioId: request-success | validation-rejected | dependency-timeout | null + activeEventIndex: number + visibleEventIds: string[] + completedScenarioIds: string[] + correlationCheck: pending | passed | failed + redactionCheck: pending | passed | failed + terminalOutcome: success | rejected | failed | null + lastFeedback: none | success | blocked | redaction-failed + lastMessage: string + canReset: true +} +``` + +建議的 observable stage 順序為 `select → inspect-event → verify-correlation → verify-redaction → verify-terminal-outcome`。視覺上可以把檢查合併,但必須保留目前 event、固定 correlation、redaction 結果與 terminal outcome。 + +## 6. Failure feedback contract + +- 未選擇 scenario 就執行 inspect:阻擋,提示先選擇一個 fixture。 +- 跳過目前 event 或直接要求 terminal outcome:阻擋,指出尚未檢查的 `sequence` 與下一步。 +- 同一 scenario 的 events 出現不同 `correlationId`:保留目前事件,顯示 correlation failure,不得標記 scenario 完成。 +- `validation-rejected` 使用 `error` 或 `dependency-timeout` 使用 `warn`:阻擋並指出 severity 與 fixture evidence 不一致。 +- 任一 raw sensitive value 出現在輸出:進入 `redaction-failed`,清楚指出被洩漏的欄位名稱;不顯示該 raw value 作為錯誤訊息的一部分。 +- 未完成前一個 scenario 就宣告全部完成:阻擋,指出尚未完成的 scenario ids。 +- 已完成的 scenario 再次操作:提示先 reset,避免重複累加 events 或副作用。 +- reset:回到固定 initial fixture,清除目前 scenario、visible events、checks、feedback、terminal outcome 與 completion state。 + +每個錯誤訊息都必須說明「目前看到的證據、哪個 contract 不符、下一步要檢查什麼」,不可只顯示 `failed`、顏色或未處理 exception。 + +## 7. Completion contract + +只有下列條件全部成立時,Logs Lab 才算完成: + +- 三個 required scenarios 都完成各自的 terminal outcome。 +- `request-success` 顯示 `info`/`201`/`success`,並保留固定 `req-logs-001`。 +- `validation-rejected` 顯示 `warn`/`400`/`rejected`,並保留 `field: "amount"`。 +- `dependency-timeout` 顯示 `error`/`503`/`failed`,並保留 `payment-provider` 與 `3000ms` 證據。 +- 三個 scenarios 的 raw sensitive values 都沒有出現在 serialized log output;redaction checks 全部通過。 +- 每個 scenario 的 events 都有一致的 `correlationId`,且 correlation checks 全部通過。 +- reset 後重跑固定 regression flow,events、feedback、checks 與 terminal outcomes 與第一次一致。 +- 完成後使用 `se-workshop-logs-complete` 保存進度。 +- 本 task 只定義 contract;在 `LOGS-05` 前不得把 `logs` 改成 curriculum `ready`。 + +單獨看見一筆 log、通過 redaction、完成一條 scenario 或顯示 terminal event,都不得單獨標記 topic 完成。 + +## 8. Out of scope + +- 真實 logger SDK、stdout/file sink、log collector、search backend、retention 或 production alerting。 +- OpenTelemetry、distributed tracing、metrics aggregation、dashboard、SLO/告警門檻與跨服務 trace propagation。 +- 真實 HTTP server、payment provider、資料庫、網路、登入帳號或 CI provider。 +- 完整 production PII classification、加密、access control、audit retention policy;本 task 只鎖定 Lab 可驗證的敏感欄位 redaction。 +- 使用真實時鐘、random UUID、瀏覽器 telemetry 或第三方分析服務。 +- E2E browser automation、snapshot testing、load testing,以及重複 M1 共用 simulator harness 的改動。 +- 修改既有 Git/Auth persistence key、共用 route registry、ProgressRepository 或 `CORE-008` dispatcher。 + +## 9. LOGS-01 驗收 + +- 文件明確描述學習目標、Lesson/Lab pageflow、lesson outline、三個 scenarios、event schema、deterministic fixture、redaction、state boundary、failure feedback、completion 與 out-of-scope。 +- `LOGS-02` 可直接依本文件撰寫教材,不需要重新決定 logs/metrics/traces 的責任邊界、severity 或 completion key。 +- `LOGS-03` 可直接依本文件建立純 simulator;不需要真實 logger、network、clock 或 provider。 +- `LOGS-04` 可依本文件設計 Lab 的 inspect、reset、錯誤回饋、keyboard、mobile 與 reduced-motion interaction。 +- 後續 `LOGS-05` 可使用固定 completion key、route 與 progress contract 接入,且不改變既有 topic 的完成統計。 +- 文件檢查與 `git diff --check` 通過;本 task 不要求重新執行完整 frontend test suite。 + +## 10. 已知 rework 風險 + +- 若後續產品契約要求即時 timestamp,Lab 仍應保留 fixture timestamp 或 sequence 作為驗收依據;否則 reset/regression 會失去 deterministic boundary。 +- `correlationId` 目前只代表單一 request 的教學關聯,不等同完整 distributed trace;若未來要加入 trace propagation,應新增契約欄位,不要偷換本 task 的語意。 +- 敏感欄位清單未來可能擴充;實作應以 safe context allowlist 為邊界,讓新增敏感欄位不必依賴 UI 遮罩修補。 +- 若共用 simulator harness 需要增加 event replay、redaction 或 feedback primitive,改動應歸 M1 或獨立 task,不把 framework change 偷塞進 LOGS topic。 diff --git a/docs/project-plan.md b/docs/project-plan.md index 4a836c5..86622dc 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -18,9 +18,9 @@ ## 2. 現況基線 - 課程清單:`shared/curriculum.json`,共 19 個主題 -- Core 已開放:Git、GitHub/GitLab 遠端協作、命令列、IDE/除錯器、套件管理、環境變數、建置工具、REST API/FastAPI、身分驗證與授權,共 9 / 19 +- Core 已開放:Git、GitHub/GitLab 遠端協作、命令列、IDE/除錯器、套件管理、環境變數、建置工具、REST API/FastAPI、身分驗證與授權、SQL、資料庫設計、索引與交易、PostgreSQL、單元測試、整合測試、日誌、Docker、CI/CD、部署,共 19 / 19 - Git v1 release gate:已完成 cowork/pipeline、keyboard、mobile、200% zoom 與 reduced-motion 驗收,`GIT-REVIEW` 已於 2026-08-16 通過。 -- Extension 已開放:Guardrails,共 1 個;不計入 Core 19 的完成分母 +- Extension 已開放:Guardrails、問題處理方法,共 2 個;不計入 Core 19 的完成分母 - 學習者完成數:依瀏覽器 `localStorage` 個別計算,不在專案文件中固定寫死 - 前端:Vite + React + TypeScript,靜態站點 - 持久化:瀏覽器 `localStorage` @@ -94,13 +94,14 @@ ### Extension track - Guardrails/AI/LLM Engineering(已完成;不改變 Core 19 的學習順序與完成統計) +- 問題處理方法(已完成;不改變 Core 19 的學習順序與完成統計) ### Phase 2:資料庫 -- SQL -- 資料庫設計 -- 索引與交易 -- NoSQL 基礎 +- SQL(已完成) +- 資料庫設計(已完成) +- 索引與交易(已完成) +- PostgreSQL(已完成) ### Phase 3:品質與可觀測性 diff --git a/docs/project-sa.md b/docs/project-sa.md index 9da4d35..dcb5752 100644 --- a/docs/project-sa.md +++ b/docs/project-sa.md @@ -162,13 +162,15 @@ Curriculum ## 11. 待決策但不阻塞 Phase 1 -- 何種需求會觸發 Phase 2 backend:跨裝置進度、登入、教師 dashboard、真實 provider integration,或其他需求。 -- Phase 2 API/資料庫的 hosting 與成本策略。 -- 是否要保存匿名學習分析,以及保存多久。 -- GitHub/GitLab 真實整合是否仍維持 simulator-first 的替換邊界。 -- 哪些 Extension topics 在完成 feature-level review 後提升為 ready。 +以下問題刻意保留為 Phase 2/產品治理決策;Phase 1 目前維持靜態教材、deterministic simulator 與 simulator-first 的外部副作用邊界: -這些問題由後續 project-level SD 或 Phase 2 計畫處理;不阻塞第一階段的靜態教材與 simulator 交付。 +- **Phase 2 backend 觸發條件**:跨裝置進度、登入、教師 dashboard、真實 provider integration,或其他需求何時足以啟動 backend,仍待產品決策。 +- **Phase 2 API/資料庫的 hosting 與成本**:只有 backend 觸發條件成立後,才進入 hosting、資料庫與維運成本評估。 +- **匿名學習分析**:Phase 1 不收集匿名分析;是否收集、保存哪些資料以及保存多久,仍待隱私與產品決策。 +- **GitHub/GitLab 真實整合**:Phase 1 維持 simulator-first;若要產生真實外部副作用,必須先另立整合契約與授權邊界。 +- **Extension ready 規則**:Guardrails 與問題處理方法目前已是 ready 的 Extension,不計入 Core denominator;未來新增 Extension 仍須先通過 feature-level review,再決定是否開放。 + +這些問題由後續 project-level SD 或 Phase 2 計畫處理;不阻塞第一階段的靜態教材、simulator 與目前 19 / 19 Core topic 的交付。 ## 12. 與 SD 的交界 diff --git a/docs/release-audit.md b/docs/release-audit.md new file mode 100644 index 0000000..2f079d7 --- /dev/null +++ b/docs/release-audit.md @@ -0,0 +1,59 @@ +# M6 Release Audit:Core 19/19 + +> 日期:2026-08-23 +> Release source:`origin/dev` → `feature/release-19-19` → `main` +> Scope:RELEASE-001~RELEASE-007 +> Status:Ready for release PR + +## 結論 + +Core 19 個 topic 全部為 `ready`,Extension 2 個 topic 維持獨立完成統計。`dev` 到 `main` 的 merge tree 無 conflict;本 release branch 已完成完整 regression、Pages base build、route/progress audit 與新 delivery topic 的 browser smoke。 + +## RELEASE-001:curriculum、registry、route、progress + +- Core total:19。 +- Core ready:19。 +- Core ready IDs:`git`、`remote`、`cli`、`ide`、`package`、`env`、`build`、`rest`、`auth`、`sql`、`schema`、`index`、`postgresql`、`unit`、`integration`、`logs`、`docker`、`cicd`、`deploy`。 +- Extension ready:`guardrail`、`problem-solving`,不污染 Core denominator。 +- 每個 ready topic 都有 registry module、lesson route、Lab route、completion key 與 integration coverage。 +- 最後兩個 completion key:`se-workshop-cicd-complete`、`se-workshop-deploy-complete`。 + +## RELEASE-002:既有功能回歸 + +- Git、Auth、既有 topic route、progress repository、protected completion keys 經完整 test suite 驗證。 +- 19/19 denominator assertions 已同步更新;planned-topic aggregation test 改用明確的最小 planned curriculum,避免把 ready topic 當成 planned fixture。 +- 未發現 conflict marker:`<<<<<<<`、`=======`、`>>>>>>>`。 + +## RELEASE-003/RELEASE-004:cross-topic UX、keyboard、mobile、reduced motion + +- Docker、CI/CD、Deploy 的 Lesson → Lab route 可達;Lab 具有 reset、`aria-live` feedback、progressbar、native buttons/input 與 observable state。 +- CI/CD browser smoke:green pipeline、test failure(lint/build 保持 `not-run`)、390×844 mobile、menu open/close、command input focus。 +- Deploy browser smoke:green release、artifact blocked、probe failure → rollback、reset/replay completion、390×844 mobile、menu open/close、command input focus。 +- CI/CD、Deploy mobile viewport 的 document width 為 375,viewport width 為 390,無水平溢出。 +- CI/CD、Deploy topic styles 都包含 `prefers-reduced-motion` media rule;必要資訊不依賴動畫或顏色。 +- Docker 的 desktop/mobile、failure、cleanup 與 keyboard smoke 已在 Docker topic integration 階段通過。 + +## RELEASE-005:automated gates + +- `npm --prefix frontend test`:83 test files / 265 tests passed。 +- `npm --prefix frontend run lint`:passed。 +- `VITE_BASE=/software-engineering-workshop/ npm --prefix frontend run build`:passed。 +- Pages build output `frontend/dist/index.html` 使用 `/software-engineering-workshop/assets/...` base path。 +- `git diff --check`:passed。 + +## RELEASE-006:release、遠端狀態與 Pages + +- `git merge-tree --write-tree origin/main origin/dev`:clean,未發現 merge conflict。 +- M5 的 feature PR(Docker、CI/CD、Deploy)均經 `feature/* → dev`、required checks 與 squash merge。 +- GitHub audit:0 open pull requests、0 open issues。 +- Release PR 合併到 `main` 後,需確認 `main` CI 與 GitHub Pages publish workflow 均成功,並確認 `origin/gh-pages` 更新到 release commit。 + +## RELEASE-007:文件交接 + +- README、project plan、project SA、task breakdown、acceptance contracts 與本 audit 對齊 19/19。 +- M5 Delivery 標記 done;M6 release audit 在 release PR 完成後標記 done。 +- `CORE-008` 維持非阻塞 architecture follow-up,不阻擋本 release。 + +## Remaining release check + +本文件建立時尚未執行 `main` merge 與 Pages publish;那兩項是 release PR 合併後的最後外部狀態驗證,不可用本機 build 取代。 diff --git a/docs/task-breakdown.md b/docs/task-breakdown.md index fd62fe1..e888c17 100644 --- a/docs/task-breakdown.md +++ b/docs/task-breakdown.md @@ -1,7 +1,7 @@ # Software Engineering Workshop:Milestone/Task Breakdown > 狀態:Active backlog -> 日期:2026-08-16 +> 日期:2026-08-23 > 依據:[project-plan.md](./project-plan.md)、[project-sa.md](./project-sa.md)、[project-sd.md](./project-sd.md) ## 1. 為什麼需要 Milestone @@ -34,11 +34,11 @@ Task 不等於檔案。若一個 task 同時需要修改多個檔案,但輸出 | --- | --- | --- | --- | --- | | M0 | Project Contract | Project Plan、SA、SD、task breakdown 可供開發對齊 | 無 | done | | M1 | Module Foundation | 新 topic 可依共同契約接入 route、progress、Lab、tests | M0 | done(P2 架構 follow-up) | -| M2 | Foundations/Web | 開發基本功與 Web 主題逐站開放 | M1 | in progress | -| M3 | Data | SQL、schema、index/transaction、NoSQL 開放 | M1 | planned | -| M4 | Quality | unit、integration、logs 開放 | M1 | planned | -| M5 | Delivery | Docker、CI/CD、deploy 開放 | M1、既有 CI/Pages baseline | planned | -| M6 | Hardening/Release | 19 主題一致性、回歸、文件與正式發布完成 | M2–M5 | planned | +| M2 | Foundations/Web | 開發基本功與 Web 主題逐站開放 | M1 | done | +| M3 | Data | SQL、schema、index/transaction、PostgreSQL 開放 | M1 | done | +| M4 | Quality | unit、integration、logs 開放 | M1 | done | +| M5 | Delivery | Docker、CI/CD、deploy 開放 | M1、既有 CI/Pages baseline | done | +| M6 | Hardening/Release | 19 主題一致性、回歸、文件與正式發布完成 | M2–M5 | in progress | M2、M3、M4 可在 M1 完成後平行進行;M5 的教材可平行製作,但共用 CI/Pages contract 必須維持穩定。 @@ -128,7 +128,7 @@ CORE-007(獨立) 例如第一個主題會產生:`REMOTE-01` acceptance、`REMOTE-02` lesson、`REMOTE-03` simulator、`REMOTE-04` Lab、`REMOTE-05` integration/QA。 -M2 的 hard dependency 只有 M1。Remote、CLI、IDE、Package、Env、Build、REST 之間的先後是學習順序,不是工程阻塞;若某 topic 需要共用 fixture,應把 fixture contract 放進該 topic 的 `-01`,不要直接依賴另一個 topic 的完成。 +M2 的 9 個 Core topic(含既有 Git 與 Auth)均已 ready。M2 的 hard dependency 只有 M1;Remote、CLI、IDE、Package、Env、Build、REST 之間的先後是學習順序,不是工程阻塞。若某 topic 需要共用 fixture,應把 fixture contract 放進該 topic 的 `-01`,不要直接依賴另一個 topic 的完成。 Git 與 Auth 都已有可執行 implementation;Git 的 cowork/pipeline 上線門檻與 accessibility release review 已通過。兩者仍需在 M6 建立 regression/migration 檢查。 @@ -138,7 +138,7 @@ Git 與 Auth 都已有可執行 implementation;Git 的 cowork/pipeline 上 ## 8. Extension track backlog -Guardrails 是第一個 Extension topic,使用既有 TopicModule、route、progress 與 simulator 契約,但不計入 Core 19。 +Guardrails 是第一個 Extension topic;問題處理方法是第二個。兩者都使用既有 TopicModule、route、progress 與 simulator 契約,但不計入 Core 19。 | Task ID | Task | 依賴 | 狀態 | | --- | --- | --- | --- | @@ -150,6 +150,14 @@ Guardrails 是第一個 Extension topic,使用既有 TopicModule、route、pro Guardrail 的完成狀態與 Core progress 分離;未來其他 Extension topic 可沿用同一組五 task packet。 +| Task ID | Task | 依賴 | 狀態 | +| --- | --- | --- | --- | +| PROBLEM-01 | 問題處理 acceptance、lesson outline、fixture contract | M0 | done | +| PROBLEM-02 | 問題處理 Lesson 與教學 fixture | PROBLEM-01 | done | +| PROBLEM-03 | 問題處理 deterministic simulator | PROBLEM-01、CORE-001、CORE-006 | done | +| PROBLEM-04 | 問題處理 Lab UI | PROBLEM-03、CORE-005 | done | +| PROBLEM-05 | route、progress、integration、QA | PROBLEM-02、PROBLEM-04、CORE-002、CORE-004 | done | + ## 9. M3:Data Backlog | Topic ID | 主題 | Task prefix | @@ -157,11 +165,13 @@ Guardrail 的完成狀態與 Core progress 分離;未來其他 Extension topic | sql | SQL | SQL | | schema | 資料庫設計 | SCHEMA | | index | 索引與交易 | INDEX | -| nosql | NoSQL 基礎 | NOSQL | +| postgresql | PostgreSQL | PGSQL | 每個 topic 使用 Topic Task Packet 五個 task。資料庫主題第一版仍以 fixture/模擬資料呈現,不因教學內容而提前引入正式 database。 -建議學習順序是 `SQL → Schema → Index/Transaction`,`NoSQL` 可與 `Schema` 平行開發。這是 soft dependency;只有實際共用的 fixture 或 module contract 才建立 hard dependency。 +M3 的 4 個 Data topic 均已 ready;上述建議學習順序只描述學習路徑,不是工程阻塞。 + +建議學習順序是 `SQL → Schema → Index/Transaction → PostgreSQL`。這是 soft dependency;只有實際共用的 fixture 或 module contract 才建立 hard dependency。 ## 10. M4:Quality Backlog @@ -173,6 +183,8 @@ Guardrail 的完成狀態與 Core progress 分離;未來其他 Extension topic 每個 topic 使用 Topic Task Packet 五個 task;共用測試工具的改動歸 M1 或獨立 task,不重複塞進每個 topic。 +M4 的 3 個 Quality topic 均已 ready;Logs 以 deterministic fixture 與結構化事件呈現可觀測性,不連線真實 logging backend。 + Unit、Integration、Logs 都只依賴 M1 的共用 contract;Integration 可在 Unit 教材完成前開發,避免把「測試概念順序」誤當成程式依賴。 ## 11. M5:Delivery Backlog @@ -187,6 +199,12 @@ Unit、Integration、Logs 都只依賴 M1 的共用 contract;Integration 可 M5 的 topic tasks 可平行開發。對教材內容而言,Docker、CI/CD、Deploy 不互相阻塞;只有 M6 的實際 release audit 需要等待相關教材與 workflow 都完成。 +DOCKER-01~DOCKER-05 已完成:acceptance contract、lesson/fixture、deterministic simulator、Lab UI、route/progress integration 與 required checks 均已合併到 `dev`。Docker Lab 仍維持 simulator-first,不啟動真實 Docker daemon。 + +CICD-01~CICD-05 已完成:workflow acceptance contract、lesson/fixture、deterministic simulator、Lab UI、route/progress integration 與 required checks 均已合併到 `dev`。CI/CD Lab 以固定 workflow 與 scenario 模擬,不啟動真實 runner。 + +DEPLOY-01~DEPLOY-05 已完成:release/artifact/Pages/live probe/rollback acceptance contract、lesson/fixture、deterministic simulator、Lab UI、route/progress integration 與 required checks 均已合併到 `dev`。Deploy Lab 以固定 workflow 與 release scenario 模擬,不連線真實 GitHub Pages;M5 Delivery 已完成,下一步是 M6 release audit。 + ## 12. M6:Hardening/Release | Task ID | Task | 驗收 | @@ -230,4 +248,4 @@ M6 的 task 依賴分成兩層:`RELEASE-001`、`RELEASE-002`、`RELEASE-003` ## 15. 目前下一個可開工 task -下一個可開工的是尚未 ready 的後續 Core topic;`ENV-01` 與 `BUILD-01` 已完成。`REST-01` 到 `REST-05` 已依 `docs/rest-acceptance.md` 完成。若先處理架構債務,則開 `CORE-008`;它不阻塞上述教材 task。M6 的 release audit 要等所有 Core topic integration 完成後再收斂。 +目前 19 / 19 Core topic 已 ready,M2、M3、M4、M5 已完成,M6 正在進行。下一步是 `RELEASE-001`~`RELEASE-007` 的 status、回歸、accessibility、文件、release 與 GitHub Pages audit;`CORE-008` 仍是非阻塞 architecture follow-up。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index caa75e5..3e3163c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import curriculumData from "@shared/curriculum.json"; +import { curriculum } from "./curriculum"; import { aggregateProgress, completedReadyTopicIds } from "./progress/aggregation"; import { createLocalStorageProgressRepository } from "./progress/repository"; import { parseRoute, resolveRoute, topicPath, trackPath, type RouteDefinition } from "./routes/registry"; @@ -7,9 +7,6 @@ import { TOPIC_MODULE_IDS } from "./topics/registry"; import { CurriculumMap } from "./components/CurriculumMap"; import { TrackPage } from "./components/TrackPage"; import { TopicRouteView } from "./components/TopicRouteView"; -import type { Curriculum } from "./types"; - -const curriculum = curriculumData as Curriculum; function routeLabel(route: RouteDefinition): string { if (route.kind === "map") return "MAP"; @@ -93,9 +90,18 @@ export default function App() { + + + + + + + + +
EXTENSION / AI