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((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({ 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, 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, 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() })