import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' import { computed, effectScope, reactive, ref, shallowRef, watch } from 'vue' import ts from 'typescript' import { prepareLineAliasAndBrandForSubmit, applyAliasDisplayMeta } from '../src/utils/crm-product-brand' const helper: any = await import('../src/views/m/crm/quote/quoteProductMeta').catch(() => ({})) const product = { productName: '标准名称', aliases: [ { id: 'a1', aliasName: '第一别名', orderNum: 7 }, { id: 'a2', aliasName: '第二别名' } ], brands: [ { id: 'b1', brandName: '新品牌名', status: 'on', orderNum: 9 }, { id: 'b2', brandName: '停用品牌', status: 'off' } ] } const line = (name = '第二别名'): any => ({ productId: 'p1', productName: name, brandMode: 'required', brandId: 'b1', brandName: '历史品牌名' }) const deferred = () => { let resolve!: (v: any) => void let reject!: (v: any) => void const promise = new Promise((y, n) => { resolve = y reject = n }) return { resolve, reject, promise } } function actualFunction(file: string, name: string, bindings: any) { const source = parse(readFileSync(new URL(file, import.meta.url), 'utf8')).descriptor.scriptSetup! .content const ast = ts.createSourceFile('component.ts', source, ts.ScriptTarget.Latest, true) const declaration = ast.statements.find( (n) => ts.isFunctionDeclaration(n) && n.name?.text === name )! const code = ts.transpile(declaration.getText(ast), { target: ts.ScriptTarget.ESNext }) return new Function(...Object.keys(bindings), `${code}; return ${name}`)( ...Object.values(bindings) ) } for (const failure of [false, true]) test(`detail preserves saved names and brands (${failure ? 'GET failure' : 'success'})`, async () => { const rows = [ line(), line('标准名称'), line('未知历史名称'), { ...line(), brandMode: 'any', brandId: undefined, brandName: undefined }, { ...line(), brandMode: undefined, brandId: undefined, brandName: undefined }, { ...line(), productId: '', aliasName: '错误旧别名' } ] const enrich = actualFunction( '../src/views/m/crm/quote/detail.vue', 'enrichQuoteProductAliasDisplay', { applyAliasDisplayMeta, fetchProductDetail: async () => { if (failure) throw Error('offline') return { data: product } }, enrichQuoteDetailProductMeta: helper.enrichQuoteDetailProductMeta } ) await enrich(rows) assert.deepEqual( rows.map((r) => r.aliasName), failure ? Array(6).fill(undefined) : ['第二别名', undefined, undefined, '第二别名', '第二别名', undefined] ) assert.deepEqual( rows.map((r) => r.productName), ['第二别名', '标准名称', '未知历史名称', '第二别名', '第二别名', '第二别名'] ) assert.equal(rows[0].brandName, '历史品牌名') assert.equal(rows[3].brandMode, 'any') assert.equal(rows[4].brandMode, undefined) assert.equal(rows[5].standardProductName, '第二别名') }) function editor(rows: any[], fetch = async () => ({ data: product })) { assert.equal( typeof helper.createQuoteProductMetaEditor, 'function', 'mobile metadata controller is missing' ) return helper.createQuoteProductMetaEditor(() => rows, fetch) } test('one row selection is isolated; maintenance refreshes matching rows and clears removed aliases', async () => { const rows = [line(), line(), { ...line(), productId: 'other' }] const e = editor(rows) await e.load(rows) assert.equal(e.open(rows[0], 'alias'), true) e.confirm({ id: '第一别名' }) assert.equal(rows[0].aliasName, '第一别名') assert.equal(rows[1].aliasName, '第二别名') const unrelated = JSON.stringify(rows[2]) e.managed({ productId: 'p1', product: { ...product, aliases: [{ aliasName: '第二别名' }], brands: [] } }) assert.equal(rows[0].aliasName, undefined) assert.equal(rows[0].productName, '标准名称') assert.equal(rows[1].aliasName, '第二别名') assert.equal(rows[0].brandId, undefined) assert.equal(JSON.stringify(rows[2]), unrelated) }) test('explicit clear resets history while untouched history survives real submit helper', async () => { const rows = [line('历史名称'), line('历史名称')] const e = editor(rows) await e.load(rows) e.open(rows[0], 'alias') e.confirm(null) rows.forEach(prepareLineAliasAndBrandForSubmit) assert.equal(rows[0].productName, '标准名称') assert.equal(rows[1].productName, '历史名称') }) test('GET failure preserves snapshots, blocks folding/save and retry becomes ready', async () => { const rows = [{ ...line(), aliasName: '第二别名', brandOptions: [] }] let fail = true const e = editor(rows, async () => { if (fail) throw Error('offline') return product as any }) const before = JSON.stringify(rows) const loading = e.load(rows) assert.match(e.blockingMessage(), /加载/) await loading assert.equal(JSON.stringify(rows), before) assert.match(e.blockingMessage(), /失败/) assert.equal(e.prepare(), false) assert.equal(JSON.stringify(rows), before) fail = false await e.load(rows) assert.equal(e.blockingMessage(), '') assert.equal(e.prepare(), true) assert.equal(rows[0].brandName, '新品牌名') }) test('requests deduplicate and ignore invalidated or removed rows; deleted active row cannot confirm', async () => { const rows = [line(), line()] const pending = deferred() let gets = 0 const e = editor(rows, async () => { gets++ return pending.promise }) const load = e.load(rows) assert.equal(gets, 1) e.invalidate() pending.resolve(product) await load assert.equal(rows[0].standardProductName, undefined) const removed = rows[0] e.open(removed, 'alias') rows.splice(0, 1) e.confirm({ id: '第一别名' }) assert.equal(removed.aliasName, undefined) const p = deferred() const e2 = editor(rows, () => p.promise) const second = e2.load(rows) const original = rows.pop() p.resolve(product) await second assert.equal(original.standardProductName, undefined) }) function manager( fetch = async () => product, update: (payload: any) => Promise = async () => {} ) { assert.equal( typeof helper.createQuoteProductMetaManager, 'function', 'mobile maintenance controller is missing' ) const saved: any[] = [] const m = helper.createQuoteProductMetaManager(fetch, update, (event: any) => saved.push(event)) return { m, saved } } test('cancel zero writes, copied draft, blank/duplicate blocked, brand status/order/id preserved', async () => { const writes: any[] = [] const { m } = manager(undefined, async (p) => { writes.push(p) }) await m.open('p1', 'alias') m.rows.value[0].aliasName = 'changed' m.close() assert.equal(writes.length, 0) assert.equal(product.aliases[0].aliasName, '第一别名') await m.open('p1', 'alias') m.rows.value.push({ aliasName: ' ' }) await m.save() assert.match(m.error.value, /不能为空/) assert.equal(writes.length, 0) m.rows.value.pop() m.rows.value[1].aliasName = ' 第一别名 ' await m.save() assert.match(m.error.value, /重复/) assert.equal(writes.length, 0) await m.open('p1', 'brand') assert.equal(m.rows.value[1].status, 'off') m.add() assert.equal(m.rows.value[2].status, 'on') m.remove(2) await m.save() assert.deepEqual(Object.keys(writes[0]).sort(), ['brands', 'id']) assert.equal(writes[0].brands[0].orderNum, 9) assert.equal(writes[0].brands[1].status, 'off') }) test('update failure retains draft, emits nothing, double save one write and busy cannot close', async () => { const pending = deferred() let writes = 0 const { m, saved } = manager(undefined, async () => { writes++ await pending.promise }) await m.open('p1', 'alias') m.rows.value[0].aliasName = '改名' const save = m.save() await m.save() assert.equal(writes, 1) assert.equal(m.close(), false) pending.reject(Error('403 无权限')) await save assert.match(m.error.value, /403/) assert.equal(m.rows.value[0].aliasName, '改名') assert.equal(saved.length, 0) assert.equal(m.busy.value, false) }) test('update success GET failure retries GET only, emits once with captured product id', async () => { let gets = 0 let writes = 0 const { m, saved } = manager( async () => { gets++ if (gets === 2) throw Error('offline') return product }, async () => { writes++ } ) await m.open('p1', 'alias') await m.save() assert.equal(writes, 1) assert.match(m.refreshError.value, /已保存.*刷新选项失败/) assert.equal(saved.length, 0) await m.save() assert.equal(writes, 1) assert.equal(gets, 3) assert.equal(saved.length, 1) assert.equal(saved[0].productId, 'p1') await m.save() assert.equal(saved.length, 1) }) test('old success/error/finally cannot touch reopened manager session', async () => { const first = deferred() const next = deferred() let gets = 0 const { m, saved } = manager(() => (++gets === 1 ? first.promise : next.promise)) const a = m.open('p1', 'alias') m.close() const b = m.open('p2', 'brand') first.reject(Error('old error')) await a assert.equal(m.loading.value, true) assert.equal(m.error.value, '') next.resolve(product) await b assert.equal(m.productId.value, 'p2') assert.equal(saved.length, 0) const put = deferred() const n = manager(undefined, () => put.promise) await n.m.open('p1', 'alias') const old = n.m.save() n.m.invalidate() await n.m.open('p2', 'brand') put.resolve({}) await old assert.equal(n.m.productId.value, 'p2') assert.equal(n.saved.length, 0) assert.equal(n.m.busy.value, false) }) test('PAGE submit guard executes actual component function; desktop never calls mobile guard', async () => { const rows = [line()] const e = editor(rows, async () => { throw Error('offline') }) await e.load(rows) const form = { value: { products: rows } } const sync = actualFunction( '../src/views/crm/quote/components/QuoteFormPanel.vue', 'syncQuoteProductsForSubmit', { isPageVariant: { value: true }, quoteMeta: { prepare: e.prepare, blockingMessage: e.blockingMessage }, form, showFailToast: () => {}, prepareLineAliasAndBrandForSubmit } ) const before = JSON.stringify(rows) assert.equal(sync(), false) assert.equal(JSON.stringify(rows), before) const desktop = actualFunction( '../src/views/crm/quote/components/QuoteFormPanel.vue', 'syncQuoteProductsForSubmit', { isPageVariant: { value: false }, quoteMeta: new Proxy( {}, { get() { throw Error('desktop used mobile guard') } } ), form, showFailToast: () => {}, prepareLineAliasAndBrandForSubmit } ) desktop() assert.equal(rows[0].brandMode, undefined) }) test('brand picking uses real IDs and existing change handler, empty lists still open maintenance', async () => { const rows = [line(), line()] let called: any const e = helper.createQuoteProductMetaEditor( () => rows, async () => product, (row: any, id: any) => { called = { row, id } } ) await e.load(rows) e.open(rows[0], 'brand') e.confirm({ id: 'b1' }) assert.equal(called.row, rows[0]) assert.equal(called.id, 'b1') assert.equal(rows[1].brandId, 'b1') e.managed({ productId: 'p1', product: { productName: '标准名称', aliases: [], brands: [] } }) assert.equal(e.open(rows[0], 'alias'), true) assert.deepEqual(e.options.value, []) assert.equal(e.open(rows[0], 'brand'), true) assert.deepEqual(e.options.value, []) }) test('deleting an actually open row and changing product ID invalidate selection by object identity', async () => { const rows = [line(), line()] const e = editor(rows) await e.load(rows) const removed = rows[0] e.open(removed, 'alias') rows.splice(0, 1) e.cancelRemovedRow() assert.equal(e.pickerOpen.value, false) e.confirm({ id: '第一别名' }) assert.equal(removed.aliasName, '第二别名') assert.equal(rows[0].aliasName, '第二别名') e.open(rows[0], 'alias') rows[0].productId = 'changed' e.confirm(null) assert.equal(rows[0].aliasName, '第二别名') }) test('manager trim payload only updates requested field and saved event does not autochoose new entries', async () => { let payload: any const { m, saved } = manager(undefined, async (p) => { payload = p }) await m.open('p1', 'alias') m.rows.value[0].aliasName = ' 第一别名 ' m.remove(1) await m.save() assert.deepEqual(payload, { id: 'p1', aliases: [{ id: 'a1', aliasName: '第一别名', orderNum: 7 }] }) const rows = [line('标准名称')] const e = editor(rows) await e.load(rows) e.managed(saved[0]) assert.equal(rows[0].aliasName, undefined) }) test('successful empty GET alone applies empty semantics; failed same-product retry preserves selected snapshot', async () => { const rows = [line()] let fail = false const e = editor(rows, async () => { if (fail) throw Error('offline') return product as any }) await e.load(rows) e.open(rows[0], 'alias') e.confirm({ id: '第一别名' }) const before = JSON.stringify(rows) fail = true await e.load(rows) assert.equal(JSON.stringify(rows), before) const empty = helper.createQuoteProductMetaEditor( () => rows, async () => ({ productName: '标准名称', aliases: [], brands: [] }) ) await empty.load(rows) assert.equal(rows[0].brandMode, undefined) assert.equal(rows[0].brandName, undefined) assert.equal(rows[0].aliasName, undefined) }) test('a concurrent metadata load cannot reuse pre-maintenance data for a new row', async () => { const pending = deferred() let calls = 0 const rows = [line()] const e = helper.createQuoteProductMetaEditor( () => rows, async () => { calls++ return calls === 1 ? pending.promise : { ...product, aliases: [{ aliasName: '新别名' }] } } ) const old = e.load(rows) e.managed({ productId: 'p1', product: { ...product, aliases: [{ aliasName: '新别名' }] } }) const added = line('新别名') rows.push(added) const latest = e.load([added]) pending.resolve(product) await Promise.all([old, latest]) assert.equal(added.aliasName, '新别名') assert.deepEqual(added.aliasOptions, ['新别名']) }) test('retrying same-product metadata retains an existing unsaved alias selection', async () => { const rows = [line()] let fail = false const e = editor(rows, async () => { if (fail) throw Error('offline') return product as any }) await e.load(rows) e.open(rows[0], 'alias') e.confirm({ id: '第一别名' }) fail = true await e.load(rows) fail = false await e.load(rows) assert.equal(rows[0].aliasName, '第一别名') }) function pageStateHarness() { const source = parse( readFileSync( new URL('../src/views/crm/quote/components/QuoteFormPanel.vue', import.meta.url), 'utf8' ) ).descriptor.scriptSetup!.content const ast = ts.createSourceFile('component.ts', source, ts.ScriptTarget.Latest, true) const names: string[] = [] const selected = ast.statements.filter((node) => { if (ts.isVariableStatement(node)) { const name = node.declarationList.declarations[0].name.getText(ast) if (name.startsWith('quoteMeta') || name === 'handleBrandIdChange') { names.push(name) return true } } if (ts.isFunctionDeclaration(node)) { const name = node.name?.text || '' if ( [ 'openQuoteMetaPicker', 'openQuoteMetaManage', 'resetQuoteMetaSession', 'enrichQuoteProductBrandMeta' ].includes(name) ) { names.push(name) return true } } return ( ts.isExpressionStatement(node) && /^(watch\(|onBeforeUnmount\()/.test(node.getText(ast)) && /quoteMeta|resetQuoteMetaSession/.test(node.getText(ast)) ) }) const cleanup: Array<() => void> = [] const bindings = { ref, shallowRef, computed, reactive, watch, onBeforeUnmount: (fn: () => void) => cleanup.push(fn), isPageVariant: ref(true), form: ref({ products: [line(), line()] }), route: reactive({ fullPath: '/m/crm/quote/form?id=1' }), createQuoteProductMetaEditor: helper.createQuoteProductMetaEditor, fetchProductDetail: async () => product, showFailToast: () => {}, applyBrandSelectChange: (row: any, id: any, options: any[]) => { row.brandId = id row.brandName = options.find((item) => item.id === id)?.brandName row.brandMode = id ? 'required' : 'any' } } const scope = effectScope() const code = ts.transpile(selected.map((node) => node.getText(ast)).join('\n'), { target: ts.ScriptTarget.ESNext }) const state = scope.run(() => new Function(...Object.keys(bindings), `${code}; return {${names.join(',')}}`)( ...Object.values(bindings) ) ) return { ...bindings, ...state, dispose() { cleanup.forEach((fn) => fn()) scope.stop() } } } test('actual PAGE wiring closes picker before maintenance; deletion/route/unmount cancel sessions', async () => { const h = pageStateHarness() await h.enrichQuoteProductBrandMeta(h.form.value.products) h.openQuoteMetaPicker(h.form.value.products[0], 'alias') assert.equal(h.quoteMeta.pickerOpen, true) h.openQuoteMetaManage() assert.equal(h.quoteMeta.pickerOpen, false) assert.equal(h.quoteMetaManageVisible.value, true) assert.equal(h.quoteMetaManageId.value, 'p1') h.form.value.products.splice(0, 1) assert.equal(h.quoteMetaManageVisible.value, false) h.openQuoteMetaPicker(h.form.value.products[0], 'brand') h.route.fullPath = '/m/crm/quote/form?id=2' assert.equal(h.quoteMeta.pickerOpen, false) assert.equal(h.quoteMetaManageVisible.value, false) await h.enrichQuoteProductBrandMeta(h.form.value.products) h.openQuoteMetaPicker(h.form.value.products[0], 'alias') h.dispose() assert.equal(h.quoteMeta.pickerOpen, false) }) test('changed Vue files compile their actual scripts and templates', () => { for (const file of [ '../src/views/crm/quote/components/QuoteFormPanel.vue', '../src/views/m/crm/quote/detail.vue', '../src/views/m/crm/quote/QuoteProductMetaManageSheet.vue' ]) { const { descriptor, errors } = parse(readFileSync(new URL(file, import.meta.url), 'utf8'), { filename: file }) assert.deepEqual(errors, []) const script = compileScript(descriptor, { id: 'quote-meta-test' }) const compiled = compileTemplate({ id: 'quote-meta-test', filename: file, source: descriptor.template!.content, compilerOptions: { bindingMetadata: script.bindings } }) assert.deepEqual(compiled.errors, []) } }) test('a missing product response is an error, never successful empty metadata', async () => { const rows = [line()] const before = JSON.stringify(rows) const e = editor(rows, async () => ({ data: null }) as any) await e.load(rows) assert.match(e.blockingMessage(), /失败/) assert.equal(JSON.stringify(rows), before) }) for (const failed of [false, true]) { test(`deleted newly added duplicate product settles surviving ready rows (${failed ? 'failure' : 'success'})`, async () => { const rows = [line()] const pending = deferred() let calls = 0 const e = editor(rows, async () => (++calls === 1 ? product : pending.promise) as any) await e.load(rows) const existingSnapshot = JSON.stringify(rows[0]) const added = line() rows.push(added) const load = e.load([added]) assert.match(e.blockingMessage(), /加载/) rows.splice(1, 1) if (failed) pending.reject(Error('offline')) else pending.resolve(product) await load assert.equal(e.states.value.p1, 'ready') assert.equal(e.blockingMessage(), '') assert.equal(JSON.stringify(rows[0]), existingSnapshot) assert.equal(added.standardProductName, undefined) assert.equal(e.prepare(), true) }) } test('duplicate deletion never marks surviving unresolved metadata ready and failed rows remain retryable', async () => { const rows = [line()] const pending = deferred() let calls = 0 const e = editor(rows, async () => (++calls === 1 ? product : pending.promise) as any) await e.load(rows) const removed = line() const retained = line() rows.push(removed, retained) const first = e.load([removed]) const second = e.load([retained]) rows.splice(1, 1) assert.equal(e.prepare(), false) pending.reject(Error('offline')) await Promise.all([first, second]) assert.equal(e.states.value.p1, 'error') assert.match(e.blockingMessage(), /失败/) assert.equal(e.prepare(), false) assert.equal(retained.standardProductName, undefined) }) test('mobile maintenance uses brand palette and a full cell touch target with single toggle and busy locks', () => { const file = '../src/views/m/crm/quote/QuoteProductMetaManageSheet.vue' const descriptor = parse(readFileSync(new URL(file, import.meta.url), 'utf8')).descriptor assert.match(descriptor.styles[0].content, /--van-primary-color:\s*#0a9f6d/) assert.doesNotMatch(descriptor.styles[0].content, /#10b981/) let target: any function walk(node: any) { if (node.tag === 'van-cell' && node.loc.source.includes('toggleBrandStatus')) target = node node.children?.forEach(walk) } walk(descriptor.template!.ast) assert.ok(target, 'brand switch needs a clickable cell target') assert.match(target.loc.source, /@click="toggleBrandStatus\(row\)"/) assert.match(target.loc.source, /@click\.stop/) assert.match( descriptor.styles[0].content, /\.quote-meta-manage__brand-toggle\s*\{[^}]*min-height:\s*44px/s ) const manager = { busy: false, persisted: false } const toggle = actualFunction(file, 'toggleBrandStatus', { manager }) const row = { status: 'on' } toggle(row) assert.equal(row.status, 'off') toggle(row) assert.equal(row.status, 'on') manager.busy = true toggle(row) assert.equal(row.status, 'on') manager.busy = false manager.persisted = true toggle(row) assert.equal(row.status, 'on') }) for (const mode of ['alias', 'brand'] as const) { for (const retryFrom of ['sheet', 'page'] as const) { test(`persisted ${mode} changes block quote writes until ${retryFrom} GET retry synchronizes current choices`, async () => { const rows = [line(), line()] let serverProduct = JSON.parse(JSON.stringify(product)) let writes = 0 let reads = 0 let failRefresh = true const fetch = async () => { reads++ if (writes && failRefresh) throw Error('offline after PUT') return JSON.parse(JSON.stringify(serverProduct)) } const e = helper.createQuoteProductMetaEditor(() => rows, fetch) await e.load(rows) const snapshot = JSON.stringify(rows) const saved: any[] = [] const persistedIds: string[] = [] const m = helper.createQuoteProductMetaManager( fetch, async (payload: any) => { writes++ serverProduct = { ...serverProduct, ...payload } }, (event: any) => { saved.push(event) e.managed(event) }, (id: string) => { persistedIds.push(id) e.markPersisted(id) } ) await m.open('p1', mode) if (mode === 'alias') m.remove(1) else m.rows.value[0].status = 'off' await m.save() assert.match(m.refreshError.value, /已保存.*刷新选项失败/) assert.equal( e.prepare(), false, 'persisted but unread product metadata must block quote write' ) assert.equal( JSON.stringify(rows), snapshot, 'failure preserves line snapshots until refreshed' ) assert.deepEqual(persistedIds, ['p1']) assert.equal(saved.length, 0) const readsBeforeRetry = reads if (retryFrom === 'page') { assert.equal(m.close(), true) assert.equal(e.prepare(), false, 'closing maintenance must not bypass stale metadata') await e.load(rows) assert.equal(e.prepare(), false, 'failed page retry must retain the write block') failRefresh = false await e.load(rows) } else { failRefresh = false await m.save() } assert.ok(reads > readsBeforeRetry) assert.equal(writes, 1, 'refresh retries must never repeat product update') assert.equal(e.blockingMessage(), '') assert.equal(e.prepare(), true) for (const row of rows) { if (mode === 'alias') { assert.equal(row.aliasName, undefined) assert.equal(row.productName, '标准名称') } else { assert.equal(row.brandId, undefined) assert.equal(row.brandMode, undefined) assert.equal(row.brandName, undefined) } } }) } } test('actual sheet persisted emit is wired to the PAGE metadata invalidation handler', async () => { const rows = [line()] let updated = false const fetch = async () => { if (updated) throw Error('refresh unavailable') return product } const e = helper.createQuoteProductMetaEditor(() => rows, fetch) await e.load(rows) const sheet = parse( readFileSync( new URL('../src/views/m/crm/quote/QuoteProductMetaManageSheet.vue', import.meta.url), 'utf8' ) ).descriptor const page = parse( readFileSync( new URL('../src/views/crm/quote/components/QuoteFormPanel.vue', import.meta.url), 'utf8' ) ).descriptor let persistedHandler = '' function visit(node: any) { if (node.tag === 'QuoteProductMetaManageSheet') persistedHandler = node.props.find((prop: any) => prop.name === 'on' && prop.arg?.content === 'persisted')?.exp ?.content || '' node.children?.forEach(visit) } visit(page.template!.ast) assert.ok(persistedHandler, 'PAGE must receive the persisted event') const ast = ts.createSourceFile( 'sheet.ts', sheet.scriptSetup!.content, ts.ScriptTarget.Latest, true ) const declaration = ast.statements.find( (node) => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(ast) === 'manager' )! const code = ts.transpile(declaration.getText(ast), { target: ts.ScriptTarget.ESNext }) const events: string[] = [] const emit = (name: string, value: any) => { events.push(name) if (name === 'persisted') new Function('quoteMeta', 'value', `return ${persistedHandler}(value)`)(e, value) } const m = new Function( 'reactive', 'createQuoteProductMetaManager', 'fetchProductDetail', 'fetchUpdateProduct', 'emit', `${code}; return manager` )( reactive, helper.createQuoteProductMetaManager, fetch, async () => { updated = true }, emit ) await m.open('p1', 'alias') m.remove(1) await m.save() assert.deepEqual(events, ['persisted']) assert.equal(e.prepare(), false) assert.match(e.blockingMessage(), /产品资料已保存.*刷新/) })