254 lines
14 KiB
JavaScript
254 lines
14 KiB
JavaScript
// 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(/<div\s+class="option-(pill|card)"/.test(healthSource), false)
|
|
const healthChoiceCount = (healthSource.match(/class="option-(pill|card)"/g) ?? []).length
|
|
assert.ok(healthChoiceCount > 30)
|
|
assert.equal((healthSource.match(/:aria-pressed=/g) ?? []).length, healthChoiceCount)
|
|
assert.match(healthSource, /<fieldset[^>]*:disabled="submitting"[^>]*:inert="submitting \|\| undefined"/)
|
|
assert.match(tcmSource, /<fieldset[^>]*: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)
|
|
})
|