mobile-quote-product-meta.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  5. import { computed, effectScope, reactive, ref, shallowRef, watch } from 'vue'
  6. import ts from 'typescript'
  7. import {
  8. prepareLineAliasAndBrandForSubmit,
  9. applyAliasDisplayMeta
  10. } from '../src/utils/crm-product-brand'
  11. const helper: any = await import('../src/views/m/crm/quote/quoteProductMeta').catch(() => ({}))
  12. const product = {
  13. productName: '标准名称',
  14. aliases: [
  15. { id: 'a1', aliasName: '第一别名', orderNum: 7 },
  16. { id: 'a2', aliasName: '第二别名' }
  17. ],
  18. brands: [
  19. { id: 'b1', brandName: '新品牌名', status: 'on', orderNum: 9 },
  20. { id: 'b2', brandName: '停用品牌', status: 'off' }
  21. ]
  22. }
  23. const line = (name = '第二别名'): any => ({
  24. productId: 'p1',
  25. productName: name,
  26. brandMode: 'required',
  27. brandId: 'b1',
  28. brandName: '历史品牌名'
  29. })
  30. const deferred = () => {
  31. let resolve!: (v: any) => void
  32. let reject!: (v: any) => void
  33. const promise = new Promise<any>((y, n) => {
  34. resolve = y
  35. reject = n
  36. })
  37. return { resolve, reject, promise }
  38. }
  39. function actualFunction(file: string, name: string, bindings: any) {
  40. const source = parse(readFileSync(new URL(file, import.meta.url), 'utf8')).descriptor.scriptSetup!
  41. .content
  42. const ast = ts.createSourceFile('component.ts', source, ts.ScriptTarget.Latest, true)
  43. const declaration = ast.statements.find(
  44. (n) => ts.isFunctionDeclaration(n) && n.name?.text === name
  45. )!
  46. const code = ts.transpile(declaration.getText(ast), { target: ts.ScriptTarget.ESNext })
  47. return new Function(...Object.keys(bindings), `${code}; return ${name}`)(
  48. ...Object.values(bindings)
  49. )
  50. }
  51. for (const failure of [false, true])
  52. test(`detail preserves saved names and brands (${failure ? 'GET failure' : 'success'})`, async () => {
  53. const rows = [
  54. line(),
  55. line('标准名称'),
  56. line('未知历史名称'),
  57. { ...line(), brandMode: 'any', brandId: undefined, brandName: undefined },
  58. { ...line(), brandMode: undefined, brandId: undefined, brandName: undefined },
  59. { ...line(), productId: '', aliasName: '错误旧别名' }
  60. ]
  61. const enrich = actualFunction(
  62. '../src/views/m/crm/quote/detail.vue',
  63. 'enrichQuoteProductAliasDisplay',
  64. {
  65. applyAliasDisplayMeta,
  66. fetchProductDetail: async () => {
  67. if (failure) throw Error('offline')
  68. return { data: product }
  69. },
  70. enrichQuoteDetailProductMeta: helper.enrichQuoteDetailProductMeta
  71. }
  72. )
  73. await enrich(rows)
  74. assert.deepEqual(
  75. rows.map((r) => r.aliasName),
  76. failure
  77. ? Array(6).fill(undefined)
  78. : ['第二别名', undefined, undefined, '第二别名', '第二别名', undefined]
  79. )
  80. assert.deepEqual(
  81. rows.map((r) => r.productName),
  82. ['第二别名', '标准名称', '未知历史名称', '第二别名', '第二别名', '第二别名']
  83. )
  84. assert.equal(rows[0].brandName, '历史品牌名')
  85. assert.equal(rows[3].brandMode, 'any')
  86. assert.equal(rows[4].brandMode, undefined)
  87. assert.equal(rows[5].standardProductName, '第二别名')
  88. })
  89. function editor(rows: any[], fetch = async () => ({ data: product })) {
  90. assert.equal(
  91. typeof helper.createQuoteProductMetaEditor,
  92. 'function',
  93. 'mobile metadata controller is missing'
  94. )
  95. return helper.createQuoteProductMetaEditor(() => rows, fetch)
  96. }
  97. test('one row selection is isolated; maintenance refreshes matching rows and clears removed aliases', async () => {
  98. const rows = [line(), line(), { ...line(), productId: 'other' }]
  99. const e = editor(rows)
  100. await e.load(rows)
  101. assert.equal(e.open(rows[0], 'alias'), true)
  102. e.confirm({ id: '第一别名' })
  103. assert.equal(rows[0].aliasName, '第一别名')
  104. assert.equal(rows[1].aliasName, '第二别名')
  105. const unrelated = JSON.stringify(rows[2])
  106. e.managed({
  107. productId: 'p1',
  108. product: { ...product, aliases: [{ aliasName: '第二别名' }], brands: [] }
  109. })
  110. assert.equal(rows[0].aliasName, undefined)
  111. assert.equal(rows[0].productName, '标准名称')
  112. assert.equal(rows[1].aliasName, '第二别名')
  113. assert.equal(rows[0].brandId, undefined)
  114. assert.equal(JSON.stringify(rows[2]), unrelated)
  115. })
  116. test('explicit clear resets history while untouched history survives real submit helper', async () => {
  117. const rows = [line('历史名称'), line('历史名称')]
  118. const e = editor(rows)
  119. await e.load(rows)
  120. e.open(rows[0], 'alias')
  121. e.confirm(null)
  122. rows.forEach(prepareLineAliasAndBrandForSubmit)
  123. assert.equal(rows[0].productName, '标准名称')
  124. assert.equal(rows[1].productName, '历史名称')
  125. })
  126. test('GET failure preserves snapshots, blocks folding/save and retry becomes ready', async () => {
  127. const rows = [{ ...line(), aliasName: '第二别名', brandOptions: [] }]
  128. let fail = true
  129. const e = editor(rows, async () => {
  130. if (fail) throw Error('offline')
  131. return product as any
  132. })
  133. const before = JSON.stringify(rows)
  134. const loading = e.load(rows)
  135. assert.match(e.blockingMessage(), /加载/)
  136. await loading
  137. assert.equal(JSON.stringify(rows), before)
  138. assert.match(e.blockingMessage(), /失败/)
  139. assert.equal(e.prepare(), false)
  140. assert.equal(JSON.stringify(rows), before)
  141. fail = false
  142. await e.load(rows)
  143. assert.equal(e.blockingMessage(), '')
  144. assert.equal(e.prepare(), true)
  145. assert.equal(rows[0].brandName, '新品牌名')
  146. })
  147. test('requests deduplicate and ignore invalidated or removed rows; deleted active row cannot confirm', async () => {
  148. const rows = [line(), line()]
  149. const pending = deferred()
  150. let gets = 0
  151. const e = editor(rows, async () => {
  152. gets++
  153. return pending.promise
  154. })
  155. const load = e.load(rows)
  156. assert.equal(gets, 1)
  157. e.invalidate()
  158. pending.resolve(product)
  159. await load
  160. assert.equal(rows[0].standardProductName, undefined)
  161. const removed = rows[0]
  162. e.open(removed, 'alias')
  163. rows.splice(0, 1)
  164. e.confirm({ id: '第一别名' })
  165. assert.equal(removed.aliasName, undefined)
  166. const p = deferred()
  167. const e2 = editor(rows, () => p.promise)
  168. const second = e2.load(rows)
  169. const original = rows.pop()
  170. p.resolve(product)
  171. await second
  172. assert.equal(original.standardProductName, undefined)
  173. })
  174. function manager(
  175. fetch = async () => product,
  176. update: (payload: any) => Promise<any> = async () => {}
  177. ) {
  178. assert.equal(
  179. typeof helper.createQuoteProductMetaManager,
  180. 'function',
  181. 'mobile maintenance controller is missing'
  182. )
  183. const saved: any[] = []
  184. const m = helper.createQuoteProductMetaManager(fetch, update, (event: any) => saved.push(event))
  185. return { m, saved }
  186. }
  187. test('cancel zero writes, copied draft, blank/duplicate blocked, brand status/order/id preserved', async () => {
  188. const writes: any[] = []
  189. const { m } = manager(undefined, async (p) => {
  190. writes.push(p)
  191. })
  192. await m.open('p1', 'alias')
  193. m.rows.value[0].aliasName = 'changed'
  194. m.close()
  195. assert.equal(writes.length, 0)
  196. assert.equal(product.aliases[0].aliasName, '第一别名')
  197. await m.open('p1', 'alias')
  198. m.rows.value.push({ aliasName: ' ' })
  199. await m.save()
  200. assert.match(m.error.value, /不能为空/)
  201. assert.equal(writes.length, 0)
  202. m.rows.value.pop()
  203. m.rows.value[1].aliasName = ' 第一别名 '
  204. await m.save()
  205. assert.match(m.error.value, /重复/)
  206. assert.equal(writes.length, 0)
  207. await m.open('p1', 'brand')
  208. assert.equal(m.rows.value[1].status, 'off')
  209. m.add()
  210. assert.equal(m.rows.value[2].status, 'on')
  211. m.remove(2)
  212. await m.save()
  213. assert.deepEqual(Object.keys(writes[0]).sort(), ['brands', 'id'])
  214. assert.equal(writes[0].brands[0].orderNum, 9)
  215. assert.equal(writes[0].brands[1].status, 'off')
  216. })
  217. test('update failure retains draft, emits nothing, double save one write and busy cannot close', async () => {
  218. const pending = deferred()
  219. let writes = 0
  220. const { m, saved } = manager(undefined, async () => {
  221. writes++
  222. await pending.promise
  223. })
  224. await m.open('p1', 'alias')
  225. m.rows.value[0].aliasName = '改名'
  226. const save = m.save()
  227. await m.save()
  228. assert.equal(writes, 1)
  229. assert.equal(m.close(), false)
  230. pending.reject(Error('403 无权限'))
  231. await save
  232. assert.match(m.error.value, /403/)
  233. assert.equal(m.rows.value[0].aliasName, '改名')
  234. assert.equal(saved.length, 0)
  235. assert.equal(m.busy.value, false)
  236. })
  237. test('update success GET failure retries GET only, emits once with captured product id', async () => {
  238. let gets = 0
  239. let writes = 0
  240. const { m, saved } = manager(
  241. async () => {
  242. gets++
  243. if (gets === 2) throw Error('offline')
  244. return product
  245. },
  246. async () => {
  247. writes++
  248. }
  249. )
  250. await m.open('p1', 'alias')
  251. await m.save()
  252. assert.equal(writes, 1)
  253. assert.match(m.refreshError.value, /已保存.*刷新选项失败/)
  254. assert.equal(saved.length, 0)
  255. await m.save()
  256. assert.equal(writes, 1)
  257. assert.equal(gets, 3)
  258. assert.equal(saved.length, 1)
  259. assert.equal(saved[0].productId, 'p1')
  260. await m.save()
  261. assert.equal(saved.length, 1)
  262. })
  263. test('old success/error/finally cannot touch reopened manager session', async () => {
  264. const first = deferred()
  265. const next = deferred()
  266. let gets = 0
  267. const { m, saved } = manager(() => (++gets === 1 ? first.promise : next.promise))
  268. const a = m.open('p1', 'alias')
  269. m.close()
  270. const b = m.open('p2', 'brand')
  271. first.reject(Error('old error'))
  272. await a
  273. assert.equal(m.loading.value, true)
  274. assert.equal(m.error.value, '')
  275. next.resolve(product)
  276. await b
  277. assert.equal(m.productId.value, 'p2')
  278. assert.equal(saved.length, 0)
  279. const put = deferred()
  280. const n = manager(undefined, () => put.promise)
  281. await n.m.open('p1', 'alias')
  282. const old = n.m.save()
  283. n.m.invalidate()
  284. await n.m.open('p2', 'brand')
  285. put.resolve({})
  286. await old
  287. assert.equal(n.m.productId.value, 'p2')
  288. assert.equal(n.saved.length, 0)
  289. assert.equal(n.m.busy.value, false)
  290. })
  291. test('PAGE submit guard executes actual component function; desktop never calls mobile guard', async () => {
  292. const rows = [line()]
  293. const e = editor(rows, async () => {
  294. throw Error('offline')
  295. })
  296. await e.load(rows)
  297. const form = { value: { products: rows } }
  298. const sync = actualFunction(
  299. '../src/views/crm/quote/components/QuoteFormPanel.vue',
  300. 'syncQuoteProductsForSubmit',
  301. {
  302. isPageVariant: { value: true },
  303. quoteMeta: { prepare: e.prepare, blockingMessage: e.blockingMessage },
  304. form,
  305. showFailToast: () => {},
  306. prepareLineAliasAndBrandForSubmit
  307. }
  308. )
  309. const before = JSON.stringify(rows)
  310. assert.equal(sync(), false)
  311. assert.equal(JSON.stringify(rows), before)
  312. const desktop = actualFunction(
  313. '../src/views/crm/quote/components/QuoteFormPanel.vue',
  314. 'syncQuoteProductsForSubmit',
  315. {
  316. isPageVariant: { value: false },
  317. quoteMeta: new Proxy(
  318. {},
  319. {
  320. get() {
  321. throw Error('desktop used mobile guard')
  322. }
  323. }
  324. ),
  325. form,
  326. showFailToast: () => {},
  327. prepareLineAliasAndBrandForSubmit
  328. }
  329. )
  330. desktop()
  331. assert.equal(rows[0].brandMode, undefined)
  332. })
  333. test('brand picking uses real IDs and existing change handler, empty lists still open maintenance', async () => {
  334. const rows = [line(), line()]
  335. let called: any
  336. const e = helper.createQuoteProductMetaEditor(
  337. () => rows,
  338. async () => product,
  339. (row: any, id: any) => {
  340. called = { row, id }
  341. }
  342. )
  343. await e.load(rows)
  344. e.open(rows[0], 'brand')
  345. e.confirm({ id: 'b1' })
  346. assert.equal(called.row, rows[0])
  347. assert.equal(called.id, 'b1')
  348. assert.equal(rows[1].brandId, 'b1')
  349. e.managed({ productId: 'p1', product: { productName: '标准名称', aliases: [], brands: [] } })
  350. assert.equal(e.open(rows[0], 'alias'), true)
  351. assert.deepEqual(e.options.value, [])
  352. assert.equal(e.open(rows[0], 'brand'), true)
  353. assert.deepEqual(e.options.value, [])
  354. })
  355. test('deleting an actually open row and changing product ID invalidate selection by object identity', async () => {
  356. const rows = [line(), line()]
  357. const e = editor(rows)
  358. await e.load(rows)
  359. const removed = rows[0]
  360. e.open(removed, 'alias')
  361. rows.splice(0, 1)
  362. e.cancelRemovedRow()
  363. assert.equal(e.pickerOpen.value, false)
  364. e.confirm({ id: '第一别名' })
  365. assert.equal(removed.aliasName, '第二别名')
  366. assert.equal(rows[0].aliasName, '第二别名')
  367. e.open(rows[0], 'alias')
  368. rows[0].productId = 'changed'
  369. e.confirm(null)
  370. assert.equal(rows[0].aliasName, '第二别名')
  371. })
  372. test('manager trim payload only updates requested field and saved event does not autochoose new entries', async () => {
  373. let payload: any
  374. const { m, saved } = manager(undefined, async (p) => {
  375. payload = p
  376. })
  377. await m.open('p1', 'alias')
  378. m.rows.value[0].aliasName = ' 第一别名 '
  379. m.remove(1)
  380. await m.save()
  381. assert.deepEqual(payload, {
  382. id: 'p1',
  383. aliases: [{ id: 'a1', aliasName: '第一别名', orderNum: 7 }]
  384. })
  385. const rows = [line('标准名称')]
  386. const e = editor(rows)
  387. await e.load(rows)
  388. e.managed(saved[0])
  389. assert.equal(rows[0].aliasName, undefined)
  390. })
  391. test('successful empty GET alone applies empty semantics; failed same-product retry preserves selected snapshot', async () => {
  392. const rows = [line()]
  393. let fail = false
  394. const e = editor(rows, async () => {
  395. if (fail) throw Error('offline')
  396. return product as any
  397. })
  398. await e.load(rows)
  399. e.open(rows[0], 'alias')
  400. e.confirm({ id: '第一别名' })
  401. const before = JSON.stringify(rows)
  402. fail = true
  403. await e.load(rows)
  404. assert.equal(JSON.stringify(rows), before)
  405. const empty = helper.createQuoteProductMetaEditor(
  406. () => rows,
  407. async () => ({ productName: '标准名称', aliases: [], brands: [] })
  408. )
  409. await empty.load(rows)
  410. assert.equal(rows[0].brandMode, undefined)
  411. assert.equal(rows[0].brandName, undefined)
  412. assert.equal(rows[0].aliasName, undefined)
  413. })
  414. test('a concurrent metadata load cannot reuse pre-maintenance data for a new row', async () => {
  415. const pending = deferred()
  416. let calls = 0
  417. const rows = [line()]
  418. const e = helper.createQuoteProductMetaEditor(
  419. () => rows,
  420. async () => {
  421. calls++
  422. return calls === 1 ? pending.promise : { ...product, aliases: [{ aliasName: '新别名' }] }
  423. }
  424. )
  425. const old = e.load(rows)
  426. e.managed({ productId: 'p1', product: { ...product, aliases: [{ aliasName: '新别名' }] } })
  427. const added = line('新别名')
  428. rows.push(added)
  429. const latest = e.load([added])
  430. pending.resolve(product)
  431. await Promise.all([old, latest])
  432. assert.equal(added.aliasName, '新别名')
  433. assert.deepEqual(added.aliasOptions, ['新别名'])
  434. })
  435. test('retrying same-product metadata retains an existing unsaved alias selection', async () => {
  436. const rows = [line()]
  437. let fail = false
  438. const e = editor(rows, async () => {
  439. if (fail) throw Error('offline')
  440. return product as any
  441. })
  442. await e.load(rows)
  443. e.open(rows[0], 'alias')
  444. e.confirm({ id: '第一别名' })
  445. fail = true
  446. await e.load(rows)
  447. fail = false
  448. await e.load(rows)
  449. assert.equal(rows[0].aliasName, '第一别名')
  450. })
  451. function pageStateHarness() {
  452. const source = parse(
  453. readFileSync(
  454. new URL('../src/views/crm/quote/components/QuoteFormPanel.vue', import.meta.url),
  455. 'utf8'
  456. )
  457. ).descriptor.scriptSetup!.content
  458. const ast = ts.createSourceFile('component.ts', source, ts.ScriptTarget.Latest, true)
  459. const names: string[] = []
  460. const selected = ast.statements.filter((node) => {
  461. if (ts.isVariableStatement(node)) {
  462. const name = node.declarationList.declarations[0].name.getText(ast)
  463. if (name.startsWith('quoteMeta') || name === 'handleBrandIdChange') {
  464. names.push(name)
  465. return true
  466. }
  467. }
  468. if (ts.isFunctionDeclaration(node)) {
  469. const name = node.name?.text || ''
  470. if (
  471. [
  472. 'openQuoteMetaPicker',
  473. 'openQuoteMetaManage',
  474. 'resetQuoteMetaSession',
  475. 'enrichQuoteProductBrandMeta'
  476. ].includes(name)
  477. ) {
  478. names.push(name)
  479. return true
  480. }
  481. }
  482. return (
  483. ts.isExpressionStatement(node) &&
  484. /^(watch\(|onBeforeUnmount\()/.test(node.getText(ast)) &&
  485. /quoteMeta|resetQuoteMetaSession/.test(node.getText(ast))
  486. )
  487. })
  488. const cleanup: Array<() => void> = []
  489. const bindings = {
  490. ref,
  491. shallowRef,
  492. computed,
  493. reactive,
  494. watch,
  495. onBeforeUnmount: (fn: () => void) => cleanup.push(fn),
  496. isPageVariant: ref(true),
  497. form: ref({ products: [line(), line()] }),
  498. route: reactive({ fullPath: '/m/crm/quote/form?id=1' }),
  499. createQuoteProductMetaEditor: helper.createQuoteProductMetaEditor,
  500. fetchProductDetail: async () => product,
  501. showFailToast: () => {},
  502. applyBrandSelectChange: (row: any, id: any, options: any[]) => {
  503. row.brandId = id
  504. row.brandName = options.find((item) => item.id === id)?.brandName
  505. row.brandMode = id ? 'required' : 'any'
  506. }
  507. }
  508. const scope = effectScope()
  509. const code = ts.transpile(selected.map((node) => node.getText(ast)).join('\n'), {
  510. target: ts.ScriptTarget.ESNext
  511. })
  512. const state = scope.run(() =>
  513. new Function(...Object.keys(bindings), `${code}; return {${names.join(',')}}`)(
  514. ...Object.values(bindings)
  515. )
  516. )
  517. return {
  518. ...bindings,
  519. ...state,
  520. dispose() {
  521. cleanup.forEach((fn) => fn())
  522. scope.stop()
  523. }
  524. }
  525. }
  526. test('actual PAGE wiring closes picker before maintenance; deletion/route/unmount cancel sessions', async () => {
  527. const h = pageStateHarness()
  528. await h.enrichQuoteProductBrandMeta(h.form.value.products)
  529. h.openQuoteMetaPicker(h.form.value.products[0], 'alias')
  530. assert.equal(h.quoteMeta.pickerOpen, true)
  531. h.openQuoteMetaManage()
  532. assert.equal(h.quoteMeta.pickerOpen, false)
  533. assert.equal(h.quoteMetaManageVisible.value, true)
  534. assert.equal(h.quoteMetaManageId.value, 'p1')
  535. h.form.value.products.splice(0, 1)
  536. assert.equal(h.quoteMetaManageVisible.value, false)
  537. h.openQuoteMetaPicker(h.form.value.products[0], 'brand')
  538. h.route.fullPath = '/m/crm/quote/form?id=2'
  539. assert.equal(h.quoteMeta.pickerOpen, false)
  540. assert.equal(h.quoteMetaManageVisible.value, false)
  541. await h.enrichQuoteProductBrandMeta(h.form.value.products)
  542. h.openQuoteMetaPicker(h.form.value.products[0], 'alias')
  543. h.dispose()
  544. assert.equal(h.quoteMeta.pickerOpen, false)
  545. })
  546. test('changed Vue files compile their actual scripts and templates', () => {
  547. for (const file of [
  548. '../src/views/crm/quote/components/QuoteFormPanel.vue',
  549. '../src/views/m/crm/quote/detail.vue',
  550. '../src/views/m/crm/quote/QuoteProductMetaManageSheet.vue'
  551. ]) {
  552. const { descriptor, errors } = parse(readFileSync(new URL(file, import.meta.url), 'utf8'), {
  553. filename: file
  554. })
  555. assert.deepEqual(errors, [])
  556. const script = compileScript(descriptor, { id: 'quote-meta-test' })
  557. const compiled = compileTemplate({
  558. id: 'quote-meta-test',
  559. filename: file,
  560. source: descriptor.template!.content,
  561. compilerOptions: { bindingMetadata: script.bindings }
  562. })
  563. assert.deepEqual(compiled.errors, [])
  564. }
  565. })
  566. test('a missing product response is an error, never successful empty metadata', async () => {
  567. const rows = [line()]
  568. const before = JSON.stringify(rows)
  569. const e = editor(rows, async () => ({ data: null }) as any)
  570. await e.load(rows)
  571. assert.match(e.blockingMessage(), /失败/)
  572. assert.equal(JSON.stringify(rows), before)
  573. })
  574. for (const failed of [false, true]) {
  575. test(`deleted newly added duplicate product settles surviving ready rows (${failed ? 'failure' : 'success'})`, async () => {
  576. const rows = [line()]
  577. const pending = deferred()
  578. let calls = 0
  579. const e = editor(rows, async () => (++calls === 1 ? product : pending.promise) as any)
  580. await e.load(rows)
  581. const existingSnapshot = JSON.stringify(rows[0])
  582. const added = line()
  583. rows.push(added)
  584. const load = e.load([added])
  585. assert.match(e.blockingMessage(), /加载/)
  586. rows.splice(1, 1)
  587. if (failed) pending.reject(Error('offline'))
  588. else pending.resolve(product)
  589. await load
  590. assert.equal(e.states.value.p1, 'ready')
  591. assert.equal(e.blockingMessage(), '')
  592. assert.equal(JSON.stringify(rows[0]), existingSnapshot)
  593. assert.equal(added.standardProductName, undefined)
  594. assert.equal(e.prepare(), true)
  595. })
  596. }
  597. test('duplicate deletion never marks surviving unresolved metadata ready and failed rows remain retryable', async () => {
  598. const rows = [line()]
  599. const pending = deferred()
  600. let calls = 0
  601. const e = editor(rows, async () => (++calls === 1 ? product : pending.promise) as any)
  602. await e.load(rows)
  603. const removed = line()
  604. const retained = line()
  605. rows.push(removed, retained)
  606. const first = e.load([removed])
  607. const second = e.load([retained])
  608. rows.splice(1, 1)
  609. assert.equal(e.prepare(), false)
  610. pending.reject(Error('offline'))
  611. await Promise.all([first, second])
  612. assert.equal(e.states.value.p1, 'error')
  613. assert.match(e.blockingMessage(), /失败/)
  614. assert.equal(e.prepare(), false)
  615. assert.equal(retained.standardProductName, undefined)
  616. })
  617. test('mobile maintenance uses brand palette and a full cell touch target with single toggle and busy locks', () => {
  618. const file = '../src/views/m/crm/quote/QuoteProductMetaManageSheet.vue'
  619. const descriptor = parse(readFileSync(new URL(file, import.meta.url), 'utf8')).descriptor
  620. assert.match(descriptor.styles[0].content, /--van-primary-color:\s*#0a9f6d/)
  621. assert.doesNotMatch(descriptor.styles[0].content, /#10b981/)
  622. let target: any
  623. function walk(node: any) {
  624. if (node.tag === 'van-cell' && node.loc.source.includes('toggleBrandStatus')) target = node
  625. node.children?.forEach(walk)
  626. }
  627. walk(descriptor.template!.ast)
  628. assert.ok(target, 'brand switch needs a clickable cell target')
  629. assert.match(target.loc.source, /@click="toggleBrandStatus\(row\)"/)
  630. assert.match(target.loc.source, /@click\.stop/)
  631. assert.match(
  632. descriptor.styles[0].content,
  633. /\.quote-meta-manage__brand-toggle\s*\{[^}]*min-height:\s*44px/s
  634. )
  635. const manager = { busy: false, persisted: false }
  636. const toggle = actualFunction(file, 'toggleBrandStatus', { manager })
  637. const row = { status: 'on' }
  638. toggle(row)
  639. assert.equal(row.status, 'off')
  640. toggle(row)
  641. assert.equal(row.status, 'on')
  642. manager.busy = true
  643. toggle(row)
  644. assert.equal(row.status, 'on')
  645. manager.busy = false
  646. manager.persisted = true
  647. toggle(row)
  648. assert.equal(row.status, 'on')
  649. })
  650. for (const mode of ['alias', 'brand'] as const) {
  651. for (const retryFrom of ['sheet', 'page'] as const) {
  652. test(`persisted ${mode} changes block quote writes until ${retryFrom} GET retry synchronizes current choices`, async () => {
  653. const rows = [line(), line()]
  654. let serverProduct = JSON.parse(JSON.stringify(product))
  655. let writes = 0
  656. let reads = 0
  657. let failRefresh = true
  658. const fetch = async () => {
  659. reads++
  660. if (writes && failRefresh) throw Error('offline after PUT')
  661. return JSON.parse(JSON.stringify(serverProduct))
  662. }
  663. const e = helper.createQuoteProductMetaEditor(() => rows, fetch)
  664. await e.load(rows)
  665. const snapshot = JSON.stringify(rows)
  666. const saved: any[] = []
  667. const persistedIds: string[] = []
  668. const m = helper.createQuoteProductMetaManager(
  669. fetch,
  670. async (payload: any) => {
  671. writes++
  672. serverProduct = { ...serverProduct, ...payload }
  673. },
  674. (event: any) => {
  675. saved.push(event)
  676. e.managed(event)
  677. },
  678. (id: string) => {
  679. persistedIds.push(id)
  680. e.markPersisted(id)
  681. }
  682. )
  683. await m.open('p1', mode)
  684. if (mode === 'alias') m.remove(1)
  685. else m.rows.value[0].status = 'off'
  686. await m.save()
  687. assert.match(m.refreshError.value, /已保存.*刷新选项失败/)
  688. assert.equal(
  689. e.prepare(),
  690. false,
  691. 'persisted but unread product metadata must block quote write'
  692. )
  693. assert.equal(
  694. JSON.stringify(rows),
  695. snapshot,
  696. 'failure preserves line snapshots until refreshed'
  697. )
  698. assert.deepEqual(persistedIds, ['p1'])
  699. assert.equal(saved.length, 0)
  700. const readsBeforeRetry = reads
  701. if (retryFrom === 'page') {
  702. assert.equal(m.close(), true)
  703. assert.equal(e.prepare(), false, 'closing maintenance must not bypass stale metadata')
  704. await e.load(rows)
  705. assert.equal(e.prepare(), false, 'failed page retry must retain the write block')
  706. failRefresh = false
  707. await e.load(rows)
  708. } else {
  709. failRefresh = false
  710. await m.save()
  711. }
  712. assert.ok(reads > readsBeforeRetry)
  713. assert.equal(writes, 1, 'refresh retries must never repeat product update')
  714. assert.equal(e.blockingMessage(), '')
  715. assert.equal(e.prepare(), true)
  716. for (const row of rows) {
  717. if (mode === 'alias') {
  718. assert.equal(row.aliasName, undefined)
  719. assert.equal(row.productName, '标准名称')
  720. } else {
  721. assert.equal(row.brandId, undefined)
  722. assert.equal(row.brandMode, undefined)
  723. assert.equal(row.brandName, undefined)
  724. }
  725. }
  726. })
  727. }
  728. }
  729. test('actual sheet persisted emit is wired to the PAGE metadata invalidation handler', async () => {
  730. const rows = [line()]
  731. let updated = false
  732. const fetch = async () => {
  733. if (updated) throw Error('refresh unavailable')
  734. return product
  735. }
  736. const e = helper.createQuoteProductMetaEditor(() => rows, fetch)
  737. await e.load(rows)
  738. const sheet = parse(
  739. readFileSync(
  740. new URL('../src/views/m/crm/quote/QuoteProductMetaManageSheet.vue', import.meta.url),
  741. 'utf8'
  742. )
  743. ).descriptor
  744. const page = parse(
  745. readFileSync(
  746. new URL('../src/views/crm/quote/components/QuoteFormPanel.vue', import.meta.url),
  747. 'utf8'
  748. )
  749. ).descriptor
  750. let persistedHandler = ''
  751. function visit(node: any) {
  752. if (node.tag === 'QuoteProductMetaManageSheet')
  753. persistedHandler =
  754. node.props.find((prop: any) => prop.name === 'on' && prop.arg?.content === 'persisted')?.exp
  755. ?.content || ''
  756. node.children?.forEach(visit)
  757. }
  758. visit(page.template!.ast)
  759. assert.ok(persistedHandler, 'PAGE must receive the persisted event')
  760. const ast = ts.createSourceFile(
  761. 'sheet.ts',
  762. sheet.scriptSetup!.content,
  763. ts.ScriptTarget.Latest,
  764. true
  765. )
  766. const declaration = ast.statements.find(
  767. (node) =>
  768. ts.isVariableStatement(node) &&
  769. node.declarationList.declarations[0].name.getText(ast) === 'manager'
  770. )!
  771. const code = ts.transpile(declaration.getText(ast), { target: ts.ScriptTarget.ESNext })
  772. const events: string[] = []
  773. const emit = (name: string, value: any) => {
  774. events.push(name)
  775. if (name === 'persisted')
  776. new Function('quoteMeta', 'value', `return ${persistedHandler}(value)`)(e, value)
  777. }
  778. const m = new Function(
  779. 'reactive',
  780. 'createQuoteProductMetaManager',
  781. 'fetchProductDetail',
  782. 'fetchUpdateProduct',
  783. 'emit',
  784. `${code}; return manager`
  785. )(
  786. reactive,
  787. helper.createQuoteProductMetaManager,
  788. fetch,
  789. async () => {
  790. updated = true
  791. },
  792. emit
  793. )
  794. await m.open('p1', 'alias')
  795. m.remove(1)
  796. await m.save()
  797. assert.deepEqual(events, ['persisted'])
  798. assert.equal(e.prepare(), false)
  799. assert.match(e.blockingMessage(), /产品资料已保存.*刷新/)
  800. })