Files
shanxia2.0/web-admin/scripts/mobile-quote-versions-lifecycle.test.ts
2026-10-23 23:07:00 +08:00

305 lines
10 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import * as vue from 'vue'
import { formatQuoteMoney, getMpQuoteAmountSummary } from '../src/utils/mp-crm-quote-card'
const descriptor = parse(
readFileSync(new URL('../src/views/m/crm/quote/versions.vue', import.meta.url), 'utf8')
).descriptor
const ast = ts.createSourceFile(
'versions.ts',
descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
const names = statements.flatMap((s) =>
ts.isFunctionDeclaration(s)
? [s.name!.text]
: ts.isVariableStatement(s)
? s.declarationList.declarations.map((d) => d.name.getText(ast))
: []
)
const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
target: ts.ScriptTarget.ESNext
})
function deferred() {
let resolve!: (value: any) => void
let reject!: (error: Error) => void
const promise = new Promise<any>((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
const flush = async () => {
for (let i = 0; i < 12; i++) await Promise.resolve()
}
const destination = (id = 'A', name = 'MpCrmQuoteVersions', query = {}) => ({
name,
params: { id },
query
})
// Execute the entire real SFC setup in a mounted Vue component: lifecycle and watchers are real.
function mount(initial = destination()) {
const current = vue.shallowRef(initial)
const route = Object.fromEntries(['name', 'params', 'query'].map((key) => [key, null]))
for (const key of Object.keys(route))
Object.defineProperty(route, key, { get: () => current.value[key] })
const reads: any[] = [],
navigation: any[] = []
const transport = (kind: string) => (value: string) => {
const d = deferred()
reads.push({ kind, value, ...d })
return d.promise
}
let api: any
const bindings = {
...Object.fromEntries(
Object.entries(vue).filter(([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default')
),
useRoute: () => route,
useRouter: () => ({
push: (v: any) => navigation.push(v),
back: () => navigation.push('back')
}),
useDictStore: () => ({ getLabel: (_: string, v: string) => v, getTagType: () => 'info' }),
getQuote: transport('detail'),
queryVersions: transport('versions'),
formatQuoteMoney,
getMpQuoteAmountSummary
}
const renderer = vue.createRenderer<any, any>({
patchProp() {},
insert() {},
remove() {},
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
setText() {},
setElementText() {},
parentNode: () => null,
nextSibling: () => null
})
const app = renderer.createApp({
setup() {
api = new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
...Object.values(bindings)
)
return () => vue.h('div')
}
})
app.mount({})
return {
api,
reads,
navigation,
go: (v: any) => {
current.value = v
},
unmount: () => app.unmount()
}
}
async function detail(h: ReturnType<typeof mount>, index = h.reads.length - 1) {
h.reads[index].resolve({
data: { id: h.reads[index].value, quoteNo: `Q-${h.reads[index].value}` }
})
await flush()
}
async function rows(h: ReturnType<typeof mount>, value: any[] = [{ id: 'version-A' }]) {
h.reads.at(-1).resolve({ data: value })
await flush()
}
test('auth gate, ready idempotence and same-ID query changes', async () => {
const h = mount()
void h.api.loadVersions()
h.go(destination('B'))
assert.equal(h.reads.length, 0)
h.api.onShellReady()
h.api.onShellReady()
assert.equal(h.reads.length, 1)
h.go(destination('B', 'MpCrmQuoteVersions', { tab: 'new' }))
assert.equal(h.reads.length, 1)
await detail(h)
await rows(h)
h.api.onShellReady()
assert.equal(h.reads.length, 2)
h.unmount()
})
for (const aba of [false, true])
for (const stage of ['detail', 'versions'])
for (const reject of [false, true]) {
test(`latest route owns both awaits: ABA=${aba}, stage=${stage}, reject=${reject}`, async () => {
const h = mount()
h.api.onShellReady()
if (stage === 'versions') await detail(h)
const old = h.reads.at(-1)
h.go(destination('B'))
if (aba) h.go(destination('A'))
assert.equal(h.reads.at(-1).value, aba ? 'A' : 'B')
assert.deepEqual(h.api.versions.value, [])
const count = h.reads.length
if (reject) old.reject(new Error('stale failure'))
else old.resolve(stage === 'detail' ? { id: 'A', quoteNo: 'old' } : [{ id: 'old' }])
await flush()
assert.equal(h.reads.length, count, 'old detail cannot issue second read')
assert.equal(h.api.loading.value, true, 'old finally cannot end new loading')
assert.equal(h.api.errorText.value, '')
assert.deepEqual(h.api.versions.value, [])
await detail(h)
await rows(h, [{ id: 'fresh' }])
assert.equal(h.api.versions.value[0].id, 'fresh')
h.unmount()
})
}
test('loaded list clears synchronously; navigation only accepts a current list item', async () => {
const h = mount()
h.api.onShellReady()
await detail(h)
await rows(h)
const item = h.api.versions.value[0]
h.api.goView({ id: 'forged' })
assert.deepEqual(h.navigation, [])
h.api.goView(item)
assert.equal(h.navigation[0].path, '/m/crm/quote/version-A')
h.go(destination('B'))
assert.deepEqual(h.api.versions.value, [])
h.api.goView(item)
assert.equal(h.navigation.length, 1)
h.api.goBack()
assert.deepEqual(h.navigation[1], { path: '/m/crm/quote/B', query: { from: 'manage' } })
h.unmount()
})
for (const dest of [
destination(''),
destination('A', 'MpCrmQuoteDetail'),
destination('B', 'MpCrmProductDetail')
]) {
test(`invalid owner ${dest.name}/${dest.params.id} blocks reads and stale navigation`, async () => {
const h = mount()
h.api.onShellReady()
h.go(dest)
h.api.onShellReady()
void h.api.loadVersions()
h.api.goView({ id: 'A' })
assert.equal(h.reads.length, 1)
h.reads[0].resolve({ id: 'A', quoteNo: 'old' })
await flush()
assert.equal(h.reads.length, 1)
assert.deepEqual(h.api.versions.value, [])
assert.deepEqual(h.navigation, [])
h.unmount()
})
}
for (const stage of ['detail', 'versions'])
for (const reject of [false, true]) {
test(`unmount invalidates ${stage} success/error (${reject}) and entrypoints`, async () => {
const h = mount()
h.api.onShellReady()
if (stage === 'versions') await detail(h)
const count = h.reads.length
h.unmount()
if (reject) h.reads.at(-1).reject(new Error('late'))
else
h.reads.at(-1).resolve(stage === 'detail' ? { id: 'A', quoteNo: 'old' } : [{ id: 'old' }])
await flush()
h.api.onShellReady()
void h.api.loadVersions()
h.api.goView({ id: 'old' })
assert.equal(h.reads.length, count)
assert.deepEqual(h.api.versions.value, [])
assert.equal(h.api.errorText.value, '')
assert.deepEqual(h.navigation, [])
})
}
test('wrong detail ID cannot query versions; retry recovers through failure and empty', async () => {
const h = mount()
h.api.onShellReady()
h.reads[0].resolve({ id: 'wrong', quoteNo: 'old' })
await flush()
assert.equal(h.reads.length, 1)
assert.ok(h.api.errorText.value)
assert.equal(h.api.loading.value, false)
void h.api.loadVersions()
await detail(h)
h.reads.at(-1).reject(new Error('offline'))
await flush()
assert.equal(h.api.errorText.value, 'offline')
void h.api.loadVersions()
assert.equal(h.api.errorText.value, '')
await detail(h)
await rows(h, [])
assert.equal(h.api.errorText.value, '')
assert.equal(h.api.loading.value, false)
assert.deepEqual(h.api.versions.value, [])
void h.api.loadVersions()
await detail(h)
await rows(h)
assert.equal(h.api.versions.value.length, 1)
h.unmount()
})
test('initial missing ID is visible and makes no request; a valid route recovers', async () => {
const h = mount(destination(''))
h.api.onShellReady()
assert.equal(h.reads.length, 0)
assert.equal(h.api.loading.value, false)
assert.ok(h.api.errorText.value)
h.go(destination('B'))
assert.equal(h.reads[0].value, 'B')
await detail(h)
await rows(h)
assert.equal(h.api.errorText.value, '')
h.unmount()
})
test('missing quote number and first-stage failure remain retryable', async () => {
const h = mount()
h.api.onShellReady()
h.reads[0].reject(new Error('detail unavailable'))
await flush()
assert.equal(h.api.errorText.value, 'detail unavailable')
assert.equal(h.api.loading.value, false)
void h.api.loadVersions()
h.reads.at(-1).resolve({ id: 'A', quoteNo: ' ' })
await flush()
assert.equal(h.reads.length, 2)
assert.equal(h.api.errorText.value, '无法获取报价单号')
void h.api.loadVersions()
await detail(h)
await rows(h)
assert.equal(h.api.errorText.value, '')
h.unmount()
})
test('actual template renders failure, empty recovery and long names with amount helper', async () => {
const { renderToString } = await import('vue/server-renderer')
const render = vue.compile(descriptor.template!.content, { isCustomElement: () => true })
const h = mount()
const markup = () => {
const context = vue.proxyRefs({ ...h.api, formatQuoteMoney, getMpQuoteAmountSummary })
return renderToString(vue.createSSRApp({ render: () => render.call(context, context, []) }))
}
h.api.onShellReady()
h.reads[0].reject(new Error('网络失败'))
await flush()
assert.match(await markup(), /网络失败/)
assert.match(await markup(), /重试/)
void h.api.loadVersions()
await detail(h)
await rows(h, [])
assert.match(await markup(), /暂无版本历史/)
assert.match(await markup(), /重新加载/)
void h.api.loadVersions()
await detail(h)
const customerName = '客户ABCDEFGHIJKLMNOPQRSTUVWXYZ'.repeat(8)
await rows(h, [{ id: 'version', version: 'V-LONG'.repeat(30), customerName, isLatest: 1 }])
const html = await markup()
assert.ok(html.includes(customerName))
assert.match(html, /当前版本/)
assert.match(html, /role="link"/)
h.unmount()
})