const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const vm = require('node:vm') const ts = require('typescript') const vue = require('vue') const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc') const sourceFlag = process.argv.indexOf('--source') const filename = sourceFlag >= 0 ? path.resolve(process.argv[sourceFlag + 1]) : path.join(__dirname, '../src/views/tcm/diagnosis/components/DailyMatrix.vue') const source = fs.readFileSync(filename, 'utf8') const { descriptor, errors } = parse(source, { filename }) assert.deepEqual(errors, [], 'actual SFC parses') const script = compileScript(descriptor, { id: 'daily-record-test' }) const template = compileTemplate({ source: descriptor.template.content, filename, id: 'daily-record-test', compilerOptions: { bindingMetadata: script.bindings } }) assert.deepEqual(template.errors, [], 'actual template compiles') const calls = [] const pending = { window: null, notes: null, bloodEdit: null } const api = new Proxy({}, { get(_target, name) { return (...args) => { calls.push({ name, args: JSON.parse(JSON.stringify(args)) }) if (name === 'diagnosisTrackingWindow') return pending.window ? pending.window(...args) : Promise.resolve({}) if (name === 'diagnosisTrackingNotes') return pending.notes ? pending.notes(...args) : Promise.resolve([]) if (name === 'bloodRecordEdit' && pending.bloodEdit) return pending.bloodEdit(...args) return Promise.resolve(true) } } }) const thresholdSource = fs.readFileSync(path.join(__dirname, '../src/utils/blood-thresholds.ts'), 'utf8') const thresholdExports = {} vm.runInNewContext(ts.transpileModule(thresholdSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { exports: thresholdExports }) const moduleObject = { exports: {} } const lifecycle = { mounted: [], unmounted: [] } const runtime = { ...vue, onMounted: fn => lifecycle.mounted.push(fn), onUnmounted: fn => lifecycle.unmounted.push(fn) } vm.runInNewContext(ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: moduleObject.exports, module: moduleObject, console, Date, Map, window: { addEventListener() {}, removeEventListener() {} }, require(name) { if (name === 'vue') return runtime if (name === '@/api/tcm') return api if (name === '@/utils/perm') return { hasPermission: () => true } if (name === '@/utils/blood-thresholds') return thresholdExports if (name === '@/utils/feedback') return { default: { msgWarning: message => calls.push({ name: 'warning', message }) } } if (name === 'vue-echarts' || name.endsWith('.vue')) return { default: {} } throw new Error(`Unexpected import: ${name}`) } }, { filename }) const props = vue.reactive({ diagnosisId: 7, patientId: 70, age: 50, readOnly: false, patientName: '合成测试' }) let exposed const scope = vue.effectScope() const state = scope.run(() => moduleObject.exports.default.setup(props, { expose(value) { exposed = value } })) const date = '2026-09-20' const blood = [ { id: 103, diagnosis_id: 7, record_date: date, record_time: '19:30', fasting_blood_sugar: 7.1, record_time_estimated: 1, record_time_period: 'evening', record_time_text: '昨晚', followup_audio_task_id: 80, remark: '晚间原始行' }, { id: 102, diagnosis_id: 7, record_date: date, record_time: '08:30', fasting_blood_sugar: 7.1, source: 1, remark: '早间原始行' }, { id: 101, diagnosis_id: 7, record_date: date, record_time: '07:00', postprandial_blood_sugar: 8.4, remark: '独立原始行' } ] const diet = [ { id: 201, record_date: date, breakfast_foods: '鸡蛋', breakfast_images: ['fixture-a.png'] }, { id: 202, record_date: date, lunch_foods: '豆腐', lunch_images: ['fixture-b.png'] } ] const exercise = [ { id: 301, record_date: date, exercise_type: '散步', duration: 20, intensity: 1, images: ['fixture-c.png'] }, { id: 302, record_date: date, exercise_type: '散步', duration: 20, intensity: 1 } ] function fixtures() { state.bloodRecords.value = structuredClone(blood) state.dietRecords.value = structuredClone(diet) state.exerciseRecords.value = structuredClone(exercise) } function rowCount(map) { const rows = map.value.get(date); return Array.isArray(rows) ? rows.length : rows ? 1 : 0 } function observe() { fixtures() state.handleCellClick('fasting', date) return { bloodRows: rowCount(state.bloodByDate), dietRows: rowCount(state.dietByDate), exerciseRows: rowCount(state.exerciseByDate), dayDetailOpen: !!state.dayDetailVisible?.value, implicitBloodEditId: state.bloodForm.value.id || null, fastingCell: state.getCell('fasting', date).value } } async function main() { for (const style of descriptor.styles) { const result = await compileStyleAsync({ source: style.content, filename, id: 'daily-record-test', scoped: style.scoped, preprocessLang: style.lang }) assert.deepEqual(result.errors, [], 'actual styles compile') } const canonicalFlag = process.argv.indexOf('--canonical-fixture') if (canonicalFlag >= 0) { const fixture = JSON.parse(fs.readFileSync(process.argv[canonicalFlag + 1], 'utf8')) assert.equal(fixture.synthetic, true) assert.equal(fixture.source, 'real-disposable-mysql-apply') for (const alias of ['breakfast', 'lunch', 'dinner']) assert.equal(fixture.diet_columns.includes(alias), false) const actual = { ...fixture.diet_model, record_date: new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date(Number(fixture.diet_model.record_date) * 1000)) } state.dietRecords.value = [actual] state.openDietRecord(actual, actual.record_date) for (const meal of ['breakfast', 'lunch', 'dinner']) { assert.equal(state.dietForm.value[`${meal}_foods`], actual[`${meal}_foods`], 'real adopted meal opens in existing edit input') assert.deepEqual(JSON.parse(JSON.stringify(state.dietForm.value[`${meal}_images`])), actual[`${meal}_images`]) assert.equal(state.getCell(meal, actual.record_date).value, '✓') } state.dietForm.value.note = 'only synthetic note changed' await state.submitDiet() const payload = calls.findLast(x => x.name === 'dietRecordEdit').args[0] for (const meal of ['breakfast', 'lunch', 'dinner']) { assert.equal(payload[`${meal}_foods`], actual[`${meal}_foods`], 'submit preserves untouched canonical meals') assert.deepEqual(payload[`${meal}_images`], actual[`${meal}_images`], 'submit preserves images') assert.equal(meal in payload, false, 'no extraction alias leaks to existing edit API') } console.log('DAILY_RECORDS_CANONICAL_MYSQL PASS actual_model=1 edit_inputs=1 canonical_payload=1 preserved_images=1') } if (process.argv.includes('--observe')) { console.log(JSON.stringify(observe())); scope.stop(); return } assert.deepEqual(observe(), { bloodRows: 3, dietRows: 2, exerciseRows: 2, dayDetailOpen: true, implicitBloodEditId: null, fastingCell: '7.1 / 7.1' }) assert.equal(state.dayBloodRecords.value.length, 3, 'all same-day blood rows present') assert.equal(state.dayDietRecords.value.length, 2, 'all diet rows present') assert.equal(state.dayExerciseRecords.value.length, 2, 'identical exercise rows remain separate') assert.equal(state.recordTimeLabel(blood[0]), '19:30') assert.equal(state.recordTimePeriodLabel(blood[0]), '晚上') assert.equal(state.recordTimeLabel(diet[0]), '未记录具体时间', 'do not invent times for old diet rows') assert.match(descriptor.template.content, /v-for="record in dayBloodRecords"/) assert.match(descriptor.template.content, /v-for="record in dayDietRecords"/) assert.match(descriptor.template.content, /v-for="record in dayExerciseRecords"/) assert.match(descriptor.template.content, /估计时间/) assert.match(descriptor.template.content, /record\.record_time_text/) state.openBloodRecord(blood[0], date) assert.equal(state.bloodForm.value.id, 103) assert.equal(state.bloodForm.value.postprandial_blood_sugar, 0, 'no field from another row merged into edit') state.bloodForm.value.remark = '只改备注' await state.submitBlood() let sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0] assert.equal(sent.id, 103) assert.equal(sent.record_time_estimated, 1, 'unmodified time retains estimate') assert.equal(sent.record_time_text, '昨晚') assert.equal(sent.followup_audio_task_id, 80, 'keep provenance') state.openBloodRecord(blood[0], date) state.bloodForm.value.record_time = '20:05' await state.submitBlood() sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0] assert.equal(sent.record_time_estimated, 0, 'manual time correction clears estimate') assert.equal(sent.record_time_period, '') assert.equal(sent.record_time_text, '') assert.equal(sent.followup_audio_task_id, 80) state.openBloodRecord({ ...blood[0], record_time: '19:30:25' }, date) state.bloodForm.value.record_time = '19:30' await state.submitBlood() sent = calls.findLast(x => x.name === 'bloodRecordEdit').args[0] assert.equal(sent.record_time_estimated, 0, 'seconds correction must not retain estimated time') assert.equal(state.recordTimeLabel({ record_time: '19:30:25' }), '19:30:25', 'precise original seconds remain visible') state.openBloodRecord(undefined, date) await state.submitBlood() sent = calls.findLast(x => x.name === 'bloodRecordAdd').args[0] assert.equal(sent.id, '') assert.equal('record_time_estimated' in sent, false, 'legacy manual add does not require migration columns') state.openDietRecord(diet[1], date) assert.equal(state.dietForm.value.id, 202) state.dietForm.value.lunch_images.push('fixture-new.png') assert.equal(diet[1].lunch_images.length, 1, 'edit image arrays cloned') await state.submitDiet() assert.equal(calls.findLast(x => x.name === 'dietRecordEdit').args[0].id, 202) state.openExerciseRecord(exercise[1], date) await state.submitExercise() assert.equal(calls.findLast(x => x.name === 'exerciseRecordEdit').args[0].id, 302) fixtures() props.readOnly = true state.dayDetailVisible.value = false state.handleCellClick('exercise', date) assert.equal(state.dayDetailVisible.value, true, 'readonly viewers can inspect actual rows') state.bloodDialogVisible.value = false state.openBloodRecord(blood[0], date) assert.equal(state.bloodDialogVisible.value, false, 'readonly cannot edit') props.readOnly = false const windows = [] pending.window = params => new Promise(resolve => windows.push({ params, resolve })) const oldFetch = state.fetchTracking() props.diagnosisId = 8 await vue.nextTick() assert.equal(state.dayDetailVisible.value, false, 'patient switch closes prior details') assert.equal(state.bloodRecords.value.length, 0, 'patient switch immediately clears old rows') assert.equal(windows.length, 2) assert.equal(windows[0].params.id, 7) assert.equal(windows[1].params.id, 8) windows[1].resolve({ blood_records: [{ id: 888, record_date: date }] }) await new Promise(resolve => setImmediate(resolve)) windows[0].resolve({ blood_records: blood }) await oldFetch assert.equal(state.bloodRecords.value[0].id, 888, 'late old-patient fetch cannot overwrite new data') assert.deepEqual(calls.filter(x => x.name === 'diagnosisTrackingNotes').slice(-2).map(x => x.args[0].diagnosis_id), [8, 7], 'both sequential requests use their own bound identity') let releaseEdit pending.bloodEdit = () => new Promise(resolve => { releaseEdit = resolve }) pending.window = null state.openBloodRecord({ ...blood[0], diagnosis_id: 8 }, date) const oldSubmit = state.submitBlood() props.diagnosisId = 9 await vue.nextTick() state.bloodDialogVisible.value = true releaseEdit(true) await oldSubmit assert.equal(state.bloodDialogVisible.value, true, 'late old-patient submit cannot close new dialog') // Single-flight guard prevents duplicate submissions of the same manual form. state.bloodSubmitting.value = true const callCount = calls.length await state.submitBlood() assert.equal(calls.length, callCount) state.bloodSubmitting.value = false const unmountedWindows = [] pending.window = () => new Promise(resolve => unmountedWindows.push(resolve)) const unmountedFetch = state.fetchTracking() for (const unmount of lifecycle.unmounted) unmount() unmountedWindows[0]({ blood_records: blood }) await unmountedFetch assert.notEqual(state.bloodRecords.value[0]?.id, 103, 'unmounted request cannot repopulate component') scope.stop() assert.equal(typeof exposed.refresh, 'function') console.log('DailyMatrix actual SFC setup/template/style: 3 blood + 2 diet + 2 exercise rows, exact edit IDs, time metadata, readonly detail, stale isolation: PASS') } main().catch(error => { scope.stop(); console.error(error); process.exitCode = 1 })