feat: add reviewed follow-up audio patient enrichment
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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 })
|
||||
Reference in New Issue
Block a user