| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- 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()
- })
|