// Isolated page logic regression checks. No HTTP, accounts, or real devices. import assert from 'node:assert/strict' import { test } from 'node:test' import fs from 'node:fs' import path from 'node:path' import vm from 'node:vm' import { fileURLToPath } from 'node:url' import { parse, compileScript } from 'vue/compiler-sfc' import ts from 'typescript' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src') const deferred = () => { let resolve, reject const promise = new Promise((yes, no) => { resolve = yes; reject = no }) return { promise, resolve, reject } } const healthAnswers = () => ({ sleepDuration: '7-8', fallingAsleepSpeed: 'normal', temperaturePreference: 'normal', nightAwakening: 'none', mattressPreference: 'medium', exerciseFrequency: 'daily', muscleFatigueArea: 'none', activeTimeOfDay: 'morning', mealRegularity: 'regular', flavorPreference: 'light', lateNightEating: 'rarely', dailyWaterIntake: 'moderate' }) const tcmRecord = () => ({ hasCompleted: true, constitutionCode: 'balanced', constitutionName: '平和质', answers: { face: 'rosy', body: 'balanced', spirit: 'energetic', voice: 'normal', coldHeat: 'neutral', sweating: 'normal', mouth: 'normal', stool: 'normal', sleep: 'good', emotion: 'steady', allergy: 'none', pulse: 'unknown' }, notes: { tongue: '', odor: '', breath: '', pulseNote: '' }, sceneConfig: {} }) function environment(overrides = {}) { const mounted = [], disposed = [], toasts = [], navigations = [], scrolls = [], ticks = [] const vue = { defineComponent: options => options, ref: value => ({ value }), reactive: value => value, computed: getter => ({ get value() { return getter() } }), onMounted: handler => mounted.push(handler), onUnmounted: handler => disposed.push(handler), nextTick: async () => { ticks.push('tick') } } const healthApi = { getAssessment: async () => ({ hasCompletedAssessment: false }), saveAssessment: async () => ({ hasCompletedAssessment: true }), getTcmAssessment: async () => ({ hasCompleted: false }), saveTcmAssessment: async () => tcmRecord(), ...overrides } const mocks = { vue, 'vue-router': { useRouter: () => ({ replace: location => navigations.push(location) }) }, vant: { Button: {}, showToast: message => toasts.push(message), showSuccessToast: message => toasts.push(message), showFailToast: message => toasts.push(message) } } const cache = new Map() function load(relative) { const file = path.resolve(root, /\.(ts|vue)$/.test(relative) ? relative : relative + '.ts') if (file === path.join(root, 'services/health.ts')) return { healthApi } if (file.endsWith('AppTabBar.vue')) return { default: {} } if (cache.has(file)) return cache.get(file).exports const module = { exports: {} }; cache.set(file, module) const raw = fs.readFileSync(file, 'utf8') const source = file.endsWith('.vue') ? compileScript(parse(raw).descriptor, { id: 'test-assessment' }).content : raw const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText vm.runInNewContext(output, { exports: module.exports, module, require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))), document: { getElementById: id => ({ scrollIntoView: options => scrolls.push({ id, options, tickCount: ticks.length }) }) }, Date, Error }, { filename: file }) return module.exports } const page = relative => load('views/' + relative + '.vue').default.setup({}, { expose() {} }) return { load, page, healthApi, toasts, navigations, scrolls, ticks, dispose: () => disposed.forEach(handler => handler()) } } test('validation rejects non-finite numbers, fractional ages, stale choices, and impossible birthdays', () => { const validation = environment().load('services/formValidation') for (const input of [NaN, Infinity, -Infinity, '70', '', null, undefined]) { assert.equal(validation.finiteNumberInRange(input, 20, 250), false) } assert.equal(validation.finiteNumberInRange(31.5, 1, 120, true), false) assert.equal(validation.finiteNumberInRange(31, 1, 120, true), true) assert.equal(validation.finiteNumberInRange(70.5, 20, 250), true) assert.equal(validation.healthAnswersComplete(healthAnswers()), true) assert.equal(validation.healthAnswersComplete({ ...healthAnswers(), sleepDuration: 'legacy-choice' }), false) const today = new Date(2026, 9, 9) assert.equal(validation.ageFromBirthday('2000-10-10T00:00:00', today), 25) assert.equal(validation.ageFromBirthday('2000-10-09', today), 26) assert.equal(validation.ageFromBirthday('2000-02-29', today), 26) assert.equal(validation.ageFromBirthday('2001-02-29', today), undefined) assert.equal(validation.ageFromBirthday('2027-01-01', today), undefined) assert.equal(validation.ageFromBirthday('bad-date', today), undefined) }) test('age calculation accepts backend timestamps without shifting the calendar birthday', () => { const validation = environment().load('services/formValidation') const today = new Date(2026, 9, 9) for (const birthday of ['2006-01-01', '2006-01-01 00:00:00', '2006-01-01T00:00:00', '2006-01-01T00:00:00.000+08:00', '2006-01-01T23:00:00Z']) { assert.equal(validation.ageFromBirthday(birthday, today), 20, birthday) } assert.equal(validation.ageFromBirthday('2000-10-10 00:00:00', today), 25) assert.equal(validation.ageFromBirthday('2000-10-09 00:00:00', today), 26) assert.equal(validation.ageFromBirthday('2001-02-29 00:00:00', today), undefined) assert.equal(validation.ageFromBirthday('2027-01-01 00:00:00', today), undefined) assert.equal(validation.ageFromBirthday(null, today), undefined) assert.equal(validation.ageFromBirthday('2006-01-01 unexpected', today), undefined) }) test('assessment loads and resaves the backend birthday without losing age or rewriting the saved date', async () => { const birthday = '2006-01-01 00:00:00', payloads = [] let stored = { hasCompletedAssessment: true, basicInfo: { gender: '男', heightCm: 165, weightKg: 60, birthday }, answers: healthAnswers() } const env = environment({ getAssessment: async () => stored, saveAssessment: async payload => { payloads.push(payload) stored = { ...payload, hasCompletedAssessment: true } return stored } }) const page = env.page('AssessmentView'); await page.loadExisting() const expectedAge = new Date().getFullYear() - 2006 assert.equal(page.age.value, expectedAge) await page.handleSave() assert.equal(payloads.length, 1) assert.equal(payloads[0].basicInfo.birthday, birthday) await page.loadExisting() assert.equal(page.age.value, expectedAge) assert.equal(page.basicInfo.birthday, birthday) }) test('assessment cannot save while records are loading or after a failed read; retry restores saved information', async () => { const read = deferred(); let writes = 0 const env = environment({ getAssessment: () => read.promise, saveAssessment: async () => { writes++; return { hasCompletedAssessment: true } } }) const page = env.page('AssessmentView'), pending = page.loadExisting() await page.handleSave(); assert.equal(writes, 0) read.reject(new Error('读取失败')); await pending assert.equal(page.loadError.value, '读取失败') await page.handleSave(); assert.equal(writes, 0) env.healthApi.getAssessment = async () => ({ hasCompletedAssessment: true, basicInfo: { gender: '男', heightCm: 175, weightKg: 68, birthday: '1990-05-21' }, answers: healthAnswers() }) await page.loadExisting() assert.equal(page.loadError.value, ''); assert.equal(page.hasPreviousAssessment.value, true) assert.equal(page.basicInfo.weightKg, 68); assert.equal(page.answers.sleepDuration, '7-8') }) test('assessment save preserves a real birthday, blocks repeat submission, and navigates once', async () => { const saved = deferred(); const payloads = [] const env = environment({ getAssessment: async () => ({ hasCompletedAssessment: true, basicInfo: { gender: '女', heightCm: 165, weightKg: 60, birthday: '1990-05-21T00:00:00' }, answers: healthAnswers() }), saveAssessment: payload => { payloads.push(payload); return saved.promise } }) const page = env.page('AssessmentView'); await page.loadExisting() const save = page.handleSave(); await page.handleSave() assert.equal(payloads.length, 1); assert.equal(page.submitting.value, true) assert.equal(payloads[0].basicInfo.birthday, '1990-05-21T00:00:00') saved.resolve({ hasCompletedAssessment: true }); await save assert.deepEqual(env.navigations, ['/dashboard']); assert.equal(page.submitting.value, false) }) test('assessment does not toast or navigate after leaving during a save', async () => { const saved = deferred() const env = environment({ saveAssessment: () => saved.promise }) const page = env.page('AssessmentView'); await page.loadExisting() Object.assign(page.basicInfo, { gender: '男', heightCm: 175, weightKg: 70 }); page.age.value = 30 Object.assign(page.answers, healthAnswers()) const request = page.handleSave(); env.dispose() saved.resolve({ hasCompletedAssessment: true }); await request assert.equal(env.navigations.length, 0); assert.equal(env.toasts.length, 0) }) test('a failed save does not change the original birthday when the user reverts an age edit', async () => { const payloads = [] const env = environment({ getAssessment: async () => ({ hasCompletedAssessment: true, basicInfo: { gender: '女', heightCm: 165, weightKg: 60, birthday: '1990-05-21' }, answers: healthAnswers() }), saveAssessment: async payload => { payloads.push(payload); throw new Error('保存失败') } }) const page = env.page('AssessmentView'); await page.loadExisting() const originalAge = page.age.value; page.age.value = originalAge + 1 await page.handleSave() assert.equal(page.basicInfo.birthday, '1990-05-21') page.age.value = originalAge; await page.handleSave() assert.equal(payloads[1].basicInfo.birthday, '1990-05-21') assert.equal(env.navigations.length, 0) }) test('four diagnoses show changed answers as outdated until regeneration and scroll only after rendering', async () => { const saved = deferred(); let writes = 0 const env = environment({ getTcmAssessment: async () => tcmRecord(), saveTcmAssessment: () => { writes++; return saved.promise } }) const page = env.page('TcmAssessmentView'); await page.loadExisting() assert.equal(page.resultOutdated.value, false) page.answers.face = 'pale'; assert.equal(page.resultOutdated.value, true) page.answers.face = 'rosy'; assert.equal(page.resultOutdated.value, false) page.notes.tongue = '备注'; assert.equal(page.resultOutdated.value, true) const request = page.submit(); await page.submit() assert.equal(writes, 1); assert.equal(page.saving.value, true) saved.resolve(tcmRecord()); await request assert.equal(page.resultOutdated.value, false); assert.equal(page.saving.value, false) assert.equal(env.scrolls.at(-1).id, 'tcm-result'); assert.equal(env.scrolls.at(-1).tickCount, 1) }) test('four diagnoses reject unknown stored choices and prevent saves after a failed read', async () => { let writes = 0 const env = environment({ getTcmAssessment: async () => { throw new Error('网络异常') }, saveTcmAssessment: async () => { writes++; return tcmRecord() } }) const page = env.page('TcmAssessmentView'); await page.loadExisting(); await page.submit() assert.equal(writes, 0); assert.equal(page.loadError.value, '网络异常') env.healthApi.getTcmAssessment = async () => tcmRecord(); await page.loadExisting() page.answers.face = 'legacy-choice'; await page.submit() assert.equal(writes, 0); assert.equal(page.answeredCount.value, 11) assert.equal(env.scrolls.at(-1).id, 'tcm-face') }) test('both pages discard reads completed after leaving instead of treating them as active data', async () => { for (const [file, api, completed] of [ ['AssessmentView', 'getAssessment', { hasCompletedAssessment: true, basicInfo: { weightKg: 70 }, answers: healthAnswers() }], ['TcmAssessmentView', 'getTcmAssessment', tcmRecord()] ]) { const read = deferred(), env = environment({ [api]: () => read.promise }) const page = env.page(file), request = page.loadExisting(); env.dispose() read.resolve(completed); await request assert.equal(page.loading.value, true) assert.equal(env.toasts.length, 0) if (file === 'AssessmentView') assert.equal(page.basicInfo.weightKg, undefined) else assert.equal(page.result.value, undefined) } }) test('four diagnoses do not change results, toast, or scroll after leaving during generation', async () => { const saved = deferred() const env = environment({ getTcmAssessment: async () => tcmRecord(), saveTcmAssessment: () => saved.promise }) const page = env.page('TcmAssessmentView'); await page.loadExisting() const previous = page.result.value; page.answers.face = 'pale' const request = page.submit(); env.dispose() saved.resolve({ ...tcmRecord(), constitutionName: '新结果' }); await request assert.equal(page.result.value, previous) assert.equal(env.toasts.length, 0); assert.equal(env.scrolls.length, 0) }) test('health choices are native buttons and both forms lock editing while saving', () => { const healthSource = fs.readFileSync(path.join(root, 'views/AssessmentView.vue'), 'utf8') const tcmSource = fs.readFileSync(path.join(root, 'views/TcmAssessmentView.vue'), 'utf8') assert.equal(/ 30) assert.equal((healthSource.match(/:aria-pressed=/g) ?? []).length, healthChoiceCount) assert.match(healthSource, /]*:disabled="submitting"[^>]*:inert="submitting \|\| undefined"/) assert.match(tcmSource, /]*:disabled="saving"[^>]*:inert="saving \|\| undefined"/) }) test('page request guard also ignores superseded results', () => { const env = environment(), guard = env.load('composables/usePageRequest').usePageRequest() const first = guard.begin(), second = guard.begin() assert.equal(guard.isCurrent(first), false); assert.equal(guard.isCurrent(second), true) env.dispose(); assert.equal(guard.isCurrent(second), false) })