735 lines
25 KiB
TypeScript
735 lines
25 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'
|
|
function deferred() {
|
|
let resolve!: (value?: any) => void
|
|
let reject!: (error: any) => void
|
|
const promise = new Promise<any>((a, b) => {
|
|
resolve = a
|
|
reject = b
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
const flush = async () => {
|
|
for (let i = 0; i < 20; i++) await Promise.resolve()
|
|
}
|
|
function harness(page: string, overrides: Record<string, any> = {}, initialProps: any = {}) {
|
|
const content = parse(
|
|
readFileSync(new URL(`../src/views/m/pl/${page}.vue`, import.meta.url), 'utf8')
|
|
).descriptor.scriptSetup!.content
|
|
const ast = ts.createSourceFile('page.ts', content, ts.ScriptTarget.Latest, true)
|
|
const route = vue.reactive({
|
|
params: { id: 'A' },
|
|
query: {} as Record<string, string>,
|
|
fullPath: '/A',
|
|
path: '/A'
|
|
})
|
|
const props = vue.reactive({ modelValue: true, planId: 'A', permitted: true, ...initialProps })
|
|
const messages: any[] = []
|
|
const unmounts: Array<() => void> = []
|
|
const writes: any[] = [],
|
|
pushes: any[] = [],
|
|
calls: string[] = []
|
|
const bindings: Record<string, any> = Object.fromEntries(
|
|
Object.entries(vue).filter(
|
|
([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default' && key !== 'version'
|
|
)
|
|
)
|
|
for (const s of ast.statements) {
|
|
if (!ts.isImportDeclaration(s)) continue
|
|
const clause = s.importClause
|
|
if (clause?.name) bindings[clause.name.text] = () => undefined
|
|
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
|
|
for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
|
|
}
|
|
const read = async (arg: any) => {
|
|
const id = typeof arg === 'string' ? arg : arg.id
|
|
calls.push(id)
|
|
return {
|
|
data: {
|
|
id,
|
|
status: 'draft',
|
|
products: [{ id: 'p', productId: 'p', isToxic: 1 }],
|
|
productList: []
|
|
}
|
|
}
|
|
}
|
|
const planApi = {
|
|
get: read,
|
|
getArrangeInfo: async () => ({ data: {} }),
|
|
waitNotice: async (id: string) => {
|
|
writes.push(id)
|
|
return { data: { id, products: [] } }
|
|
},
|
|
cancelWaitNotice: async (id: string) => {
|
|
writes.push(id)
|
|
return { data: { id, products: [] } }
|
|
},
|
|
cancel: async (...args: any[]) => {
|
|
writes.push(args)
|
|
return { data: { id: args[0], products: [] } }
|
|
},
|
|
updateProductCerts: async (...args: any[]) => writes.push(args),
|
|
...overrides.planApi
|
|
}
|
|
Object.assign(bindings, {
|
|
useRoute: () => route,
|
|
useRouter: () => ({
|
|
push: (v: any) => pushes.push(v),
|
|
replace: async (v: any) => {
|
|
if (typeof v === 'object') route.query = v.query
|
|
else pushes.push(v)
|
|
}
|
|
}),
|
|
useUserStore: () => ({ accessToken: 'session', info: { id: 'user' } }),
|
|
useDictStore: () => ({ getLabel: () => '', getTagType: () => '' }),
|
|
onMounted() {},
|
|
onUnmounted: (fn: any) => unmounts.push(fn),
|
|
onBeforeUnmount: (fn: any) => unmounts.push(fn),
|
|
loadMobileDetailReads: async () => ({ errors: [] }),
|
|
resolvePlanStopsForList: (plan: any) => ({
|
|
load: plan.load || {},
|
|
deliveries: plan.deliveries || []
|
|
}),
|
|
mapPlanProductToFormRow: (product: any) => product,
|
|
mapLoadFromApi: (load: any) => load,
|
|
mapDeliveriesFromApi: (deliveries: any[]) => deliveries,
|
|
resolveStopProductsForView: (products: any[]) => products || [],
|
|
resolveLoadViewAddress: (_plan: any, load: any) => load.address || '',
|
|
resolveDeliveryViewAddress: (_plan: any, stop: any) => stop.address || '',
|
|
getContract: read,
|
|
getQuote: read,
|
|
fetchProductDetail: read,
|
|
listContractApproval: async () => ({ data: [] }),
|
|
ElMessage: Object.fromEntries(
|
|
['warning', 'error', 'success', 'info'].map((k) => [
|
|
k,
|
|
(...args: any[]) => messages.push([k, ...args])
|
|
])
|
|
),
|
|
fetchGetMenuCodes: async () => ({ all: true }),
|
|
canEditPlan: () => true,
|
|
canArrangeTransport: () => true,
|
|
canWaitNotice: () => true,
|
|
canCancelWaitNotice: () => true,
|
|
canVoidPlan: () => true,
|
|
canEdit: (p: any) => !!p,
|
|
canSave: (a: boolean, u: boolean, s: boolean, n: number) => a && !u && !s && n > 0,
|
|
certPayload: (p: any) => JSON.parse(JSON.stringify(p)),
|
|
isHazardProduct: (p: any) => !!p.isToxic,
|
|
enrichPlanProductsHazardFromCrm: async (p: any) => p,
|
|
isHazardCertReady: () => false,
|
|
planHasHazardProducts: (p: any) => !!p?.length,
|
|
buildPlanCard: () => ({}),
|
|
splitMpPlanDetailActions: () => ({ primary: [], more: [] }),
|
|
usePlPlanArrangeActions: (options: any) => arrangeHarness(planApi, options),
|
|
showCrmConfirmDialog: async () => {},
|
|
defineExpose() {},
|
|
defineProps: () => props,
|
|
defineEmits:
|
|
() =>
|
|
(...v: any[]) =>
|
|
pushes.push(v),
|
|
document: { querySelector: () => null },
|
|
window: { removeEventListener() {}, scrollTo() {} },
|
|
...overrides,
|
|
planApi
|
|
})
|
|
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.flatMap((d) =>
|
|
ts.isObjectBindingPattern(d.name)
|
|
? d.name.elements.map((e) => e.name.getText(ast))
|
|
: [d.name.getText(ast)]
|
|
)
|
|
: []
|
|
)
|
|
const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
|
|
target: ts.ScriptTarget.ESNext
|
|
})
|
|
const scope = vue.effectScope()
|
|
const api: any = scope.run(() =>
|
|
new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
|
|
...Object.values(bindings)
|
|
)
|
|
)
|
|
return {
|
|
...api,
|
|
props: bindings.defineProps(),
|
|
messages,
|
|
planApi,
|
|
route,
|
|
calls,
|
|
writes,
|
|
pushes,
|
|
ready: api.onShellReady || api.onReady,
|
|
unmount: () => {
|
|
unmounts.forEach((fn) => fn())
|
|
scope.stop()
|
|
}
|
|
}
|
|
}
|
|
function arrangeHarness(planApi: any, options: any = undefined, overrides: any = {}) {
|
|
const text = readFileSync(
|
|
new URL('../src/composables/usePlPlanArrangeActions.ts', import.meta.url),
|
|
'utf8'
|
|
)
|
|
const ast = ts.createSourceFile('arrange.ts', text, ts.ScriptTarget.Latest, true)
|
|
const code = ts.transpile(
|
|
ast.statements
|
|
.filter((s) => !ts.isImportDeclaration(s))
|
|
.map((s) => s.getText(ast))
|
|
.join('\n')
|
|
.replace(/export /g, ''),
|
|
{ target: ts.ScriptTarget.ESNext }
|
|
)
|
|
const env = {
|
|
...Object.fromEntries(
|
|
Object.entries(vue).filter(([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'version')
|
|
),
|
|
planApi,
|
|
pickBxData: (r: any) => r.data ?? r,
|
|
useUserStore: () => ({ accessToken: 'session', info: { id: 'user' } }),
|
|
useDictStore: () => ({ getLabel: () => '' }),
|
|
ElMessage: { success() {}, warning() {}, error() {} },
|
|
...overrides
|
|
}
|
|
delete (env as any).default
|
|
return new Function(...Object.keys(env), `${code}; return usePlPlanArrangeActions`)(
|
|
...Object.values(env)
|
|
)(options)
|
|
}
|
|
test('transport: ready gate, idempotence and sync reset', async () => {
|
|
const h = harness('plan/detail')
|
|
h.route.params.id = 'B'
|
|
await flush()
|
|
assert.deepEqual(h.calls, [])
|
|
h.onShellReady()
|
|
await flush()
|
|
h.onShellReady()
|
|
await flush()
|
|
assert.deepEqual(h.calls, ['B'])
|
|
h.moreOpen.value = h.voidOpen.value = h.vehicleOpen.value = true
|
|
h.route.params.id = 'C'
|
|
assert.equal(h.plan.value, null)
|
|
assert.equal(h.moreOpen.value, false)
|
|
assert.equal(h.voidOpen.value, false)
|
|
assert.equal(h.vehicleOpen.value, false)
|
|
await flush()
|
|
assert.deepEqual(h.calls, ['B', 'C'])
|
|
h.route.query.tab = 'info'
|
|
await flush()
|
|
assert.deepEqual(h.calls, ['B', 'C'])
|
|
h.unmount()
|
|
})
|
|
for (const result of [null, {}, { id: 'WRONG' }])
|
|
test(`transport invalid success ${JSON.stringify(result)}`, async () => {
|
|
const h = harness('plan/detail', { planApi: { get: async () => ({ data: result }) } })
|
|
h.onShellReady()
|
|
await flush()
|
|
assert.equal(h.plan.value, null)
|
|
assert.ok(h.errorText.value)
|
|
h.unmount()
|
|
})
|
|
for (const kind of ['wait', 'cancel-wait', 'void'])
|
|
test(`transport ${kind} delayed confirmation`, async () => {
|
|
const confirm = deferred()
|
|
const h = harness('plan/detail', { showCrmConfirmDialog: () => confirm.promise })
|
|
h.onShellReady()
|
|
await flush()
|
|
const pending = kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind)
|
|
confirm.resolve()
|
|
h.route.params.id = 'B'
|
|
await pending
|
|
await flush()
|
|
assert.deepEqual(h.writes, [])
|
|
h.unmount()
|
|
})
|
|
test('arrange opt-in old internal info cannot publish after reset', async () => {
|
|
const old = deferred()
|
|
const a = arrangeHarness({ getArrangeInfo: () => old.promise }, { isolateRecord: true })
|
|
const pending = a.loadArrangeActions('A', { id: 'A', transportStatus: 'arranged' })
|
|
a.resetArrangeActions()
|
|
old.resolve({ data: { driverUrl: 'OLD', siteRecords: [{ siteUrl: 'OLD' }] } })
|
|
await pending
|
|
assert.equal(a.driverUrl.value, '')
|
|
assert.deepEqual(a.siteRecords.value, [])
|
|
})
|
|
test('cert upload old successful save cannot close or notify new record', async () => {
|
|
const saved = deferred()
|
|
const h = harness('plan-cert/CertUpload', {
|
|
planApi: { updateProductCerts: () => saved.promise }
|
|
})
|
|
await flush()
|
|
const pending = h.save()
|
|
h.props.planId = 'B'
|
|
await flush()
|
|
saved.resolve()
|
|
await pending
|
|
assert.deepEqual(h.pushes, [])
|
|
assert.deepEqual(h.messages, [])
|
|
h.unmount()
|
|
})
|
|
function detailLoader(read: (id: string) => Promise<any>, useInitialPlan = false) {
|
|
return () => {
|
|
const state: any = Object.fromEntries(
|
|
[
|
|
'plan',
|
|
'products',
|
|
'transport',
|
|
'freightSettlement',
|
|
'vehicleBundle',
|
|
'dibangRows',
|
|
'ioOrder'
|
|
].map((k) => [k, vue.ref(k === 'products' || k === 'dibangRows' ? [] : null)])
|
|
)
|
|
state.loading = vue.ref(false)
|
|
state.resetDetail = () => {
|
|
state.plan.value = null
|
|
}
|
|
state.loadDetail = async (id: string, options?: any) => {
|
|
state.loading.value = true
|
|
try {
|
|
state.plan.value =
|
|
useInitialPlan && options?.initialPlan ? options.initialPlan : await read(id)
|
|
} finally {
|
|
state.loading.value = false
|
|
}
|
|
}
|
|
return state
|
|
}
|
|
}
|
|
for (const reject of [false, true])
|
|
test(`cert detail unmount guards old ${reject ? 'error' : 'success'} and contract`, async () => {
|
|
const slow = deferred(),
|
|
contract = deferred()
|
|
const h = harness('plan-cert/CertDetail', {
|
|
usePlPlanViewDetail: detailLoader(() => slow.promise),
|
|
getContract: () => contract.promise
|
|
})
|
|
await flush()
|
|
const pending = h.openContract('C')
|
|
h.unmount()
|
|
if (reject) slow.reject(new Error('old'))
|
|
else slow.resolve({ id: 'A' })
|
|
contract.resolve({ data: { id: 'C' } })
|
|
await pending
|
|
await flush()
|
|
assert.equal(h.contractOpen.value, false)
|
|
assert.equal(h.error.value, '')
|
|
assert.equal(h.plan.value, null)
|
|
})
|
|
for (const reject of [false, true])
|
|
for (const transition of ['B', 'ABA', 'retry', 'unmount'])
|
|
test(`transport ${transition}: old ${reject ? 'error/finally' : 'success'} cannot publish`, async () => {
|
|
const old = deferred(),
|
|
fresh = deferred()
|
|
let n = 0
|
|
const h = harness('plan/detail', {
|
|
planApi: { get: () => (++n === 1 ? old.promise : fresh.promise) }
|
|
})
|
|
h.onShellReady()
|
|
await flush()
|
|
if (transition === 'unmount') h.unmount()
|
|
else if (transition === 'retry') void h.loadDetail()
|
|
else {
|
|
h.route.params.id = 'B'
|
|
if (transition === 'ABA') h.route.params.id = 'A'
|
|
}
|
|
if (reject) old.reject(new Error('old'))
|
|
else old.resolve({ data: { id: 'A' } })
|
|
await flush()
|
|
assert.equal(h.plan.value, null)
|
|
assert.equal(h.errorText.value, '')
|
|
if (transition !== 'unmount') {
|
|
assert.equal(h.loading.value, true)
|
|
fresh.resolve({ data: { id: h.route.params.id } })
|
|
await flush()
|
|
assert.equal(h.plan.value.id, h.route.params.id)
|
|
assert.equal(h.loading.value, false)
|
|
h.unmount()
|
|
}
|
|
})
|
|
for (const kind of ['wait', 'cancel-wait', 'void'])
|
|
for (const reject of [false, true])
|
|
test(`transport ${kind}: old issued write ${reject ? 'rejects' : 'resolves'} after switching`, async () => {
|
|
const old = deferred()
|
|
const apiName =
|
|
kind === 'wait' ? 'waitNotice' : kind === 'void' ? 'cancel' : 'cancelWaitNotice'
|
|
const h = harness('plan/detail', { planApi: { [apiName]: () => old.promise } })
|
|
h.onShellReady()
|
|
await flush()
|
|
const pending = kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind)
|
|
await flush()
|
|
h.route.params.id = 'B'
|
|
await flush()
|
|
h.acting.value = true
|
|
h.voidOpen.value = true
|
|
if (reject) old.reject(new Error('old'))
|
|
else old.resolve({ data: { id: 'A' } })
|
|
await pending
|
|
assert.equal(h.plan.value.id, 'B')
|
|
assert.equal(h.acting.value, true)
|
|
assert.equal(h.voidOpen.value, true)
|
|
assert.deepEqual(h.messages, [])
|
|
assert.deepEqual(h.calls, ['A', 'B'])
|
|
h.unmount()
|
|
})
|
|
for (const kind of ['wait', 'cancel-wait', 'void'])
|
|
test(`transport ${kind}: cancelled confirmation is handled`, async () => {
|
|
const h = harness('plan/detail', {
|
|
showCrmConfirmDialog: async () => {
|
|
throw new Error('cancel')
|
|
}
|
|
})
|
|
h.onShellReady()
|
|
await flush()
|
|
await (kind === 'void' ? h.confirmVoidPlan() : h.runAction(kind))
|
|
assert.deepEqual(h.writes, [])
|
|
assert.equal(h.acting.value, false)
|
|
assert.deepEqual(h.messages, [])
|
|
h.unmount()
|
|
})
|
|
test('transport reason is captured before confirmation; unrelated query does not cancel', async () => {
|
|
const confirm = deferred(),
|
|
h = harness('plan/detail', { showCrmConfirmDialog: () => confirm.promise })
|
|
h.onShellReady()
|
|
await flush()
|
|
h.voidReason.value = 'original'
|
|
const pending = h.confirmVoidPlan()
|
|
h.voidReason.value = 'changed'
|
|
h.route.query.tab = 'x'
|
|
confirm.resolve()
|
|
await pending
|
|
assert.deepEqual(h.writes, [['A', { cancelReason: 'original' }]])
|
|
h.unmount()
|
|
})
|
|
test('transport entry rejects missing loading busy and denied actions', async () => {
|
|
const h = harness('plan/detail', {
|
|
canWaitNotice: () => false,
|
|
canCancelWaitNotice: () => false,
|
|
canVoidPlan: () => false
|
|
})
|
|
await h.runAction('wait')
|
|
h.onShellReady()
|
|
await flush()
|
|
for (const kind of ['wait', 'cancel-wait', 'void']) await h.runAction(kind)
|
|
await h.confirmVoidPlan()
|
|
assert.deepEqual(h.writes, [])
|
|
h.unmount()
|
|
})
|
|
test('arrange opt-in ABA cancels old get and reuses existing plan', async () => {
|
|
const old = deferred()
|
|
let reads = 0
|
|
const a = arrangeHarness(
|
|
{
|
|
get: () => {
|
|
reads++
|
|
return old.promise
|
|
},
|
|
getArrangeInfo: async (id: string) => ({ data: { driverUrl: id } })
|
|
},
|
|
{ isolateRecord: true }
|
|
)
|
|
const pending = a.loadArrangeActions('A')
|
|
await a.loadArrangeActions('B', { id: 'B', transportStatus: 'arranged' })
|
|
await a.loadArrangeActions('A', { id: 'A', transportStatus: 'arranged' })
|
|
old.resolve({ data: { id: 'A', transportStatus: 'pending' } })
|
|
await pending
|
|
assert.equal(a.driverUrl.value, 'A')
|
|
assert.equal(a.plan.value.transportStatus, 'arranged')
|
|
assert.equal(reads, 1)
|
|
})
|
|
test('arrange default PC retains legacy publication and lookup behavior', async () => {
|
|
const old = deferred()
|
|
let reads = 0
|
|
const a = arrangeHarness({
|
|
get: async () => {
|
|
reads++
|
|
return { data: { id: 'A', transportStatus: 'arranged' } }
|
|
},
|
|
getArrangeInfo: () => old.promise
|
|
})
|
|
const pending = a.loadArrangeActions('A')
|
|
await flush()
|
|
a.resetArrangeActions()
|
|
old.resolve({ data: { driverUrl: 'legacy' } })
|
|
await pending
|
|
assert.equal(a.driverUrl.value, 'legacy')
|
|
assert.equal(reads, 1)
|
|
})
|
|
test('arrange QR nextTick cannot copy newly bound dialog after reset', async () => {
|
|
let copies = 0
|
|
const a = arrangeHarness({}, { isolateRecord: true })
|
|
const pending = a.copyPoundSlipQrcodeForRecord({ dbInventoryId: 'A' })
|
|
a.resetArrangeActions()
|
|
a.qrcodeDialogRef.value = {
|
|
copyQrcodeImage: () => {
|
|
copies++
|
|
}
|
|
}
|
|
await pending
|
|
assert.equal(copies, 0)
|
|
assert.equal(a.qrcodeVisible.value, false)
|
|
})
|
|
for (const transition of ['B', 'ABA', 'close-reopen', 'unmount', 'invalidate', 'permission'])
|
|
for (const reject of [false, true])
|
|
test(`upload ${transition}: stale save ${reject ? 'error' : 'success'}`, async () => {
|
|
const old = deferred()
|
|
const h = harness('plan-cert/CertUpload', {
|
|
planApi: { updateProductCerts: () => old.promise }
|
|
})
|
|
await flush()
|
|
const pending = h.save()
|
|
if (transition === 'B' || transition === 'ABA') {
|
|
h.props.planId = 'B'
|
|
if (transition === 'ABA') h.props.planId = 'A'
|
|
}
|
|
if (transition === 'close-reopen') {
|
|
h.props.modelValue = false
|
|
h.props.modelValue = true
|
|
}
|
|
if (transition === 'unmount') h.unmount()
|
|
if (transition === 'invalidate') h.invalidateRecord()
|
|
if (transition === 'permission') h.props.permitted = false
|
|
await flush()
|
|
h.saving.value = true
|
|
if (reject) old.reject(new Error('old'))
|
|
else old.resolve()
|
|
await pending
|
|
assert.deepEqual(h.pushes, [])
|
|
assert.deepEqual(h.messages, [])
|
|
assert.equal(h.error.value, '')
|
|
assert.equal(h.saving.value, true)
|
|
if (transition !== 'unmount') h.unmount()
|
|
})
|
|
for (const invalid of [{ id: 'B', products: [] }, { id: 'A' }, null])
|
|
test(`upload invalid metadata response ${JSON.stringify(invalid)} blocks saving`, async () => {
|
|
const h = harness('plan-cert/CertUpload', { planApi: { get: async () => ({ data: invalid }) } })
|
|
await flush()
|
|
await h.save()
|
|
assert.ok(h.error.value)
|
|
assert.equal(h.allowed.value, false)
|
|
assert.deepEqual(h.writes, [])
|
|
h.unmount()
|
|
})
|
|
test('upload strict enrichment failure is explicit, retriable and blocks write', async () => {
|
|
let strictArg: any
|
|
const h = harness('plan-cert/CertUpload', {
|
|
enrichPlanProductsHazardFromCrm: async (_: any, strict: any) => {
|
|
strictArg = strict
|
|
throw new Error('metadata')
|
|
}
|
|
})
|
|
await flush()
|
|
await h.save()
|
|
assert.equal(strictArg, true)
|
|
assert.ok(h.error.value)
|
|
assert.deepEqual(h.writes, [])
|
|
h.unmount()
|
|
})
|
|
test('upload keeps edits when same ID/visibility and unrelated query change; payload is captured', async () => {
|
|
const old = deferred(),
|
|
writes: any[] = []
|
|
const h = harness('plan-cert/CertUpload', {
|
|
planApi: {
|
|
updateProductCerts: (...args: any[]) => {
|
|
writes.push(args)
|
|
return old.promise
|
|
}
|
|
}
|
|
})
|
|
await flush()
|
|
h.products.value[0].purchaseCertUrl = 'original'
|
|
h.props.planId = 'A'
|
|
h.props.modelValue = true
|
|
h.route.query.tab = 'info'
|
|
await flush()
|
|
assert.equal(h.products.value[0].purchaseCertUrl, 'original')
|
|
assert.equal(h.calls.length, 1)
|
|
const pending = h.save()
|
|
h.products.value[0].purchaseCertUrl = 'changed'
|
|
old.resolve()
|
|
await pending
|
|
assert.equal(writes[0][0], 'A')
|
|
assert.equal(writes[0][1][0].purchaseCertUrl, 'original')
|
|
assert.deepEqual(h.pushes, [
|
|
['update:modelValue', false],
|
|
['success', 'A']
|
|
])
|
|
h.unmount()
|
|
})
|
|
for (const reject of [false, true])
|
|
test(`upload stale metadata ${reject ? 'error' : 'success'} cannot clear new loading`, async () => {
|
|
const old = deferred(),
|
|
fresh = deferred()
|
|
let n = 0
|
|
const h = harness('plan-cert/CertUpload', {
|
|
planApi: { get: () => (++n === 1 ? old.promise : fresh.promise) }
|
|
})
|
|
h.props.planId = 'B'
|
|
if (reject) old.reject(new Error('old'))
|
|
else old.resolve({ data: { id: 'A', products: [] } })
|
|
await flush()
|
|
assert.equal(h.loading.value, true)
|
|
assert.equal(h.plan.value, null)
|
|
assert.equal(h.error.value, '')
|
|
await h.save()
|
|
assert.deepEqual(h.writes, [])
|
|
fresh.resolve({ data: { id: 'B', products: [] } })
|
|
await flush()
|
|
assert.equal(h.plan.value.id, 'B')
|
|
h.unmount()
|
|
})
|
|
for (const reject of [false, true])
|
|
test(`cert detail same-record retry isolates old loader ${reject ? 'error' : 'success/finally'}`, async () => {
|
|
const old = deferred(),
|
|
fresh = deferred()
|
|
let n = 0
|
|
const h = harness('plan-cert/CertDetail', {
|
|
usePlPlanViewDetail: detailLoader(() => (++n === 1 ? old.promise : fresh.promise))
|
|
})
|
|
await flush()
|
|
const pending = h.reload()
|
|
if (reject) old.reject(new Error('old'))
|
|
else old.resolve({ id: 'A', planNo: 'OLD' })
|
|
await flush()
|
|
assert.equal(h.plan.value, null)
|
|
assert.equal(h.loading.value, true)
|
|
assert.equal(h.error.value, '')
|
|
fresh.resolve({ id: 'A', planNo: 'FRESH' })
|
|
await pending
|
|
assert.equal(h.plan.value.planNo, 'FRESH')
|
|
h.unmount()
|
|
})
|
|
test('cert detail parent sync invalidation prevents stale contract and linked callbacks', async () => {
|
|
const contract = deferred(),
|
|
linked = deferred()
|
|
const h = harness('plan-cert/CertDetail', {
|
|
usePlPlanViewDetail: detailLoader(async (id) => ({ id })),
|
|
getContract: async () => ({ data: { id: 'C' } }),
|
|
enrichContractReadFields: () => contract.promise,
|
|
planApi: { list: () => linked.promise }
|
|
})
|
|
await flush()
|
|
const pending = h.openContract('C')
|
|
await flush()
|
|
contract.resolve({ contract: { id: 'C' }, warnings: [] })
|
|
await flush()
|
|
h.invalidateRecord()
|
|
linked.resolve({ data: { list: [{ id: 'OLD' }], total: 1 } })
|
|
await pending
|
|
assert.equal(h.contractOpen.value, false)
|
|
assert.deepEqual(h.linkedPlans.value, [])
|
|
assert.equal(h.linkedLoading.value, false)
|
|
h.unmount()
|
|
})
|
|
test('transport local tabs retain single-line 44px targets and bordered content fits available width', () => {
|
|
const sfc = parse(
|
|
readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
|
|
).descriptor
|
|
const style = sfc.styles.find((s) => s.scoped)!.content
|
|
assert.match(
|
|
style,
|
|
/\.mp-plan-detail-page\.mp-h5-detail-page[\s\S]*?:deep\(\.van-tab\)\s*\{[^}]*flex:\s*0 0 auto !important/
|
|
)
|
|
assert.match(style, /:deep\(\.van-tab__text\)\s*\{[^}]*white-space:\s*nowrap/)
|
|
assert.match(
|
|
style,
|
|
/:deep\(\.van-tabs__nav\)\s*\{[^}]*height:\s*44px[^}]*box-sizing:\s*border-box/
|
|
)
|
|
assert.match(style, /\.mp-wv-section\s*\{[^}]*min-width:\s*0[^}]*box-sizing:\s*border-box/)
|
|
})
|
|
for (const reject of [false, true])
|
|
test(`cert detail reload resets copy busy while obsolete ${reject ? 'failure' : 'success'} preserves new copy`, async () => {
|
|
const old = deferred(),
|
|
fresh = deferred()
|
|
const h = harness('plan-cert/CertDetail', {
|
|
usePlPlanViewDetail: detailLoader(async (id) => ({ id }))
|
|
})
|
|
await flush()
|
|
const first = h.copyAction(() => old.promise)
|
|
assert.equal(h.copying.value, true)
|
|
const reload = h.reload()
|
|
assert.equal(h.copying.value, false, 'new load must synchronously reset previous copy busy')
|
|
await reload
|
|
const second = h.copyAction(() => fresh.promise)
|
|
assert.equal(h.copying.value, true)
|
|
if (reject) old.reject(new Error('old copy'))
|
|
else old.resolve()
|
|
await first
|
|
assert.equal(h.copying.value, true, 'obsolete copy must not clear new copy busy')
|
|
assert.deepEqual(h.messages, [])
|
|
fresh.resolve()
|
|
await second
|
|
assert.equal(h.copying.value, false)
|
|
h.unmount()
|
|
})
|
|
|
|
test('plan detail identity switch invalidates old reads and closes every record action', async () => {
|
|
const older = deferred()
|
|
const user = vue.reactive({ accessToken: 'old', info: { id: 'old-user' } })
|
|
let reads = 0
|
|
const h = harness('plan/detail', {
|
|
useUserStore: () => user,
|
|
planApi: {
|
|
get: () => (++reads === 1 ? older.promise : Promise.resolve({ id: 'A', marker: 'new' }))
|
|
},
|
|
loadMobileDetailReads: async () => ({ errors: [] })
|
|
})
|
|
h.ready()
|
|
await flush()
|
|
h.moreOpen.value = h.voidOpen.value = h.vehicleOpen.value = true
|
|
user.accessToken = 'new'
|
|
assert.equal(h.moreOpen.value, false)
|
|
assert.equal(h.voidOpen.value, false)
|
|
assert.equal(h.vehicleOpen.value, false)
|
|
await flush()
|
|
older.resolve({ id: 'A', marker: 'old' })
|
|
await flush()
|
|
assert.equal(h.plan.value.marker, 'new')
|
|
assert.equal(reads, 2)
|
|
user.accessToken = ''
|
|
assert.equal(h.plan.value, null)
|
|
assert.equal(h.loading.value, false)
|
|
assert.match(h.errorText.value, /登录状态/)
|
|
await h.runAction('wait')
|
|
assert.deepEqual(h.writes, [])
|
|
h.unmount()
|
|
})
|
|
|
|
test('cert detail requests mobile metadata once and passes that snapshot to the shared read loader', async () => {
|
|
const snapshot = { id: 'A', products: [], deliveries: [] }
|
|
const reads: any[] = [],
|
|
snapshots: any[] = []
|
|
const loader = detailLoader(async () => snapshot)
|
|
const h = harness('plan-cert/CertDetail', {
|
|
planApi: {
|
|
get: async (...args: any[]) => {
|
|
reads.push(args)
|
|
return { data: snapshot }
|
|
}
|
|
},
|
|
usePlPlanViewDetail: () => {
|
|
const detail = loader()
|
|
const load = detail.loadDetail
|
|
detail.loadDetail = (id: string, options: any) => {
|
|
snapshots.push(options?.initialPlan)
|
|
return load(id)
|
|
}
|
|
return detail
|
|
}
|
|
})
|
|
await flush()
|
|
assert.deepEqual(reads, [['A', true]])
|
|
assert.equal(snapshots[0], snapshot)
|
|
assert.equal(h.plan.value.id, 'A')
|
|
h.unmount()
|
|
})
|