1
This commit is contained in:
@@ -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, /<details class="startup-preset-details">/)
|
||||
assert.ok(html.indexOf('首页</span>') < html.indexOf('中医四诊</span>'))
|
||||
assert.ok(html.indexOf('中医四诊</span>') < html.indexOf('健康评估</span>'))
|
||||
assert.ok(html.indexOf('健康评估</span>') < html.indexOf('我的</span>'))
|
||||
})
|
||||
|
||||
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('<textarea'))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
// Isolated page logic regression checks. No HTTP, accounts, or real devices.
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import vm from 'node:vm'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parse, compileScript } from 'vue/compiler-sfc'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const healthAnswers = () => ({
|
||||
sleepDuration: '7-8', fallingAsleepSpeed: 'normal', temperaturePreference: 'normal',
|
||||
nightAwakening: 'none', mattressPreference: 'medium', exerciseFrequency: 'daily',
|
||||
muscleFatigueArea: 'none', activeTimeOfDay: 'morning', mealRegularity: 'regular',
|
||||
flavorPreference: 'light', lateNightEating: 'rarely', dailyWaterIntake: 'moderate'
|
||||
})
|
||||
const tcmRecord = () => ({
|
||||
hasCompleted: true, constitutionCode: 'balanced', constitutionName: '平和质',
|
||||
answers: { face: 'rosy', body: 'balanced', spirit: 'energetic', voice: 'normal',
|
||||
coldHeat: 'neutral', sweating: 'normal', mouth: 'normal', stool: 'normal',
|
||||
sleep: 'good', emotion: 'steady', allergy: 'none', pulse: 'unknown' },
|
||||
notes: { tongue: '', odor: '', breath: '', pulseNote: '' }, sceneConfig: {}
|
||||
})
|
||||
|
||||
function environment(overrides = {}) {
|
||||
const mounted = [], disposed = [], toasts = [], navigations = [], scrolls = [], ticks = []
|
||||
const vue = {
|
||||
defineComponent: options => options, ref: value => ({ value }), reactive: value => value,
|
||||
computed: getter => ({ get value() { return getter() } }),
|
||||
onMounted: handler => mounted.push(handler), onUnmounted: handler => disposed.push(handler),
|
||||
nextTick: async () => { ticks.push('tick') }
|
||||
}
|
||||
const healthApi = {
|
||||
getAssessment: async () => ({ hasCompletedAssessment: false }),
|
||||
saveAssessment: async () => ({ hasCompletedAssessment: true }),
|
||||
getTcmAssessment: async () => ({ hasCompleted: false }),
|
||||
saveTcmAssessment: async () => tcmRecord(), ...overrides
|
||||
}
|
||||
const mocks = {
|
||||
vue, 'vue-router': { useRouter: () => ({ replace: location => navigations.push(location) }) },
|
||||
vant: { Button: {}, showToast: message => toasts.push(message),
|
||||
showSuccessToast: message => toasts.push(message), showFailToast: message => toasts.push(message) }
|
||||
}
|
||||
const cache = new Map()
|
||||
function load(relative) {
|
||||
const file = path.resolve(root, /\.(ts|vue)$/.test(relative) ? relative : relative + '.ts')
|
||||
if (file === path.join(root, 'services/health.ts')) return { healthApi }
|
||||
if (file.endsWith('AppTabBar.vue')) return { default: {} }
|
||||
if (cache.has(file)) return cache.get(file).exports
|
||||
const module = { exports: {} }; cache.set(file, module)
|
||||
const raw = fs.readFileSync(file, 'utf8')
|
||||
const source = file.endsWith('.vue')
|
||||
? compileScript(parse(raw).descriptor, { id: 'test-assessment' }).content
|
||||
: raw
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
vm.runInNewContext(output, {
|
||||
exports: module.exports, module,
|
||||
require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))),
|
||||
document: { getElementById: id => ({ scrollIntoView: options => scrolls.push({ id, options, tickCount: ticks.length }) }) },
|
||||
Date, Error
|
||||
}, { filename: file })
|
||||
return module.exports
|
||||
}
|
||||
const page = relative => load('views/' + relative + '.vue').default.setup({}, { expose() {} })
|
||||
return { load, page, healthApi, toasts, navigations, scrolls, ticks, dispose: () => disposed.forEach(handler => handler()) }
|
||||
}
|
||||
|
||||
test('validation rejects non-finite numbers, fractional ages, stale choices, and impossible birthdays', () => {
|
||||
const validation = environment().load('services/formValidation')
|
||||
for (const input of [NaN, Infinity, -Infinity, '70', '', null, undefined]) {
|
||||
assert.equal(validation.finiteNumberInRange(input, 20, 250), false)
|
||||
}
|
||||
assert.equal(validation.finiteNumberInRange(31.5, 1, 120, true), false)
|
||||
assert.equal(validation.finiteNumberInRange(31, 1, 120, true), true)
|
||||
assert.equal(validation.finiteNumberInRange(70.5, 20, 250), true)
|
||||
assert.equal(validation.healthAnswersComplete(healthAnswers()), true)
|
||||
assert.equal(validation.healthAnswersComplete({ ...healthAnswers(), sleepDuration: 'legacy-choice' }), false)
|
||||
const today = new Date(2026, 9, 9)
|
||||
assert.equal(validation.ageFromBirthday('2000-10-10T00:00:00', today), 25)
|
||||
assert.equal(validation.ageFromBirthday('2000-10-09', today), 26)
|
||||
assert.equal(validation.ageFromBirthday('2000-02-29', today), 26)
|
||||
assert.equal(validation.ageFromBirthday('2001-02-29', today), undefined)
|
||||
assert.equal(validation.ageFromBirthday('2027-01-01', today), undefined)
|
||||
assert.equal(validation.ageFromBirthday('bad-date', today), undefined)
|
||||
})
|
||||
|
||||
test('age calculation accepts backend timestamps without shifting the calendar birthday', () => {
|
||||
const validation = environment().load('services/formValidation')
|
||||
const today = new Date(2026, 9, 9)
|
||||
for (const birthday of ['2006-01-01', '2006-01-01 00:00:00', '2006-01-01T00:00:00',
|
||||
'2006-01-01T00:00:00.000+08:00', '2006-01-01T23:00:00Z']) {
|
||||
assert.equal(validation.ageFromBirthday(birthday, today), 20, birthday)
|
||||
}
|
||||
assert.equal(validation.ageFromBirthday('2000-10-10 00:00:00', today), 25)
|
||||
assert.equal(validation.ageFromBirthday('2000-10-09 00:00:00', today), 26)
|
||||
assert.equal(validation.ageFromBirthday('2001-02-29 00:00:00', today), undefined)
|
||||
assert.equal(validation.ageFromBirthday('2027-01-01 00:00:00', today), undefined)
|
||||
assert.equal(validation.ageFromBirthday(null, today), undefined)
|
||||
assert.equal(validation.ageFromBirthday('2006-01-01 unexpected', today), undefined)
|
||||
})
|
||||
|
||||
test('assessment loads and resaves the backend birthday without losing age or rewriting the saved date', async () => {
|
||||
const birthday = '2006-01-01 00:00:00', payloads = []
|
||||
let stored = { hasCompletedAssessment: true,
|
||||
basicInfo: { gender: '男', heightCm: 165, weightKg: 60, birthday }, answers: healthAnswers() }
|
||||
const env = environment({ getAssessment: async () => stored,
|
||||
saveAssessment: async payload => {
|
||||
payloads.push(payload)
|
||||
stored = { ...payload, hasCompletedAssessment: true }
|
||||
return stored
|
||||
} })
|
||||
const page = env.page('AssessmentView'); await page.loadExisting()
|
||||
const expectedAge = new Date().getFullYear() - 2006
|
||||
assert.equal(page.age.value, expectedAge)
|
||||
await page.handleSave()
|
||||
assert.equal(payloads.length, 1)
|
||||
assert.equal(payloads[0].basicInfo.birthday, birthday)
|
||||
await page.loadExisting()
|
||||
assert.equal(page.age.value, expectedAge)
|
||||
assert.equal(page.basicInfo.birthday, birthday)
|
||||
})
|
||||
|
||||
test('assessment cannot save while records are loading or after a failed read; retry restores saved information', async () => {
|
||||
const read = deferred(); let writes = 0
|
||||
const env = environment({ getAssessment: () => read.promise, saveAssessment: async () => { writes++; return { hasCompletedAssessment: true } } })
|
||||
const page = env.page('AssessmentView'), pending = page.loadExisting()
|
||||
await page.handleSave(); assert.equal(writes, 0)
|
||||
read.reject(new Error('读取失败')); await pending
|
||||
assert.equal(page.loadError.value, '读取失败')
|
||||
await page.handleSave(); assert.equal(writes, 0)
|
||||
env.healthApi.getAssessment = async () => ({ hasCompletedAssessment: true,
|
||||
basicInfo: { gender: '男', heightCm: 175, weightKg: 68, birthday: '1990-05-21' }, answers: healthAnswers() })
|
||||
await page.loadExisting()
|
||||
assert.equal(page.loadError.value, ''); assert.equal(page.hasPreviousAssessment.value, true)
|
||||
assert.equal(page.basicInfo.weightKg, 68); assert.equal(page.answers.sleepDuration, '7-8')
|
||||
})
|
||||
|
||||
test('assessment save preserves a real birthday, blocks repeat submission, and navigates once', async () => {
|
||||
const saved = deferred(); const payloads = []
|
||||
const env = environment({ getAssessment: async () => ({ hasCompletedAssessment: true,
|
||||
basicInfo: { gender: '女', heightCm: 165, weightKg: 60, birthday: '1990-05-21T00:00:00' }, answers: healthAnswers() }),
|
||||
saveAssessment: payload => { payloads.push(payload); return saved.promise } })
|
||||
const page = env.page('AssessmentView'); await page.loadExisting()
|
||||
const save = page.handleSave(); await page.handleSave()
|
||||
assert.equal(payloads.length, 1); assert.equal(page.submitting.value, true)
|
||||
assert.equal(payloads[0].basicInfo.birthday, '1990-05-21T00:00:00')
|
||||
saved.resolve({ hasCompletedAssessment: true }); await save
|
||||
assert.deepEqual(env.navigations, ['/dashboard']); assert.equal(page.submitting.value, false)
|
||||
})
|
||||
|
||||
test('assessment does not toast or navigate after leaving during a save', async () => {
|
||||
const saved = deferred()
|
||||
const env = environment({ saveAssessment: () => saved.promise })
|
||||
const page = env.page('AssessmentView'); await page.loadExisting()
|
||||
Object.assign(page.basicInfo, { gender: '男', heightCm: 175, weightKg: 70 }); page.age.value = 30
|
||||
Object.assign(page.answers, healthAnswers())
|
||||
const request = page.handleSave(); env.dispose()
|
||||
saved.resolve({ hasCompletedAssessment: true }); await request
|
||||
assert.equal(env.navigations.length, 0); assert.equal(env.toasts.length, 0)
|
||||
})
|
||||
|
||||
test('a failed save does not change the original birthday when the user reverts an age edit', async () => {
|
||||
const payloads = []
|
||||
const env = environment({ getAssessment: async () => ({ hasCompletedAssessment: true,
|
||||
basicInfo: { gender: '女', heightCm: 165, weightKg: 60, birthday: '1990-05-21' }, answers: healthAnswers() }),
|
||||
saveAssessment: async payload => { payloads.push(payload); throw new Error('保存失败') } })
|
||||
const page = env.page('AssessmentView'); await page.loadExisting()
|
||||
const originalAge = page.age.value; page.age.value = originalAge + 1
|
||||
await page.handleSave()
|
||||
assert.equal(page.basicInfo.birthday, '1990-05-21')
|
||||
page.age.value = originalAge; await page.handleSave()
|
||||
assert.equal(payloads[1].basicInfo.birthday, '1990-05-21')
|
||||
assert.equal(env.navigations.length, 0)
|
||||
})
|
||||
|
||||
test('four diagnoses show changed answers as outdated until regeneration and scroll only after rendering', async () => {
|
||||
const saved = deferred(); let writes = 0
|
||||
const env = environment({ getTcmAssessment: async () => tcmRecord(), saveTcmAssessment: () => { writes++; return saved.promise } })
|
||||
const page = env.page('TcmAssessmentView'); await page.loadExisting()
|
||||
assert.equal(page.resultOutdated.value, false)
|
||||
page.answers.face = 'pale'; assert.equal(page.resultOutdated.value, true)
|
||||
page.answers.face = 'rosy'; assert.equal(page.resultOutdated.value, false)
|
||||
page.notes.tongue = '备注'; assert.equal(page.resultOutdated.value, true)
|
||||
const request = page.submit(); await page.submit()
|
||||
assert.equal(writes, 1); assert.equal(page.saving.value, true)
|
||||
saved.resolve(tcmRecord()); await request
|
||||
assert.equal(page.resultOutdated.value, false); assert.equal(page.saving.value, false)
|
||||
assert.equal(env.scrolls.at(-1).id, 'tcm-result'); assert.equal(env.scrolls.at(-1).tickCount, 1)
|
||||
})
|
||||
|
||||
test('four diagnoses reject unknown stored choices and prevent saves after a failed read', async () => {
|
||||
let writes = 0
|
||||
const env = environment({ getTcmAssessment: async () => { throw new Error('网络异常') },
|
||||
saveTcmAssessment: async () => { writes++; return tcmRecord() } })
|
||||
const page = env.page('TcmAssessmentView'); await page.loadExisting(); await page.submit()
|
||||
assert.equal(writes, 0); assert.equal(page.loadError.value, '网络异常')
|
||||
env.healthApi.getTcmAssessment = async () => tcmRecord(); await page.loadExisting()
|
||||
page.answers.face = 'legacy-choice'; await page.submit()
|
||||
assert.equal(writes, 0); assert.equal(page.answeredCount.value, 11)
|
||||
assert.equal(env.scrolls.at(-1).id, 'tcm-face')
|
||||
})
|
||||
|
||||
test('both pages discard reads completed after leaving instead of treating them as active data', async () => {
|
||||
for (const [file, api, completed] of [
|
||||
['AssessmentView', 'getAssessment', { hasCompletedAssessment: true, basicInfo: { weightKg: 70 }, answers: healthAnswers() }],
|
||||
['TcmAssessmentView', 'getTcmAssessment', tcmRecord()]
|
||||
]) {
|
||||
const read = deferred(), env = environment({ [api]: () => read.promise })
|
||||
const page = env.page(file), request = page.loadExisting(); env.dispose()
|
||||
read.resolve(completed); await request
|
||||
assert.equal(page.loading.value, true)
|
||||
assert.equal(env.toasts.length, 0)
|
||||
if (file === 'AssessmentView') assert.equal(page.basicInfo.weightKg, undefined)
|
||||
else assert.equal(page.result.value, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('four diagnoses do not change results, toast, or scroll after leaving during generation', async () => {
|
||||
const saved = deferred()
|
||||
const env = environment({ getTcmAssessment: async () => tcmRecord(), saveTcmAssessment: () => saved.promise })
|
||||
const page = env.page('TcmAssessmentView'); await page.loadExisting()
|
||||
const previous = page.result.value; page.answers.face = 'pale'
|
||||
const request = page.submit(); env.dispose()
|
||||
saved.resolve({ ...tcmRecord(), constitutionName: '新结果' }); await request
|
||||
assert.equal(page.result.value, previous)
|
||||
assert.equal(env.toasts.length, 0); assert.equal(env.scrolls.length, 0)
|
||||
})
|
||||
|
||||
test('health choices are native buttons and both forms lock editing while saving', () => {
|
||||
const healthSource = fs.readFileSync(path.join(root, 'views/AssessmentView.vue'), 'utf8')
|
||||
const tcmSource = fs.readFileSync(path.join(root, 'views/TcmAssessmentView.vue'), 'utf8')
|
||||
assert.equal(/<div\s+class="option-(pill|card)"/.test(healthSource), false)
|
||||
const healthChoiceCount = (healthSource.match(/class="option-(pill|card)"/g) ?? []).length
|
||||
assert.ok(healthChoiceCount > 30)
|
||||
assert.equal((healthSource.match(/:aria-pressed=/g) ?? []).length, healthChoiceCount)
|
||||
assert.match(healthSource, /<fieldset[^>]*:disabled="submitting"[^>]*:inert="submitting \|\| undefined"/)
|
||||
assert.match(tcmSource, /<fieldset[^>]*:disabled="saving"[^>]*:inert="saving \|\| undefined"/)
|
||||
})
|
||||
|
||||
test('page request guard also ignores superseded results', () => {
|
||||
const env = environment(), guard = env.load('composables/usePageRequest').usePageRequest()
|
||||
const first = guard.begin(), second = guard.begin()
|
||||
assert.equal(guard.isCurrent(first), false); assert.equal(guard.isCurrent(second), true)
|
||||
env.dispose(); assert.equal(guard.isCurrent(second), false)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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(/<button[^>]*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(/<button[^>]*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(/<button[^>]*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(!/<span class="status-dot[^>]*>已关闭<\/span>/.test(html))
|
||||
const group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
|
||||
assert.equal((group.match(/<button/g) ?? []).length, 3)
|
||||
assert.equal((group.match(/disabled/g) ?? []).length, 3)
|
||||
})
|
||||
|
||||
test('curtain online missing position permits feature-supported actions but no fake slider; zero is a valid reading', async () => {
|
||||
const item = device('curtain-ready', '可用窗帘', 'curtain', 'Curtain', [reading('cover.room', 'cover', 'open', { supported_features: 15 })], { primaryControlEntityId: 'cover.room' })
|
||||
let html = (await renderNewDevice(item)).html
|
||||
assert.ok(html.includes('已打开')); assert.ok(!html.includes('van-slider'))
|
||||
let group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
|
||||
assert.ok(!group.includes('disabled'))
|
||||
item.entities[0].attributes.current_position = 0; item.entities[0].state = 'closed'
|
||||
html = (await renderNewDevice(item)).html
|
||||
assert.ok(html.includes('已关闭')); assert.ok(html.includes('0%')); assert.ok(html.includes('van-slider'))
|
||||
item.entities[0].attributes.supported_features = 1
|
||||
html = (await renderNewDevice(item)).html
|
||||
assert.ok(!html.includes('窗帘开度')); assert.ok(!html.includes('van-slider'))
|
||||
group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
|
||||
assert.equal((group.match(/disabled/g) ?? []).length, 2)
|
||||
})
|
||||
|
||||
test('unbound devices in new categories remain generic and cannot borrow bound controls', async () => {
|
||||
for (const category of ['Climate', 'Curtain']) {
|
||||
const domain = category === 'Climate' ? 'climate' : 'cover'
|
||||
const item = device(`unbound-${domain}`, `未绑定${category}`, '', category, [reading(`${domain}.room`, domain, 'off', climateAttributes())], { primaryControlEntityId: `${domain}.room` })
|
||||
const { html } = await renderNewDevice(item)
|
||||
assert.ok(html.includes('设备能力'))
|
||||
for (const text of ['空调运行模式', '空调目标温度', '窗帘开合', '窗帘开度']) assert.ok(!html.includes(text), text)
|
||||
}
|
||||
})
|
||||
|
||||
test('overview has compact tiles and room tabs without detailed sliders or mattress controls', async () => {
|
||||
const { html } = await render()
|
||||
assert.equal((html.match(/<article class="device-overview-tile/g) ?? []).length, 4)
|
||||
for (const value of ['一键启动全屋舒睡模式', '一键关闭设备', '全屋', '睡眠空间', '音量 46%', '演示智能床垫']) assert.ok(html.includes(value), value)
|
||||
for (const value of ['左侧加热档位', '按摩力度', '灯光色温调节', 'van-slider']) assert.ok(!html.includes(value), value)
|
||||
// 快捷开关与进入详情按钮是同级元素,不会发生嵌套按钮或点击开关误进详情。
|
||||
for (const tile of html.match(/<article class="device-overview-tile[\s\S]*?<\/article>/g) ?? []) {
|
||||
const mainButton = tile.match(/<button class="device-tile-open"[\s\S]*?<\/button>/)?.[0]
|
||||
assert.ok(mainButton)
|
||||
assert.ok(!mainButton.includes('device-tile-power'))
|
||||
}
|
||||
})
|
||||
|
||||
const outlet = () => device('outlet', '红灯插座', 'outlet', 'Outlet', [
|
||||
reading('switch.service', 'switch', 'on'), reading('switch.power', 'switch', 'off'),
|
||||
{ ...reading('number.protection', 'number', '8472528'), name: '充电保护、过载保护' },
|
||||
{ ...reading('sensor.energy', 'sensor', '0', { device_class: 'energy' }), unit: 'kWh' },
|
||||
], { primaryControlEntityId: 'switch.power' })
|
||||
const outletSnapshot = item => ({ connected: true, devices: [item],
|
||||
summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters: {} })
|
||||
|
||||
test('outlet detail renders its own on/off control and real energy without service or packed engineering controls', async () => {
|
||||
const item = outlet(), snapshot = outletSnapshot(item)
|
||||
let html = (await render('/device-control/outlet', { haStatus: ref(snapshot) })).html
|
||||
for (const text of ['智能插座', '插座可用', '开启电源', '插座电源状态', '已断电', '累计用电', '0 kWh', '暂不参与一键舒睡']) assert.ok(html.includes(text), text)
|
||||
for (const text of ['8472528', '充电保护、过载保护', 'switch.service', '查看全部能力', '设备能力']) assert.ok(!html.includes(text), text)
|
||||
assert.ok(!html.match(/<button[^>]*disabled[^>]*>[\s\S]*?开启电源/))
|
||||
item.entities[1].state = 'on'
|
||||
html = (await render('/device-control/outlet', { haStatus: ref(snapshot) })).html
|
||||
assert.ok(html.includes('关闭电源')); assert.ok(html.includes('已通电'))
|
||||
})
|
||||
|
||||
test('outlet detail does not report power off when main state is missing despite online service switch', async () => {
|
||||
const item = outlet(); item.entities[1].state = 'unknown'
|
||||
const { html } = await render('/device-control/outlet', { haStatus: ref(outletSnapshot(item)) })
|
||||
assert.ok(html.includes('插座主开关当前不可用')); assert.ok(html.includes('暂不可用'))
|
||||
assert.ok(html.includes('<strong class="p-val">--</strong>')); assert.ok(!html.includes('已断电'))
|
||||
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
|
||||
assert.ok(card.match(/<button[^>]*disabled/)); assert.ok(!card.includes('开启电源'))
|
||||
})
|
||||
|
||||
test('outlet overview has a dedicated compact tile and trusted quick power switch', async () => {
|
||||
const { html } = await render('/device-control', { haStatus: ref(outletSnapshot(outlet())) })
|
||||
for (const text of ['智能插座', '红灯插座', '已断电', '累计用电 0kWh', 'device-tile-power']) assert.ok(html.includes(text), text)
|
||||
for (const text of ['8472528', '充电保护、过载保护', '插座电源状态']) assert.ok(!html.includes(text), text)
|
||||
})
|
||||
|
||||
test('mattress detail retains full controls and never renders other devices or scene-wide actions', async () => {
|
||||
const { html } = await render('/device-control/mattress')
|
||||
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1)
|
||||
for (const value of ['演示智能床垫', '全部设备', '左侧加热档位', '右侧加热档位', '左侧承托模式', '右侧承托模式', '左肩软硬度', '右腿软硬度', '左侧按摩部位', '右侧按摩部位', '按摩力度']) assert.ok(html.includes(value), value)
|
||||
for (const value of ['演示卧室灯', '演示香氛机', '一键启动全屋舒睡模式', 'device-overview-tile']) assert.ok(!html.includes(value), value)
|
||||
})
|
||||
|
||||
test('fragrance detail retains separate nightlight and unavailable quick-release feedback', async () => {
|
||||
const { html } = await render('/device-control/fragrance')
|
||||
for (const value of ['单次出香时长', '自动出香间隔', '感应夜灯', '已关闭', '暂不可用']) assert.ok(html.includes(value), value)
|
||||
assert.ok(!html.includes('演示智能床垫'))
|
||||
})
|
||||
|
||||
test('each registered device detail renders its own parameters without requesting the network', async () => {
|
||||
const cases = [
|
||||
[devices[0], ['灯光亮度', '灯光色温调节']],
|
||||
[devices[3], ['音箱音量', '音箱睡眠模式']],
|
||||
[device('dehumidifier', '演示除湿机', 'dehumidifier', 'EnvironmentControl', [reading('humidifier.room', 'humidifier', 'off')],
|
||||
{ primaryControlEntityId: 'humidifier.room' }), ['恒湿目标湿度', '除湿运行模式', '除湿机风速档位', 'UV 深度紫外消杀']],
|
||||
[device('environment', '演示环境检测仪', 'environment', 'EnvironmentSensor'), ['温度', '湿度', 'PM2.5', 'CO2 浓度', '甲醛 HCHO', 'TVOC']],
|
||||
[device('scale', '演示体脂秤', '', 'BodyComposition'), ['健康身体成分', '健康档案']],
|
||||
[device('generic', '未接入模块的灯', '', 'Lighting', [reading('sensor.example', 'sensor', '12')]), ['设备能力', 'sensor.example', '12']],
|
||||
]
|
||||
for (const [item, labels] of cases) {
|
||||
const snapshot = { connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: 1 },
|
||||
environment: { temperature: 25.6, humidity: 58.8 }, controlParameters: {} }
|
||||
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
|
||||
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1, item.name)
|
||||
for (const label of labels) assert.ok(html.includes(label), `${item.name}: ${label}`)
|
||||
if (item.deviceId === 'generic') assert.ok(!html.includes('灯光色温调节'))
|
||||
}
|
||||
})
|
||||
|
||||
test('missing, offline and disconnected devices display honest explanatory states', async () => {
|
||||
assert.ok((await render('/device-control/removed')).html.includes('未找到这个设备'))
|
||||
const offline = { ...devices[1], online: false }
|
||||
const snapshot = { connected: true, devices: [offline], summary: { totalDevices: 1, onlineDevices: 0 } }
|
||||
const { html } = await render('/device-control/mattress', { haStatus: ref(snapshot) })
|
||||
assert.ok(html.includes('设备回读显示离线'))
|
||||
assert.ok(html.includes('右侧按摩部位'))
|
||||
const disconnected = await render('/device-control/mattress', { haStatus: ref({ ...snapshot, connected: false, message: '连接失败' }) })
|
||||
assert.ok(disconnected.html.includes('暂时无法读取设备'))
|
||||
assert.ok(!disconnected.html.includes('未找到这个设备'))
|
||||
const loading = await render('/device-control/mattress', { haStatus: ref(), loading: ref(true) })
|
||||
assert.ok(loading.html.includes('正在读取设备详情'))
|
||||
})
|
||||
|
||||
test('detail route safely round-trips unusual device IDs; unfinished constitution keeps scene start disabled', async () => {
|
||||
const { router, html } = await render('/device-control', { tcmResult: ref({ hasCompleted: false }) })
|
||||
const deviceId = '房间/灯 #1?'
|
||||
const resolved = router.resolve(deviceDetailLocation(deviceId))
|
||||
assert.equal(router.resolve(resolved.fullPath).params.deviceId, deviceId)
|
||||
assert.match(html, /<button class="scene-btn sleep-btn" disabled/)
|
||||
assert.ok(html.includes('去完成中医四诊'))
|
||||
})
|
||||
|
||||
test('offline parameter details disable controls and do not present default values as current readings', async () => {
|
||||
for (const item of [devices[0], devices[1], devices[2]]) {
|
||||
const snapshot = { connected: true, devices: [{ ...item, online: false }], controlParameters: {} }
|
||||
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
|
||||
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]
|
||||
assert.ok(card)
|
||||
for (const button of card.match(/<button\b[^>]*>/g) ?? []) assert.ok(button.includes('disabled'), button)
|
||||
for (const select of card.match(/<select\b[^>]*>/g) ?? []) assert.ok(select.includes('disabled'), select)
|
||||
for (const slider of card.match(/class="van-slider(?: |\")[^"\n]*"/g) ?? []) assert.ok(slider.includes('disabled'), slider)
|
||||
assert.ok(!card.includes('<strong class="p-val">2700K</strong>'))
|
||||
assert.ok(!card.includes('<strong class="p-val">20 分钟</strong>'))
|
||||
assert.ok(!card.includes('<strong class="p-val">0 档</strong>'))
|
||||
}
|
||||
})
|
||||
|
||||
test('environment detail uses only that device metrics and hides offline or malformed readings', async () => {
|
||||
const item = device('environment', '自己的环境检测仪', 'environment', 'EnvironmentSensor', [],
|
||||
{ metrics: { temperature: '26.2', humidity: 'bad', co2: '900' } })
|
||||
const snapshot = { connected: true, devices: [item], environment: { temperature: 99, humidity: 99 }, controlParameters: {} }
|
||||
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
|
||||
assert.ok(html.includes('26.2')); assert.ok(html.includes('900')); assert.ok(!html.includes('<strong>99'))
|
||||
item.online = false
|
||||
const offline = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
|
||||
assert.ok(!offline.html.includes('26.2')); assert.ok(!offline.html.includes('900'))
|
||||
})
|
||||
|
||||
test('independent audio gateway still supports HA-offline playback while HA-only sleep mode is disabled', async () => {
|
||||
const item = { ...devices[3], online: false, entities: devices[3].entities.map(value => ({ ...value, available: false })) }
|
||||
const snapshot = { connected: true, devices: [item], controlParameters: {} }
|
||||
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot),
|
||||
musicTransition: ref({ running: true, hasVolume: true, volumePercent: 0 }) })
|
||||
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]
|
||||
assert.ok(card.includes('0% 静音'))
|
||||
const playback = card.match(/<button\b[^>]*>[\s\S]*?播放测试音乐[\s\S]*?<\/button>/)?.[0]
|
||||
assert.ok(playback); assert.ok(!playback.match(/^<button\b[^>]*disabled/))
|
||||
assert.match(card, /<button class="mini-toggle" disabled/)
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
// Isolated navigation/authentication checks: no server or real device requests.
|
||||
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')
|
||||
|
||||
function environment(pathname = '/dashboard') {
|
||||
const storage = new Map(), redirects = [], cache = new Map()
|
||||
const handlers = {}
|
||||
const http = {
|
||||
interceptors: {
|
||||
request: { use: callback => { handlers.request = callback } },
|
||||
response: { use: (success, failure) => { handlers.success = success; handlers.failure = failure } },
|
||||
},
|
||||
}
|
||||
const window = { location: { pathname, replace: url => redirects.push(url) } }
|
||||
const mocks = {
|
||||
axios: { default: { create: () => http } },
|
||||
'vue-router': {
|
||||
createWebHistory: () => ({}),
|
||||
createRouter: options => ({
|
||||
options,
|
||||
beforeEach: callback => { handlers.guard = callback },
|
||||
afterEach: callback => { handlers.after = callback },
|
||||
}),
|
||||
},
|
||||
}
|
||||
function load(relative) {
|
||||
const file = path.resolve(root, relative)
|
||||
if (cache.has(file)) return cache.get(file).exports
|
||||
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
|
||||
vm.runInNewContext(source, {
|
||||
exports: module.exports, module, window,
|
||||
localStorage: {
|
||||
getItem: key => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: key => storage.delete(key),
|
||||
},
|
||||
require: name => mocks[name] ?? (name.endsWith('.vue') ? { default: {} }
|
||||
: load(path.relative(root, path.resolve(path.dirname(file), `${name}.ts`)))),
|
||||
}, { filename: file })
|
||||
return module.exports
|
||||
}
|
||||
const auth = load('services/auth.ts')
|
||||
load('services/http.ts')
|
||||
return { auth, handlers, redirects, storage, load }
|
||||
}
|
||||
|
||||
function request(env) {
|
||||
return env.handlers.request({ headers: {} })
|
||||
}
|
||||
|
||||
test('a current HTTP 401 clears both tokens and redirects only once', async () => {
|
||||
const env = environment()
|
||||
env.auth.saveTokens('session-one', 'refresh-one')
|
||||
const config = request(env)
|
||||
const fail = () => env.handlers.failure({ config, response: { status: 401 }, message: 'Unauthorized' })
|
||||
await assert.rejects(fail(), /Unauthorized/)
|
||||
await assert.rejects(fail(), /Unauthorized/)
|
||||
assert.equal(env.auth.hasToken(), false)
|
||||
assert.equal(env.storage.size, 0)
|
||||
assert.deepEqual(env.redirects, ['/auth'])
|
||||
})
|
||||
|
||||
test('an HTTP 200 with an unauthorized envelope also expires the current session', async () => {
|
||||
const env = environment()
|
||||
env.auth.saveTokens('session-one', 'refresh-one')
|
||||
const config = request(env)
|
||||
await assert.rejects(env.handlers.success({ config, data: { code: 401, type: 'error', result: null } }), /登录已过期/)
|
||||
assert.equal(env.auth.hasToken(), false)
|
||||
assert.deepEqual(env.redirects, ['/auth'])
|
||||
})
|
||||
|
||||
test('a late HTTP/envelope 401 from the previous login cannot remove a newer session', async () => {
|
||||
const env = environment()
|
||||
env.auth.saveTokens('old-session', 'old-refresh')
|
||||
const oldConfig = request(env)
|
||||
env.auth.saveTokens('new-session', 'new-refresh')
|
||||
await assert.rejects(env.handlers.failure({ config: oldConfig, response: { status: 401 }, message: 'Expired' }), /Expired/)
|
||||
await assert.rejects(env.handlers.success({ config: oldConfig, data: { code: 401, message: 'Expired' } }), /Expired/)
|
||||
assert.equal(env.auth.getToken(), 'new-session')
|
||||
assert.equal(env.storage.size, 2)
|
||||
assert.deepEqual(env.redirects, [])
|
||||
})
|
||||
|
||||
test('an anonymous request that returns 401 cannot log out a later authenticated session', async () => {
|
||||
const env = environment('/auth')
|
||||
const anonymousConfig = request(env)
|
||||
assert.equal(anonymousConfig.headers.Authorization, undefined)
|
||||
env.auth.saveTokens('new-session', 'new-refresh')
|
||||
await assert.rejects(env.handlers.failure({ config: anonymousConfig, response: { status: 401 }, message: 'Invalid password' }))
|
||||
assert.equal(env.auth.getToken(), 'new-session')
|
||||
assert.deepEqual(env.redirects, [])
|
||||
})
|
||||
|
||||
test('failed credentials on the login page do not reload that page', async () => {
|
||||
const env = environment('/auth')
|
||||
await assert.rejects(env.handlers.failure({ config: request(env), response: { status: 401 }, message: 'Invalid password' }))
|
||||
assert.deepEqual(env.redirects, [])
|
||||
})
|
||||
|
||||
test('normal API results still unwrap and business failures are still rejected', async () => {
|
||||
const env = environment()
|
||||
env.auth.saveTokens('session-one', 'refresh-one')
|
||||
assert.equal(request(env).headers.Authorization, 'Bearer session-one')
|
||||
const result = { success: false, message: 'device unavailable' }
|
||||
assert.equal(env.handlers.success({ data: { code: 200, type: 'success', result } }), result)
|
||||
await assert.rejects(env.handlers.success({ data: { type: 'error', result: null, message: 'failed' } }), /failed/)
|
||||
assert.equal(env.auth.hasToken(), true)
|
||||
})
|
||||
|
||||
test('route guards protect pages while allowing login, with a fallback for unknown URLs', () => {
|
||||
const env = environment(), router = env.load('router.ts').default
|
||||
assert.equal(env.handlers.guard({ meta: {} }), '/auth')
|
||||
assert.equal(env.handlers.guard({ meta: { guest: true } }), undefined)
|
||||
env.auth.saveTokens('session-one', 'refresh-one')
|
||||
assert.equal(env.handlers.guard({ meta: {} }), undefined)
|
||||
assert.equal(env.handlers.guard({ meta: { guest: true } }), '/dashboard')
|
||||
assert.equal(router.options.routes.at(-1).path, '/:pathMatch(.*)*')
|
||||
assert.equal(router.options.routes.at(-1).redirect, '/dashboard')
|
||||
const devices = router.options.routes.find(route => route.path === '/device-control')
|
||||
assert.equal(devices.children.find(route => route.name === 'device-detail').path, ':deviceId')
|
||||
})
|
||||
|
||||
test('new pages open at the top and browser back preserves its saved scroll position', () => {
|
||||
const env = environment(), router = env.load('router.ts').default
|
||||
const saved = { left: 0, top: 512 }
|
||||
assert.equal(router.options.scrollBehavior({}, {}, saved), saved)
|
||||
const fresh = router.options.scrollBehavior({}, {}, null)
|
||||
assert.equal(fresh.left, 0)
|
||||
assert.equal(fresh.top, 0)
|
||||
})
|
||||
Reference in New Issue
Block a user