Files
shanxia2.0/web-admin/scripts/mobile-pl-contract-line-picker.test.ts
T
2026-10-23 23:07:00 +08:00

619 lines
22 KiB
TypeScript

import { mobileStopIdentity } from '../src/views/m/pl/plan/mobileStopIdentity'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import test from 'node:test'
import { computed, ref } from 'vue'
import * as vue from 'vue'
import { renderToString } from 'vue/server-renderer'
import { compileScript, parse } from '@vue/compiler-sfc'
import ts from 'typescript'
const parentPath = new URL(
'../src/components/business/pl-plan-form-dialog/index.vue',
import.meta.url
)
const sheetPath = new URL('../src/views/m/pl/plan/ContractLinePickerSheet.vue', import.meta.url)
const parent = readFileSync(parentPath, 'utf8')
const sheet = existsSync(sheetPath) ? readFileSync(sheetPath, 'utf8') : ''
function execute(source: string, names: string[], bindings: Record<string, any>) {
const ast = ts.createSourceFile(
'component.ts',
parse(source).descriptor.scriptSetup?.content || '',
ts.ScriptTarget.Latest,
true
)
const code = names
.map((name) => {
const node = ast.statements.find(
(node) =>
(ts.isFunctionDeclaration(node) && node.name?.text === name) ||
(ts.isVariableStatement(node) &&
node.declarationList.declarations.some((d) => d.name.getText(ast) === name))
)
assert.ok(node, `production declaration ${name} must exist`)
return node.getText(ast)
})
.join('\n')
return new Function(
...Object.keys(bindings),
ts.transpile(code, { target: ts.ScriptTarget.ESNext }) + `;return {${names.join(',')}}`
)(...Object.values(bindings))
}
const rows = [
{
id: 'line-a',
contractId: 'a',
contractNo: 'HT-A',
productId: 'same',
productName: '长名称液体',
standardProductName: '标准名称',
productNo: 'P001',
model: 'M-A',
brandName: '品牌甲',
aliasName: '别名甲',
quantity: 0,
remainingQty: 0,
taxRate: 0
},
{
id: 'line-b',
contractId: 'b',
contractNo: 'HT-B',
productId: 'same',
productName: '液体乙',
productNo: 'P002',
productModel: 'M-B'
}
]
function sheetHarness() {
const events: any[] = []
const props = { rows, busy: false, loading: false }
return {
props,
events,
...execute(
sheet,
[
'keyword',
'selectedRows',
'filteredRows',
'rowKey',
'isSelected',
'toggleRow',
'resetSelection',
'confirmSelection',
'displayNumber'
],
{ ref, computed, props, emit: (...args: any[]) => events.push(args) }
)
}
}
test('mobile picker replaces desktop dialog only for page variant', () => {
assert.match(parent, /<ContractLinePickerSheet\s+v-if="isPageVariant"/)
assert.match(parent, /<el-dialog\s+v-else\s+v-model="linePickerVisible"/)
assert.doesNotMatch(sheet, /el-table|el-dialog/)
})
test('search trims and ignores case across product, contract, model, brand and alias', () => {
const h = sheetHarness()
for (const query of [' 标准名称 ', '长名称', ' p001 ', 'm-a', 'ht-a', '品牌甲', '别名甲']) {
h.keyword.value = query
assert.deepEqual(h.filteredRows.value, [rows[0]], query)
}
h.keyword.value = 'm-b'
assert.deepEqual(h.filteredRows.value, [rows[1]])
})
test('selection survives search and keeps same product across distinct contract lines', () => {
const h = sheetHarness()
h.toggleRow(rows[0])
h.keyword.value = 'HT-B'
h.toggleRow(rows[1])
assert.equal(h.selectedRows.value.length, 2)
assert.notEqual(h.rowKey(rows[0]), h.rowKey(rows[1]))
h.confirmSelection()
assert.equal(h.events[0][0], 'confirm')
assert.deepEqual(h.events[0][1], rows)
h.resetSelection()
assert.equal(h.keyword.value, '')
assert.deepEqual(h.selectedRows.value, [])
h.confirmSelection()
assert.equal(h.events.length, 1)
})
test('zero quantities and tax are visible, missing numbers are placeholders', () => {
const h = sheetHarness()
assert.equal(h.displayNumber(0), '0')
for (const value of [undefined, null, '', NaN]) assert.equal(h.displayNumber(value), '—')
})
const detail = readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
function scrollHarness({ y = 600, height = 1200, locked = false, missingLast = false } = {}) {
const activeTab = ref('hazard')
const sections: Record<string, number> = { base: -600, hazard: -30, remark: 500 }
const h = execute(detail, ['syncActiveTabFromScroll'], {
activeTab,
scrollLockUntil: locked ? Date.now() + 10000 : 0,
plan: ref({}),
scrollMargin: ref(100),
detailTabs: Object.keys(sections).map((key) => ({ key, anchor: key })),
window: { innerHeight: 600, scrollY: y },
document: {
scrollingElement: { scrollHeight: height },
getElementById: (key: string) =>
missingLast && key === 'remark'
? null
: { getBoundingClientRect: () => ({ top: sections[key] - (y - 600) }) }
}
})
return { ...h, activeTab }
}
test('short final remark remains highlighted at document bottom', () => {
const h = scrollHarness()
h.syncActiveTabFromScroll()
assert.equal(h.activeTab.value, 'remark')
})
for (const [name, options, expected] of [
['Home restores base', { y: 0 }, 'base'],
['short unscrolled page avoids bottom override', { y: 0, height: 500 }, 'base'],
['click lock is respected', { locked: true }, 'hazard'],
['missing last section falls back to top probe', { missingLast: true }, 'hazard']
] as const)
test(name, () => {
const h = scrollHarness(options)
h.syncActiveTabFromScroll()
assert.equal(h.activeTab.value, expected)
})
function deferred() {
let resolve!: (value: any) => void
const promise = new Promise((yes) => {
resolve = yes
})
return { resolve, promise }
}
function parentHarness({
enrich = async (rows: any) => rows,
fetch = async () => {},
page = true
} = {}) {
const loadForm = ref({
customerName: '客户甲',
contracts: [{ contractId: 'a' }],
products: [] as any[]
})
const deliveryForms = ref([
{ customerName: '客户乙', contracts: [{ contractId: 'b' }], products: [] as any[] },
{ customerName: '客户丙', contracts: [{ contractId: 'c' }], products: [] as any[] }
])
const bindings = {
mobileStopIdentity,
initLineBrandMetaFromProduct: () => ({}),
packageSpecsForProduct: () => [],
normalizePackageSpec: (value: any) => String(value || '').trim(),
productPackageKey: (id: any, pkg: any) => `${id}::${pkg || ''}`,
unionContractProductRows: () => [],
loadContractProductRows: ref([]),
contractProductRowsCache: ref({}),
ref,
isPageVariant: ref(page),
loadForm,
deliveryForms,
productSelectorTarget: ref({ side: 'load', deliveryIndex: 0 }),
productSelectorVisible: ref(false),
isPlInternalCustomerName: () => false,
collectStopContractIds: (contracts: any[]) => contracts.map((row) => row.contractId),
fetchContractProductIds: fetch,
enrichContractLinesForPicker: async (rows: any) => rows,
enrichPlanProductsHazardFromCrm: enrich,
enrichPlanProductLinesAlias: async (rows: any) => rows,
createStopProductsFromPlanProducts: (rows: any) => rows.map((row: any) => ({ ...row })),
ElMessage: { warning() {}, info() {}, error() {} },
freightCalc: { resetFreightCalc() {} }
}
const h = execute(
parent,
[
'pageReady',
'pageDisposed',
'pageLoadGeneration',
'isPageLoadCurrent',
'isPageSubmitCurrent',
'invalidatePageLoad',
'linePickerVisible',
'linePickerRows',
'linePickerSelected',
'contractLinesCache',
'mobileLinePickerSession',
'mobileLinePickerGeneration',
'mobileLinePickerBusy',
'mobileLinePickerLoading',
'mobileLinePickerError',
'setMobileLinePickerVisible',
'handleMobileLinePickerConfirm',
'openProductSelector',
'handleLinePickerConfirm',
'handleProductConfirm'
],
bindings
)
h.pageReady.value = true
h.contractLinesCache.value = {
a: [rows[0]],
b: [rows[1]],
c: [{ ...rows[1], id: 'line-c', contractId: 'c' }]
}
return { ...bindings, ...h }
}
test('parent delivers exact selected contract line identities into requested delivery segment', async () => {
const h = parentHarness()
await h.openProductSelector('delivery', 1)
await h.handleMobileLinePickerConfirm([rows[0], rows[1]])
assert.equal(h.deliveryForms.value[0].products.length, 0)
assert.deepEqual(
h.deliveryForms.value[1].products.map((r: any) => [r.contractId, r.contractProductId]),
[
['a', 'line-a'],
['b', 'line-b']
]
)
assert.equal(h.loadForm.value.products.length, 0)
})
test('parent busy gate prevents a double confirm while enrichment is pending', async () => {
const d = deferred()
let calls = 0
const h = parentHarness({
enrich: async (rows: any) => {
calls++
await d.promise
return rows
}
})
await h.openProductSelector('load')
const first = h.handleMobileLinePickerConfirm([rows[0]])
assert.equal(h.mobileLinePickerBusy.value, true)
await h.handleMobileLinePickerConfirm([rows[0]])
assert.equal(calls, 1)
d.resolve(null)
await first
assert.equal(h.loadForm.value.products.length, 1)
})
for (const action of ['close', 'switch', 'unmount'] as const)
test(`old confirm cannot append after ${action}`, async () => {
const d = deferred()
const h = parentHarness({
enrich: async (rows: any) => {
await d.promise
return rows
}
})
await h.openProductSelector('load')
const pending = h.handleMobileLinePickerConfirm([rows[0]])
if (action === 'close') {
h.setMobileLinePickerVisible(false)
await h.openProductSelector('load')
}
if (action === 'switch') await h.openProductSelector('delivery', 1)
if (action === 'unmount') h.invalidatePageLoad()
d.resolve(null)
await pending
assert.equal(h.loadForm.value.products.length, 0)
assert.equal(h.deliveryForms.value[1].products.length, 0)
if (action !== 'unmount') assert.equal(h.linePickerVisible.value, true)
})
test('out-of-order opening does not apply old rows to a new segment', async () => {
const d = deferred()
const h = parentHarness({
fetch: async (id?: string) => {
if (id === 'a') await d.promise
}
})
const old = h.openProductSelector('load')
await h.openProductSelector('delivery', 1)
d.resolve(null)
await old
assert.equal(h.linePickerRows.value[0].contractId, 'c')
assert.equal(h.productSelectorTarget.value.deliveryIndex, 1)
})
test('desktop confirm remains usable without mobile page readiness or sheet state', async () => {
const h = parentHarness({ page: false })
h.pageReady.value = false
h.linePickerSelected.value = [rows[0]]
await h.handleLinePickerConfirm()
assert.equal(h.loadForm.value.products[0].contractProductId, 'line-a')
})
test('mobile confirmation deduplicates by contract plus line id, not line id alone', async () => {
const h = parentHarness()
await h.openProductSelector('load')
await h.handleMobileLinePickerConfirm([rows[0], { ...rows[1], id: rows[0].id }])
assert.equal(h.loadForm.value.products.length, 2)
})
test('failed mobile opening remains visible with retry and clears loading', async () => {
let fail = true
const h = parentHarness({
fetch: async () => {
if (fail) throw new Error('offline')
}
})
await h.openProductSelector('load')
assert.equal(h.linePickerVisible.value, true)
assert.equal(h.mobileLinePickerLoading.value, false)
assert.match(h.mobileLinePickerError.value, /重新加载/)
fail = false
await h.openProductSelector('load')
assert.equal(h.mobileLinePickerError.value, '')
assert.equal(h.linePickerRows.value.length, 1)
})
test('cancel pending opening cannot reopen or populate the closed sheet', async () => {
const d = deferred()
const h = parentHarness({
fetch: async () => {
await d.promise
}
})
const pending = h.openProductSelector('load')
h.setMobileLinePickerVisible(false)
d.resolve(null)
await pending
assert.equal(h.linePickerVisible.value, false)
assert.equal(h.linePickerRows.value.length, 0)
})
test('old confirm finally cannot clear a new session busy flag', async () => {
const old = deferred()
const fresh = deferred()
let count = 0
const h = parentHarness({
enrich: async (rows: any) => {
await (++count === 1 ? old.promise : fresh.promise)
return rows
}
})
await h.openProductSelector('load')
const first = h.handleMobileLinePickerConfirm([rows[0]])
h.setMobileLinePickerVisible(false)
await h.openProductSelector('delivery', 1)
const second = h.handleMobileLinePickerConfirm([rows[1]])
old.resolve(null)
await first
assert.equal(h.mobileLinePickerBusy.value, true)
fresh.resolve(null)
await second
assert.equal(h.mobileLinePickerBusy.value, false)
assert.equal(h.deliveryForms.value[1].products.length, 1)
})
test('sheet session records the page generation and rejects confirmation from an older page', async () => {
const h = parentHarness()
await h.openProductSelector('load')
h.mobileLinePickerGeneration.value -= 1
await h.handleMobileLinePickerConfirm([rows[0]])
assert.equal(h.loadForm.value.products.length, 0)
})
function compiledSheet() {
const script = compileScript(parse(sheet).descriptor, { id: 'picker-test', inlineTemplate: true })
const code = ts.transpile(script.content, {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext
})
const ast = ts.createSourceFile('sheet.js', code, ts.ScriptTarget.Latest, true)
const bindings: Record<string, any> = Object.fromEntries(
Object.entries(vue).filter(([name]) => /^[a-zA-Z_$][\w$]*$/.test(name) && name !== 'default')
)
const statements: string[] = []
for (const node of ast.statements) {
if (ts.isImportDeclaration(node)) {
const imports = node.importClause?.namedBindings
if (imports && ts.isNamedImports(imports))
for (const item of imports.elements) {
bindings[item.name.text] = (vue as any)[item.propertyName?.text || item.name.text]
}
} else statements.push(node.getText(ast))
}
return new Function(
...Object.keys(bindings),
statements.join('\n').replace('export default', 'return')
)(...Object.values(bindings))
}
test('real sheet renderer keeps all card fields, formatter results, zero values and no desktop table', async () => {
const app = vue.createSSRApp(compiledSheet(), {
modelValue: true,
rows,
busy: false,
loading: false,
error: '',
formatPackage: () => '字典包装',
formatBrand: () => '品牌规则结果',
formatRemaining: (n: any) => (n == null ? '—' : String(n)),
dictLabel: (_type: string, value: string) => '字典-' + value,
dictTagType: () => 'success'
})
app.component('van-popup', {
setup:
(_props: any, { slots }: any) =>
() =>
vue.h('section', slots.default?.())
})
app.component('el-tag', {
setup:
(_props: any, { slots }: any) =>
() =>
vue.h('span', slots.default?.())
})
const html = await renderToString(app)
for (const text of [
'标准名称',
'HT-A',
'HT-B',
'P001',
'M-A',
'字典包装',
'品牌规则结果',
'别名甲',
'0%',
'合同数量',
'剩余数量',
'计量单位',
'状态'
])
assert.ok(html.includes(text), text)
assert.match(html, /<small>合同数量<\/small><span>0<\/span>/)
assert.match(html, /<small>剩余数量<\/small><span>0<\/span>/)
assert.match(html, /确认添加/)
assert.match(html, /disabled/)
assert.doesNotMatch(html, /<table|el-dialog/)
})
test('production visibility watcher resets search and temporary selection on close and reopen', () => {
const h = sheetHarness()
const props = vue.reactive({ modelValue: true })
const ast = ts.createSourceFile(
'sheet.ts',
parse(sheet).descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
const watcher = ast.statements.find(
(node) => ts.isExpressionStatement(node) && node.getText(ast).startsWith('watch(')
)
assert.ok(watcher)
const scope = vue.effectScope()
scope.run(() =>
new Function('watch', 'props', 'resetSelection', ts.transpile(watcher.getText(ast)))(
vue.watch,
props,
h.resetSelection
)
)
h.keyword.value = 'HT-A'
h.toggleRow(rows[0])
props.modelValue = false
assert.equal(h.keyword.value, '')
assert.equal(h.selectedRows.value.length, 0)
props.modelValue = true
assert.equal(h.selectedRows.value.length, 0)
assert.equal(h.events.length, 0)
scope.stop()
})
function realFetchHarness(
read: (id: string) => Promise<any> = async () => {
throw new Error('offline')
}
) {
const h = parentHarness({ fetch: (...args: any[]) => fetchReal(...args) })
const pageLoadError = ref('')
const prod = execute(
parent,
['fetchContractProductIds', 'assertPageLoadCurrent', 'failPageLoad', 'pageLoadErrorMessage'],
{
...h,
pageLoadGeneration: 0,
pageLoadError,
footerMoreOpen: ref(false),
props: {},
contractProductIdsCache: ref({}),
contractProductRowsCache: ref({}),
getContractDetailCached: read,
invalidateContractDetailCache() {},
collectContractProductRows: (rows: any) => rows,
collectContractProductIds: (rows: any) => rows.map((row: any) => row.productId)
}
)
const fetchReal = prod.fetchContractProductIds
return { ...h, pageLoadError }
}
test('real contract failure blocks the page, releases loading and closes the sheet for page retry', async () => {
const h = realFetchHarness()
await h.openProductSelector('load')
assert.equal(h.pageReady.value, false)
assert.ok(h.pageLoadError.value)
assert.equal(h.mobileLinePickerLoading.value, false)
assert.match(h.mobileLinePickerError.value, /重新加载/)
assert.equal(h.linePickerVisible.value, false)
})
for (const side of ['load', 'delivery'] as const)
test(`${side} stop replacement by the contract watcher releases loading and closes only its current sheet`, async () => {
const d = deferred()
const h = realFetchHarness(async (id) => {
await d.promise
return { id, products: rows }
})
const pending = h.openProductSelector(side)
if (side === 'load') h.loadForm.value = { ...h.loadForm.value, products: [] }
else h.deliveryForms.value = h.deliveryForms.value.map((stop) => ({ ...stop, products: [] }))
d.resolve(null)
await pending
assert.equal(h.mobileLinePickerLoading.value, false)
assert.equal(h.linePickerVisible.value, false)
assert.equal(h.pageReady.value, true, 'obsolete selection must not invalidate the ready page')
})
for (const failure of [true, false])
test(`old real fetch ${failure ? 'failure' : 'success'} cannot fail the page or close/reset a newer loading sheet`, async () => {
const old = deferred(),
fresh = deferred()
const h = realFetchHarness(async (id) => {
await (id === 'a' ? old.promise : fresh.promise)
if (id === 'a' && failure) throw new Error('old request failed')
return { id, products: id === 'a' ? [rows[0]] : [rows[1]] }
})
const previous = h.openProductSelector('load')
const current = h.openProductSelector('delivery')
old.resolve(null)
await previous
assert.equal(h.pageReady.value, true)
assert.equal(h.linePickerVisible.value, true)
assert.equal(h.mobileLinePickerLoading.value, true)
assert.equal(h.mobileLinePickerError.value, '')
fresh.resolve(null)
await current
assert.equal(h.mobileLinePickerLoading.value, false)
assert.equal(h.linePickerVisible.value, true)
assert.equal(h.linePickerRows.value[0].contractId, 'b')
})
test('mobile opened line picker follows segment identity through reorder before confirm',async()=>{
const h=parentHarness()
await h.openProductSelector('delivery',0)
const identity=mobileStopIdentity(h.deliveryForms.value[0])
h.deliveryForms.value=[{...h.deliveryForms.value[1]},{...h.deliveryForms.value[0]}]
await h.handleMobileLinePickerConfirm([rows[0]])
assert.equal(h.deliveryForms.value[0].products.length,0)
assert.equal(mobileStopIdentity(h.deliveryForms.value[1]),identity)
assert.equal(h.deliveryForms.value[1].products.length,1)
})
test('mobile opened line picker discards confirmation after target segment deletion',async()=>{
const h=parentHarness()
await h.openProductSelector('delivery',0)
h.deliveryForms.value=[h.deliveryForms.value[1]]
await h.handleMobileLinePickerConfirm([rows[0]])
assert.equal(h.deliveryForms.value[0].products.length,0)
})
test('mobile pending line enrichment follows original segment after reorder',async()=>{
const pending=deferred()
const h=parentHarness({enrich:async(input:any)=>{await pending.promise;return input}})
await h.openProductSelector('delivery',0)
const task=h.handleMobileLinePickerConfirm([rows[0]])
h.deliveryForms.value=[{...h.deliveryForms.value[1]},{...h.deliveryForms.value[0]}]
pending.resolve();await task
assert.equal(h.deliveryForms.value[0].products.length,0)
assert.equal(h.deliveryForms.value[1].products.length,1)
})
test('mobile pending internal catalog enrichment follows original segment after reorder',async()=>{
const pending=deferred()
const h=parentHarness({enrich:async(input:any)=>{await pending.promise;return input}})
await h.openProductSelector('delivery',0)
const task=h.handleProductConfirm([{id:'catalog-product',productName:'目录产品'}])
h.deliveryForms.value=[{...h.deliveryForms.value[1]},{...h.deliveryForms.value[0]}]
pending.resolve();await task
assert.equal(h.deliveryForms.value[0].products.length,0)
assert.equal(h.deliveryForms.value[1].products[0].productId,'catalog-product')
})
test('mobile pending contract list opening follows original segment after reorder',async()=>{
const pending=deferred()
const h=parentHarness({fetch:async()=>{await pending.promise;return []}})
const task=h.openProductSelector('delivery',0)
h.deliveryForms.value=[{...h.deliveryForms.value[1]},{...h.deliveryForms.value[0]}]
pending.resolve();await task
assert.equal(h.linePickerVisible.value,true)
await h.handleMobileLinePickerConfirm([rows[0]])
assert.equal(h.deliveryForms.value[0].products.length,0)
assert.equal(h.deliveryForms.value[1].products.length,1)
})