186 lines
11 KiB
JavaScript
186 lines
11 KiB
JavaScript
// 全站页面的假数据逻辑和 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'))
|
|
}
|
|
})
|