2026-10-02 12:21:04 +08:00
|
|
|
import test from 'node:test'
|
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
import ts from 'typescript'
|
|
|
|
|
import * as vue from 'vue'
|
|
|
|
|
|
|
|
|
|
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
|
|
|
|
function harness(t) {
|
|
|
|
|
const source = readFileSync(new URL('./CustomsDocumentsDialog.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
2026-10-03 02:35:28 +08:00
|
|
|
const code = ts.transpileModule(source + '\nexport { loadDetail, detail, clearanceResults, loading, errorMessage, selectedInboundIds, clearanceError, clearanceLoading, retryClearanceResults, close }', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
2026-10-02 12:21:04 +08:00
|
|
|
const props = vue.reactive({ modelValue: false, orderId: 'A', canEdit: true }), requests = [], clearances = [], updates = [], view = {}
|
|
|
|
|
const unmounts = []
|
|
|
|
|
const dependencies = { vue: { ...vue, onBeforeUnmount: fn => unmounts.push(fn) }, 'element-plus': { ElMessage: {} }, '@/modules/first-leg/api/customs': {
|
|
|
|
|
getMerchantCustomsForOrder: id => { const pending = deferred(); requests.push({ id, ...pending }); return pending.promise },
|
|
|
|
|
getMerchantClearanceResults: id => { const pending = deferred(); clearances.push({ id, ...pending }); return pending.promise },
|
|
|
|
|
merchantCustomsErrorMessage: error => error.message,
|
|
|
|
|
} }
|
|
|
|
|
const scope = vue.effectScope()
|
|
|
|
|
scope.run(() => new Function('require', 'exports', 'defineProps', 'defineEmits', code)(id => {
|
|
|
|
|
assert.ok(id in dependencies, id); return dependencies[id]
|
|
|
|
|
}, view, () => props, () => (...args) => updates.push(args)))
|
|
|
|
|
t.after(() => scope.stop())
|
|
|
|
|
return { props, requests, clearances, updates, view, unmount: () => unmounts.forEach(fn => fn()) }
|
|
|
|
|
}
|
|
|
|
|
const detail = id => ({ loadingPlanId: id, inbounds: [{ orderId: id, inboundId: `IN-${id}`, taxMode: 'TAX_REFUND' }] })
|
|
|
|
|
const tick = async () => { await Promise.resolve(); await vue.nextTick(); await Promise.resolve() }
|
|
|
|
|
|
|
|
|
|
test('switching orders while open starts the new request and ignores stale detail', async t => {
|
|
|
|
|
const h = harness(t); h.props.modelValue = true; await tick()
|
|
|
|
|
h.props.orderId = 'B'; await tick()
|
|
|
|
|
assert.deepEqual(h.requests.map(r => r.id), ['A', 'B'])
|
|
|
|
|
h.requests[1].resolve(detail('B')); await tick(); h.clearances[0].resolve([]); await tick()
|
|
|
|
|
h.requests[0].resolve(detail('A')); await tick()
|
|
|
|
|
assert.equal(h.view.detail.value.loadingPlanId, 'B')
|
|
|
|
|
assert.deepEqual(h.view.selectedInboundIds.value, ['IN-B'])
|
|
|
|
|
assert.deepEqual(h.clearances.map(r => r.id), ['B'])
|
|
|
|
|
assert.equal(h.updates.length, 1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('closing before detail response prevents stale data and events', async t => {
|
|
|
|
|
const h = harness(t); h.props.modelValue = true; await tick()
|
|
|
|
|
h.props.modelValue = false; await tick()
|
|
|
|
|
h.requests[0].resolve(detail('A')); await tick()
|
|
|
|
|
assert.equal(h.view.detail.value, null); assert.equal(h.view.loading.value, false)
|
|
|
|
|
assert.equal(h.clearances.length, 0); assert.equal(h.updates.length, 0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('closing and reopening ignores the previous clearance response', async t => {
|
|
|
|
|
const h = harness(t); h.props.modelValue = true; await tick()
|
|
|
|
|
h.requests[0].resolve(detail('A')); await tick()
|
|
|
|
|
h.props.modelValue = false; await tick(); h.props.orderId = 'B'; h.props.modelValue = true; await tick()
|
|
|
|
|
h.requests[1].resolve(detail('B')); await tick()
|
|
|
|
|
h.clearances[1].resolve([{ kind: 'B' }]); await tick()
|
|
|
|
|
h.clearances[0].resolve([{ kind: 'A' }]); await tick()
|
|
|
|
|
assert.deepEqual(h.view.clearanceResults.value, [{ kind: 'B' }])
|
|
|
|
|
assert.equal(h.view.detail.value.loadingPlanId, 'B'); assert.equal(h.updates.length, 1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('stale detail failure cannot clear the next request loading or show an error', async t => {
|
|
|
|
|
const h = harness(t); h.props.modelValue = true; await tick()
|
|
|
|
|
h.props.orderId = 'B'; await tick(); h.requests[0].reject(new Error('old failure')); await tick()
|
|
|
|
|
assert.equal(h.view.loading.value, true); assert.equal(h.view.errorMessage.value, '')
|
|
|
|
|
h.requests[1].resolve(detail('B')); await tick(); h.clearances[0].resolve([]); await tick()
|
|
|
|
|
assert.equal(h.view.loading.value, false); assert.equal(h.view.detail.value.loadingPlanId, 'B')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('unmount invalidates pending detail requests', async t => {
|
|
|
|
|
const h = harness(t); h.props.modelValue = true; await tick(); h.unmount()
|
|
|
|
|
h.requests[0].resolve(detail('A')); await tick()
|
|
|
|
|
assert.equal(h.clearances.length, 0); assert.equal(h.updates.length, 0); assert.equal(h.view.detail.value, null)
|
|
|
|
|
})
|
2026-10-03 02:35:28 +08:00
|
|
|
|
|
|
|
|
test('result failure retains documents and exposes error; retry queries only results and deduplicates clicks', async t => {
|
|
|
|
|
const h = harness(t); h.props.modelValue = true; await tick()
|
|
|
|
|
h.requests[0].resolve({ ...detail('A'), documents: [{ documentId: 'document-A' }] }); await tick()
|
|
|
|
|
h.clearances[0].reject(new Error('结果服务暂不可用')); await tick()
|
|
|
|
|
assert.equal(h.view.errorMessage.value, '')
|
|
|
|
|
assert.equal(h.view.clearanceError.value, '结果服务暂不可用')
|
|
|
|
|
assert.equal(h.view.detail.value.documents[0].documentId, 'document-A')
|
|
|
|
|
assert.equal(h.view.loading.value, false)
|
|
|
|
|
const retry = h.view.retryClearanceResults()
|
|
|
|
|
await h.view.retryClearanceResults()
|
|
|
|
|
assert.equal(h.requests.length, 1); assert.equal(h.clearances.length, 2)
|
|
|
|
|
assert.equal(h.view.clearanceLoading.value, true)
|
|
|
|
|
h.clearances[1].resolve([{ id: 'result-A' }]); await retry
|
|
|
|
|
assert.equal(h.view.clearanceError.value, '')
|
|
|
|
|
assert.deepEqual(h.view.clearanceResults.value, [{ id: 'result-A' }])
|
|
|
|
|
assert.equal(h.view.clearanceLoading.value, false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('normal open requests detail and results exactly once and distinguishes successful empty results', async t => {
|
|
|
|
|
const h = harness(t); h.props.modelValue = true; await tick()
|
|
|
|
|
h.requests[0].resolve(detail('A')); await tick(); h.clearances[0].resolve([]); await tick()
|
|
|
|
|
assert.equal(h.requests.length, 1); assert.equal(h.clearances.length, 1)
|
|
|
|
|
assert.equal(h.view.clearanceError.value, '')
|
|
|
|
|
assert.equal(h.view.clearanceLoading.value, false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const transition of ['switch', 'close', 'unmount']) {
|
|
|
|
|
for (const outcome of ['resolve', 'reject']) {
|
|
|
|
|
test(`${transition} ignores stale result retry ${outcome}`, async t => {
|
|
|
|
|
const h = harness(t); h.props.modelValue = true; await tick()
|
|
|
|
|
h.requests[0].resolve(detail('A')); await tick(); h.clearances[0].reject(new Error('first failure')); await tick()
|
|
|
|
|
const retry = h.view.retryClearanceResults()
|
|
|
|
|
if (transition === 'switch') { h.props.orderId = 'B'; await tick() }
|
|
|
|
|
else if (transition === 'close') { h.view.close(); h.props.modelValue = false; await tick() }
|
|
|
|
|
else h.unmount()
|
|
|
|
|
if (outcome === 'resolve') h.clearances[1].resolve([{ id: 'old-A' }])
|
|
|
|
|
else h.clearances[1].reject(new Error('old retry failure'))
|
|
|
|
|
await retry
|
|
|
|
|
assert.equal(h.view.clearanceError.value, '')
|
|
|
|
|
assert.deepEqual(h.view.clearanceResults.value, [])
|
|
|
|
|
if (transition === 'switch') {
|
|
|
|
|
h.requests[1].resolve(detail('B')); await tick(); h.clearances[2].resolve([{ id: 'B' }]); await tick()
|
|
|
|
|
assert.deepEqual(h.view.clearanceResults.value, [{ id: 'B' }])
|
|
|
|
|
} else {
|
|
|
|
|
await h.view.retryClearanceResults()
|
|
|
|
|
assert.equal(h.clearances.length, 2)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('result error and retry reuse existing section error styling before the empty state', () => {
|
|
|
|
|
const source = readFileSync(new URL('./CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
|
|
|
|
|
assert.match(source, /v-if="clearanceError" class="customs-error" role="alert"/)
|
|
|
|
|
assert.match(source, /@click="retryClearanceResults"/)
|
|
|
|
|
assert.match(source, /v-else-if="clearanceLoading"/)
|
|
|
|
|
assert.match(source, /v-else-if="!clearanceResults.length"/)
|
|
|
|
|
})
|