1
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// 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)
|
||||
})
|
||||
Reference in New Issue
Block a user