mobile-pl-list-state.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { compile, createRenderer, defineComponent, h, nextTick, reactive, ref } from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import * as vue from 'vue'
  8. import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
  9. import { plPlanColumnFilterDefs } from '../src/views/pl/plan/columnFilterDefs'
  10. import { plPlanListTabToFilters } from '../src/utils/pl-display'
  11. import {
  12. applyMergedCustomerQueryParams,
  13. applyContractSideQueryParams
  14. } from '../src/utils/pl-plan-pair-list-query'
  15. const root = new URL('../src/views/m/pl/', import.meta.url)
  16. function harness(file: string) {
  17. const requests: any[] = []
  18. const state: any = {
  19. userStore: reactive({
  20. accessToken: 'session-a',
  21. getUserInfo: { tenantNo: 'tenant-a', roles: ['sales'] }
  22. }),
  23. activeTab: ref('all'),
  24. dateRange: ref([]),
  25. plPlanListTabToFilters,
  26. ...useServerColumnFilters(plPlanColumnFilterDefs),
  27. sort: reactive({ sortField: '', sortOrder: 'desc' }),
  28. applyMergedCustomerQueryParams,
  29. applyContractSideQueryParams,
  30. keyword: ref('a'),
  31. appliedKeyword: ref('a'),
  32. page: ref(1),
  33. plans: ref([{ id: 'existing' }]),
  34. loading: ref(false),
  35. total: ref(50),
  36. error: ref(''),
  37. forbidden: ref(false),
  38. failedMore: ref(false),
  39. selected: ref(new Set()),
  40. expandedIds: ref(new Set()),
  41. pageSize: 20,
  42. planDetails: { reset() {} },
  43. planApi: {
  44. list: (params: any) =>
  45. new Promise((resolve, reject) => requests.push({ params, resolve, reject }))
  46. },
  47. HttpError: Error,
  48. ApiStatus: { forbidden: 403 }
  49. }
  50. state.query = () => ({ q: state.appliedKeyword.value })
  51. const source = ts.createSourceFile(
  52. file,
  53. parse(readFileSync(new URL(file, root), 'utf8')).descriptor.scriptSetup!.content,
  54. ts.ScriptTarget.Latest,
  55. true
  56. )
  57. const fn = file.startsWith('plan/') ? 'fetchPage' : 'load'
  58. const declarations = source.statements.filter(
  59. (n) =>
  60. (ts.isFunctionDeclaration(n) &&
  61. [fn, 'requestKey', ...(file.startsWith('plan/') ? ['buildParams'] : [])].includes(
  62. n.name?.text || ''
  63. )) ||
  64. (ts.isVariableStatement(n) &&
  65. n.declarationList.declarations.some((d) => d.name.getText(source) === 'sessionScope'))
  66. )
  67. const code = ts.transpile(declarations.map((n) => n.getText(source)).join('\n'), {
  68. target: ts.ScriptTarget.ESNext
  69. })
  70. const api = new Function(
  71. ...Object.keys(state),
  72. `let requestVersion=0; let disposed=false; ${code}; return { run:${fn}, dispose:()=>{disposed=true; requestVersion++} }`
  73. )(...Object.values(state))
  74. return {
  75. state,
  76. requests,
  77. dispose: api.dispose,
  78. run: (reset: boolean) => api.run(fn === 'load' ? !reset : reset)
  79. }
  80. }
  81. for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
  82. test(`${file}: latest search wins including stale failure and finally`, async () => {
  83. const h = harness(file)
  84. const old = h.run(true)
  85. h.state.keyword.value = h.state.appliedKeyword.value = 'new'
  86. const fresh = h.run(true)
  87. h.requests[0].reject(new Error('old failure'))
  88. await old
  89. assert.equal(h.state.loading.value, true)
  90. assert.equal(h.state.error.value, '')
  91. h.requests[1].resolve({ list: [{ id: 'new' }], total: 1 })
  92. assert.equal(await fresh, true)
  93. assert.deepEqual(h.state.plans.value, [{ id: 'new' }])
  94. })
  95. test(`${file}: typing before debounce invalidates response, clear wins`, async () => {
  96. const h = harness(file)
  97. const old = h.run(true)
  98. h.state.keyword.value = ''
  99. h.requests[0].resolve({ list: [{ id: 'stale' }], total: 1 })
  100. await old
  101. assert.deepEqual(h.state.plans.value, [])
  102. h.state.appliedKeyword.value = ''
  103. const fresh = h.run(true)
  104. h.requests[1].resolve({ list: [{ id: 'clear' }], total: 1 })
  105. await fresh
  106. assert.deepEqual(h.state.plans.value, [{ id: 'clear' }])
  107. })
  108. test(`${file}: failed page retries same page, concurrent more is ignored, rows dedupe`, async () => {
  109. const h = harness(file)
  110. const failed = h.run(false)
  111. void h.run(false)
  112. assert.equal(h.requests.length, 1)
  113. assert.equal(h.requests[0].params.page, 2)
  114. h.requests[0].reject(new Error('offline'))
  115. await failed
  116. assert.equal(h.state.page.value, 1)
  117. assert.ok(h.state.error.value)
  118. const retry = h.run(false)
  119. assert.equal(h.requests[1].params.page, 2)
  120. h.requests[1].resolve({ list: [{ id: 'existing' }, { id: 'next' }], total: 50 })
  121. await retry
  122. assert.equal(h.state.page.value, 2)
  123. assert.deepEqual(
  124. h.state.plans.value.map((p: any) => p.id),
  125. ['existing', 'next']
  126. )
  127. })
  128. test(`${file}: unmount suppresses success and late debounce cannot request`, async () => {
  129. const h = harness(file)
  130. const pending = h.run(true)
  131. h.dispose()
  132. h.requests[0].resolve({ list: [{ id: 'late' }], total: 1 })
  133. await pending
  134. void h.run(true)
  135. assert.deepEqual(h.state.plans.value, [])
  136. assert.equal(h.requests.length, 1)
  137. })
  138. }
  139. function actionHarness(file: string, overrides: any = {}) {
  140. const userStore = reactive({
  141. accessToken: 'session-a',
  142. getUserInfo: { tenantNo: 'tenant-a', roles: ['sales'] }
  143. })
  144. const events: string[] = []
  145. let confirmResolve: () => void = () => {}
  146. const confirm = new Promise<void>((resolve) => {
  147. confirmResolve = resolve
  148. })
  149. const plan = { id: 'p1', products: [] }
  150. const state: any = {
  151. acting: ref(false),
  152. canReady: ref(true),
  153. morePlan: ref(plan),
  154. moreOpen: ref(true),
  155. voidTarget: ref(plan),
  156. voidOpen: ref(true),
  157. voidReason: ref('original reason'),
  158. userStore,
  159. canWaitNotice: () => true,
  160. canCancelWaitNotice: () => true,
  161. canVoidPlan: () => true,
  162. readyTargets: (rows: any[]) => rows,
  163. refreshSidebarBadges: async () => {},
  164. planHasHazardProducts: () => true,
  165. HAZARD_CERT_READY_CONFIRM_HINT: '',
  166. showCrmConfirmDialog: () => {
  167. events.push('confirm')
  168. return confirm
  169. },
  170. planApi: {
  171. get: async (id: string) => ({ id, products: [] }),
  172. setHazardCertReady: async (id: string) => {
  173. events.push(`ready:${id}`)
  174. },
  175. cancel: async (id: string, data: any) => {
  176. events.push(`void:${id}:${data.cancelReason}`)
  177. },
  178. waitNotice: async (id: string) => {
  179. events.push(`wait:${id}`)
  180. },
  181. cancelWaitNotice: async (id: string) => {
  182. events.push(`cancel-wait:${id}`)
  183. }
  184. },
  185. ElMessage: Object.assign(() => events.push('toast'), {
  186. warning: () => events.push('toast'),
  187. success: () => events.push('toast'),
  188. error: () => events.push('toast')
  189. }),
  190. load: async () => {
  191. events.push('reload')
  192. },
  193. reload: () => {
  194. events.push('reload')
  195. }
  196. }
  197. const source = ts.createSourceFile(
  198. file,
  199. parse(readFileSync(new URL(file, root), 'utf8')).descriptor.scriptSetup!.content,
  200. ts.ScriptTarget.Latest,
  201. true
  202. )
  203. Object.assign(state.planApi, overrides.planApi || {})
  204. const names = ['setReady', 'runMoreAction', 'confirmVoidPlan']
  205. const code = ts.transpile(
  206. source.statements
  207. .filter(
  208. (n) =>
  209. (ts.isFunctionDeclaration(n) && names.includes(n.name?.text || '')) ||
  210. (ts.isVariableStatement(n) &&
  211. n.declarationList.declarations.some((d) => d.name.getText(source) === 'sessionScope'))
  212. )
  213. .map((n) => n.getText(source))
  214. .join('\n'),
  215. { target: ts.ScriptTarget.ESNext }
  216. )
  217. const api = new Function(
  218. ...Object.keys(state),
  219. `let disposed=false; let actionVersion=0; ${code}; return { run: ${file.startsWith('plan-cert') ? '(row={id:"p1"})=>setReady(typeof row==="object"?row:{id:"p1"},true)' : '(key)=>key==="void" ? confirmVoidPlan() : runMoreAction(key)'}, dispose:()=>{disposed=true;actionVersion++}, invalidate:()=>{actionVersion++;acting.value=false} }`
  220. )(...Object.values(state))
  221. return {
  222. state,
  223. events,
  224. confirmResolve,
  225. ...api,
  226. changeScope: () => {
  227. userStore.accessToken = 'session-b'
  228. }
  229. }
  230. }
  231. for (const kind of ['ready', 'wait', 'cancel-wait', 'void']) {
  232. for (const invalidation of ['dispose', 'scope', 'session']) {
  233. test(`${kind}: confirmation followed by ${invalidation} in same tick does not write`, async () => {
  234. const h = actionHarness(kind === 'ready' ? 'plan-cert/index.vue' : 'plan/index.vue')
  235. const pending = h.run(kind)
  236. for (let n = 0; n < 5; n++) await Promise.resolve()
  237. assert.ok(h.events.includes('confirm'))
  238. h.confirmResolve()
  239. if (invalidation === 'dispose') h.dispose()
  240. if (invalidation === 'scope') h.changeScope()
  241. if (invalidation === 'session') h.invalidate()
  242. await pending
  243. assert.deepEqual(h.events, ['confirm'])
  244. })
  245. }
  246. test(`${kind}: duplicate confirmation is locked before dialog, captured target/reason is written once`, async () => {
  247. const h = actionHarness(kind === 'ready' ? 'plan-cert/index.vue' : 'plan/index.vue')
  248. const pending = h.run(kind)
  249. for (let n = 0; n < 5; n++) await Promise.resolve()
  250. const duplicate = h.run(kind)
  251. h.state.voidReason.value = 'changed'
  252. h.confirmResolve()
  253. await Promise.all([pending, duplicate])
  254. assert.equal(h.events.filter((e: string) => e === 'confirm').length, 1)
  255. assert.equal(h.events.filter((e: string) => e.startsWith(`${kind}:`)).length, 1)
  256. if (kind === 'void') assert.ok(h.events.includes('void:p1:original reason'))
  257. })
  258. }
  259. function mountTemplate(file: string, mode = 'manage') {
  260. type Node = { type: string; children: Node[]; parent?: Node; props: Record<string, any> }
  261. const node = (type: string): Node =>
  262. ({
  263. type,
  264. children: [],
  265. props: {},
  266. style: {},
  267. addEventListener() {},
  268. removeEventListener() {}
  269. }) as Node
  270. const renderer = createRenderer<Node, Node>({
  271. createElement: node,
  272. createText: node,
  273. createComment: node,
  274. setText() {},
  275. setElementText() {},
  276. parentNode: (el) => el.parent!,
  277. nextSibling: (el) => el.parent?.children[el.parent.children.indexOf(el) + 1] || null,
  278. patchProp: (el, key, _old, value) => {
  279. el.props[key] = value
  280. },
  281. insert(el, parent, anchor) {
  282. if (el.parent) el.parent.children.splice(el.parent.children.indexOf(el), 1)
  283. el.parent = parent
  284. const index = anchor ? parent.children.indexOf(anchor) : -1
  285. parent.children.splice(index < 0 ? parent.children.length : index, 0, el)
  286. },
  287. remove(el) {
  288. el.parent?.children.splice(el.parent.children.indexOf(el), 1)
  289. }
  290. })
  291. const state = reactive<Record<string, any>>({
  292. loading: false,
  293. forbidden: false,
  294. keyword: '山峡',
  295. rows: [],
  296. plans: [],
  297. planRows: [],
  298. visibleCards: [],
  299. PL_PLAN_LIST_TABS: [],
  300. PL_PLAN_CERT_TABS: [],
  301. moreActions: [],
  302. planDateValue: [],
  303. selected: new Set(),
  304. expandedIds: new Set(),
  305. products: [],
  306. cards: [],
  307. views: [],
  308. viewTabs: [],
  309. selectedIds: new Set(),
  310. previewFields: [],
  311. filtersOpen: true,
  312. moreOpen: true,
  313. submitForm: { approverIds: [] },
  314. moreDraft: {},
  315. mode,
  316. error: '',
  317. errorText: ''
  318. })
  319. const fallback = () => []
  320. const context = new Proxy(state, {
  321. has: (_, key) =>
  322. typeof key === 'string' &&
  323. !key.startsWith('_') &&
  324. !['String', 'Boolean', 'Number'].includes(key),
  325. get: (target, key) =>
  326. key === Symbol.unscopables ? undefined : key in target ? target[key as string] : fallback
  327. })
  328. const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8'))
  329. const render = compile(descriptor.template!.content)
  330. const passthrough = defineComponent({
  331. setup:
  332. (_, { slots }) =>
  333. () =>
  334. h(
  335. 'stub',
  336. Object.values(slots).flatMap((slot) => slot?.())
  337. )
  338. })
  339. const header = defineComponent({
  340. setup:
  341. (_, { slots }) =>
  342. () =>
  343. h(
  344. 'input',
  345. Object.values(slots).flatMap((slot) => slot?.())
  346. )
  347. })
  348. const app = renderer.createApp({ render: () => render.call(context, context, []) })
  349. const tags = [...descriptor.template!.content.matchAll(/<([A-Z][\w]+|van-[\w-]+)/g)].map(
  350. (match) => match[1]
  351. )
  352. for (const tag of new Set(tags)) {
  353. const type =
  354. tag === 'CertFilters'
  355. ? 'filters'
  356. : tag === 'van-popup'
  357. ? 'popup'
  358. : tag === 'MpListPageSkeleton'
  359. ? 'skeleton'
  360. : ''
  361. app.component(
  362. tag,
  363. tag === 'MpBusinessHeader'
  364. ? header
  365. : type
  366. ? defineComponent({
  367. setup:
  368. (_, { slots }) =>
  369. () =>
  370. h(type, slots.default?.())
  371. })
  372. : passthrough
  373. )
  374. }
  375. const container = node('root')
  376. app.mount(container)
  377. const find = (type: string, start = container): Node | undefined => {
  378. if (start.type === type) return start
  379. for (const child of start.children) {
  380. const found = find(type, child)
  381. if (found) return found
  382. }
  383. }
  384. return { state, find, app }
  385. }
  386. for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
  387. test(`${file}: header input survives reload, empty result, error and retry`, async () => {
  388. const { state, find, app } = mountTemplate(file)
  389. const input = find('input')
  390. assert.ok(input)
  391. for (const phase of [
  392. { loading: true },
  393. { loading: false },
  394. { loading: false, error: 'offline' },
  395. { loading: true, error: '' }
  396. ]) {
  397. Object.assign(state, phase)
  398. await nextTick()
  399. assert.equal(find('input'), input)
  400. if (phase.loading) assert.ok(find('skeleton'))
  401. }
  402. app.unmount()
  403. })
  404. }
  405. function returnStateHarness(base = '/m/pl/plan') {
  406. let path = base
  407. let leave: (to: { path: string }) => void
  408. let unmount: () => void
  409. const afterEach = new Set<(to: { path: string }, from: { path: string }) => void>()
  410. const user = {
  411. accessToken: 'session-a',
  412. getUserInfo: { userId: 'a', tenantNo: 'tenant-a', roles: ['sales'] }
  413. }
  414. const scrolls: number[] = []
  415. const bindings = {
  416. ref,
  417. nextTick,
  418. useRoute: () => ({ path }),
  419. useRouter: () => ({
  420. afterEach(fn: any) {
  421. afterEach.add(fn)
  422. return () => afterEach.delete(fn)
  423. }
  424. }),
  425. useUserStore: () => user,
  426. onBeforeRouteLeave: (fn: typeof leave) => {
  427. leave = fn
  428. },
  429. onBeforeUnmount: (fn: typeof unmount) => {
  430. unmount = fn
  431. },
  432. window: {
  433. scrollY: 720,
  434. scrollTo: ({ top }: { top: number }) => {
  435. scrolls.push(top)
  436. }
  437. }
  438. }
  439. const source = readFileSync(new URL('../crm/useListReturnState.ts', root), 'utf8')
  440. .replace(/^import .*$/gm, '')
  441. .replace('export function', 'function')
  442. const code = ts.transpile(source, { target: ts.ScriptTarget.ESNext })
  443. const useState = new Function(...Object.keys(bindings), `${code}; return useListReturnState`)(
  444. ...Object.values(bindings)
  445. )
  446. function create() {
  447. const keyword = ref('')
  448. const query = ref<Record<string, string>>({})
  449. const page = ref(3)
  450. const expandedIds = ref(new Set(['customer-2']))
  451. const hasMore = ref(true)
  452. const calls: number[] = []
  453. let failPage = 0
  454. let duringFetch: (() => void) | undefined
  455. const state = useState({
  456. isDetailPath: (target: string) => target.startsWith(`${base}/`) && target !== `${base}/new`,
  457. fields: { keyword, query },
  458. page,
  459. expandedIds,
  460. hasMore,
  461. queryKey: () => JSON.stringify([keyword.value, query.value]),
  462. async fetchPage(reset: boolean) {
  463. const requested = reset ? 1 : page.value + 1
  464. calls.push(requested)
  465. if (requested === failPage) return
  466. page.value = requested
  467. expandedIds.value = new Set()
  468. duringFetch?.()
  469. return true
  470. }
  471. })
  472. return {
  473. state,
  474. keyword,
  475. query,
  476. page,
  477. expandedIds,
  478. calls,
  479. failAt: (value: number) => {
  480. failPage = value
  481. },
  482. duringFetch: (fn: () => void) => {
  483. duringFetch = fn
  484. }
  485. }
  486. }
  487. function navigate(to: string) {
  488. const from = path
  489. path = to
  490. for (const fn of afterEach) fn({ path: to }, { path: from })
  491. }
  492. function openDetail() {
  493. leave({ path: `${base}/customer-2` })
  494. navigate(`${base}/customer-2`)
  495. unmount()
  496. }
  497. return { create, navigate, openDetail, user, scrolls }
  498. }
  499. for (const base of ['/m/pl/plan', '/m/pl/plan-cert']) {
  500. test(`${base}: details restore filters/pages/expanded/scroll with no row cache`, async () => {
  501. const env = returnStateHarness(base)
  502. const list = env.create()
  503. list.keyword.value = '测试计划'
  504. list.query.value = { date: '2026-10-21', ready: '0', tab: 'all' }
  505. env.openDetail()
  506. env.navigate(base)
  507. const returned = env.create()
  508. await returned.state.restore()
  509. assert.equal(returned.keyword.value, '测试计划')
  510. assert.deepEqual(returned.query.value, { date: '2026-10-21', ready: '0', tab: 'all' })
  511. assert.deepEqual(returned.calls, [1, 2, 3])
  512. assert.deepEqual([...returned.expandedIds.value], ['customer-2'])
  513. assert.deepEqual(env.scrolls, [720])
  514. })
  515. for (const change of ['session', 'tenant', 'outside', 'path']) {
  516. test(`${base}: ${change} cannot reuse a private return state`, async () => {
  517. const env = returnStateHarness(base)
  518. env.create().keyword.value = 'private'
  519. env.openDetail()
  520. if (change === 'session') env.user.accessToken = 'session-b'
  521. if (change === 'tenant') env.user.getUserInfo.tenantNo = 'tenant-b'
  522. if (change === 'outside') env.navigate('/workbench')
  523. env.navigate(change === 'path' ? '/m/other' : base)
  524. const returned = env.create()
  525. await returned.state.restore()
  526. assert.equal(returned.keyword.value, '')
  527. assert.deepEqual(returned.calls, [1])
  528. assert.deepEqual(env.scrolls, [])
  529. })
  530. }
  531. }
  532. function deferred() {
  533. let resolve!: (value?: any) => void
  534. let reject!: (error: any) => void
  535. const promise = new Promise<any>((a, b) => {
  536. resolve = a
  537. reject = b
  538. })
  539. return { promise, resolve, reject }
  540. }
  541. const flush = async () => {
  542. for (let n = 0; n < 20; n++) await Promise.resolve()
  543. }
  544. for (const response of [{ id: 'B', products: [] }, { products: [] }, null]) {
  545. test(`ready: deferred GET A returning ${JSON.stringify(response)} cannot confirm or write another record`, async () => {
  546. const get = deferred()
  547. const h = actionHarness('plan-cert/index.vue', { planApi: { get: () => get.promise } })
  548. h.confirmResolve()
  549. const pending = h.run({ id: 'A' })
  550. await flush()
  551. assert.deepEqual(h.events, [])
  552. get.resolve({ data: response })
  553. await pending
  554. assert.deepEqual(h.events, ['toast'])
  555. assert.equal(h.state.acting.value, false)
  556. })
  557. }
  558. for (const failure of ['get', 'write']) {
  559. test(`ready: delayed ${failure} failure and revoked permission cannot show stale result or refresh`, async () => {
  560. const pendingApi = deferred()
  561. const gets: string[] = [],
  562. writes: string[] = []
  563. const h = actionHarness('plan-cert/index.vue', {
  564. planApi: {
  565. get: (id: string) => {
  566. gets.push(id)
  567. return failure === 'get' ? pendingApi.promise : Promise.resolve({ id, products: [] })
  568. },
  569. setHazardCertReady: (id: string) => {
  570. writes.push(id)
  571. return pendingApi.promise
  572. }
  573. }
  574. })
  575. h.confirmResolve()
  576. const pending = h.run({ id: 'A' })
  577. await flush()
  578. h.state.canReady.value = false
  579. pendingApi.reject(new Error('late failure'))
  580. await pending
  581. assert.deepEqual(gets, ['A'])
  582. assert.deepEqual(writes, failure === 'get' ? [] : ['A'])
  583. assert.deepEqual(h.events, failure === 'get' ? [] : ['confirm'])
  584. })
  585. }
  586. for (const file of ['plan/index.vue', 'plan-cert/index.vue']) {
  587. for (const outcome of ['success', 'error']) {
  588. test(`${file}: actual requestKey and sessionScope discard old ${outcome} after tenant change`, async () => {
  589. const h = harness(file)
  590. const pending = h.run(true)
  591. h.state.userStore.getUserInfo.tenantNo = 'tenant-b'
  592. if (outcome === 'success') h.requests[0].resolve({ list: [{ id: 'private-A' }], total: 1 })
  593. else h.requests[0].reject(new Error('old tenant failure'))
  594. await pending
  595. assert.deepEqual(h.state.plans.value, [])
  596. assert.equal(h.state.error.value, '')
  597. const next = h.run(true)
  598. h.requests[1].resolve({ list: [{ id: 'tenant-B' }], total: 1 })
  599. await next
  600. assert.deepEqual(h.state.plans.value, [{ id: 'tenant-B' }])
  601. assert.equal(h.state.loading.value, false)
  602. })
  603. }
  604. }
  605. function copySessionHarness() {
  606. const clipboard = deferred()
  607. const messages: string[] = []
  608. let copies = 0
  609. const userStore = reactive({
  610. accessToken: 'session-a',
  611. getUserInfo: { tenantNo: 'tenant-a', roles: ['sales'] }
  612. })
  613. const planApi = {
  614. get: async (id: string) => ({ id, transportStatus: 'arranged' }),
  615. getArrangeInfo: async () => ({ driverUrl: 'driver-A' })
  616. }
  617. const composableText = readFileSync(
  618. new URL('../src/composables/usePlPlanArrangeActions.ts', import.meta.url),
  619. 'utf8'
  620. )
  621. const composable = ts.createSourceFile('actions.ts', composableText, ts.ScriptTarget.Latest, true)
  622. const code = ts.transpile(
  623. composable.statements
  624. .filter((s) => !ts.isImportDeclaration(s))
  625. .map((s) => s.getText(composable))
  626. .join('\n')
  627. .replace(/export /g, ''),
  628. { target: ts.ScriptTarget.ESNext }
  629. )
  630. const env: any = {
  631. ...Object.fromEntries(
  632. Object.entries(vue).filter(
  633. ([k]) => /^[a-zA-Z_$][\w$]*$/.test(k) && !['version', 'default'].includes(k)
  634. )
  635. ),
  636. planApi,
  637. userStore,
  638. pickBxData: (r: any) => r.data ?? r,
  639. useDictStore: () => ({ getLabel: () => '' }),
  640. ElMessage: {
  641. success: (m: string) => messages.push(m),
  642. warning: (m: string) => messages.push(m),
  643. error: (m: string) => messages.push(m)
  644. },
  645. copyTextToClipboard: () => {
  646. copies++
  647. return clipboard.promise
  648. },
  649. buildDriverSummaryFromPlan: () => 'driver summary',
  650. buildDriverStopBlocksFromPlan: () => [],
  651. buildPoundQrcodeLinksFromInventoryRecords: () => []
  652. }
  653. const actions = new Function(...Object.keys(env), `${code};return usePlPlanArrangeActions`)(
  654. ...Object.values(env)
  655. )({ isolateRecord: true })
  656. Object.assign(env, actions, {
  657. morePlan: ref({ id: 'A' }),
  658. moreOpen: ref(true),
  659. voidOpen: ref(false),
  660. voidTarget: ref(null),
  661. acting: ref(false),
  662. plans: ref([{ id: 'private-A' }]),
  663. expandedIds: ref(new Set(['A'])),
  664. page: ref(1),
  665. total: ref(1),
  666. loading: ref(true),
  667. error: ref(''),
  668. forbidden: ref(false),
  669. speedOpen: ref(false),
  670. filterOpen: ref(true),
  671. fetchPage: (reset: boolean) => {
  672. env.reloads.push(reset)
  673. },
  674. reloads: []
  675. })
  676. const ast = ts.createSourceFile(
  677. 'page.ts',
  678. parse(readFileSync(new URL('plan/index.vue', root), 'utf8')).descriptor.scriptSetup!.content,
  679. ts.ScriptTarget.Latest,
  680. true
  681. )
  682. const pageCode = ts.transpile(
  683. ast.statements
  684. .filter(
  685. (s) =>
  686. (ts.isFunctionDeclaration(s) && s.name?.text === 'runMoreAction') ||
  687. (ts.isVariableStatement(s) &&
  688. s.declarationList.declarations.some((d) => d.name.getText(ast) === 'sessionScope')) ||
  689. (ts.isExpressionStatement(s) &&
  690. ts.isCallExpression(s.expression) &&
  691. s.expression.expression.getText(ast) === 'watch' &&
  692. s.expression.arguments[0]?.getText(ast) === 'sessionScope')
  693. )
  694. .map((s) => s.getText(ast))
  695. .join('\n'),
  696. { target: ts.ScriptTarget.ESNext }
  697. )
  698. const scope = vue.effectScope()
  699. const api = scope.run(() =>
  700. new Function(
  701. ...Object.keys(env),
  702. `let disposed=false;let shellReady=true;let actionVersion=0;let requestVersion=0;${pageCode};return {run:()=>runMoreAction('copy-driver'),version:()=>requestVersion}`
  703. )(...Object.values(env))
  704. )!
  705. return {
  706. ...api,
  707. env,
  708. actions,
  709. clipboard,
  710. messages,
  711. userStore,
  712. copies: () => copies,
  713. stop: () => scope.stop()
  714. }
  715. }
  716. for (const change of ['token', 'tenant', 'roles']) {
  717. test(`transport: actual page scope watcher resets real copy composable during clipboard pending (${change})`, async () => {
  718. const h = copySessionHarness()
  719. const pending = h.run()
  720. await flush()
  721. assert.equal(h.copies(), 1)
  722. if (change === 'token') h.userStore.accessToken = 'session-b'
  723. if (change === 'tenant') h.userStore.getUserInfo.tenantNo = 'tenant-b'
  724. if (change === 'roles') h.userStore.getUserInfo.roles = ['viewer']
  725. assert.equal(h.actions.plan.value, null, 'copy record must invalidate synchronously with scope')
  726. assert.equal(h.version(), 1, 'list request generation also invalidates')
  727. assert.deepEqual(h.env.plans.value, [])
  728. assert.equal(h.env.filterOpen.value, false, 'the old account filter sheet closes')
  729. assert.deepEqual(h.env.reloads, [true], 'the new account reloads the first page')
  730. h.env.acting.value = true // A new session owns the busy indicator now.
  731. h.clipboard.resolve(true)
  732. await pending
  733. assert.deepEqual(h.messages, [])
  734. assert.equal(h.env.acting.value, true, 'old finally cannot clear the new busy indicator')
  735. h.stop()
  736. })
  737. }
  738. // Execute the production setup and Vue watchers, stubbing only external services/hooks.
  739. function certSessionHarness() {
  740. const lists: any[] = [],
  741. permissions: any[] = [],
  742. gets: any[] = [],
  743. writes: any[] = []
  744. const events: string[] = []
  745. const user = reactive({ accessToken: 'a', getUserInfo: { tenantNo: 'a', roles: ['sales'] } })
  746. const queue = (items: any[], params?: any) => {
  747. const request = deferred()
  748. items.push({ ...request, params })
  749. return request.promise
  750. }
  751. const env = {
  752. ...Object.fromEntries(
  753. Object.entries(vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
  754. ),
  755. useUserStore: () => user,
  756. useRouter: () => ({ push: (path: string) => events.push(path) }),
  757. useMenuStore: () => ({ refreshSidebarBadges: async () => {} }),
  758. onBeforeUnmount: () => {},
  759. useDebounceFn: (fn: any) => fn,
  760. useIntersectionObserver: () => {},
  761. useListReturnState: () => ({ restoring: ref(false), restore: () => events.push('restore') }),
  762. useMobilePlanListDetails: () => ({ reset() {}, ensure: async () => true, records: {} }),
  763. PL_PLAN_DEFAULT_FOLLOW_STATUSES: ['pending'],
  764. plPlanCertColumnFilterDefs: [],
  765. useServerColumnFilters: (_defs: any, options: any) => {
  766. const filters = reactive(options.initialFilters)
  767. return { filters, toQueryParams: () => ({ ...filters }) }
  768. },
  769. buildQuery: (tab: string, filters: any, keyword: string, dates: any) => ({
  770. tab,
  771. filters,
  772. keyword,
  773. dates
  774. }),
  775. applyMergedCustomerQueryParams: () => {},
  776. applyContractSideQueryParams: () => {},
  777. canEdit: () => true,
  778. readyTargets: (rows: any[]) => rows,
  779. planHasHazardProducts: () => true,
  780. HAZARD_CERT_READY_CONFIRM_HINT: '',
  781. showCrmConfirmDialog: async () => {
  782. events.push('confirm')
  783. },
  784. ElMessage: Object.assign(() => events.push('toast'), { warning: () => events.push('toast') }),
  785. planApi: {
  786. list: (params: any) => queue(lists, params),
  787. get: (id: string) => queue(gets, id),
  788. setHazardCertReady: (id: string) => queue(writes, id)
  789. },
  790. fetchGetMenuCodes: () => queue(permissions)
  791. }
  792. const source = ts.createSourceFile(
  793. 'page.ts',
  794. parse(readFileSync(new URL('plan-cert/index.vue', root), 'utf8')).descriptor.scriptSetup!
  795. .content,
  796. ts.ScriptTarget.Latest,
  797. true
  798. )
  799. const code = ts.transpile(
  800. source.statements
  801. .filter((s) => !ts.isImportDeclaration(s))
  802. .map((s) => s.getText(source))
  803. .join('\n'),
  804. { target: ts.ScriptTarget.ESNext }
  805. )
  806. const scope = vue.effectScope()
  807. const state = scope.run(() =>
  808. new Function(
  809. ...Object.keys(env),
  810. `${code}; return { load, loadPermissions, setReady, saved, openUpload, userStore, plans, expandedIds, total, page, loading, acting, error, failedMore, canUpload, canReady, canPrice, permissionsLoaded, permissionError, uploadOpen, uploadId, keyword, appliedKeyword, dates, dateKind, readyKind, tab, filters, sort, filtersOpen, ${code.includes('function reloadSession') ? 'reloadSession, sessionChanged, uploadRef,' : ''} }`
  811. )(...Object.values(env))
  812. )
  813. return { state, user, lists, permissions, gets, writes, events, stop: () => scope.stop() }
  814. }
  815. for (const change of ['token', 'tenant', 'roles']) {
  816. test(`cert session: ${change} synchronously clears private state and recovers without old callbacks`, async () => {
  817. const h = certSessionHarness(),
  818. s = h.state
  819. s.plans.value = [{ id: 'private' }]
  820. s.loading.value = false
  821. s.total.value = 40
  822. s.page.value = 2
  823. s.expandedIds.value = new Set(['private'])
  824. s.keyword.value = s.appliedKeyword.value = 'private keyword'
  825. s.filters.customer = { text: 'private customer' }
  826. s.filtersOpen.value = true
  827. const oldPermission = s.loadPermissions()
  828. s.canUpload.value = s.canReady.value = s.canPrice.value = true
  829. s.openUpload({ id: 'private' })
  830. let invalidations = 0
  831. if (s.uploadRef) s.uploadRef.value = { invalidateRecord: () => invalidations++ }
  832. const oldAction = s.setReady({ id: 'private' }, true)
  833. const oldList = s.load(true)
  834. h.user.getUserInfo = { tenantNo: 'a', roles: ['sales'] }
  835. assert.equal(s.loading.value, true, 'same-content user info does not invalidate')
  836. if (change === 'token') h.user.accessToken = 'b'
  837. if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
  838. if (change === 'roles') h.user.getUserInfo.roles = ['viewer']
  839. assert.deepEqual(s.plans.value, [], 'old tenant rows must clear synchronously')
  840. assert.equal(s.loading.value, false)
  841. assert.equal(s.acting.value, false)
  842. for (const name of ['canUpload', 'canReady', 'canPrice', 'uploadOpen', 'filtersOpen'])
  843. assert.equal(s[name].value, false)
  844. assert.equal(s.uploadId.value, '')
  845. assert.equal(invalidations, 1, 'child must invalidate before Vue propagates props')
  846. assert.equal(s.total.value, 0)
  847. assert.equal(s.page.value, 1)
  848. assert.equal(s.expandedIds.value.size, 0)
  849. assert.equal(s.keyword.value + s.appliedKeyword.value, '')
  850. assert.equal(s.filters.customer, undefined)
  851. assert.equal(s.sessionChanged.value, true)
  852. await s.saved('private')
  853. assert.equal(h.lists.length, 1, 'late upload success cannot trigger reload')
  854. const recovery = s.reloadSession()
  855. assert.equal(h.lists[1].params.page, 1)
  856. assert.equal(h.lists[1].params.keyword, '')
  857. h.permissions[0].resolve({ all: true })
  858. h.lists[0].reject(new Error('old tenant failed'))
  859. h.gets[0].resolve({ id: 'private', products: [] })
  860. await Promise.all([oldPermission, oldList, oldAction])
  861. assert.equal(s.loading.value, true, 'old finally cannot release fresh load')
  862. assert.equal(s.canUpload.value, false)
  863. assert.equal(s.error.value, '')
  864. assert.deepEqual(h.events, [])
  865. h.permissions[1].resolve({ menuCodes: ['pl:plan-cert:upload'] })
  866. h.lists[1].resolve({ list: [{ id: 'fresh' }], total: 1 })
  867. await recovery
  868. assert.deepEqual(s.plans.value, [{ id: 'fresh' }])
  869. assert.equal(s.canUpload.value, true)
  870. assert.equal(s.canReady.value, false)
  871. assert.equal(s.loading.value, false)
  872. h.stop()
  873. })
  874. }
  875. for (const outcome of ['success', 'failure']) {
  876. test(`cert session: late ${outcome} write cannot release a new action or reload`, async () => {
  877. const h = certSessionHarness(),
  878. s = h.state
  879. s.canReady.value = true
  880. const old = s.setReady({ id: 'private' }, true)
  881. h.gets[0].resolve({ id: 'private', products: [] })
  882. await flush()
  883. assert.equal(h.writes.length, 1)
  884. h.user.accessToken = 'b'
  885. s.canReady.value = true
  886. const fresh = s.setReady({ id: 'fresh' }, true)
  887. if (outcome === 'success') h.writes[0].resolve()
  888. else h.writes[0].reject(new Error('late write failure'))
  889. await old
  890. assert.equal(s.acting.value, true)
  891. assert.equal(h.lists.length, 0)
  892. assert.deepEqual(h.events, ['confirm'])
  893. h.gets[1].resolve({ id: 'fresh', products: [] })
  894. await flush()
  895. h.writes[1].resolve()
  896. await flush()
  897. assert.equal(h.lists.length, 1)
  898. h.lists[0].resolve({ list: [{ id: 'fresh' }], total: 1 })
  899. await fresh
  900. assert.equal(s.acting.value, false)
  901. h.stop()
  902. })
  903. }
  904. for (const changed of [false, true]) {
  905. test(`cert session: upload success ${changed ? 'cannot navigate after scope change during refresh' : 'navigates after normal child close'}`, async () => {
  906. const h = certSessionHarness(),
  907. s = h.state
  908. s.canUpload.value = true
  909. s.openUpload({ id: 'target' })
  910. s.uploadOpen.value = false // The child closes its v-model before emitting success.
  911. const saved = s.saved('target')
  912. assert.equal(h.lists.length, 1)
  913. if (changed) h.user.accessToken = 'b'
  914. h.lists[0].resolve({ list: [{ id: 'target' }], total: 1 })
  915. await saved
  916. assert.deepEqual(h.events, changed ? [] : ['/m/pl/plan-cert/target'])
  917. assert.deepEqual(s.plans.value, changed ? [] : [{ id: 'target' }])
  918. h.stop()
  919. })
  920. }