From 660039b3cffc8f5a92c846ce88d9ecec7b3305de Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 16 Jun 2026 08:18:10 -0600 Subject: [PATCH] update --- .../plans/2026-06-16-frontend-routing.md | 340 ++++++++++++++++++ package-lock.json | 152 ++++---- package.json | 16 +- src/App.test.ts | 59 ++- src/App.vue | 110 +++++- src/components/WorkspaceView.test.ts | 18 + src/components/WorkspaceView.vue | 8 +- src/style.css | 8 +- src/style.test.ts | 31 ++ 9 files changed, 639 insertions(+), 103 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-16-frontend-routing.md create mode 100644 src/style.test.ts diff --git a/docs/superpowers/plans/2026-06-16-frontend-routing.md b/docs/superpowers/plans/2026-06-16-frontend-routing.md new file mode 100644 index 0000000..d051fbb --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-frontend-routing.md @@ -0,0 +1,340 @@ +# Frontend Routing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add URL-backed frontend routes for login, book list, book workspace, and admin. + +**Architecture:** Keep route ownership in `src/App.vue` and leave page components event-driven. Use `window.history.pushState`, `window.history.replaceState`, and `popstate` to maintain one small local route state without adding dependencies. + +**Tech Stack:** Vue 3 Composition API, Vite, Vitest, Vue Test Utils, browser History API. + +--- + +## File Structure + +- Modify `src/App.test.ts` for TDD coverage of URL-backed navigation and logged-out redirects. +- Modify `src/App.vue` to replace local `currentBookId` / `showAdmin` view state with parsed route state. +- Do not modify `package.json` or `package-lock.json`; existing user changes there are unrelated. + +## Task 1: Add failing route tests + +**Files:** +- Modify: `src/App.test.ts` +- Test: `src/App.test.ts` + +- [ ] **Step 1: Replace the auth mock with mutable test state** + +Use a hoisted auth state so each test can switch between logged-in and logged-out behavior: + +```ts +const authState = vi.hoisted(() => { + const { computed, ref } = require('vue') as typeof import('vue') + return { + loggedIn: ref(true), + user: ref(null), + fetchMe: vi.fn(), + } +}) + +vi.mock('./composables/useAuth', () => ({ + useAuth: () => ({ + isLoggedIn: computed(() => authState.loggedIn.value), + fetchMe: authState.fetchMe, + user: authState.user, + }), +})) +``` + +- [ ] **Step 2: Reset URL and auth state before each test** + +Add this to the existing `beforeEach`: + +```ts +authState.loggedIn.value = true +authState.user.value = null +authState.fetchMe.mockReset() +window.history.replaceState(null, '', '/books') +``` + +- [ ] **Step 3: Add tests for the route behaviors** + +Add these tests: + +```ts +it('opens a book route when a book is selected', async () => { + vi.mocked(booksApi.listBooks).mockResolvedValue([ + { id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 }, + ]) + vi.mocked(booksApi.getBook).mockResolvedValue({ + id: 'b1', + name: '示例整本', + updatedAt: '2026-01-01T00:00:00.000Z', + data: createEmptyBook(), + }) + + const wrapper = mount(App) + await flushPromises() + + await wrapper.get('[data-testid="open-b1"]').trigger('click') + await flushPromises() + + expect(window.location.pathname).toBe('/books/b1') + expect(wrapper.find('[data-testid="back"]').exists()).toBe(true) +}) + +it('returns to the books route from the workspace', async () => { + vi.mocked(booksApi.listBooks).mockResolvedValue([ + { id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 }, + ]) + vi.mocked(booksApi.getBook).mockResolvedValue({ + id: 'b1', + name: '示例整本', + updatedAt: '2026-01-01T00:00:00.000Z', + data: createEmptyBook(), + }) + + const wrapper = mount(App) + await flushPromises() + + await wrapper.get('[data-testid="open-b1"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="back"]').trigger('click') + await flushPromises() + + expect(window.location.pathname).toBe('/books') + expect(wrapper.text()).toContain('教学设计') +}) + +it('opens the admin route from the book list', async () => { + authState.user.value = { id: 'u1', username: 'admin', role: 'admin' } + vi.mocked(booksApi.listBooks).mockResolvedValue([]) + + const wrapper = mount(App) + await flushPromises() + + await wrapper.get('button').trigger('click') + await flushPromises() + + expect(window.location.pathname).toBe('/admin') + expect(wrapper.text()).toContain('用户管理') +}) + +it('routes logged-out users to login', async () => { + authState.loggedIn.value = false + window.history.replaceState(null, '', '/books/b1') + + const wrapper = mount(App) + await flushPromises() + + expect(window.location.pathname).toBe('/login') + expect(wrapper.text()).toContain('登录') +}) +``` + +- [ ] **Step 4: Run test to verify RED** + +Run: + +```bash +rtk npm test -- src/App.test.ts +``` + +Expected: FAIL because `App.vue` does not update `window.location.pathname` for book/admin navigation and does not redirect logged-out users. + +## Task 2: Implement route state in App.vue + +**Files:** +- Modify: `src/App.vue` +- Test: `src/App.test.ts` + +- [ ] **Step 1: Replace local page flags with route state** + +In `src/App.vue`, replace `currentBookId` and `showAdmin` with this route model: + +```ts +type AppRoute = + | { name: 'login' } + | { name: 'books' } + | { name: 'book'; bookId: string } + | { name: 'admin' } + +const route = ref(parseRoute(window.location.pathname)) +``` + +- [ ] **Step 2: Add route parsing and navigation helpers** + +Add helpers in `src/App.vue`: + +```ts +function parseRoute(pathname: string): AppRoute { + if (pathname === '/login') return { name: 'login' } + if (pathname === '/admin') return { name: 'admin' } + if (pathname === '/books') return { name: 'books' } + + const bookMatch = pathname.match(/^\/books\/([^/]+)$/) + if (bookMatch?.[1]) { + return { name: 'book', bookId: decodeURIComponent(bookMatch[1]) } + } + + return { name: 'books' } +} + +function routeToPath(nextRoute: AppRoute): string { + if (nextRoute.name === 'login') return '/login' + if (nextRoute.name === 'admin') return '/admin' + if (nextRoute.name === 'book') return `/books/${encodeURIComponent(nextRoute.bookId)}` + return '/books' +} + +function replaceRoute(nextRoute: AppRoute): void { + const path = routeToPath(nextRoute) + route.value = nextRoute + if (window.location.pathname !== path) { + window.history.replaceState(null, '', path) + } +} + +function pushRoute(nextRoute: AppRoute): void { + const path = routeToPath(nextRoute) + route.value = nextRoute + if (window.location.pathname !== path) { + window.history.pushState(null, '', path) + } +} +``` + +- [ ] **Step 3: Wire mount, popstate, and auth redirects** + +Update imports and lifecycle: + +```ts +import { onBeforeUnmount, onMounted, ref, watch } from 'vue' +``` + +Use: + +```ts +function syncRouteForAuth(): void { + if (!isLoggedIn.value) { + replaceRoute({ name: 'login' }) + return + } + if (route.value.name === 'login') { + replaceRoute({ name: 'books' }) + } +} + +function handlePopState(): void { + route.value = parseRoute(window.location.pathname) + syncRouteForAuth() +} + +onMounted(async () => { + window.addEventListener('popstate', handlePopState) + await fetchMe() + syncRouteForAuth() +}) + +onBeforeUnmount(() => { + window.removeEventListener('popstate', handlePopState) +}) + +watch(isLoggedIn, syncRouteForAuth) +``` + +- [ ] **Step 4: Map existing component events to route navigation** + +Use: + +```ts +async function handleLoginSuccess(): Promise { + await fetchMe() + pushRoute({ name: 'books' }) +} + +function openBook(id: string): void { + pushRoute({ name: 'book', bookId: id }) +} + +function backToList(): void { + pushRoute({ name: 'books' }) +} + +function openAdmin(): void { + pushRoute({ name: 'admin' }) +} +``` + +- [ ] **Step 5: Update template route conditions** + +Use: + +```vue + + +``` + +- [ ] **Step 6: Run test to verify GREEN** + +Run: + +```bash +rtk npm test -- src/App.test.ts +``` + +Expected: PASS for all `App.test.ts` cases. + +## Task 3: Final verification + +**Files:** +- Verify: `src/App.vue` +- Verify: `src/App.test.ts` + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +rtk npm test -- src/App.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run full frontend test suite** + +Run: + +```bash +rtk npm test +``` + +Expected: PASS. + +- [ ] **Step 3: Run production build** + +Run: + +```bash +rtk npm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Review git diff** + +Run: + +```bash +rtk git diff -- src/App.vue src/App.test.ts docs/superpowers/plans/2026-06-16-frontend-routing.md +``` + +Expected: diff only contains routing implementation, routing tests, and this plan. diff --git a/package-lock.json b/package-lock.json index 3cd1bd9..50d38b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,21 +11,22 @@ "hono": "^4.12.25", "jszip": "^3.10.1", "markdown-it": "^14.2.0", - "vue": "^3.5.34" + "vue": "^3.5.38" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", + "@types/bun": "^1.3.14", "@types/markdown-it": "^14.1.2", - "@types/node": "^24.12.3", - "@vitejs/plugin-vue": "^6.0.6", - "@vitest/coverage-v8": "^4.1.8", + "@types/node": "^25.9.3", + "@vitejs/plugin-vue": "^6.0.7", + "@vitest/coverage-v8": "^4.1.9", "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "^0.9.1", "jsdom": "^29.1.1", - "typescript": "~6.0.2", - "vite": "^8.0.12", - "vitest": "^4.1.8", - "vue-tsc": "^3.2.8" + "typescript": "^6.0.3", + "vite": "^8.0.16", + "vitest": "^4.1.9", + "vue-tsc": "^3.3.5" } }, "node_modules/@adobe/css-tools": { @@ -754,6 +755,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bun": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.14" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -805,14 +816,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", - "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~7.18.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@vitejs/plugin-vue": { @@ -833,15 +843,15 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -855,8 +865,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -865,16 +875,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -883,13 +893,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -920,9 +930,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -933,13 +943,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -947,14 +957,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -963,9 +973,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -973,13 +983,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1292,6 +1302,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/bun-types": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2918,9 +2938,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -3010,20 +3030,20 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3051,12 +3071,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 0d6baa9..bae8ece 100644 --- a/package.json +++ b/package.json @@ -18,21 +18,21 @@ "hono": "^4.12.25", "jszip": "^3.10.1", "markdown-it": "^14.2.0", - "vue": "^3.5.34" + "vue": "^3.5.38" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@types/bun": "^1.3.14", "@types/markdown-it": "^14.1.2", - "@types/node": "^24.12.3", - "@vitejs/plugin-vue": "^6.0.6", - "@vitest/coverage-v8": "^4.1.8", + "@types/node": "^25.9.3", + "@vitejs/plugin-vue": "^6.0.7", + "@vitest/coverage-v8": "^4.1.9", "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "^0.9.1", "jsdom": "^29.1.1", - "typescript": "~6.0.2", - "vite": "^8.0.12", - "vitest": "^4.1.8", - "vue-tsc": "^3.2.8" + "typescript": "^6.0.3", + "vite": "^8.0.16", + "vitest": "^4.1.9", + "vue-tsc": "^3.3.5" } } diff --git a/src/App.test.ts b/src/App.test.ts index 0841ca1..84cd74b 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -1,22 +1,39 @@ import { flushPromises, mount } from '@vue/test-utils' -import { computed, ref } from 'vue' +import { computed } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import App from './App.vue' import { createEmptyBook } from './domain/teachingDesign' import * as booksApi from './services/booksApi' vi.mock('./services/booksApi') + +const authState = vi.hoisted(() => ({ + authedFetch: vi.fn(), + fetchMe: vi.fn(), + loggedIn: true, + login: vi.fn(), + logout: vi.fn(), + user: null as { id: string; username: string; role: 'admin' | 'user' } | null, +})) + vi.mock('./composables/useAuth', () => ({ + authedFetch: authState.authedFetch, useAuth: () => ({ - isLoggedIn: computed(() => true), - fetchMe: vi.fn(), - user: ref(null), + fetchMe: authState.fetchMe, + isLoggedIn: computed(() => authState.loggedIn), + login: authState.login, + logout: authState.logout, + user: computed(() => authState.user), }), })) describe('App', () => { beforeEach(() => { vi.clearAllMocks() + authState.authedFetch.mockResolvedValue([]) + authState.loggedIn = true + authState.user = null + window.history.replaceState(null, '', '/books') }) it('starts with the book list entry page', async () => { @@ -29,7 +46,7 @@ describe('App', () => { expect(wrapper.text()).toContain('新建整本') }) - it('switches to the workspace view when a book is opened', async () => { + it('opens a book route when a book is selected', async () => { vi.mocked(booksApi.listBooks).mockResolvedValue([ { id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 }, ]) @@ -46,10 +63,11 @@ describe('App', () => { await wrapper.get('[data-testid="open-b1"]').trigger('click') await flushPromises() + expect(window.location.pathname).toBe('/books/b1') expect(wrapper.find('[data-testid="back"]').exists()).toBe(true) }) - it('returns to the book list when back is emitted', async () => { + it('returns to the books route from the workspace', async () => { vi.mocked(booksApi.listBooks).mockResolvedValue([ { id: 'b1', name: '示例整本', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 }, ]) @@ -69,6 +87,35 @@ describe('App', () => { await wrapper.get('[data-testid="back"]').trigger('click') await flushPromises() + expect(window.location.pathname).toBe('/books') expect(wrapper.text()).toContain('教学设计') }) + + it('opens the admin route from the book list', async () => { + authState.user = { id: 'u1', username: 'admin', role: 'admin' } + vi.mocked(booksApi.listBooks).mockResolvedValue([]) + + const wrapper = mount(App) + await flushPromises() + + const adminButton = wrapper.findAll('button').find((button) => button.text() === '用户管理') + expect(adminButton).toBeDefined() + + await adminButton!.trigger('click') + await flushPromises() + + expect(window.location.pathname).toBe('/admin') + expect(wrapper.text()).toContain('用户管理') + }) + + it('routes logged-out users to login', async () => { + authState.loggedIn = false + window.history.replaceState(null, '', '/books/b1') + + const wrapper = mount(App) + await flushPromises() + + expect(window.location.pathname).toBe('/login') + expect(wrapper.text()).toContain('登录') + }) }) diff --git a/src/App.vue b/src/App.vue index e9b3eb7..918fd68 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,52 +1,126 @@