mobile-customer-panel-identity.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync, existsSync } from 'node:fs'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import * as vue from 'vue'
  7. import { useClientColumnFilters } from '../src/composables/useClientColumnFilters'
  8. import * as customerFilterDefinitions from '../src/views/crm/customer/dialogColumnFilterDefs'
  9. import { contactFields } from '../src/views/m/crm/customer/model'
  10. import { contactDetailsError, requiredFieldsError } from '../src/views/m/crm/customer/validation'
  11. function deferred() {
  12. let resolve!: (value?: any) => void
  13. let reject!: (error: any) => void
  14. const promise = new Promise<any>((a, b) => {
  15. resolve = a
  16. reject = b
  17. })
  18. return { promise, resolve, reject }
  19. }
  20. const flush = async () => {
  21. for (let i = 0; i < 20; i++) await Promise.resolve()
  22. }
  23. // Execute actual script-setup functions inside Vue's renderer; API responses alone are deferred.
  24. const helperPath = new URL('../src/utils/mp-customer-panel-identity.ts', import.meta.url)
  25. function evaluate(page: string, props: any, overrides: Record<string, any>, events: any[]) {
  26. const source = parse(
  27. readFileSync(new URL(`../src/views/m/crm/customer/${page}.vue`, import.meta.url), 'utf8')
  28. ).descriptor.scriptSetup!.content
  29. const ast = ts.createSourceFile('panel.ts', source, ts.ScriptTarget.Latest, true)
  30. const vueEntries = Object.entries(vue).filter(
  31. ([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && k !== 'default'
  32. )
  33. const bindings: Record<string, any> = Object.fromEntries(vueEntries)
  34. delete bindings.default
  35. for (const statement of ast.statements) {
  36. if (!ts.isImportDeclaration(statement)) continue
  37. const clause = statement.importClause
  38. if (clause?.name) bindings[clause.name.text] = () => undefined
  39. if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
  40. for (const item of clause.namedBindings.elements) bindings[item.name.text] = () => undefined
  41. }
  42. if (existsSync(helperPath)) {
  43. const helper = ts.transpile(
  44. readFileSync(helperPath, 'utf8').replace('export function', 'function'),
  45. { target: ts.ScriptTarget.ESNext }
  46. )
  47. bindings.useCustomerPanelIdentity = new Function(
  48. ...vueEntries.map(([k]) => k),
  49. `${helper};return useCustomerPanelIdentity`
  50. )(...vueEntries.map(([, v]) => v))
  51. }
  52. Object.assign(bindings, {
  53. useClientColumnFilters,
  54. ...customerFilterDefinitions,
  55. defineProps: () => props,
  56. defineEmits:
  57. () =>
  58. (...args: any[]) =>
  59. events.push(args),
  60. defineExpose() {},
  61. useRouter: () => ({ push: async () => {} }),
  62. useDictStore: () => ({ getOptions: () => [], getLabel: () => '', getTagType: () => '' }),
  63. unwrapRows: (v: any) => (Array.isArray(v) ? v : v?.data?.rows || v?.data || v?.rows || []),
  64. unwrapData: (v: any) => v?.data || v,
  65. canDeleteByCreator: () => true,
  66. contactFields: [{ key: 'contactName', required: true }],
  67. invoiceFields: [{ key: 'companyName' }],
  68. requiredFieldsError: () => '',
  69. contactDetailsError: () => '',
  70. percentageError: () => '',
  71. apiTime: (v: any) => v,
  72. opportunityProgress: () => ({}),
  73. showCrmConfirmDialog: async () => {},
  74. fetchContactByCustomerId: async () => [],
  75. fetchFollowByCustomerId: async () => [],
  76. getInvoiceInfoByCustomerId: async () => [],
  77. fetchContactDetail: async (v: any) => v,
  78. fetchUserOptions: async () => [],
  79. fetchCustomerOptions: async () => [],
  80. listOpportunity: async () => [],
  81. listContract: async () => [],
  82. listOperLog: async () => [],
  83. getOpportunity: async (id: string) => ({ id }),
  84. listFollowByOpportunity: async () => [],
  85. getStageLogList: async () => [],
  86. ...overrides
  87. })
  88. const statements = ast.statements.filter((s) => !ts.isImportDeclaration(s))
  89. const names = statements.flatMap((s) =>
  90. ts.isFunctionDeclaration(s)
  91. ? [s.name!.text]
  92. : ts.isVariableStatement(s)
  93. ? s.declarationList.declarations.map((d) => d.name.getText(ast))
  94. : []
  95. )
  96. const code = ts.transpile(statements.map((s) => s.getText(ast)).join('\n'), {
  97. target: ts.ScriptTarget.ESNext
  98. })
  99. return new Function(...Object.keys(bindings), `${code};return {${names.join(',')}}`)(
  100. ...Object.values(bindings)
  101. )
  102. }
  103. function mount(page: string, overrides: Record<string, any> = {}) {
  104. const routeState = vue.reactive({ params: { id: 'A' }, query: { tab: 'basic' }, path: '/A' })
  105. const route = {
  106. get id() {
  107. return routeState.params.id
  108. },
  109. set id(id: string) {
  110. routeState.params.id = id
  111. },
  112. get tab() {
  113. return routeState.query.tab
  114. },
  115. set tab(tab: string) {
  116. routeState.query.tab = tab
  117. }
  118. }
  119. const events: any[] = []
  120. let api: any
  121. let actions: any
  122. const renderer = vue.createRenderer<any, any>({
  123. patchProp() {},
  124. insert() {},
  125. remove() {},
  126. createElement: () => ({}),
  127. createText: () => ({}),
  128. createComment: () => ({}),
  129. setText() {},
  130. setElementText() {},
  131. parentNode: () => null,
  132. nextSibling: () => null
  133. })
  134. const NestedActions = vue.defineComponent({
  135. props: ['opportunity', 'getIdentity'],
  136. setup(props) {
  137. actions = evaluate('OpportunityActions', props, overrides, events)
  138. return () => vue.h('div')
  139. }
  140. })
  141. const Child = vue.defineComponent({
  142. props: ['customerId', 'opportunity', 'getIdentity', 'kind'],
  143. setup(props) {
  144. api = evaluate(
  145. page === 'RelatedPanelActions' ? 'RelatedPanel' : page,
  146. props,
  147. overrides,
  148. events
  149. )
  150. return () =>
  151. page === 'RelatedPanelActions' && api.detail.value && api.detailOpen.value
  152. ? vue.h(NestedActions, { opportunity: api.detail.value, getIdentity: api.identity.key })
  153. : vue.h('div')
  154. }
  155. })
  156. const Parent = vue.defineComponent({
  157. setup() {
  158. const parent = evaluate(
  159. 'detail',
  160. {},
  161. { useRoute: () => routeState, useRouter: () => ({ replace() {}, push() {} }) },
  162. []
  163. )
  164. const getIdentity = parent.getPanelIdentity || (() => route.id)
  165. return () =>
  166. vue.h(Child, {
  167. customerId: route.id,
  168. opportunity: { id: route.id, status: 'following' },
  169. kind: 'opportunity',
  170. getIdentity
  171. })
  172. }
  173. })
  174. const app = renderer.createApp(Parent)
  175. app.mount({})
  176. return {
  177. get api() {
  178. return api
  179. },
  180. get actions() {
  181. return actions
  182. },
  183. route,
  184. events,
  185. unmount: () => app.unmount()
  186. }
  187. }
  188. const listApis: Record<string, string> = {
  189. ContactPanel: 'fetchContactByCustomerId',
  190. FollowPanel: 'fetchFollowByCustomerId',
  191. InvoicePanel: 'getInvoiceInfoByCustomerId',
  192. RelatedPanel: 'listOpportunity'
  193. }
  194. test('ContactPanel: editing visible fields preserves hidden contact values in the update payload', async () => {
  195. const writes: any[] = []
  196. const original = {
  197. id: 'contact-A',
  198. contactName: '验收联系人',
  199. mobile: '19900000000',
  200. qq: 'synthetic-qq',
  201. birthday: '2000-02-29',
  202. address: '验收合成住址',
  203. department: '原部门'
  204. }
  205. const h = mount('ContactPanel', {
  206. contactFields,
  207. contactDetailsError,
  208. requiredFieldsError,
  209. fetchContactDetail: async () => ({ data: original }),
  210. fetchUpdateContact: async (payload: any) => writes.push(payload)
  211. })
  212. try {
  213. await h.api.open({ id: original.id })
  214. assert.equal(h.api.visibleContactFields.value.some((field: any) => field.hidden), false)
  215. h.api.form.value.department = '已改部门'
  216. await h.api.save()
  217. assert.equal(writes.length, 1)
  218. assert.equal(writes[0].department, '已改部门')
  219. for (const key of ['qq', 'birthday', 'address']) assert.equal(writes[0][key], original[key])
  220. assert.equal(writes[0].id, original.id)
  221. assert.equal(writes[0].customerId, 'A')
  222. assert.equal(original.department, '原部门')
  223. assert.equal(h.api.editing.value, false)
  224. } finally {
  225. h.unmount()
  226. }
  227. })
  228. test('ContactPanel: opening another contact does not carry hidden fields from the previous draft', async () => {
  229. const writes: any[] = []
  230. const h = mount('ContactPanel', {
  231. contactFields,
  232. contactDetailsError,
  233. requiredFieldsError,
  234. fetchContactDetail: async ({ id }: { id: string }) => ({
  235. data: {
  236. id,
  237. contactName: id,
  238. mobile: '19900000000',
  239. ...(id === 'contact-A'
  240. ? { qq: 'synthetic-qq', birthday: '2000-02-29', address: '验收合成住址' }
  241. : { qq: null })
  242. }
  243. }),
  244. fetchUpdateContact: async (payload: any) => writes.push(payload)
  245. })
  246. try {
  247. await h.api.open({ id: 'contact-A' })
  248. h.api.closeEditing()
  249. await h.api.open({ id: 'contact-B' })
  250. h.api.form.value.department = '第二位联系人部门'
  251. await h.api.save()
  252. assert.equal(writes.length, 1)
  253. assert.equal(writes[0].id, 'contact-B')
  254. for (const key of ['qq', 'birthday', 'address']) assert.equal(writes[0][key], '')
  255. } finally {
  256. h.unmount()
  257. }
  258. })
  259. test('ContactPanel: failed update retains hidden values and retries the same edited payload', async () => {
  260. const writes: any[] = []
  261. const h = mount('ContactPanel', {
  262. contactFields,
  263. contactDetailsError,
  264. requiredFieldsError,
  265. fetchContactDetail: async () => ({
  266. data: {
  267. id: 'contact-A',
  268. contactName: '验收联系人',
  269. mobile: '19900000000',
  270. qq: 'synthetic-qq',
  271. birthday: '2000-02-29',
  272. address: '验收合成住址'
  273. }
  274. }),
  275. fetchUpdateContact: async (payload: any) => {
  276. writes.push(payload)
  277. if (writes.length === 1) throw new Error('验收模拟保存失败')
  278. }
  279. })
  280. try {
  281. await h.api.open({ id: 'contact-A' })
  282. h.api.form.value.department = '重试仍保留的新部门'
  283. await h.api.save()
  284. assert.equal(h.api.editing.value, true)
  285. assert.equal(h.api.acting.value, false)
  286. assert.equal(h.api.saveError.value, '验收模拟保存失败')
  287. assert.equal(h.api.form.value.birthday, '2000-02-29')
  288. assert.equal(h.api.form.value.department, '重试仍保留的新部门')
  289. await h.api.save()
  290. assert.equal(writes.length, 2)
  291. assert.deepEqual(writes[1], writes[0])
  292. assert.equal(writes[1].qq, 'synthetic-qq')
  293. assert.equal(writes[1].address, '验收合成住址')
  294. assert.equal(h.api.editing.value, false)
  295. assert.equal(h.api.saveError.value, '')
  296. } finally {
  297. h.unmount()
  298. }
  299. })
  300. test('InvoicePanel: setting default announces success only after the write succeeds', async () => {
  301. const write = deferred()
  302. const messages: string[] = []
  303. const h = mount('InvoicePanel', {
  304. request: { post: () => write.promise },
  305. showSuccessToast: (message: string) => messages.push(message)
  306. })
  307. try {
  308. await flush()
  309. const pending = h.api.setDefault({ id: 'invoice-A' })
  310. assert.deepEqual(messages, [])
  311. write.resolve()
  312. await pending
  313. assert.deepEqual(messages, ['设置成功'])
  314. assert.equal(h.api.acting.value, false)
  315. } finally {
  316. h.unmount()
  317. }
  318. })
  319. test('InvoicePanel: rejected default write retains its error without announcing success', async () => {
  320. const messages: string[] = []
  321. const h = mount('InvoicePanel', {
  322. request: { post: async () => { throw new Error('验收请求失败') } },
  323. showSuccessToast: (message: string) => messages.push(message)
  324. })
  325. try {
  326. await flush()
  327. await h.api.setDefault({ id: 'invoice-A' })
  328. assert.deepEqual(messages, [])
  329. assert.equal(h.api.error.value, '验收请求失败')
  330. assert.equal(h.api.acting.value, false)
  331. } finally {
  332. h.unmount()
  333. }
  334. })
  335. for (const outcome of ['switch', 'ABA', 'unmount', 'same-customer-tab']) {
  336. test(`InvoicePanel: default success feedback respects ${outcome}`, async () => {
  337. const write = deferred()
  338. const messages: string[] = []
  339. const h = mount('InvoicePanel', {
  340. request: { post: () => write.promise },
  341. showSuccessToast: (message: string) => messages.push(message)
  342. })
  343. await flush()
  344. const pending = h.api.setDefault({ id: 'invoice-A' })
  345. if (outcome === 'unmount') h.unmount()
  346. else if (outcome === 'same-customer-tab') h.route.tab = 'invoice'
  347. else {
  348. h.route.id = 'B'
  349. if (outcome === 'ABA') h.route.id = 'A'
  350. }
  351. write.resolve()
  352. try {
  353. await pending
  354. assert.deepEqual(messages, outcome === 'same-customer-tab' ? ['设置成功'] : [])
  355. } finally {
  356. if (outcome !== 'unmount') h.unmount()
  357. }
  358. })
  359. }
  360. for (const [page, read] of Object.entries(listApis)) {
  361. test(`${page}: late initial load cannot overwrite B or ABA`, async () => {
  362. const old = deferred()
  363. let calls = 0
  364. const h = mount(page, {
  365. [read]: () => (++calls === 1 ? old.promise : Promise.resolve([{ id: 'fresh' }]))
  366. })
  367. h.route.id = 'B'
  368. await flush()
  369. h.route.id = 'A'
  370. await flush()
  371. old.resolve([{ id: 'old' }])
  372. await flush()
  373. assert.deepEqual(h.api.rows.value, [{ id: 'fresh' }])
  374. h.unmount()
  375. })
  376. }
  377. const deletes: Record<string, string> = {
  378. ContactPanel: 'fetchDeleteContact',
  379. FollowPanel: 'fetchDeleteCustomerFollow',
  380. InvoicePanel: 'delInvoiceInfo',
  381. OpportunityActions: 'delOpportunity'
  382. }
  383. for (const [page, write] of Object.entries(deletes)) {
  384. for (const outcome of ['switch', 'ABA', 'unmount'])
  385. test(`${page}: confirmation resolves before same-tick ${outcome}`, async () => {
  386. const confirm = deferred(),
  387. writes: any[] = []
  388. const h = mount(page, {
  389. showCrmConfirmDialog: () => confirm.promise,
  390. [write]: async (v: any) => writes.push(v)
  391. })
  392. await flush()
  393. const pending = h.api.remove(page === 'OpportunityActions' ? undefined : { id: 'row-A' })
  394. confirm.resolve()
  395. if (outcome === 'unmount') h.unmount()
  396. else {
  397. h.route.id = 'B'
  398. if (outcome === 'ABA') h.route.id = 'A'
  399. }
  400. await pending
  401. assert.deepEqual(writes, [])
  402. assert.deepEqual(h.events, [])
  403. if (outcome !== 'unmount') h.unmount()
  404. })
  405. test(`${page}: same-ID tab query retains legitimate confirmation`, async () => {
  406. const confirm = deferred(),
  407. writes: any[] = []
  408. const h = mount(page, {
  409. showCrmConfirmDialog: () => confirm.promise,
  410. [write]: async (v: any) => writes.push(v)
  411. })
  412. await flush()
  413. const pending = h.api.remove(page === 'OpportunityActions' ? undefined : { id: 'row-A' })
  414. h.route.tab = 'contact'
  415. confirm.resolve()
  416. await pending
  417. assert.equal(writes.length, 1)
  418. h.unmount()
  419. })
  420. }
  421. test('ContactPanel: reversed detail opens retain latest row', async () => {
  422. const first = deferred(),
  423. second = deferred()
  424. const h = mount('ContactPanel', {
  425. fetchContactDetail: ({ id }: any) => (id === 'one' ? first.promise : second.promise)
  426. })
  427. const a = h.api.open({ id: 'one' }),
  428. b = h.api.open({ id: 'two' })
  429. second.resolve({ id: 'two' })
  430. await b
  431. first.resolve({ id: 'one' })
  432. await a
  433. assert.equal(h.api.form.value.id, 'two')
  434. h.unmount()
  435. })
  436. test('OpportunityActions: reversed customer searches retain latest query', async () => {
  437. const first = deferred(),
  438. second = deferred()
  439. const h = mount('OpportunityActions', {
  440. fetchCustomerOptions: ({ keyword }: any) => (keyword === 'one' ? first.promise : second.promise)
  441. })
  442. await h.api.open('edit')
  443. h.api.customerKeyword.value = 'one'
  444. const a = h.api.searchCustomers()
  445. h.api.customerKeyword.value = 'two'
  446. const b = h.api.searchCustomers()
  447. second.resolve([{ id: 'two', customerName: 'two' }])
  448. await b
  449. first.resolve([{ id: 'one', customerName: 'one' }])
  450. await a
  451. assert.equal(h.api.customers.value[0].value, 'two')
  452. h.unmount()
  453. })
  454. const saves: Record<string, string> = {
  455. ContactPanel: 'fetchCreateContact',
  456. FollowPanel: 'fetchCreateCustomerFollow',
  457. InvoicePanel: 'addInvoiceInfo',
  458. OpportunityActions: 'addOpportunityFollow'
  459. }
  460. for (const [page, write] of Object.entries(saves)) {
  461. test(`${page}: old save completion does not close, emit or clear new busy`, async () => {
  462. const old = deferred(),
  463. fresh = deferred()
  464. let count = 0
  465. const h = mount(page, { [write]: () => (++count === 1 ? old.promise : fresh.promise) })
  466. await flush()
  467. await (page === 'OpportunityActions' ? h.api.open('follow') : h.api.open())
  468. Object.assign(h.api.form.value, {
  469. contactName: 'A',
  470. companyName: 'A',
  471. followContent: 'A',
  472. followType: 'phone'
  473. })
  474. const a = h.api.save()
  475. h.route.id = 'B'
  476. await flush()
  477. await (page === 'OpportunityActions' ? h.api.open('follow') : h.api.open())
  478. Object.assign(h.api.form.value, {
  479. contactName: 'B',
  480. companyName: 'B',
  481. followContent: 'B',
  482. followType: 'phone'
  483. })
  484. const b = h.api.save()
  485. old.resolve()
  486. await a
  487. assert.equal((h.api.acting || h.api.busy).value, true)
  488. assert.equal(
  489. (h.api.editing || h.api.mode).value,
  490. page === 'OpportunityActions' ? 'follow' : true
  491. )
  492. assert.deepEqual(h.events, [])
  493. fresh.resolve()
  494. await b
  495. assert.equal((h.api.acting || h.api.busy).value, false)
  496. h.unmount()
  497. })
  498. }
  499. test('FollowPanel: close/reopen ignores old contacts error', async () => {
  500. const first = deferred(),
  501. second = deferred()
  502. let calls = 0
  503. const h = mount('FollowPanel', {
  504. fetchContactByCustomerId: () => (++calls === 1 ? first.promise : second.promise)
  505. })
  506. const a = h.api.open()
  507. h.api.closeEditing()
  508. const b = h.api.open()
  509. second.resolve([{ id: 'new' }])
  510. await b
  511. first.reject(new Error('old error'))
  512. await a
  513. assert.equal(h.api.saveError.value, '')
  514. assert.deepEqual(h.api.contacts.value, [{ id: 'new' }])
  515. h.unmount()
  516. })
  517. test('RelatedPanel: reversed opportunity opens keep fresh detail and actions', async () => {
  518. const first = deferred(),
  519. second = deferred()
  520. const h = mount('RelatedPanel', {
  521. getOpportunity: (id: string) => (id === 'one' ? first.promise : second.promise)
  522. })
  523. const a = h.api.open({ id: 'one' }),
  524. b = h.api.open({ id: 'two' })
  525. second.resolve({ id: 'two' })
  526. await b
  527. first.resolve({ id: 'one' })
  528. await a
  529. assert.equal(h.api.detail.value.id, 'two')
  530. h.unmount()
  531. })
  532. for (const transition of ['openB', 'close', 'closeReopenA', 'routeABA']) {
  533. test(`nested RelatedPanel/OpportunityActions: confirmation cannot survive ${transition}`, async () => {
  534. const confirm = deferred(),
  535. writes: any[] = []
  536. const h = mount('RelatedPanelActions', {
  537. showCrmConfirmDialog: () => confirm.promise,
  538. delOpportunity: async (id: string) => writes.push(id)
  539. })
  540. await flush()
  541. await h.api.open({ id: 'opportunity-A' })
  542. await flush()
  543. const pending = h.actions.remove()
  544. confirm.resolve()
  545. if (transition === 'openB') void h.api.open({ id: 'opportunity-B' })
  546. if (transition === 'close') h.api.closeDetail()
  547. if (transition === 'closeReopenA') {
  548. h.api.closeDetail()
  549. void h.api.open({ id: 'opportunity-A' })
  550. }
  551. if (transition === 'routeABA') {
  552. h.route.id = 'B'
  553. h.route.id = 'A'
  554. }
  555. await pending
  556. assert.deepEqual(writes, [])
  557. assert.deepEqual(h.events, [])
  558. h.unmount()
  559. })
  560. }
  561. for (const [page, read] of Object.entries(listApis)) {
  562. test(`${page}: old list error and finally cannot overwrite B loading`, async () => {
  563. const first = deferred(),
  564. second = deferred()
  565. let calls = 0
  566. const h = mount(page, { [read]: () => (++calls === 1 ? first.promise : second.promise) })
  567. h.route.id = 'B'
  568. await flush()
  569. first.reject(new Error('old error'))
  570. await flush()
  571. assert.equal(h.api.error.value, '')
  572. assert.equal(h.api.loading.value, true)
  573. second.resolve([{ id: 'B' }])
  574. await flush()
  575. assert.deepEqual(h.api.rows.value, [{ id: 'B' }])
  576. h.unmount()
  577. })
  578. }
  579. test('ContactPanel: primary confirmation identifies the selected contact before writing', async () => {
  580. const confirmation = deferred()
  581. const prompts: any[] = []
  582. const writes: any[] = []
  583. const h = mount('ContactPanel', {
  584. showCrmConfirmDialog: (prompt: any) => {
  585. prompts.push(prompt)
  586. return confirmation.promise
  587. },
  588. fetchSetPrimaryContact: async (value: any) => writes.push(value)
  589. })
  590. try {
  591. await flush()
  592. const pending = h.api.primary({ id: 'contact-A', contactName: '验收联系人甲' })
  593. assert.equal(prompts[0].message, '确认将「验收联系人甲」设置为主要联系人吗?')
  594. assert.deepEqual(writes, [])
  595. confirmation.reject('cancel')
  596. await pending
  597. assert.deepEqual(writes, [])
  598. assert.equal(h.api.error.value, '')
  599. } finally {
  600. confirmation.reject('cancel')
  601. h.unmount()
  602. }
  603. })
  604. for (const outcome of ['success', 'failure', 'switch', 'ABA', 'unmount']) {
  605. test(`ContactPanel: primary feedback respects ${outcome}`, async () => {
  606. const write = deferred()
  607. const messages: string[] = []
  608. const h = mount('ContactPanel', {
  609. fetchSetPrimaryContact: () => write.promise,
  610. showSuccessToast: (message: string) => messages.push(message)
  611. })
  612. await flush()
  613. const pending = h.api.primary({ id: 'contact-A', contactName: '验收联系人甲' })
  614. await flush()
  615. assert.deepEqual(messages, [])
  616. if (outcome === 'unmount') h.unmount()
  617. if (outcome === 'switch' || outcome === 'ABA') {
  618. h.route.id = 'B'
  619. if (outcome === 'ABA') h.route.id = 'A'
  620. }
  621. if (outcome === 'failure') write.reject(new Error('验收请求失败'))
  622. else write.resolve()
  623. try {
  624. await pending
  625. assert.deepEqual(messages, outcome === 'success' ? ['设置成功'] : [])
  626. if (outcome === 'failure') assert.equal(h.api.error.value, '验收请求失败')
  627. } finally {
  628. if (outcome !== 'unmount') h.unmount()
  629. }
  630. })
  631. }
  632. test('ContactPanel: stale primary confirmation cannot set a row on the next customer', async () => {
  633. const confirm = deferred(),
  634. writes: any[] = []
  635. const h = mount('ContactPanel', {
  636. showCrmConfirmDialog: () => confirm.promise,
  637. fetchSetPrimaryContact: async (v: any) => writes.push(v)
  638. })
  639. await flush()
  640. const pending = h.api.primary({ id: 'contact-A' })
  641. confirm.resolve()
  642. h.route.id = 'B'
  643. await pending
  644. assert.deepEqual(writes, [])
  645. h.unmount()
  646. })
  647. test('InvoicePanel: default write failure cannot replace the next customer error or busy', async () => {
  648. const first = deferred(),
  649. second = deferred()
  650. let calls = 0
  651. const h = mount('InvoicePanel', {
  652. request: { post: () => (++calls === 1 ? first.promise : second.promise) }
  653. })
  654. await flush()
  655. const a = h.api.setDefault({ id: 'invoice-A' })
  656. h.route.id = 'B'
  657. await flush()
  658. const b = h.api.setDefault({ id: 'invoice-B' })
  659. first.reject(new Error('old error'))
  660. await a
  661. assert.equal(h.api.error.value, '')
  662. assert.equal(h.api.acting.value, true)
  663. second.resolve()
  664. await b
  665. assert.equal(h.api.acting.value, false)
  666. h.unmount()
  667. })
  668. test('OpportunityActions: old owner options cannot replace a reopened edit session', async () => {
  669. const first = deferred(),
  670. second = deferred()
  671. let calls = 0
  672. const h = mount('OpportunityActions', {
  673. fetchUserOptions: () => (++calls === 1 ? first.promise : second.promise)
  674. })
  675. const a = h.api.open('edit')
  676. const b = h.api.open('edit')
  677. second.resolve([{ value: 'B', label: 'B' }])
  678. await b
  679. first.resolve([{ value: 'A', label: 'A' }])
  680. await a
  681. assert.deepEqual(h.api.users.value, [{ value: 'B', label: 'B' }])
  682. h.unmount()
  683. })
  684. test('OpportunityActions: delete uses the captured follow ID', async () => {
  685. const confirm = deferred(),
  686. writes: any[] = []
  687. const h = mount('OpportunityActions', {
  688. showCrmConfirmDialog: () => confirm.promise,
  689. delOpportunityFollow: async (v: any) => writes.push(v)
  690. })
  691. const row = { id: 'follow-A' }
  692. const pending = h.api.remove(row)
  693. row.id = 'follow-B'
  694. confirm.resolve()
  695. await pending
  696. assert.deepEqual(writes, ['follow-A'])
  697. assert.deepEqual(h.events, [['changed']])
  698. h.unmount()
  699. })