diff --git a/package.json b/package.json index 9e98fe7..e3b2a3d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,12 @@ "version": "0.1.0", "type": "module", "scripts": { + "test:devices": "node scripts/test-device-modules.mjs", + "test:device-pages": "node scripts/test-device-pages.mjs", + "test:navigation": "node scripts/test-navigation.mjs", + "test:assessments": "node scripts/test-assessment-pages.mjs", + "test:pages": "node scripts/test-app-pages.mjs", + "test": "npm run test:devices && npm run test:device-pages && npm run test:navigation && npm run test:assessments && npm run test:pages", "dev": "vite --host 0.0.0.0", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview --host 0.0.0.0" diff --git a/scripts/test-app-pages.mjs b/scripts/test-app-pages.mjs new file mode 100644 index 0000000..836852a --- /dev/null +++ b/scripts/test-app-pages.mjs @@ -0,0 +1,185 @@ +// 全站页面的假数据逻辑和 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 } 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 = {}) { + 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/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') { + const { default: view } = await server.ssrLoadModule(`/src/views/${name}.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) }); 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() }) + for (const value of ['我的舒睡空间', '一键开启舒睡', '查看舒睡设备', '查看体质启动预设', '打开我的健康档案']) assert.ok(html.includes(value), value) + assert.match(html, /
/) + assert.ok(html.indexOf('首页') < html.indexOf('中医四诊')) + assert.ok(html.indexOf('中医四诊') < html.indexOf('健康评估')) + assert.ok(html.indexOf('健康评估') < html.indexOf('我的')) +}) + +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(' { + 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(/ 30) + assert.equal((healthSource.match(/:aria-pressed=/g) ?? []).length, healthChoiceCount) + assert.match(healthSource, /]*:disabled="submitting"[^>]*:inert="submitting \|\| undefined"/) + assert.match(tcmSource, /]*: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) +}) diff --git a/scripts/test-device-modules.mjs b/scripts/test-device-modules.mjs new file mode 100644 index 0000000..146d64d --- /dev/null +++ b/scripts/test-device-modules.mjs @@ -0,0 +1,503 @@ +// 无真实网络/家具的回归检查;使用现有 TypeScript 编译器和 Node 内置测试运行器。 +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 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 flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() } +const control = (state, attributes = {}, available = true) => ({ entityId: 'fake', state, attributes, available }) +const status = (controls = {}, marker = '') => ({ connected: true, message: marker, devices: [], controlParameters: controls }) +const dashboard = () => ({ user: { id: 1 }, tcmResult: { hasCompleted: true } }) +const sceneResult = (message, sceneCode = 'sleep') => ({ success: true, status: 'Succeeded', message, sceneCode, actions: [] }) + +function environment(overrides = {}) { + const intervals = new Map(), timeouts = new Map(), storage = new Map(), disposed = [], mounted = [], toasts = [] + let id = 0 + const vue = { + ref: value => ({ value }), reactive: value => value, + computed: getter => ({ get value() { return getter() } }), + onUnmounted: handler => disposed.push(handler), onMounted: handler => mounted.push(handler), + } + const healthApi = { + homeAssistantStatus: async () => status(), dashboard: async () => dashboard(), + musicTransitionStatus: async () => ({ running: false, hasVolume: false, volumePercent: 0 }), + lightTransitionStatus: async () => ({ running: false, brightnessPercent: 0 }), + ...overrides, + } + const mocks = { + vue, 'vue-router': { useRouter: () => ({ push() {}, replace() {}, back() {} }) }, + vant: { + showFailToast: message => toasts.push({ type: 'fail', message }), + showSuccessToast: message => toasts.push({ type: 'success', message }), + showConfirmDialog: overrides.confirmDialog ?? (async () => {}), + showLoadingToast: options => { const toast = { ...options, close() { this.closed = true } }; toasts.push(toast); return toast }, + }, + } + const cache = new Map() + function load(relative) { + const file = path.resolve(root, relative.endsWith('.ts') ? relative : relative + '.ts') + if (cache.has(file)) return cache.get(file).exports + if (file === path.join(root, 'services/health.ts')) return { healthApi } + const module = { exports: {} }; cache.set(file, module) + const source = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, + }).outputText + const context = { + exports: module.exports, module, + require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))), + setInterval: callback => { const key = ++id; intervals.set(key, callback); return key }, + clearInterval: key => intervals.delete(key), + setTimeout: callback => { const key = ++id; timeouts.set(key, callback); return key }, + clearTimeout: key => timeouts.delete(key), + localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }, + window: { history: { length: 1 } }, Intl, Date, + } + vm.runInNewContext(source, context, { filename: file }) + return module.exports + } + return { load, healthApi, intervals, timeouts, toasts, vue, dispose: () => disposed.forEach(fn => fn()), mount: () => mounted.forEach(fn => fn()) } +} + +test('parameter conversion rejects junk and unavailable data; light zero and hardware limits are respected', () => { + const env = environment(), helpers = env.load('devices/parameterState') + assert.equal(helpers.numberValue('31.1garbage'), undefined) + assert.equal(helpers.numberValue(''), undefined) + assert.equal(helpers.boolValue(control('on', {}, false)), undefined) + assert.equal(helpers.parameterIsAvailable({ quick: control('unknown') }, 'quick'), true) + assert.equal(helpers.parameterIsAvailable({}, 'quick'), false) + const { createDeviceParameterState, syncDeviceParameters } = env.load('devices/parameterRegistry') + const params = createDeviceParameterState() + syncDeviceParameters(params, { brightness: control('off'), color_temp: control('on', { color_temp_kelvin: 6500 }), + mattress_left_heating: control('4'), volume: control('on', { volume_level: .56 }), fragrance_auto_light: control('on') }) + assert.equal(params.lightBrightness, 0); assert.equal(params.lightColorTemp, 5000) + assert.equal(params.mattressLeftHeating, 4); assert.equal(params.speakerVolume, 56) + assert.equal(params.fragranceAutoLight, true) + syncDeviceParameters(params, { fragrance_auto_light: control('unavailable', {}, false), volume: control('on', { volume_level: 'NaN' }) }) + assert.equal(params.fragranceAutoLight, true); assert.equal(params.speakerVolume, 56) +}) + +test('parameter availability is scoped to the actual device; missing numeric attributes are not default readings', () => { + const { parameterIsAvailable, parameterHasReading } = environment().load('devices/parameterState') + const lamp = device('lighting', 'Lighting', [entity('light.room', 'light', 'on')]) + const controls = { brightness: { ...control('on'), entityId: 'light.room' } } + assert.equal(parameterIsAvailable(controls, 'brightness', lamp), true) + assert.equal(parameterHasReading(controls, 'brightness', lamp), false) + controls.brightness.attributes.brightness = 128 + assert.equal(parameterHasReading(controls, 'brightness', lamp), true) + lamp.entities[0].available = false + assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false) + lamp.entities[0].available = true; controls.brightness.entityId = 'light.foreign' + assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false) + controls.brightness.entityId = 'light.room'; lamp.online = false + assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false) +}) + +test('mattress per-user preferences remain separate from the one physically running side', () => { + const env = environment(), params = env.load('devices/parameterRegistry').createDeviceParameterState() + const user = env.vue.ref(1), hook = env.load('devices/mattress/useMattressPreferences').useMattressPreferences(params, user, () => 'mattress') + hook.syncModes({ mattress_left_mode: control('智能适配'), mattress_right_mode: control('无') }) + assert.equal(params.mattressLeftMode, '智能适配'); assert.equal(params.mattressRightMode, '智能适配') + assert.equal(hook.mattressActiveSide.value, 'left') + hook.commitMode('left', '瑜伽助眠'); hook.commitMode('right', '智能腰托') + hook.syncModes({ mattress_left_mode: control('无'), mattress_right_mode: control('智能腰托') }) + assert.equal(params.mattressLeftMode, '瑜伽助眠'); assert.equal(hook.mattressActiveSide.value, 'right') + hook.syncModes({ mattress_left_mode: control('智能适配'), mattress_right_mode: control('智能腰托') }) + assert.equal(hook.mattressActiveSide.value, 'right') + user.value = 2; hook.syncModes({}) + assert.equal(params.mattressLeftMode, '智能适配') +}) + +test('older transition response cannot replace newer volume; disposal cannot create a late timer', async () => { + const first = deferred(), second = deferred(); let reads = 0 + const env = environment({ musicTransitionStatus: () => (++reads === 1 ? first : second).promise }) + const params = env.load('devices/parameterRegistry').createDeviceParameterState() + const hook = env.load('composables/useDeviceTransitions').useDeviceTransitions(params, async () => {}) + const a = hook.syncMusicTransitionStatus(), b = hook.syncMusicTransitionStatus() + second.resolve({ running: true, hasVolume: true, volumePercent: 46 }); await b + first.resolve({ running: true, hasVolume: true, volumePercent: 50 }); await a + assert.equal(params.speakerVolume, 46); assert.equal(env.intervals.size, 1) + env.dispose(); assert.equal(env.intervals.size, 0) + const late = deferred(); env.healthApi.musicTransitionStatus = () => late.promise + const request = hook.syncMusicTransitionStatus() + late.resolve({ running: true, hasVolume: true, volumePercent: 20 }); await request + assert.equal(params.speakerVolume, 46); assert.equal(env.intervals.size, 0) +}) + +test('out-of-order status reads preserve newer state and reported music overrides old HA volume', async () => { + const first = deferred(), second = deferred(); let reads = 0 + const env = environment({ homeAssistantStatus: () => (++reads === 1 ? first : second).promise, + musicTransitionStatus: async () => ({ running: true, hasVolume: true, volumePercent: 46 }) }) + const hook = env.load('composables/useDeviceControl').useDeviceControl() + const a = hook.loadStatus(), b = hook.loadStatus() + second.resolve(status({ volume: control('on', { volume_level: .7 }) }, 'new')); await b + first.resolve(status({}, 'old')); await a + assert.equal(hook.haStatus.value.message, 'new'); assert.equal(hook.localParams.speakerVolume, 46) + assert.equal(hook.loading.value, false) + env.dispose(); assert.equal(env.intervals.size, 0) +}) + +test('all-off can interrupt start; a late start result cannot overwrite off feedback or busy state', async () => { + const start = deferred(), off = deferred() + const env = environment({ executeSleepScene: () => start.promise, executeAllOffScene: () => off.promise }) + const hook = env.load('composables/useDeviceControl').useDeviceControl() + await hook.loadStatus() + const a = hook.executeScene('sleep'), b = hook.executeScene('all-off') + assert.equal(hook.sceneRunning.value, 'all-off') + assert.equal(env.toasts.find(toast => toast.message === '正在启动舒睡场景…').forbidClick, false) + off.resolve(sceneResult('已关闭', 'all-off')); await b + start.resolve(sceneResult('已开启')); await a + assert.equal(hook.actionFeedback.value.message, '已关闭'); assert.equal(hook.sceneRunning.value, undefined) + env.dispose(); assert.equal(env.timeouts.size, 0) +}) + +test('failed parameter command restores last known reading even if refresh itself fails', async () => { + const env = environment({ homeAssistantStatus: async () => status({ fragrance_auto_light: control('off') }), + setParameter: async () => ({ success: false, message: '失败' }) }) + const hook = env.load('composables/useDeviceControl').useDeviceControl() + await hook.loadStatus(); hook.localParams.fragranceAutoLight = true + env.healthApi.homeAssistantStatus = async () => { throw new Error('离线') } + assert.equal(await hook.applyParam('fragrance', 'fragrance_auto_light', true), false) + assert.equal(hook.localParams.fragranceAutoLight, false) + assert.equal(hook.actionFeedback.value.message, '失败') + env.dispose(); assert.equal(env.timeouts.size, 0) +}) + +test('disposing a page while a scene is pending prevents late refresh and success toast', async () => { + const pending = deferred(); let reads = 0 + const env = environment({ homeAssistantStatus: async () => { reads++; return status() }, executeSleepScene: () => pending.promise }) + const hook = env.load('composables/useDeviceControl').useDeviceControl() + await hook.loadStatus(); const scene = hook.executeScene('sleep') + env.dispose(); pending.resolve(sceneResult('迟到结果')); await scene; await flush() + assert.equal(reads, 1); assert.equal(env.intervals.size, 0); assert.equal(env.timeouts.size, 0) + assert.equal(env.toasts.some(toast => toast.message === '迟到结果'), false) +}) + +test('unavailable or unknown primary switches do not send commands; pending commands are not duplicated', async () => { + let calls = 0 + const pending = deferred() + const env = environment({ switchDevice: () => { calls++; return pending.promise } }) + const hook = env.load('composables/useDeviceControl').useDeviceControl() + await hook.loadStatus() + const item = device('fragrance', 'Fragrance', [entity('switch.fragrance', 'switch', 'unknown')], + { primaryControlEntityId: 'switch.fragrance' }) + await hook.toggleDevice(item); assert.equal(calls, 0) + item.entities[0].state = 'off'; item.entities[0].available = false + await hook.toggleDevice(item); assert.equal(calls, 0) + item.entities[0].available = true + const request = hook.toggleDevice(item) + await hook.toggleDevice(item); assert.equal(calls, 1) + pending.resolve({ success: true, deviceId: item.deviceId, entityId: 'switch.fragrance', state: 'on', message: '开启完成' }) + await request; assert.equal(hook.switchingDeviceId.value, null) + env.dispose() +}) + +test('navigation closes a pending parameter toast and prevents a late result from refreshing or reporting success', async () => { + const pending = deferred(); let reads = 0, writes = 0 + const env = environment({ homeAssistantStatus: async () => { reads++; return status() }, + setParameter: () => { writes++; return pending.promise } }) + const hook = env.load('composables/useDeviceControl').useDeviceControl() + await hook.loadStatus() + const request = hook.applyParam('speaker', 'volume', 20) + assert.equal(await hook.applyParam('speaker', 'volume', 30), false) + assert.equal(writes, 1) + const toast = env.toasts.find(item => item.message === '正在设置音箱音量…') + assert.equal(toast.closed, undefined) + env.dispose(); assert.equal(toast.closed, true) + pending.resolve({ success: true, message: '迟到的参数结果' }) + assert.equal(await request, false) + assert.equal(reads, 1) + assert.equal(env.toasts.some(item => item.message === '迟到的参数结果'), false) + assert.equal(env.timeouts.size, 0) +}) + +test('mattress confirmation cannot act after leaving the page or switching to another mattress', async () => { + const confirmation = deferred(), env = environment({ confirmDialog: () => confirmation.promise }) + const selected = env.vue.ref(device('mattress', 'Sleep', [], { deviceId: 'first' })) + let writes = 0 + const hook = env.load('devices/mattress/useMattressControls').useMattressControls(selected, + async () => { writes++; return true }, async () => {}, () => {}) + const request = hook.setMattressSelect('mattress_massage_right_part', { target: { value: '全身' } }) + selected.value = undefined; confirmation.resolve(); await request + assert.equal(writes, 0) + selected.value = device('mattress', 'Sleep', [], { deviceId: 'first' }) + env.dispose() + await hook.setMattressSelect('mattress_massage_right_part', { target: { value: '全身' } }) + assert.equal(writes, 0) +}) + +const device = (moduleKey, category, entities = [], extra = {}) => ({ + deviceId: 'device', name: '测试设备', manufacturer: '', model: '', moduleKey, category, + online: true, entities, ...extra, +}) +const entity = (entityId, domain, state, attributes = {}, available = true) => ({ entityId, domain, state, attributes, available }) + +const outletDevice = () => device('outlet', 'Outlet', [entity('switch.service', 'switch', 'on'), entity('switch.power', 'switch', 'off')], + { primaryControlEntityId: 'switch.power' }) + +test('outlet summary and power capability use only the bound main switch, including a valid off state', () => { + const env = environment(), { deviceSummary } = env.load('devices/deviceSummary') + const { outletCapabilities } = env.load('devices/outlet/capabilities'), item = outletDevice() + assert.equal(outletCapabilities(item).available, true); assert.equal(outletCapabilities(item).powerOn, false) + assert.equal(deviceSummary(item).statusText, '已断电'); assert.equal(deviceSummary(item).canToggle, true) + assert.equal(deviceSummary(item).icon, 'outlet'); assert.equal(deviceSummary(item).categoryLabel, '智能插座') + item.entities[1].state = 'on' + assert.equal(deviceSummary(item).statusText, '已通电'); assert.equal(outletCapabilities(item).powerOn, true) +}) + +test('outlet unknown/offline/missing main entity never borrows a working auxiliary switch', () => { + const env = environment(), { outletCapabilities } = env.load('devices/outlet/capabilities') + const { deviceSummary } = env.load('devices/deviceSummary'), hook = env.load('composables/useDeviceControl').useDeviceControl() + for (const state of ['unknown', 'unavailable', '', 'idle', 'standby']) { + const item = outletDevice(); item.entities[1].state = state + assert.equal(outletCapabilities(item).available, false); assert.equal(outletCapabilities(item).powerOn, null) + assert.equal(deviceSummary(item).canToggle, false); assert.equal(hook.isDeviceControlAvailable(item), false) + } + const item = outletDevice(); item.entities[1].available = false + assert.equal(outletCapabilities(item).available, false) + item.entities[1].available = true; item.primaryControlEntityId = 'switch.foreign' + assert.equal(outletCapabilities(item).available, false) + item.primaryControlEntityId = 'switch.power'; item.moduleKey = '' + assert.equal(deviceSummary(item).canToggle, false); assert.equal(hook.isDeviceControlAvailable(item), false) + item.moduleKey = 'outlet'; item.online = false + assert.equal(outletCapabilities(item).powerOn, null); assert.equal(hook.isDeviceControlAvailable(item), false) + env.dispose() +}) + +test('outlet energy is standard read-only own telemetry, not an encoded power/voltage engineering field', () => { + const { outletCapabilities } = environment().load('devices/outlet/capabilities'), item = outletDevice() + item.entities.push(entity('sensor.encoded', 'sensor', '8472528')) + assert.equal(outletCapabilities(item).energy, undefined) + item.entities.push({ ...entity('sensor.energy', 'sensor', '0', { device_class: 'energy' }), unit: 'kWh' }) + assert.equal(outletCapabilities(item).energy, 0) + for (const value of ['unknown', '-1', 'NaN', '20junk']) { + item.entities[3].state = value; assert.equal(outletCapabilities(item).energy, undefined) + } + item.entities[3].state = '1.25'; item.entities[3].available = false + assert.equal(outletCapabilities(item).energy, undefined) + item.entities[3].available = true; item.entities[3].unit = 'V' + assert.equal(outletCapabilities(item).energy, undefined) +}) + +test('manual outlet toggle sends exact main power entity and refuses unavailable controls', async () => { + const writes = [], item = outletDevice() + const env = environment({ switchDevice: async payload => { + writes.push(payload); const state = payload.turnOn ? 'on' : 'off'; item.entities[1].state = state + return { success: true, deviceId: payload.deviceId, entityId: payload.entityId, state, message: '已确认' } + } }) + const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus() + await hook.toggleDevice(item); await hook.toggleDevice(item) + assert.equal(writes.length, 2); assert.equal(writes[0].turnOn, true); assert.equal(writes[1].turnOn, false) + for (const write of writes) assert.equal(write.entityId, 'switch.power') + item.entities[1].state = 'unknown'; await hook.toggleDevice(item) + assert.equal(writes.length, 2); env.dispose() +}) + +test('overview distinguishes a known off state from unavailable data and never adds unconnected climate controls', () => { + const { deviceSummary } = environment().load('devices/deviceSummary') + const bulb = device('lighting', 'Lighting', [entity('light.room', 'light', 'on', { brightness: 128 })], { primaryControlEntityId: 'light.room' }) + assert.equal(deviceSummary(bulb).metricText, '亮度 50%') + assert.equal(deviceSummary(bulb).canToggle, true) + bulb.entities[0].state = 'off' + assert.equal(deviceSummary(bulb).powerOn, false) + bulb.entities[0].state = 'unknown' + assert.equal(deviceSummary(bulb).powerOn, null) + assert.equal(deviceSummary(bulb).canToggle, false) + bulb.online = false + assert.equal(deviceSummary(bulb).statusText, '设备离线') + const climate = device('', 'Climate', [entity('climate.room', 'climate', 'cool')], { primaryControlEntityId: 'climate.room' }) + assert.equal(deviceSummary(climate).canToggle, false) +}) + +test('overview separates fragrance from its nightlight and only uses mattress readings belonging to that device', () => { + const { deviceSummary } = environment().load('devices/deviceSummary') + const fragrance = device('fragrance', 'Fragrance', [entity('switch.fragrance', 'switch', 'off'), entity('light.night', 'light', 'on')], + { primaryControlEntityId: 'switch.fragrance' }) + assert.equal(deviceSummary(fragrance).powerOn, false) + assert.equal(deviceSummary(fragrance).statusText, '已关闭') + const mattress = device('mattress', 'Sleep', [entity('select.massage', 'select', '全身')]) + assert.equal(deviceSummary(mattress).canToggle, false) + assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('全身'), entityId: 'foreign' } } }).statusText, '已连接') + assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('全身'), entityId: 'select.massage' } } }).statusText, '右侧按摩中') + assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('unknown'), entityId: 'select.massage' } } }).statusText, '已连接') +}) + +test('overview uses latest music runtime volume even if HA still reports an old value or an offline speaker', () => { + const { deviceSummary } = environment().load('devices/deviceSummary') + const audio = device('audio', 'Media', [entity('media_player.room', 'media_player', 'idle', { volume_level: .7 })], + { primaryControlEntityId: 'media_player.room' }) + const music = { running: true, hasVolume: true, volumePercent: 46 } + assert.equal(deviceSummary(audio, { music }).metricText, '音量 46%') + audio.online = false; audio.entities[0].available = false + const summary = deviceSummary(audio, { music }) + assert.equal(summary.statusText, '助眠播放中') + assert.equal(summary.powerOn, true) + assert.equal(summary.canToggle, true) + assert.equal(summary.audioControl, true) +}) + +test('module mapping never borrows same-category furniture; detail navigation preserves raw device IDs for router encoding', () => { + const { deviceModuleKey, deviceDetailLocation } = environment().load('devices/deviceKinds') + assert.equal(deviceModuleKey(device('', 'Lighting')), '') + assert.equal(deviceModuleKey(device(undefined, 'Lighting')), 'lighting') + assert.equal(deviceModuleKey(device('new-device', 'Lighting')), 'new-device') + const location = deviceDetailLocation('房间/灯 #1?') + assert.equal(location.name, 'device-detail') + assert.equal(location.params.deviceId, '房间/灯 #1?') +}) + +const climateAttributes = () => ({ hvac_modes: ['cool', 'heat', 'fan_only', 'off'], min_temp: 5, max_temp: 35, + temperature_unit: '°C', + target_temp_step: .5, fan_modes: ['自动', '低风', '中风', '高风'], current_temperature: 24.1, + temperature: 23, fan_mode: '低风', supported_features: 393 }) +const climateDevice = (attributes = climateAttributes(), state = 'off', available = true) => device('climate', 'Climate', + [entity('climate.room', 'climate', state, attributes, available)], { primaryControlEntityId: 'climate.room' }) +const curtainDevice = (attributes = { supported_features: 15 }, state = 'unavailable', available = false) => device('curtain', 'Curtain', + [entity('cover.room', 'cover', state, attributes, available)], { primaryControlEntityId: 'cover.room' }) + +test('climate reads real mode, fan and temperature capabilities instead of hardcoded hardware defaults', () => { + const { climateCapabilities } = environment().load('devices/climate/capabilities') + const item = climateDevice(), capability = climateCapabilities(item) + assert.equal(capability.available, true) + assert.deepEqual(Array.from(capability.modes), ['cool', 'heat', 'fan_only', 'off']) + assert.deepEqual(Array.from(capability.fans), ['自动', '低风', '中风', '高风']) + assert.equal(capability.range.min, 5); assert.equal(capability.range.max, 35); assert.equal(capability.range.step, .5) + assert.equal(capability.targetTemperature, 23); assert.equal(capability.currentTemperature, 24.1) + assert.equal(capability.fanMode, '低风'); assert.equal(capability.canSetTemperature, true) + assert.equal(capability.canSetFan, true); assert.equal(capability.supports(32), false) + const otherRange = climateCapabilities(climateDevice({ ...climateAttributes(), min_temp: 18, max_temp: 28, target_temp_step: 1 })) + assert.equal(otherRange.range.min, 18); assert.equal(otherRange.range.max, 28); assert.equal(otherRange.range.step, 1) +}) + +test('climate cannot fabricate missing target temperature, range, fan or mode capability', () => { + const { climateCapabilities } = environment().load('devices/climate/capabilities') + for (const bad of [undefined, null, 'NaN', '23garbage', 40]) { + const capability = climateCapabilities(climateDevice({ ...climateAttributes(), temperature: bad })) + assert.equal(capability.targetTemperature, undefined); assert.equal(capability.canSetTemperature, false) + } + for (const attribute of ['min_temp', 'max_temp', 'target_temp_step']) { + const attributes = climateAttributes(); delete attributes[attribute] + assert.equal(climateCapabilities(climateDevice(attributes)).range, undefined) + } + assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), target_temp_step: 0 })).range, undefined) + assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), supported_features: 1 })).canSetFan, false) + assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), fan_mode: 'invalid' })).fanMode, undefined) + assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), hvac_modes: ['off', null, '', 'invalid'] }, 'cool')).available, false) + const fahrenheit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: '°F' })) + assert.equal(fahrenheit.temperatureUnit, '℉') + assert.equal(fahrenheit.canSetTemperature, false) + const unknownUnit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: 'kelvin' })) + assert.equal(unknownUnit.targetTemperature, undefined); assert.equal(unknownUnit.currentTemperature, undefined) + assert.equal(unknownUnit.canSetTemperature, false) + const missingUnit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: undefined })) + assert.equal(missingUnit.temperatureUnit, undefined); assert.equal(missingUnit.canSetTemperature, false) + assert.equal(missingUnit.currentTemperature, undefined); assert.equal(missingUnit.targetTemperature, undefined) +}) + +test('climate main control must be owned, available and bound; turn-on and turn-off use directional flags', () => { + const { climateCapabilities } = environment().load('devices/climate/capabilities') + const item = climateDevice() + assert.equal(climateCapabilities(item).canToggle, true) + item.entities[0].state = 'cool'; assert.equal(climateCapabilities(item).canToggle, true) + item.entities[0].attributes.supported_features = 257; assert.equal(climateCapabilities(item).canToggle, false) + item.entities[0].state = 'off'; assert.equal(climateCapabilities(item).canToggle, true) + item.entities[0].attributes.supported_features = 129; assert.equal(climateCapabilities(item).canToggle, false) + item.entities[0].attributes.supported_features = 393 + item.entities[0].available = false; assert.equal(climateCapabilities(item).available, false) + item.entities.push(entity('sensor.online', 'sensor', '1')); assert.equal(climateCapabilities(item).canToggle, false) + item.entities[0].available = true; item.moduleKey = ''; assert.equal(climateCapabilities(item).available, false) + item.moduleKey = 'climate'; item.primaryControlEntityId = 'climate.foreign' + assert.equal(climateCapabilities(item).entity, undefined); assert.equal(climateCapabilities(item).canToggle, false) +}) + +test('curtain has dedicated feature-gated actions and never invents position for an unavailable main entity', () => { + const { curtainCapabilities } = environment().load('devices/curtain/capabilities') + const item = curtainDevice(), unavailable = curtainCapabilities(item) + assert.equal(unavailable.supports(4), true); assert.equal(unavailable.position, undefined) + for (const action of ['open', 'stop', 'close']) assert.equal(unavailable.canAction(action), false) + item.entities[0].available = true; item.entities[0].state = 'open' + const ready = curtainCapabilities(item) + for (const action of ['open', 'stop', 'close']) assert.equal(ready.canAction(action), true) + assert.equal(ready.canSetPosition, false) + item.entities[0].attributes.current_position = 0 + assert.equal(curtainCapabilities(item).position, 0); assert.equal(curtainCapabilities(item).canSetPosition, true) + item.entities[0].attributes.supported_features = 1 + const limited = curtainCapabilities(item) + assert.equal(limited.canAction('open'), true); assert.equal(limited.canAction('close'), false) + assert.equal(limited.canAction('stop'), false); assert.equal(limited.canSetPosition, false) +}) + +test('curtain rejects malformed position, unknown state, foreign entity and same-category unbound device', () => { + const { curtainCapabilities } = environment().load('devices/curtain/capabilities') + for (const current_position of [undefined, null, '', 'unknown', '50garbage', -1, 101]) + assert.equal(curtainCapabilities(curtainDevice({ supported_features: 15, current_position }, 'open', true)).position, undefined) + const item = curtainDevice({ supported_features: 15, current_position: 50 }, 'unknown', true) + assert.equal(curtainCapabilities(item).available, false) + item.entities[0].state = 'open'; item.moduleKey = ''; assert.equal(curtainCapabilities(item).available, false) + item.moduleKey = 'curtain'; item.primaryControlEntityId = 'cover.foreign' + assert.equal(curtainCapabilities(item).entity, undefined) +}) + +test('new parameter synchronization preserves real readings and missing attributes are not readable', () => { + const env = environment(), { createDeviceParameterState, syncDeviceParameters } = env.load('devices/parameterRegistry') + const { parameterHasReading, parameterIsAvailable } = env.load('devices/parameterState') + const state = createDeviceParameterState() + assert.equal(state.climateTemperature, undefined); assert.equal(state.curtainPosition, undefined) + const controls = { climate_temperature: { ...control('off', climateAttributes()), entityId: 'climate.room' }, + climate_mode: { ...control('cool'), entityId: 'climate.room' }, + climate_fan_mode: { ...control('cool', { fan_mode: '低风' }), entityId: 'climate.room' }, + curtain_position: { ...control('closed', { current_position: 0 }), entityId: 'cover.room' } } + syncDeviceParameters(state, controls) + assert.equal(state.climateTemperature, 23); assert.equal(state.climateMode, 'cool') + assert.equal(state.climateFanMode, '低风'); assert.equal(state.curtainPosition, 0) + assert.equal(parameterHasReading(controls, 'climate_temperature', climateDevice()), true) + assert.equal(parameterHasReading(controls, 'curtain_position', curtainDevice({}, 'closed', true)), true) + delete controls.climate_temperature.attributes.temperature + assert.equal(parameterHasReading(controls, 'climate_temperature', climateDevice()), false) + controls.climate_fan_mode.attributes = {} + assert.equal(parameterHasReading(controls, 'climate_fan_mode', climateDevice()), false) + controls.curtain_position.entityId = 'cover.foreign' + assert.equal(parameterIsAvailable(controls, 'curtain_position', curtainDevice({}, 'open', true)), false) + syncDeviceParameters(state, { climate_temperature: control('off', { temperature: 'garbage' }), curtain_position: control('closed', { current_position: -1 }) }) + assert.equal(state.climateTemperature, 23); assert.equal(state.curtainPosition, 0) +}) + +test('new overview summaries use the main entity and curtain never exposes a generic power toggle', () => { + const { deviceSummary } = environment().load('devices/deviceSummary') + const climate = climateDevice() + assert.equal(deviceSummary(climate).statusText, '关闭'); assert.equal(deviceSummary(climate).metricText, '室温 24.1℃ · 目标 23℃') + assert.equal(deviceSummary(climate).canToggle, true) + climate.entities[0].available = false + assert.equal(deviceSummary(climate).statusText, '空调主控不可用'); assert.equal(deviceSummary(climate).canToggle, false) + const curtain = curtainDevice({ supported_features: 15, current_position: 55 }, 'opening', true) + assert.equal(deviceSummary(curtain).statusText, '正在打开'); assert.equal(deviceSummary(curtain).metricText, '开度 55%') + assert.equal(deviceSummary(curtain).canToggle, false) + delete curtain.entities[0].attributes.current_position + assert.equal(deviceSummary(curtain).metricText, '打开 · 停止 · 关闭') + curtain.entities[0].available = false + assert.equal(deviceSummary(curtain).statusText, '窗帘主控不可用') +}) + +test('climate generic switch accepts only bound directional capability and sends exact trusted entity', async () => { + const writes = [], env = environment({ switchDevice: async payload => { writes.push(payload); return { success: true, deviceId: payload.deviceId, entityId: payload.entityId, state: 'cool', message: '已发送' } } }) + const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus() + const item = climateDevice() + assert.equal(hook.isDeviceControlAvailable(item), true) + await hook.toggleDevice(item) + assert.equal(writes.length, 1); assert.equal(writes[0].entityId, 'climate.room'); assert.equal(writes[0].turnOn, true) + item.entities[0].attributes.supported_features = 129 + await hook.toggleDevice(item); assert.equal(writes.length, 1) + item.entities[0].attributes.supported_features = 393; item.moduleKey = '' + await hook.toggleDevice(item); assert.equal(writes.length, 1) + await hook.toggleDevice(curtainDevice({ supported_features: 15, current_position: 0 }, 'closed', true)) + assert.equal(writes.length, 1) + env.dispose() +}) diff --git a/scripts/test-device-pages.mjs b/scripts/test-device-pages.mjs new file mode 100644 index 0000000..ae12996 --- /dev/null +++ b/scripts/test-device-pages.mjs @@ -0,0 +1,308 @@ +// 渲染真实 Vue 页面,注入假读数;不启动 HTTP 服务、不请求后端、不操作真实家具。 +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer } from 'vite' +import vue from '@vitejs/plugin-vue' +import { createSSRApp, h, ref } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { createRouter, createMemoryHistory, RouterView } from 'vue-router' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const server = await createServer({ configFile: false, root, plugins: [vue()], appType: 'custom', + server: { middlewareMode: true, hmr: false }, optimizeDeps: { noDiscovery: true, include: [] } }) +after(() => server.close()) +const { default: Overview } = await server.ssrLoadModule('/src/views/DeviceControlView.vue') +const { default: Detail } = await server.ssrLoadModule('/src/views/DeviceDetailView.vue') +const { deviceControlKey } = await server.ssrLoadModule('/src/devices/controlContext.ts') +const { createDeviceParameterState, syncDeviceParameters } = await server.ssrLoadModule('/src/devices/parameterRegistry.ts') +const mattressParams = await server.ssrLoadModule('/src/devices/mattress/parameters.ts') +const { deviceDetailLocation } = await server.ssrLoadModule('/src/devices/deviceKinds.ts') +const { parameterIsAvailable, parameterHasReading } = await server.ssrLoadModule('/src/devices/parameterState.ts') +const { primaryDeviceEntity, reportedPower } = await server.ssrLoadModule('/src/devices/deviceSummary.ts') +const { climateCapabilities } = await server.ssrLoadModule('/src/devices/climate/capabilities.ts') + +const reading = (entityId, domain, state, attributes = {}) => ({ entityId, domain, name: entityId, state, attributes, available: true }) +const device = (deviceId, name, moduleKey, category, entities = [], extra = {}) => ({ + deviceId, name, moduleKey, category, entities, online: true, manufacturer: '测试品牌', model: '测试型号', areaName: '睡眠空间', ...extra, +}) +const devices = [ + device('lamp', '演示卧室灯', 'lighting', 'Lighting', [reading('light.room', 'light', 'on', { brightness: 128 })], { primaryControlEntityId: 'light.room' }), + device('mattress', '演示智能床垫', 'mattress', 'Sleep'), + device('fragrance', '演示香氛机', 'fragrance', 'Fragrance', [reading('switch.fragrance', 'switch', 'off')], { primaryControlEntityId: 'switch.fragrance' }), + device('speaker', '演示音箱', 'audio', 'Media', [reading('media_player.room', 'media_player', 'idle', { volume_level: .7 })], { primaryControlEntityId: 'media_player.room' }), +] +const unexpectedCommand = () => { throw new Error('页面检查不允许发出家具命令') } + +async function render(location = '/device-control', overrides = {}) { + const router = createRouter({ history: createMemoryHistory(), routes: [ + { path: '/device-control', component: { render: () => h(RouterView) }, children: [ + { path: '', name: 'device-overview', component: Overview }, + { path: ':deviceId', name: 'device-detail', component: Detail }, + ] }, + ] }) + const context = { + router, loading: ref(false), operationBusy: ref(false), tcmResult: ref({ hasCompleted: true, constitutionName: '平和质' }), + haStatus: ref({ connected: true, message: '', devices, summary: { totalDevices: 4, onlineDevices: 4 }, + environment: { temperature: 25.6, humidity: 58.8 }, controlParameters: {} }), + musicTransition: ref({ running: true, hasVolume: true, volumePercent: 46 }), lightTransition: ref(), + sceneRunning: ref(), switchingDeviceId: ref(), settingParam: ref(), actionFeedback: ref(), currentOperationText: ref(''), lastUpdatedAt: ref(''), + localParams: createDeviceParameterState(), mattressComfort: ref(), mattressActiveSide: ref('left'), + ...mattressParams, + isDeviceTurnedOn: value => Boolean(reportedPower(primaryDeviceEntity(value))), + isDeviceControlAvailable: value => primaryDeviceEntity(value)?.domain === 'climate' ? climateCapabilities(value).canToggle + : Boolean(value?.online && reportedPower(primaryDeviceEntity(value)) !== null && + ['switch', 'humidifier', 'light', 'media_player'].includes(primaryDeviceEntity(value)?.domain)), + isParameterAvailable: (parameter, value) => parameterIsAvailable(context.haStatus.value?.controlParameters, parameter, value), + hasParameterReading: (parameter, value) => parameterHasReading(context.haStatus.value?.controlParameters, parameter, value), + toggleDevice: unexpectedCommand, controlSpeaker: unexpectedCommand, applyParam: unexpectedCommand, + executeScene: unexpectedCommand, loadStatus: unexpectedCommand, controlLightTransition: unexpectedCommand, + setMattressMode: unexpectedCommand, setMattressSelect: unexpectedCommand, ...overrides, + } + await router.push(location); await router.isReady() + const app = createSSRApp({ render: () => h(RouterView) }) + app.use(router); app.provide(deviceControlKey, context) + return { html: await renderToString(app), router } +} + +const climateAttributes = () => ({ hvac_modes: ['cool', 'heat', 'fan_only', 'off'], min_temp: 5, max_temp: 35, target_temp_step: .5, + temperature_unit: '°C', + fan_modes: ['自动', '低风', '中风', '高风'], current_temperature: 24.1, temperature: 23, fan_mode: '低风', supported_features: 393 }) +const newDeviceSnapshot = item => { + const entity = item.entities.find(value => value.entityId === item.primaryControlEntityId) + const keys = item.moduleKey === 'climate' ? ['climate_mode', 'climate_temperature', 'climate_fan_mode'] : ['curtain_action', 'curtain_position'] + const controlParameters = Object.fromEntries(keys.map(key => [key, { entityId: entity.entityId, state: entity.state, available: entity.available, attributes: entity.attributes }])) + return { connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters } +} +const renderNewDevice = async (item, change = {}) => { + const snapshot = { ...newDeviceSnapshot(item), ...change }, localParams = createDeviceParameterState() + syncDeviceParameters(localParams, snapshot.controlParameters) + return render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot), localParams }) +} + +test('climate detail exposes only real supported modes, range and fan options', async () => { + const item = device('climate-demo', '演示空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'off', climateAttributes())], { primaryControlEntityId: 'climate.room' }) + const { html } = await renderNewDevice(item) + for (const text of ['室温调节', '当前室温', '24.1℃', '23℃', '空调运行模式', '制冷', '制热', '送风', '空调目标温度', '每档 0.5℃', '5℃', '35℃', '低风', '高风']) + assert.ok(html.includes(text), text) + for (const text of ['摆风', '校准', '恢复出厂', '演示智能床垫']) assert.ok(!html.includes(text), text) + assert.ok(html.includes('van-slider')) + const modeButtons = html.match(/]*aria-pressed[^>]*>[\s\S]*?<\/button>/g) + assert.equal(modeButtons.length, 8) + assert.equal(modeButtons.filter(value => value.includes('disabled')).length, 0) +}) + +test('climate missing target or range never renders a default temperature slider', async () => { + const attributes = climateAttributes(); delete attributes.temperature + const item = device('climate-missing', '温度缺失空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'cool', attributes)], { primaryControlEntityId: 'climate.room' }) + let html = (await renderNewDevice(item)).html + assert.ok(html.includes('不显示默认温度')); assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('0℃')) + attributes.temperature = 23; delete attributes.min_temp + html = (await renderNewDevice(item)).html + assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('23℃')) + attributes.min_temp = 5; attributes.temperature_unit = '°F' + html = (await renderNewDevice(item)).html + assert.ok(html.includes('23℉')); assert.ok(html.includes('24.1℉')); assert.ok(!html.includes('23℃')) + assert.ok(html.includes('van-slider--disabled')); assert.ok(html.includes('此版仅支持摄氏温度调节')) + delete attributes.temperature_unit + html = (await renderNewDevice(item)).html + assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('23℃')); assert.ok(!html.includes('24.1℃')) +}) + +test('climate offline main entity does not borrow auxiliary availability or another device parameter', async () => { + const item = device('climate-offline', '主控离线空调', 'climate', 'Climate', [ + { ...reading('climate.room', 'climate', 'unavailable', climateAttributes()), available: false }, reading('sensor.alive', 'sensor', '1'), + ], { primaryControlEntityId: 'climate.room' }) + const { html } = await renderNewDevice(item) + assert.ok(html.includes('空调当前不可用')); assert.ok(!html.includes('24.1℃')); assert.ok(!html.includes('23℃')) + assert.ok(!html.includes('van-slider')) + const buttons = html.match(/]*aria-pressed[^>]*>[\s\S]*?<\/button>/g) + assert.ok(buttons.every(value => value.includes('disabled'))) + const owned = device('climate-owned', '未拥有参数空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'off', climateAttributes())], { primaryControlEntityId: 'climate.room' }) + const snapshot = newDeviceSnapshot(owned) + for (const control of Object.values(snapshot.controlParameters)) control.entityId = 'climate.foreign' + const foreignHtml = (await renderNewDevice(owned, snapshot)).html + assert.ok(!foreignHtml.includes('van-slider')); assert.ok(!foreignHtml.includes('23℃')) + assert.ok(foreignHtml.match(/]*aria-pressed[^>]*>[\s\S]*?<\/button>/g).every(value => value.includes('disabled'))) +}) + +test('curtain unavailable detail keeps three disabled actions without inventing a position or generic power switch', async () => { + const item = device('curtain-offline', '演示窗帘', 'curtain', 'Curtain', [ + { ...reading('cover.room', 'cover', 'unavailable', { supported_features: 15 }), available: false }, + ], { primaryControlEntityId: 'cover.room', online: false }) + const { html } = await renderNewDevice(item) + for (const text of ['窗帘开合', '打开', '停止', '关闭', '窗帘开度', '开度暂未回读', '窗帘当前不可用']) assert.ok(html.includes(text), text) + assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('0% 完全关闭')) + assert.ok(!html.includes('mini-toggle')); assert.ok(!/[\s\S]*?<\/div>/)[0] + assert.equal((group.match(/ + +
+

已载入上次评估,修改后保存即可更新。

@@ -183,7 +196,9 @@ onMounted(() => { type="number" min="1" max="120" + step="1" placeholder="请输入年龄" + aria-label="年龄(岁)" /> 岁
@@ -197,7 +212,9 @@ onMounted(() => { type="number" min="80" max="250" + step="0.1" placeholder="175" + aria-label="身高(厘米)" /> cm @@ -211,7 +228,9 @@ onMounted(() => { type="number" min="20" max="250" + step="0.1" placeholder="68" + aria-label="体重(千克)" /> kg @@ -246,34 +265,38 @@ onMounted(() => { 每晚平均睡眠时长
-
不足 6 小时 (睡眠欠缺) -
-
+
-
+
-
+
+
@@ -284,9 +307,10 @@ onMounted(() => { 入睡耗时与困难程度
-
@@ -294,11 +318,12 @@ onMounted(() => { 入睡较慢

睡前思绪繁杂、对光线声音敏感,常在床上翻来覆去无法入眠

-
+ -
@@ -306,11 +331,12 @@ onMounted(() => { 轻柔微光

入睡平稳,偶有疲劳或压力,整体处于健康范围

-
+ -
@@ -318,7 +344,7 @@ onMounted(() => { 常规舒眠

沾枕头即入睡,深睡眠过渡平顺

-
+
@@ -329,9 +355,10 @@ onMounted(() => { 睡眠时的冷热感受
-
@@ -339,11 +366,12 @@ onMounted(() => { 偏热

被窝温度稍高就烦躁踢被,需要更干爽清凉的卧房环境

-
+ -
@@ -351,11 +379,12 @@ onMounted(() => { 偏冷

入睡半天手脚捂不热,稍有微风吹拂即觉寒凉

-
+ -
@@ -363,7 +392,7 @@ onMounted(() => { 冷热适中

体感舒适度正常,对温湿度耐受度均衡

-
+
@@ -374,27 +403,30 @@ onMounted(() => { 夜间醒来与多梦浅睡情况
-
频繁易惊醒 -
-
+
-
+
+
@@ -405,27 +437,30 @@ onMounted(() => { 床垫承托硬度偏好
-
偏硬护脊 (7级) -
-
+
-
+
+
@@ -445,34 +480,38 @@ onMounted(() => { 每周活动与锻炼频率
-
几乎不运动 (久坐为主) -
-
+
-
+
-
+
+
@@ -483,34 +522,38 @@ onMounted(() => { 日常最易酸痛或劳损部位
-
肩颈僵硬酸痛 -
-
+
-
+
-
+
+
@@ -521,34 +564,38 @@ onMounted(() => { 主要活动锻炼时段
-
清晨早间活动 -
-
+
-
+
-
+
+
@@ -568,27 +615,30 @@ onMounted(() => { 三餐定时定量规律度
-
三餐基本规律 -
-
+
-
+
+
@@ -599,27 +649,30 @@ onMounted(() => { 日常饮食口味倾向
-
清淡低盐少油 -
-
+
-
+
+
@@ -630,9 +683,10 @@ onMounted(() => { 夜宵及睡前进食习惯
-
@@ -640,11 +694,12 @@ onMounted(() => { 经常晚间进食

睡前进食可能影响休息,可逐步调整进食时间

-
+ -
@@ -652,11 +707,12 @@ onMounted(() => { 偶尔晚间进食

偶尔因加班或聚餐改变晚餐时间

-
+ -
@@ -664,7 +720,7 @@ onMounted(() => { 较少夜宵

通常能给晚餐和入睡留出间隔

-
+
@@ -675,27 +731,30 @@ onMounted(() => { 每日饮水充足度
-
充沛 (>2000ml) -
-
+
-
+
+
@@ -709,11 +768,13 @@ onMounted(() => { size="large" color="#0c5045" :loading="submitting" + :disabled="submitting" @click="handleSave" > 保存健康评估 +
@@ -765,6 +826,12 @@ onMounted(() => { padding: 16px 14px; } +.assessment-form { min-width: 0; padding: 0; margin: 0; border: 0; } +.assessment-form[disabled] { opacity: .72; } +.saved-assessment-note { margin: 0 4px 14px; color: #527169; font-size: 12px; line-height: 1.6; } +.page-error-state h3 { margin: 0 0 8px; font-size: 16px; } +.page-error-state p { font-size: 13px; line-height: 1.6; } + .card-section { background: #ffffff; border-radius: 20px; @@ -980,6 +1047,7 @@ onMounted(() => { cursor: pointer; transition: all 0.2s ease; user-select: none; + font-family: inherit; } .option-pill.active { @@ -996,8 +1064,13 @@ onMounted(() => { border-radius: 12px; cursor: pointer; transition: all 0.2s ease; + width: 100%; + font-family: inherit; + text-align: left; } +.option-pill:focus-visible, .option-card:focus-visible { outline: 2px solid #0c5045; outline-offset: 3px; } + .option-card.active { background: #eef7f3; border-color: #0c5045; diff --git a/src/views/AuthView.vue b/src/views/AuthView.vue index fe666cb..7a88e5f 100644 --- a/src/views/AuthView.vue +++ b/src/views/AuthView.vue @@ -4,26 +4,36 @@ import { useRouter } from 'vue-router' import { Button as VanButton, Field as VanField, Form as VanForm, showToast } from 'vant' import { healthApi } from '../services/health' import { saveTokens } from '../services/auth' +import { usePageRequest } from '../composables/usePageRequest' const router = useRouter() const mode = ref<'login' | 'register'>('login') const loading = ref(false) const form = reactive({ phone: '', password: '', displayName: '' }) const title = computed(() => mode.value === 'login' ? '欢迎回来' : '开启专属健康档案') +const pageRequest = usePageRequest() async function submit() { + if (loading.value) return + const requestMode = mode.value + const phone = form.phone.trim(), displayName = form.displayName.trim() + if (!/^1[3-9]\d{9}$/.test(phone)) { showToast('请输入正确的手机号'); return } + if (!form.password || (requestMode === 'register' && form.password.length < 8)) { showToast(requestMode === 'register' ? '注册密码至少 8 位' : '请输入密码'); return } + if (requestMode === 'register' && !displayName) { showToast('请输入姓名或称呼'); return } + const requestId = pageRequest.begin() loading.value = true try { - const output = mode.value === 'login' - ? await healthApi.login({ phone: form.phone, password: form.password }) - : await healthApi.register(form) + const output = requestMode === 'login' + ? await healthApi.login({ phone, password: form.password }) + : await healthApi.register({ phone, password: form.password, displayName }) + if (!pageRequest.isCurrent(requestId)) return saveTokens(output.accessToken, output.refreshToken) - showToast({ type: 'success', message: mode.value === 'login' ? '登录成功' : '注册成功' }) - await router.replace(mode.value === 'login' ? '/dashboard' : '/tcm-assessment') + showToast({ type: 'success', message: requestMode === 'login' ? '登录成功' : '注册成功' }) + await router.replace(requestMode === 'login' ? '/dashboard' : '/tcm-assessment') } catch (error) { - showToast(error instanceof Error ? error.message : '操作失败') + if (pageRequest.isCurrent(requestId)) showToast(error instanceof Error ? error.message : '操作失败') } finally { - loading.value = false + if (pageRequest.isCurrent(requestId)) loading.value = false } } @@ -39,7 +49,7 @@ async function submit() {

一人一方
健康与空间协同

以睡眠为切入点,融合五感空间与动态健康方案,开启日常健康管理。

- 🌿 六维处方🌙 五感微环境📊 闭环调理 + 体质评估舒睡联动健康档案
@@ -48,6 +58,7 @@ async function submit() {