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

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