import assert from 'node:assert/strict' import test from 'node:test' import { compareWorkerHallOrderRows, evaluateWorkerKeywordVerdict, matchesWorkerWorkOrderKeyword, patchWorkerHallPersonalShares, patchWorkerWorkOrderInfiniteEnvelope, patchWorkerWorkOrderListEnvelope, resolveWorkerMyOrderRealtimeVerdict, } from '../src/utils/worker-realtime-work-orders.ts' function buildRow(overrides: Record = {}) { return { workOrderId: 9001, workOrderNo: 'WO26093017345168', status: 'in_progress', productName: '某游戏代练', platformOrderId: '2609301734', material: { collect: { fields: { gameId: 'g-1', gameNickname: '小明' } } }, ...overrides, } } function buildEnvelope(items: unknown[], total = items.length) { return { ok: true, data: { items, pagination: { page: 1, pageSize: 20, total } } } } function itemsOf(envelope: unknown): Array<{ workOrderId: number }> { return (envelope as { data: { items: Array<{ workOrderId: number }> } }).data.items } function totalOf(envelope: unknown): number { return (envelope as { data: { pagination: { total: number } } }).data.pagination.total } test('按工单号搜索必须能命中(后端搜索串第一项就是工单号,前端曾完全没比对它)', () => { assert.equal(matchesWorkerWorkOrderKeyword(buildRow(), 'WO26093017345168'), true) assert.equal(matchesWorkerWorkOrderKeyword(buildRow(), 'wo26093017'), true) // 原有字段仍然要能命中 assert.equal(matchesWorkerWorkOrderKeyword(buildRow(), '小明'), true) assert.equal(matchesWorkerWorkOrderKeyword(buildRow(), '2609301734'), true) }) test('关键词维度:找不到只能是 unknown,绝不能当成"不匹配"(这才是行被删空的根因)', () => { const row = buildRow() assert.equal(evaluateWorkerKeywordVerdict(row, 'WO26093017345168'), 'match') assert.equal(evaluateWorkerKeywordVerdict(row, ''), 'match') // 后端还能搜 material_json 里前端不知道的字段,所以找不到 ≠ 不匹配 assert.equal(evaluateWorkerKeywordVerdict(row, '后端才知道的字段'), 'unknown') }) test('按工单号搜到的行,实时事件来了也不能被删掉,并且总数要跟着走', () => { const row = buildRow({ status: 'pending_material' }) const envelope = buildEnvelope([row]) const snapshot = buildRow({ status: 'in_progress' }) // 关键词命中 → upsert,原地更新,行数不变 const updated = patchWorkerWorkOrderListEnvelope( envelope, { operation: 'upsert', data: snapshot }, false, ) assert.equal(itemsOf(updated).length, 1) assert.equal(totalOf(updated), 1) assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress') }) test('大厅轻量快照就地更新时保留 myShare 等个性化字段', () => { const envelope = buildEnvelope([ buildRow({ status: 'open', myShare: { quantity: 2 }, categoryName: '密钥' }), ]) const updated = patchWorkerWorkOrderListEnvelope( envelope, { operation: 'upsert', data: { workOrderId: 9001, status: 'open', productName: '新商品名' }, }, false, ) const item = itemsOf(updated)[0] as { productName?: string myShare?: { quantity?: number } categoryName?: string } assert.equal(item.productName, '新商品名') assert.equal(item.myShare?.quantity, 2, '广播不带 myShare 时不能把已拼份额覆盖掉') assert.equal(item.categoryName, '密钥') }) test('不可证真的关键词下,快照只能就地更新:不插入、不删除', () => { const envelope = buildEnvelope([buildRow()]) // 关键词没命中前端已知字段(unknown):既不该把它当"不匹配"删掉 const patched = patchWorkerWorkOrderListEnvelope( envelope, { operation: 'upsert', data: buildRow({ status: 'accepted' }) }, false, ) assert.equal(itemsOf(patched).length, 1) // 也不该把不在列表里的行插进来(留着让后端说了算) const notInserted = patchWorkerWorkOrderListEnvelope( envelope, { operation: 'upsert', data: buildRow({ workOrderId: 8888 }) }, false, ) assert.equal(itemsOf(notInserted).length, 1) }) test('真正移出可见范围时要删行,并同步分页总数(旧实现只删 items,分页器还显示有 N 条)', () => { const envelope = buildEnvelope([buildRow(), buildRow({ workOrderId: 9002 })], 2) const removed = patchWorkerWorkOrderListEnvelope( envelope, { operation: 'remove', data: buildRow() }, false, ) assert.deepEqual( itemsOf(removed).map((item) => item.workOrderId), [9002], ) assert.equal(totalOf(removed), 1, '删行后总数必须减一') const inserted = patchWorkerWorkOrderListEnvelope( buildEnvelope([buildRow()], 1), { operation: 'upsert', data: buildRow({ workOrderId: 9003 }) }, true, ) assert.equal(itemsOf(inserted).length, 2) assert.equal(totalOf(inserted), 2, '插行后总数必须加一') }) test('无限下拉:只有第 1 页允许插入,其余页只就地更新', () => { const page = (ids: number[]) => buildEnvelope(ids.map((id) => buildRow({ workOrderId: id, status: 'open' }))) const current = { pages: [page([1, 2]), page([3, 4])], pageParams: [1, 2] } const patched = patchWorkerWorkOrderInfiniteEnvelope( current, { operation: 'upsert', data: buildRow({ workOrderId: 99, status: 'open' }) }, true, ) as { pages: unknown[] } assert.equal(itemsOf(patched.pages[0]).length, 3, '第 1 页应插入') assert.equal(itemsOf(patched.pages[1]).length, 2, '第 2 页不得插入') }) test('大厅新单插入后,置顶单必须仍在最前方', () => { const current = { pages: [ buildEnvelope([ buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T10:00:00+08:00' }), buildRow({ workOrderId: 200, status: 'open' }), ]), ], pageParams: [1], } const patched = patchWorkerWorkOrderInfiniteEnvelope( current, { operation: 'upsert', data: buildRow({ workOrderId: 300, status: 'open' }) }, true, ) as { pages: unknown[] } assert.deepEqual( itemsOf(patched.pages[0]).map((item) => item.workOrderId), [100, 300, 200], '新单必须排在置顶单之后,未置顶部分仍按 id 倒序', ) }) test('多个置顶单之间按置顶时间倒序,且都排在普通单之前', () => { const current = { pages: [ buildEnvelope([ buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T09:00:00+08:00' }), buildRow({ workOrderId: 200, status: 'open' }), buildRow({ workOrderId: 300, status: 'open', pinnedAt: '2026-10-25T11:00:00+08:00' }), ]), ], pageParams: [1], } const patched = patchWorkerWorkOrderInfiniteEnvelope( current, { operation: 'upsert', data: buildRow({ workOrderId: 400, status: 'open' }) }, true, ) as { pages: unknown[] } assert.deepEqual( itemsOf(patched.pages[0]).map((item) => item.workOrderId), [300, 100, 400, 200], ) }) test('第 2 页的工单被置顶后必须提到第 1 页最前', () => { const current = { pages: [ buildEnvelope([ buildRow({ workOrderId: 300, status: 'open' }), buildRow({ workOrderId: 299, status: 'open' }), ]), buildEnvelope([buildRow({ workOrderId: 100, status: 'open' })]), ], pageParams: [1, 2], } const patched = patchWorkerWorkOrderInfiniteEnvelope( current, { operation: 'upsert', data: buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T12:00:00+08:00', }), }, false, ) as { pages: unknown[] } assert.deepEqual( itemsOf(patched.pages[0]).map((item) => item.workOrderId), [100, 300], '置顶单要在第 1 页最前,被顶下去的普通单顺延到第 2 页', ) assert.deepEqual( itemsOf(patched.pages[1]).map((item) => item.workOrderId), [299], ) }) /** * 「我的订单」状态页签的实时删行判定。 * * 生产现象(与后台同源):按工单号搜出来的那一单,状态一流转(代练中 → 待验收 / 已验收) * 就被实时补丁按"状态不匹配"删掉,列表变空、必须刷新整页。 * 页签只是展示偏好:后端按单号查时已经忽略状态,前端不能再拿状态删行。 */ test('显式单号检索:状态页签不匹配也不得删行(状态流转后仍要能搜到)', () => { const row = buildRow({ status: 'in_progress' }) const verdict = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ status: 'pending_acceptance' }), statusFilter: 'in_progress', keyword: 'WO26093017345168', operation: 'upsert', page: 1, explicitOrderNo: true, }) assert.equal(verdict.operation, 'upsert', '单号检索下状态不能证伪') assert.equal(verdict.shouldInsert, true, '命中的单号仍可插入第 1 页') const updated = patchWorkerWorkOrderListEnvelope( buildEnvelope([row]), { operation: verdict.operation, data: buildRow({ status: 'pending_acceptance' }) }, verdict.shouldInsert, ) assert.equal(itemsOf(updated).length, 1, '行不能被删掉') assert.equal( (itemsOf(updated)[0] as { status?: string }).status, 'pending_acceptance', '应就地更新成最新状态', ) }) test('模糊关键词:状态页签不匹配仍按原设计移除行', () => { const verdict = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ status: 'accepted' }), statusFilter: 'in_progress', keyword: '暗潮', operation: 'upsert', page: 1, explicitOrderNo: false, }) assert.equal(verdict.operation, 'remove') assert.equal(verdict.shouldInsert, false) }) test('服务端 remove(打手已失去该单)仍然生效,单号护栏不误挡', () => { const verdict = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ status: 'in_progress' }), statusFilter: '', keyword: 'WO26093017345168', operation: 'remove', page: 1, explicitOrderNo: true, }) assert.equal(verdict.operation, 'remove', '服务端明确说单子已不可见时必须移除') }) test('非第 1 页不得插入,只做就地更新', () => { const verdict = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ workOrderId: 8888 }), statusFilter: '', keyword: 'WO26093017345168', operation: 'upsert', page: 2, explicitOrderNo: true, }) assert.equal(verdict.operation, 'upsert') assert.equal(verdict.shouldInsert, false) }) /** * 同一打手在同一个工单多次拼单:一张单可以同时属于「代练中」和「待验收」, * 但快照的 status 只能是一个值。前端若按单个 status 删行,另一个页签就会莫名其妙变空 * (线上「待验收有时候没有数据」)。服务端现在带 workerStatuses,按集合判归属。 */ test('多份额工单:快照 status 是进行中,但属于待验收时不得从待验收页签删行', () => { const snapshot = buildRow({ status: 'in_progress', workerStatuses: ['in_progress', 'pending_acceptance'], }) const verdict = resolveWorkerMyOrderRealtimeVerdict({ item: snapshot, statusFilter: 'pending_acceptance', keyword: '', operation: 'upsert', page: 1, explicitOrderNo: false, }) assert.equal(verdict.operation, 'upsert', '待验收页签必须保留这张多份额工单') assert.equal(verdict.shouldInsert, true) const updated = patchWorkerWorkOrderListEnvelope( buildEnvelope([buildRow({ status: 'pending_acceptance' })]), { operation: verdict.operation, data: snapshot }, verdict.shouldInsert, ) assert.equal(itemsOf(updated).length, 1) assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress') }) test('多份额工单:workerStatuses 同时也是代练中页签的归属依据', () => { const verdict = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ status: 'in_progress', workerStatuses: ['in_progress', 'pending_acceptance'], }), statusFilter: 'in_progress', keyword: '', operation: 'upsert', page: 1, explicitOrderNo: false, }) assert.equal(verdict.operation, 'upsert') }) test('不在页签集合里时才允许删行(例如已验收后离开待验收页签)', () => { const verdict = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ status: 'accepted', workerStatuses: ['accepted'] }), statusFilter: 'pending_acceptance', keyword: '', operation: 'upsert', page: 1, explicitOrderNo: false, }) assert.equal(verdict.operation, 'remove') assert.equal(verdict.shouldInsert, false) }) test('VIP 待补图页签也靠 workerStatuses 判定,不会被当成状态不符删掉', () => { const verdict = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ status: 'accepted', workerStatuses: ['accepted', 'vip_evidence_pending'] }), statusFilter: 'vip_evidence_pending', keyword: '', operation: 'upsert', page: 1, explicitOrderNo: false, }) assert.equal(verdict.operation, 'upsert') }) test('没有 workerStatuses 的旧快照仍按单个 status 比对(保持既有行为)', () => { const mismatch = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ status: 'accepted' }), statusFilter: 'pending_acceptance', keyword: '', operation: 'upsert', page: 1, explicitOrderNo: false, }) assert.equal(mismatch.operation, 'remove') const match = resolveWorkerMyOrderRealtimeVerdict({ item: buildRow({ status: 'pending_acceptance' }), statusFilter: 'pending_acceptance', keyword: '', operation: 'upsert', page: 1, explicitOrderNo: false, }) assert.equal(match.operation, 'upsert') }) /** * 大厅按钮需求:拼过一次后按钮固定显示「再拼一次」,不再随次数变化。 * 这依赖 myShare/myShares 一直留在行上;而大厅广播是「所有打手通用」的快照, * 不含个人份额,覆盖行时必须保留,否则按钮会被冲回「拼单 ¥x/份」。 */ test('大厅广播覆盖行时必须保留本打手的 myShare/myShares,并更新拼单进度', () => { const cachedRow = buildRow({ status: 'open', myShare: { shareId: 77, quantity: 1, status: 'joined' }, myShares: [{ shareId: 77, quantity: 1, status: 'joined' }], sharingProgress: { joinedQuantity: 1, pendingSubmissionCount: 1 }, }) const envelope = { pages: [buildEnvelope([cachedRow])], pageParams: [1] } const snapshot = buildRow({ status: 'open', sharingProgress: { joinedQuantity: 2, pendingSubmissionCount: 2 }, }) const patched = patchWorkerWorkOrderInfiniteEnvelope( envelope, { operation: 'upsert', data: snapshot }, true, ['myShare', 'myShares'], ) as { pages: unknown[] } const row = itemsOf(patched.pages[0])[0] as { myShare?: { shareId: number } myShares?: unknown[] sharingProgress?: { joinedQuantity: number } } assert.equal(row.myShare?.shareId, 77, '不能把「再拼一次」的依据冲掉') assert.equal(row.myShares?.length, 1) assert.equal(row.sharingProgress?.joinedQuantity, 2, '公开进度仍要用广播里的最新值') // 新插入的行本来就不属于我,不能凭空补上旧字段 const inserted = patchWorkerWorkOrderInfiniteEnvelope( envelope, { operation: 'upsert', data: buildRow({ workOrderId: 9999, status: 'open' }) }, true, ['myShare', 'myShares'], ) as { pages: unknown[] } const insertedRow = itemsOf(inserted.pages[0]).find((item) => item.workOrderId === 9999) as | { myShare?: unknown; myShares?: unknown } | undefined assert.equal(insertedRow?.myShare ?? null, null) assert.equal(insertedRow?.myShares ?? null, null) }) test('旧版大厅广播显式带 null/[] 时也不能冲掉本打手份额', () => { const cachedRow = buildRow({ status: 'open', myShare: { shareId: 88, quantity: 2, status: 'joined' }, myShares: [{ shareId: 88, quantity: 2, status: 'joined' }], sharingProgress: { joinedQuantity: 2, pendingSubmissionCount: 1 }, }) const envelope = { pages: [buildEnvelope([cachedRow])], pageParams: [1] } const patched = patchWorkerWorkOrderInfiniteEnvelope( envelope, { operation: 'upsert', data: buildRow({ status: 'open', myShare: null, myShares: [], sharingProgress: { joinedQuantity: 3 }, }), }, true, ['myShare', 'myShares'], ) as { pages: unknown[] } const row = itemsOf(patched.pages[0])[0] as { myShare?: { shareId: number } myShares?: unknown[] sharingProgress?: { joinedQuantity: number } } assert.equal(row.myShare?.shareId, 88, '旧事件不能把「再拼一次」冲掉') assert.equal(row.myShares?.length, 1) assert.equal(row.sharingProgress?.joinedQuantity, 3, '公开进度仍取广播最新值') }) test('「我的订单」快照回填大厅行个人份额:撤销份额后清空,找不到工单不改缓存', () => { const joined = buildRow({ workOrderId: 5, status: 'open', myShare: { shareId: 66, quantity: 1, status: 'joined' }, myShares: [{ shareId: 66, quantity: 1, status: 'joined' }], }) const envelope = { pages: [buildEnvelope([joined])], pageParams: [1] } const cleared = patchWorkerHallPersonalShares(envelope, 5, null, []) as { pages: unknown[] } const row = itemsOf(cleared.pages[0])[0] as { myShare?: unknown; myShares?: unknown[] } assert.equal(row.myShare ?? null, null, '后台撤掉份额后大厅按钮要能恢复成「拼单」') assert.deepEqual(row.myShares, []) // 工单不在大厅缓存里时原样返回,不能凭空造行。 assert.equal(patchWorkerHallPersonalShares(envelope, 999, null, []), envelope) }) /** * 线上坑:进大厅后过一会儿「排名靠后的订单跑到前面去」。 * * 根因是大厅广播补丁只做「就地覆盖 + 顶部插入」、不重排缓存: * 1) 列表 SQL 按置顶时间排序时,广播快照没带 pinnedAt,排序口径直接丢失; * 2) 广播 upsert 插到第 1 页顶部,会把已置顶的行挤到后面。 * 修复 = 后端快照带 pinnedAt + 前端按「置顶优先 → 工单号倒序」重排插行后的列表。 */ test('大厅排序比较器:置顶行恒在未置顶行前,同置顶按置顶时间倒序', () => { const pinnedNewer = buildRow({ workOrderId: 1, pinnedAt: '2026-10-24T10:00:00Z' }) const pinnedOlder = buildRow({ workOrderId: 2, pinnedAt: '2026-10-24T09:00:00Z' }) const unpinned = buildRow({ workOrderId: 3, pinnedAt: null }) assert.equal(compareWorkerHallOrderRows(pinnedNewer, pinnedOlder) < 0, true) assert.equal(compareWorkerHallOrderRows(pinnedOlder, pinnedNewer) > 0, true) assert.equal(compareWorkerHallOrderRows(pinnedNewer, unpinned) < 0, true) assert.equal(compareWorkerHallOrderRows(unpinned, pinnedNewer) > 0, true) // 双方都未置顶:按工单号倒序(与列表 SQL 的 id DESC 一致) const newer = buildRow({ workOrderId: 10, pinnedAt: null }) const older = buildRow({ workOrderId: 9, pinnedAt: null }) assert.equal(compareWorkerHallOrderRows(newer, older) < 0, true) }) test('广播 upsert 插入新行后必须按大厅口径重排,不能把置顶行挤下去', () => { const pinnedRow = buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-24T09:00:00Z', }) const envelope = { pages: [buildEnvelope([pinnedRow])], pageParams: [1] } const patched = patchWorkerWorkOrderInfiniteEnvelope( envelope, { operation: 'upsert', data: buildRow({ workOrderId: 200, status: 'open', pinnedAt: null }), }, true, ['myShare', 'myShares'], compareWorkerHallOrderRows, ) as { pages: unknown[] } assert.deepEqual( itemsOf(patched.pages[0]).map((item) => item.workOrderId), [100, 200], '新单排在置顶单后面,而不是插到最前面', ) })