diff --git a/Makefile b/Makefile index 441d684..83679b0 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ check-frontend: cd frontend && $(PNPM) lint cd frontend && $(PNPM) format:check cd frontend && $(PNPM) typecheck + cd frontend && $(PNPM) test cd frontend && $(PNPM) build contracts: diff --git a/frontend/README.md b/frontend/README.md index 19dc9a2..3cc7779 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -7,6 +7,7 @@ Vue 3 + TypeScript + Vite + Element Plus,使用 Vue Router 与 Pinia。 pnpm install --frozen-lockfile pnpm dev pnpm typecheck +pnpm test pnpm build ``` @@ -22,3 +23,5 @@ pnpm build 已有代码按实际需要落位;`components/` 和 `stores/` 是后续扩展位置,不放空状态实现。 API 类型更新执行根目录 `make contracts`;前端通过 `pnpm check:api` 拒绝过期类型。 登录凭据、模型密钥和数据库连接不能进入 `VITE_` 环境变量。 + +`pnpm test` 验证代码草稿在刷新、切到后台与页面离开时的保存行为,已纳入 `make check-frontend`。 diff --git a/frontend/package.json b/frontend/package.json index 59b7459..58f7e6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "build": "vue-tsc --noEmit && vite build", "preview": "vite preview --host 127.0.0.1", "typecheck": "vue-tsc --noEmit", + "test": "node --test tests/*.test.ts", "lint": "eslint . --max-warnings 0", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/frontend/src/composables/useCodeDraft.ts b/frontend/src/composables/useCodeDraft.ts index f1ac72a..f1c17c7 100644 --- a/frontend/src/composables/useCodeDraft.ts +++ b/frontend/src/composables/useCodeDraft.ts @@ -67,11 +67,22 @@ export function useCodeDraft(problemId: string) { clearTimeout(timer) timer = setTimeout(save, 400) }, - { deep: true }, + // Mark pending changes before a lifecycle event can run in the same tick. + { deep: true, flush: 'sync' }, ) - onScopeDispose(() => { + function flushPendingDraft() { if (state.value === 'changed') save() + } + function onVisibilityChange() { + if (document.visibilityState === 'hidden') flushPendingDraft() + } + window.addEventListener('pagehide', flushPendingDraft) + document.addEventListener('visibilitychange', onVisibilityChange) + onScopeDispose(() => { + flushPendingDraft() clearTimeout(timer) + window.removeEventListener('pagehide', flushPendingDraft) + document.removeEventListener('visibilitychange', onVisibilityChange) }) return { language, code, state, save } } diff --git a/frontend/tests/useCodeDraft.test.ts b/frontend/tests/useCodeDraft.test.ts new file mode 100644 index 0000000..b2cc75a --- /dev/null +++ b/frontend/tests/useCodeDraft.test.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict' +import { test, type TestContext } from 'node:test' +import { effectScope, nextTick } from 'vue' +import { codeTemplates, useCodeDraft, type Language } from '../src/composables/useCodeDraft.ts' + +type SavedDraft = { version: number; language: Language; drafts: Record } + +function browser(t: TestContext) { + const page = new EventTarget() + const document = Object.assign(new EventTarget(), { + visibilityState: 'visible' as DocumentVisibilityState, + }) + const storage = { + items: new Map(), + writes: 0, + failWrites: false, + getItem(key: string) { + return this.items.get(key) ?? null + }, + setItem(key: string, value: string) { + this.writes++ + if (this.failWrites) throw new Error('Storage unavailable') + this.items.set(key, value) + }, + } + const globals = { window: page, document, localStorage: storage } + const descriptors = Object.keys(globals).map( + (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, + ) + for (const [name, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }) + } + t.mock.timers.enable({ apis: ['setTimeout'] }) + const scopes: ReturnType[] = [] + t.after(() => { + for (const scope of scopes) scope.stop() + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) + } + }) + return { + page, + document, + storage, + open(problemId = '1001') { + const scope = effectScope() + scopes.push(scope) + return { scope, ...scope.run(() => useCodeDraft(problemId))! } + }, + read(problemId = '1001'): SavedDraft | null { + return JSON.parse(storage.getItem('kf-oj:code-draft:' + problemId) ?? 'null') + }, + } +} + +test('pagehide saves the latest edit before the debounce and restores it after reload', (t) => { + const env = browser(t) + const draft = env.open() + draft.code.value = '// latest edit before refresh' + env.page.dispatchEvent(new Event('pagehide')) + assert.equal(env.read()?.drafts.cpp, draft.code.value) + assert.equal(draft.state.value, 'saved') + t.mock.timers.tick(400) + assert.equal(env.storage.writes, 1) + draft.scope.stop() + assert.equal(env.open().code.value, '// latest edit before refresh') +}) + +test('hiding the page flushes pending edits; remaining visible does not', async (t) => { + const env = browser(t) + const draft = env.open() + draft.code.value = '// edited while visible' + await nextTick() + env.document.dispatchEvent(new Event('visibilitychange')) + assert.equal(env.storage.writes, 0) + env.document.visibilityState = 'hidden' + env.document.dispatchEvent(new Event('visibilitychange')) + assert.equal(env.read()?.drafts.cpp, draft.code.value) + t.mock.timers.tick(400) + assert.equal(env.storage.writes, 1) +}) + +test('normal typing still debounces writes and saves the most recent edit', async (t) => { + const env = browser(t) + const draft = env.open() + draft.code.value = '// first edit' + await nextTick() + t.mock.timers.tick(399) + assert.equal(env.storage.writes, 0) + draft.code.value = '// second edit' + await nextTick() + t.mock.timers.tick(399) + assert.equal(env.storage.writes, 0) + t.mock.timers.tick(1) + assert.equal(env.read()?.drafts.cpp, '// second edit') + assert.equal(env.storage.writes, 1) +}) + +test('leaving a Vue page saves even an edit made in the same tick', (t) => { + const env = browser(t) + const draft = env.open() + draft.code.value = '// latest edit before navigation' + draft.scope.stop() + assert.equal(env.read()?.drafts.cpp, '// latest edit before navigation') + t.mock.timers.tick(400) + assert.equal(env.storage.writes, 1) +}) + +test('disposing the scope removes page lifecycle listeners', (t) => { + const env = browser(t) + const removePage = t.mock.method(env.page, 'removeEventListener') + const removeDocument = t.mock.method(env.document, 'removeEventListener') + env.open().scope.stop() + assert.deepEqual( + removePage.mock.calls.map((call) => call.arguments[0]), + ['pagehide'], + ) + assert.deepEqual( + removeDocument.mock.calls.map((call) => call.arguments[0]), + ['visibilitychange'], + ) + env.page.dispatchEvent(new Event('pagehide')) + env.document.visibilityState = 'hidden' + env.document.dispatchEvent(new Event('visibilitychange')) + assert.equal(env.storage.writes, 0) +}) + +test('pagehide preserves the selected language and keeps problem drafts separate', (t) => { + const env = browser(t) + const first = env.open('1001') + first.code.value = '// C++ draft' + first.language.value = 'python' + first.code.value = '# Python draft' + const second = env.open('1002') + second.code.value = '// another problem' + env.page.dispatchEvent(new Event('pagehide')) + assert.deepEqual(env.read('1001'), { + version: 1, + language: 'python', + drafts: { cpp: '// C++ draft', python: '# Python draft', rust: codeTemplates.rust }, + }) + assert.equal(env.read('1002')?.drafts.cpp, '// another problem') + first.scope.stop() + const restored = env.open('1001') + assert.equal(restored.language.value, 'python') + assert.equal(restored.code.value, '# Python draft') +}) + +test('a failed lifecycle save reports unavailable storage without throwing', (t) => { + const env = browser(t) + const draft = env.open() + env.storage.failWrites = true + draft.code.value = '// cannot persist yet' + assert.doesNotThrow(() => env.page.dispatchEvent(new Event('pagehide'))) + assert.equal(draft.state.value, 'unavailable') + t.mock.timers.tick(400) + assert.equal(env.storage.writes, 1) + env.storage.failWrites = false + draft.code.value = '// storage recovered' + env.page.dispatchEvent(new Event('pagehide')) + assert.equal(draft.state.value, 'saved') + assert.equal(env.read()?.drafts.cpp, '// storage recovered') +}) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a216dce..2f52f31 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "Bundler", "strict": true, "noEmit": true, + "allowImportingTsExtensions": true, "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true, @@ -15,5 +16,5 @@ "noUnusedParameters": true, "types": ["vite/client", "node"] }, - "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] + "include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "vite.config.ts"] }