Files
Evergreen-Health-Mobile/scripts/test-assessment-pages.mjs
T
2026-10-09 16:46:35 +08:00

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)
})