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) { 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, / { 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 = { 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 = 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>0<\/span>/) assert.match(html, /剩余数量<\/small>0<\/span>/) assert.match(html, /确认添加/) assert.match(html, /disabled/) assert.doesNotMatch(html, / { 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 = 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) })