305 lines
10 KiB
TypeScript
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()
|
|
})
|