817 lines
27 KiB
TypeScript
817 lines
27 KiB
TypeScript
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<any>((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<any> = 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(), /产品资料已保存.*刷新/)
|
|
})
|