// 全站页面的假数据逻辑和 SSR 检查,不登录账号、不请求后端、不操作家具。 import assert from 'node:assert/strict' import { after, 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, compileStyle } from 'vue/compiler-sfc' import { createSSRApp, h, isRef } from 'vue' import { renderToString } from 'vue/server-renderer' import { createMemoryHistory, createRouter } from 'vue-router' import { createServer } from 'vite' import vuePlugin from '@vitejs/plugin-vue' 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 dashboardRecord = (name = '演示用户') => ({ user: { id: 1, displayName: name, phone: '13800000000' }, profile: {}, hasCompletedAssessment: false, tcmResult: { hasCompleted: true, constitutionName: '平和质' } }) const deviceStatus = () => ({ connected: true, sceneDevices: { available: 3 } }) function environment(overrides = {}, ib43Get) { const disposed = [], toasts = [], navigations = [], tokenWrites = [], mounted = [] const router = { replace: location => navigations.push(location), push: location => navigations.push(location) } 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) } const healthApi = { dashboard: async () => dashboardRecord(), homeAssistantStatus: async () => deviceStatus(), getAssessment: async () => ({ hasCompletedAssessment: false, basicInfo: {}, answers: {} }), login: async () => ({ accessToken: 'test-token', refreshToken: 'test-refresh' }), register: async () => ({ accessToken: 'test-token', refreshToken: 'test-refresh' }), ...overrides } let sceneOptions const mocks = { vue, 'vue-router': { useRouter: () => router }, vant: { Button: {}, Field: {}, Form: {}, showToast: value => toasts.push(value), showConfirmDialog: async () => {} }, } const cache = new Map() function load(relative) { const file = path.resolve(root, /\.(vue|ts)$/.test(relative) ? relative : relative + '.ts') const normalized = file.replaceAll('\\', '/') if (normalized.endsWith('services/health.ts')) return { healthApi } if (normalized.endsWith('services/http.ts') && ib43Get) return { default: { get: ib43Get } } if (normalized.endsWith('services/auth.ts')) return { saveTokens: (...args) => tokenWrites.push(args), clearTokens: () => tokenWrites.push('clear') } if (file.endsWith('AppTabBar.vue')) return { default: {} } if (file.endsWith('useDeviceScenes.ts')) return { useDeviceScenes: options => { sceneOptions = options; return { sceneRunning: vue.ref(), executeScene() {} } } } if (cache.has(file)) return cache.get(file).exports const raw = fs.readFileSync(file, 'utf8') const source = file.endsWith('.vue') ? compileScript(parse(raw).descriptor, { id: 'test-page' }).content : raw const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const module = { exports: {} }; cache.set(file, module) vm.runInNewContext(output, { module, exports: module.exports, require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))), Date, Intl, Error, }, { filename: file }) return module.exports } return { page: name => load(`views/${name}.vue`).default.setup({}, { expose() {} }), healthApi, toasts, navigations, tokenWrites, feedback: (...args) => sceneOptions.feedback(...args), dispose: () => disposed.forEach(handler => handler()) } } test('dashboard failure is retryable instead of looking like an empty unassessed account', async () => { const env = environment({ dashboard: async () => { throw new Error('读取失败') } }) const page = env.page('DashboardView'); await page.load() assert.equal(page.loadError.value, '读取失败'); assert.equal(page.loading.value, false) assert.equal(page.dashboard.value, undefined) env.healthApi.dashboard = async () => dashboardRecord(); await page.load() assert.equal(page.loadError.value, ''); assert.equal(page.dashboard.value.user.displayName, '演示用户') }) test('dashboard refuses outdated reads and late feedback after leaving', async () => { const old = deferred(), latest = deferred(); let reads = 0 const env = environment({ dashboard: () => (++reads === 1 ? old : latest).promise }) const page = env.page('DashboardView'), a = page.load(), b = page.load() latest.resolve(dashboardRecord('新数据')); await b old.resolve(dashboardRecord('旧数据')); await a assert.equal(page.dashboard.value.user.displayName, '新数据') env.dispose(); env.feedback('success', '迟到反馈') assert.equal(page.actionFeedback.value, undefined) }) test('profile failed assessment reads do not silently claim an unassessed profile; retry works', async () => { const env = environment({ getAssessment: async () => { throw new Error('评估读取失败') } }) const page = env.page('ProfileView'); await page.loadData() assert.equal(page.loadError.value, '评估读取失败'); assert.equal(page.dashboard.value, undefined) env.healthApi.getAssessment = async () => ({ hasCompletedAssessment: false, basicInfo: {}, answers: {} }) await page.loadData(); assert.equal(page.loadError.value, '') page.dashboard.value.profile.birthday = 'not-a-date'; assert.equal(page.ageDisplay.value, undefined) page.dashboard.value.bmi = NaN; assert.equal(page.bmiDisplay.value, null) }) test('profile displays age from backend timestamps in either profile or assessment', async () => { const birthday = '2006-01-01 00:00:00' const env = environment({ dashboard: async () => ({ ...dashboardRecord(), profile: { birthday } }) }) const page = env.page('ProfileView'); await page.loadData() const expectedAge = new Date().getFullYear() - 2006 assert.equal(page.ageDisplay.value, expectedAge) page.dashboard.value.profile.birthday = undefined page.assessment.value.basicInfo.birthday = birthday assert.equal(page.ageDisplay.value, expectedAge) page.assessment.value.basicInfo.birthday = undefined assert.equal(page.ageDisplay.value, undefined) }) test('profile does not update after leaving during a read', async () => { const read = deferred(), env = environment({ dashboard: () => read.promise }) const page = env.page('ProfileView'), pending = page.loadData() env.dispose(); read.resolve(dashboardRecord()); await pending assert.equal(page.dashboard.value, undefined); assert.equal(env.toasts.length, 0) }) test('auth blocks duplicate submission and uses the submitted mode even if mode later changes', async () => { const pending = deferred(); let logins = 0, registers = 0 const env = environment({ login: () => { logins++; return pending.promise }, register: async () => { registers++ } }) const page = env.page('AuthView'); Object.assign(page.form, { phone: '13800000000', password: 'oldpwd' }) const request = page.submit(); page.mode.value = 'register'; await page.submit() assert.equal(logins, 1); assert.equal(registers, 0) pending.resolve({ accessToken: 'test-token', refreshToken: 'test-refresh' }); await request assert.deepEqual(env.navigations, ['/dashboard']); assert.equal(page.loading.value, false) }) test('registration validates its minimum password and still opens four diagnoses first', async () => { let writes = 0 const env = environment({ register: async () => { writes++; return { accessToken: 'test-token', refreshToken: 'test-refresh' } } }) const page = env.page('AuthView'); page.mode.value = 'register' Object.assign(page.form, { phone: '13800000000', password: 'short', displayName: '演示用户' }) await page.submit(); assert.equal(writes, 0) page.form.password = 'demo-password'; await page.submit() assert.equal(writes, 1); assert.deepEqual(env.navigations, ['/tcm-assessment']) }) test('late login response cannot write a token or navigate after leaving the auth page', async () => { const pending = deferred(), env = environment({ login: () => pending.promise }) const page = env.page('AuthView'); Object.assign(page.form, { phone: '13800000000', password: 'demo-password' }) const request = page.submit(); env.dispose() pending.resolve({ accessToken: 'test-token', refreshToken: 'test-refresh' }); await request assert.equal(env.tokenWrites.length, 0); assert.equal(env.navigations.length, 0); assert.equal(env.toasts.length, 0) }) const server = await createServer({ configFile: false, root: path.dirname(root), plugins: [vuePlugin()], appType: 'custom', server: { middlewareMode: true, hmr: false }, optimizeDeps: { noDiscovery: true, include: [] } }) after(() => server.close()) async function render(name, values = {}, routePath = '/dashboard', props = {}) { const componentPath = name.includes('/') ? name : `views/${name}` const { default: view } = await server.ssrLoadModule(`/src/${componentPath}.vue`) const component = { ...view, setup(props, context) { const state = view.setup(props, context) for (const [key, value] of Object.entries(values)) { assert.ok(isRef(state[key]), key); state[key].value = value } return state } } const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component }] }) await router.push(routePath); await router.isReady() const app = createSSRApp({ render: () => h(component, props) }); app.use(router) return renderToString(app) } test('home renders a prominent scene, collapsed presets, a real profile entry and the required navigation order', async () => { const record = { ...dashboardRecord(), bmi: 31.1, sceneConfig: { targetHumidity: 55, fanLevel: '1挡', lightBrightnessPercent: 50, lightColorTemperatureKelvin: 2700 } } const html = await render('DashboardView', { loading: false, dashboard: record, homeAssistant: { ...deviceStatus(), scenePresets: [{ deviceName: '米家除湿机', entityId: 'humidifier.keep', action: '恒湿', detail: '目标 55%', available: true }], } }) for (const value of ['我的舒睡空间', '一键开启舒睡', '查看舒睡设备', '睡眠数据', '查看体质启动预设', '打开我的健康档案']) assert.ok(html.includes(value), value) assert.match(html, /
/) assert.match(html, /米家除湿机/) assert.doesNotMatch(html, /助眠灯光|2700K/) assert.ok(html.indexOf('首页') < html.indexOf('中医四诊')) assert.ok(html.indexOf('中医四诊') < html.indexOf('健康评估')) assert.ok(html.indexOf('健康评估') < html.indexOf('我的')) }) test('automation card separates the rule switch from physical readback and preserves previous results while stopped', async () => { const state = { available: true, enabled: false, actionLabel: '关闭窗帘', targetLabel: '美百帘窗帘', records: [ { id: 'one', actionKey: 'curtain-close', targetLabel: '美百帘窗帘', triggeredAtMs: 1794380000000, result: 'readback-confirmed' }, { id: 'two', actionKey: 'curtain-close', targetLabel: '美百帘窗帘', triggeredAtMs: 1794381000000, result: 'sent-readback-unconfirmed' }, ] } const props = { bindingId: 'simulation-left' } const stopped = await render('components/Ib43AutomationCard', { status: state }, '/sleep-data', props) assert.match(stopped, /已停止/) assert.match(stopped, /开启联动/) assert.match(stopped, /美百帘窗帘 · 已确认关闭/) assert.match(stopped, /已发出关帘指令,未确认关闭/) const started = await render('components/Ib43AutomationCard', { status: { ...state, enabled: true } }, '/sleep-data', props) assert.match(started, /停止联动/) assert.doesNotMatch(started, /开启联动/) const both = await render('components/Ib43AutomationCard', { status: { ...state, rules: [ { actionKey: 'curtain-close', actionLabel: '关闭窗帘', enabled: false, targetLabel: '美百帘窗帘' }, { actionKey: 'dehumidifier-humidity-preview', actionLabel: '湿度偏高时开启除湿机', enabled: true, targetLabel: '除湿机', previewOnly: true, conditionLabel: '室内相对湿度超过 55% 时记录开机计划。' }, { actionKey: 'dehumidifier-humidity-start', actionLabel: '湿度偏高时开启除湿机', enabled: false, targetLabel: '除湿机', previewOnly: false, conditionLabel: '室内相对湿度超过 55% 且除湿机关机时,发送开机指令。' }, { actionKey: 'climate-temperature-preview', actionLabel: '按室温判断空调模式', enabled: false, targetLabel: '空调温控器2', previewOnly: true, conditionLabel: '室温高于 25℃ 时计划制冷,低于 22℃ 时计划制热。' }, { actionKey: 'climate-heat-start', actionLabel: '室温偏低时开启空调制热', enabled: false, targetLabel: '空调温控器2', previewOnly: false, conditionLabel: '室温低于 22℃ 且空调关机时,开启制热并设为 22℃。' }, { actionKey: 'wake-fragrance-quick-release', actionLabel: '让香氛机「唤醒」出香一次', enabled: false, targetLabel: '香氛机 唤醒', previewOnly: false }, { actionKey: 'speaker-sleep-music', actionLabel: '用 Xiaomi Sound Pro 播放舒睡音乐', enabled: false, targetLabel: 'Xiaomi Sound Pro', previewOnly: false }, ], records: [...state.records, { id: 'three', actionKey: 'dehumidifier-humidity-preview', targetLabel: '除湿机', triggeredAtMs: 1794382000000, result: 'preview-would-start' }, { id: 'four', actionKey: 'dehumidifier-humidity-start', targetLabel: '除湿机', triggeredAtMs: 1794383000000, result: 'readback-confirmed' }, { id: 'five', actionKey: 'climate-temperature-preview', targetLabel: '空调温控器2', triggeredAtMs: 1794384000000, result: 'preview-climate-cool' }, { id: 'six', actionKey: 'climate-heat-start', targetLabel: '空调温控器2', triggeredAtMs: 1794385000000, result: 'readback-confirmed' }, { id: 'seven', actionKey: 'wake-fragrance-quick-release', targetLabel: '香氛机 唤醒', triggeredAtMs: 1794386000000, result: 'fragrance-release-sent' }, { id: 'eight', actionKey: 'speaker-sleep-music', targetLabel: 'Xiaomi Sound Pro', triggeredAtMs: 1794387000000, result: 'speaker-playback-sent' }], } }, '/sleep-data', props) for (const text of ['开启联动', '停止预演', '超过 55% 且除湿机关机时', '除湿机 · 已确认开启', '除湿机 · 预演通过,计划开启除湿机', '按室温判断空调模式(预演)', '高于 25℃ 时计划制冷', '空调温控器2 · 预演通过,计划制冷', '室温偏低时开启空调制热', '低于 22℃ 且空调关机时', '空调温控器2 · 已确认制热至 22℃', '香氛机 唤醒 · 已发送一次快速出香指令', 'Xiaomi Sound Pro · 已发送舒睡音乐播放指令']) assert.ok(both.includes(text), text) }) const sleepBed = { id: 'left-bed', bedName: '卧室左侧', side: 'left', simulation: true, endedAtMs: null } const rightBed = { id: 'right-bed', bedName: '卧室右侧', side: 'right', simulation: false, endedAtMs: null } test('sleep data renders stale readings and missing fields separately, with the same time for UTC strings and history milliseconds', async () => { const instant = Date.parse('2026-11-11T01:55:21Z') const readings = { heartRate: { value: 72, receivedAt: '2026-11-11 01:55:21', freshness: 'stale' }, respiration: { value: null, receivedAt: '2026-11-11 01:55:21', freshness: 'recent' }, snoring: { value: 0, receivedAt: '2026-11-11 01:55:21', freshness: 'stale' }, } const html = await render('SleepDataView', { bindingsLoading: false, beds: [sleepBed], bedId: sleepBed.id, current: { bed: sleepBed, active: true, transport: 'connected', readings }, history: [{ id: '1', receivedAtMs: String(instant), readings }], }, '/sleep-data') assert.match(html, /模拟设备/) assert.match(html, /数据服务已连接/) assert.match(html, /数据已过期/) assert.match(html, /暂无有效读数/) assert.match(html, /暂无上报/) assert.match(html, /未打鼾/) assert.doesNotMatch(html, /离床/) const { formatIb43Time } = await server.ssrLoadModule('/src/services/ib43.ts') assert.equal(formatIb43Time(readings.heartRate.receivedAt), formatIb43Time(String(instant))) const empty = await render('SleepDataView', { bindingsLoading: false }, '/sleep-data') assert.match(empty, /暂无绑定床位/) assert.doesNotMatch(empty, /最近读数|历史记录/) }) test('sleep data discards late results from a previous bed and keeps the time range and cursor when paging', async () => { const oldCurrent = deferred(), oldHistory = deferred(), historyRequests = [] const cursor = '9007199254740993' const env = environment({}, async (url, options) => { if (url.endsWith('/bindings')) return [sleepBed, rightBed] if (url.includes('/left-bed/')) return url.endsWith('/current') ? oldCurrent.promise : oldHistory.promise if (url.endsWith('/right-bed/current')) return { bed: rightBed, readings: {} } if (url.endsWith('/right-bed/history')) { historyRequests.push(options.params) return options.params.beforeId == null ? { items: [{ id: 'right-1' }], nextBeforeId: cursor } : { items: [{ id: 'right-2' }], nextBeforeId: null } } throw new Error(`Unexpected request: ${url}`) }) const page = env.page('SleepDataView') await page.loadBindings() assert.equal(page.bedId.value, 'left-bed') page.bedId.value = 'right-bed' page.loadBed() await new Promise(resolve => setImmediate(resolve)) oldCurrent.resolve({ bed: sleepBed }); oldHistory.resolve({ items: [{ id: 'left-1' }] }) await new Promise(resolve => setImmediate(resolve)) assert.equal(page.current.value.bed.id, 'right-bed') assert.equal(page.history.value[0].id, 'right-1') await page.loadHistory(true) assert.equal(historyRequests[1].beforeId, cursor) assert.equal(historyRequests[1].fromMs, historyRequests[0].fromMs) assert.equal(historyRequests[1].toMs, historyRequests[0].toMs) assert.equal(page.history.value[1].id, 'right-2') assert.equal(page.nextBeforeId.value, undefined) }) test('error pages render retry controls without mistaken missing-assessment or device actions', async () => { for (const [name, path] of [['DashboardView', '/dashboard'], ['ProfileView', '/profile']]) { const html = await render(name, { loading: false, loadError: '测试连接失败' }, path) assert.ok(html.includes('测试连接失败')); assert.ok(html.includes('重新加载')) assert.ok(!html.includes('一键开启舒睡')); assert.ok(!html.includes('未评估')) } }) test('auth and both questionnaires render safely; loading records never show an editable form', async () => { const auth = await render('AuthView', {}, '/auth') assert.ok(auth.includes('autocomplete="username"')); assert.ok(auth.includes('autocomplete="current-password"')) for (const [name, path] of [['AssessmentView', '/assessment'], ['TcmAssessmentView', '/tcm-assessment']]) { const html = await render(name, {}, path) assert.ok(html.includes('正在读取')) assert.ok(!html.includes('type="number"')); assert.ok(!html.includes(' { const readStylesheet = relative => { const filename = path.join(root, relative) const result = compileStyle({ source: fs.readFileSync(filename, 'utf8'), filename, id: 'test-motion' }) assert.deepEqual(result.errors, [], relative) return result.rawResult.root } const stylesheet = readStylesheet('styles/page-polish.css') const reducedMotion = stylesheet.nodes.find(node => node.type === 'atrule' && node.name === 'media' && node.params === '(prefers-reduced-motion: reduce)') assert.ok(reducedMotion, 'keep the reduced-motion preference supported') const rule = reducedMotion.nodes.find(node => node.type === 'rule' && ['*', '*::before', '*::after'].every(selector => node.selectors.includes(selector))) assert.ok(rule, 'cover page indicators, device indicators and Vant loading animations') const declarations = Object.fromEntries(rule.nodes.filter(node => node.type === 'decl') .map(node => [node.prop, node])) assert.equal(declarations.animation?.value, 'none') assert.equal(declarations.animation?.important, true) assert.equal(declarations['transition-duration']?.value, '0s') assert.equal(declarations['transition-duration']?.important, true) reducedMotion.walkDecls('animation-duration', declaration => { assert.notEqual(declaration.value, '.01ms', 'never accelerate an infinite loading animation') }) const loadingRule = stylesheet.nodes.find(node => node.type === 'rule' && node.selector === '.page-loading-mark') assert.ok(loadingRule.nodes.some(node => node.prop === 'animation' && node.value === 'page-spin .9s linear infinite'), 'normal motion keeps the existing loading speed') const deviceStyles = readStylesheet('styles/device-control.css') const deviceLoadingRule = deviceStyles.nodes.find(node => node.type === 'rule' && node.selector === '.control-page .operation-spinner') assert.ok(deviceLoadingRule.nodes.some(node => node.prop === 'animation' && node.value === 'operation-spin 0.75s linear infinite')) }) test('loading indicators keep visible status text even when their animation is stopped', async () => { for (const [name, routePath, message] of [ ['DashboardView', '/dashboard', '正在读取健康档案与舒睡设备…'], ['ProfileView', '/profile', '正在读取您的健康档案…'], ]) { const html = await render(name, { loading: true }, routePath) assert.ok(html.includes('page-loading-mark'), name) assert.ok(html.includes(message), name) assert.ok(html.includes('role="status"'), name) } })