detailLogged.computed.rows.call({ view: 'alerts', result: { records: [row] } })[0].alertAction;
assert.equal(alertActionFor({ status: 'unknown' }).primary, 'threshold', '待核实行只给「核对阈值」');
assert.equal(alertActionFor({ status: 'unknown' }).primaryLabel, '核对阈值');
assert.notEqual(alertActionFor({ status: 'unknown' }).primary, 'purchase', '待核实行不得给补货入口:阈值不成立,补货只会被后端 hasReplenishmentAlert 拒绝');
assert.equal(alertActionFor({ status: 'excess' }).primary, 'transfer', '超储行的闭环是调拨,不是继续采购');
assert.equal(alertActionFor({ status: 'excess' }).primaryLabel, '发起调拨');
assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).primary, 'purchase-document', '已生成申请的行只给「打开采购需求」');
assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).code, 'CG-2026-0001', '单号必须透传用于行尾标记');
assert.notEqual(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).primary, 'purchase', '有单号时绝不出现「生成采购申请」,否则就是重复下单入口');
assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).document, false, '主按钮已是「打开采购需求」时不得再渲染一次');
assert.equal(alertActionFor({ status: 'critical' }).primary, 'purchase', '缺料行无单号时给「生成采购申请」');
assert.equal(alertActionFor({ status: 'warning' }).primary, 'purchase', '偏低行无单号时同样给「生成采购申请」');
assert.equal(alertActionFor({ status: 'normal' }).primary, '', '正常行不给任何动作,绝不凭空造一个点了就失败的按钮');
assert.equal(alertActionFor({ status: 'excess', purchaseRequestCode: 'CG-2' }).document, true, '超储行若已有申请单号,额外给「打开采购需求」');
assert.equal(alertActionFor({}).primary, '', '状态缺失时按兜底处理,不给动作');
console.log('F1-(2) alertActionOf 五分支通过:unknown→核对阈值(不给补货)、excess→发起调拨、有单号→打开采购需求(绝不出补货)、critical/warning→生成采购申请、normal→无动作');
// —— (3) 首页卡片模板守卫:三色点、状态标签、影响工单、已生成申请、金额待补价 ——
assert.match(overviewSource, /:style="\{ background: item\.color, color: item\.color \}"/, '三色状态点必须取 item.color(后端 color,缺失时 alertColorOf 按 status 兜底)');
assert.match(overviewSource, /class="alert-state" :style="\{ color: item\.color \}"[\s\S]{0,40}\{\{ item\.statusLabel \}\}/, '状态中文标签必须与状态点同排且用后端 statusLabel');
assert.match(overviewSource, /影响生产工单/, '汇总条必须出现「影响生产工单」');
assert.match(overviewSource, /已生成申请/, '卡片必须给出「已生成申请」标记,避免同一行被重复下单');
assert.match(overviewSource, /金额待补价/, '卡片必须写明缺价行显示「金额待补价」,不得渲染成 0');
assert.match(overviewSource, /item\.gapText/, '行内必须展示缺口量/缺口金额文案');
assert.match(overviewSource, /alertSummary\.workOrderText/, '汇总条工单数必须消费后端 workOrderText(null 时是「—」)');
assert.match(overviewSource, /level: 'critical'/, '「影响生产工单」下钻必须按 level=critical,与工单数同源');
assert.match(detailSource, /含待检、未放行批次/, '清单页口径条必须写明可用量含待检/未放行批次');
console.log('F1-(3) 卡片模板守卫通过:三色 fallback、状态标签同排、影响生产工单、已生成申请、金额待补价、工单下钻 level=critical');
// —— (4) screens.alerts 口径文案守卫:新口径关键词必须在,旧口径不得复现 ——
const alertDefinition = screenDefs.alerts.definition;
assert.match(alertDefinition, /含待检/, '预警口径条必须写明可用量含待检批次');
assert.match(alertDefinition, /偏低系数/, '预警口径条必须写明 K1 偏低系数');
assert.match(alertDefinition, /超储兜底/, '预警口径条必须写明 K2 超储兜底系数');
assert.doesNotMatch(alertDefinition, /排除未放行/, '「排除未放行」是 batchRows 的口径,不得再出现在预警口径条里');
assert.match(alertDefinition, /最低库存仅作展示列、不参与触发判定/, '口径条必须写明最低库存不参与触发判定');
assert.match(alertDefinition, /最大库存(允许为空/, '口径条必须写明最大库存可为空');
assert.match(alertDefinition, /缺料[\s\S]{0,40}偏低[\s\S]{0,40}超储/, '口径条必须写明短路顺序 缺料→偏低→超储');
assert.match(alertDefinition, /待核实单列且不计入/, '口径条必须写明待核实单列且不计入预警条数');
console.log('F1-(4) screens.alerts 文案守卫通过:含待检、偏低系数、超储兜底、最低库存不参与判定、最大库存可空、待核实单列,且不再出现「排除未放行」');
// —— (5) 后端 SQL 静态守卫(只读文本;后端仓库不在同级目录时 SKIP 而非 fail)——
const alertMapperPath = path.resolve(root, '..', 'aiot202312/aiot-biz/wms-biz/wms-biz-provider/src/main/resources/mapper/WmsHomeMapper.xml');
if (!fs.existsSync(alertMapperPath)) {
console.log('SKIP F1-(5) 后端 SQL 静态守卫:未找到 ' + alertMapperPath + '(后端仓库不在当前工作区同级目录,跳过不 fail)');
} else {
const alertMapperXml = fs.readFileSync(alertMapperPath, 'utf8');
const sqlFragment = (id) => {
const start = alertMapperXml.indexOf('');
if (start < 0) return null;
const end = alertMapperXml.indexOf('', start);
return end < 0 ? null : alertMapperXml.slice(start, end);
};
const alertFragment = sqlFragment('alertRows');
const batchFragment = sqlFragment('batchRows');
assert.ok(alertFragment, 'alertRows 片段必须存在(预警列表与首页 TOP5 共用)');
assert.ok(batchFragment, 'batchRows 片段必须存在');
// critical 判定行:只能由 secureInventory 判,不得引用 minInventory(最低库存只作展示列)。
const alertCriticalLines = alertFragment.split('\n').filter((line) => /THEN 'critical'/.test(line));
assert.equal(alertCriticalLines.length, 1, 'critical 判定行必须唯一(短路判定里出现两处说明有人改坏了 CASE)');
assert.doesNotMatch(alertCriticalLines[0], /minInventory/, 'critical 判定不得引用 minInventory:最低库存只作展示列、不参与触发');
assert.match(alertCriticalLines[0], /secureInventory/, 'critical 必须由 secureInventory 判定(可用量<安全库存)');
// K1/K2 必须由 Mapper 参数下发,不能写死 1.2 / 3——写死就没法按环境调。
assert.match(alertFragment, /#\{warningRatio\}/, 'warning 判定必须用参数 #{warningRatio}(wms.home.alert.warning-ratio,默认 1.2)');
assert.match(alertFragment, /#\{excessRatio\}/, '超储兜底必须用参数 #{excessRatio}(wms.home.alert.excess-ratio,默认 3)');
assert.match(alertFragment, /COALESCE\(cfg\.maxInventory,cfg\.secureInventory\*#\{excessRatio\}\)/, '超储兜底只应在 maxInventory 为空时生效');
// 排序:缺口金额倒序 + 缺价(NULL)排最后 + 状态严重度 + id 稳定收尾。
assert.match(alertMapperXml, /ORDER BY gapAmount IS NULL,gapAmount DESC/, '预警排序必须 gapAmount IS NULL,gapAmount DESC(缺价行排最后,不依赖 DESC 的隐式 NULL 规则)');
assert.match(alertMapperXml, /FIELD\(status,'critical','warning','excess','normal','unknown'\)/, '预警次级排序必须按状态严重度 critical→warning→excess→normal→unknown');
// 护栏①:batchRows 的 availableQuantity 定义不得改(四个既有 KPI 同时消费它)。
assert.match(batchFragment, /available_count_base-COALESCE\(b\.lock_count_base,0\)/, 'batchRows 的 availableQuantity 必须仍是 available_count_base−锁定量(护栏:改它会静默改掉四个既有消费者的数字)');
assert.equal((batchFragment.match(/AS availableQuantity/g) || []).length, 1, 'batchRows 只能有一个 availableQuantity 定义');
// 护栏②:预警可用量必须走独立 av 子查询(Σ在库量−Σ锁定量,含待检),不得复用 batchRows 的 available_count_base 口径。
assert.match(alertFragment, /SUM\(b\.stock_count_base\)-SUM\(COALESCE\(b\.lock_count_base,0\)\)/, '预警可用量必须是独立 av 子查询的 Σ(stock_count_base)−Σ(lock_count_base)(含待检)');
assert.doesNotMatch(alertFragment, /available_count_base/, '预警可用量不得复用 batchRows 的 available_count_base(那会排除未放行批次,与阈值判定口径冲突)');
// 缺口量 / 缺口金额 / 工单数 / 申请单号的 SQL 语义(缺价为 NULL,不兜底成 0)。
assert.match(alertFragment, /GREATEST\(b0\.secureInventory-b0\.availableQuantity,0\)/, '缺口量必须是 GREATEST(安全库存−可用量,0)');
assert.match(alertFragment, /ar\.gapQuantity\*ar\.costUnitPrice/, '缺口金额必须是 缺口量×移动加权不含税单价');
assert.match(alertFragment, /THEN '缺口物料缺少移动加权不含税单价,缺口金额不可折算'/, '缺价时必须给 gapAmountReason,不得用 0 冒充');
assert.match(alertFragment, /CASE WHEN ar\.status='critical' THEN wo\.workOrderCount END AS workOrderCount/, '行级 workOrderCount 只给 critical 行,非缺料行必须是 NULL 而不是 0');
assert.match(alertFragment, /command_type/, '申请单号必须关联 t_wms_dashboard_command(command_type=PURCHASE)');
assert.match(alertFragment, /CAST\(pc\.business_id AS CHAR\) AS purchaseRequestId/, '申请主键必须 CAST AS CHAR,禁止让大整数进 JS 数字');
// 阈值落库:max_inventory 允许留空 → jdbcType=DECIMAL 无条件写,留空写 NULL。
assert.match(alertMapperXml, /max_inventory=#\{q\.maxInventory,jdbcType=DECIMAL\}/, '阈值 insert 必须显式 jdbcType=DECIMAL(max_inventory 允许留空写 NULL)');
assert.match(alertMapperXml, /max_inventory=#\{q\.maxInventory,jdbcType=DECIMAL\},update_user_id/, '阈值 update 必须显式 jdbcType=DECIMAL(留空写 NULL,不能要求必填)');
console.log('F1-(5) 后端 SQL 静态守卫实际执行通过(未 SKIP):critical 只由 secureInventory 判、无 minInventory、K1/K2 走参数、gapAmount DESC 排序、batchRows 护栏、av 子查询 Σ 口径、缺价 NULL、workOrderCount 仅 critical、business_id CAST CHAR、max_inventory jdbcType=DECIMAL');
}
// ===== E3「最近作业动态」断言块 =====
// 口径来源:docs/仓储首页看板口径.md 的 E3 节 + WmsHomeMapper.xml 的 activities 分支。
// 只读既有实现:装载 dashboard-utils.js 调真实纯函数(一律传固定 now,否则断言会跟着跑脚本的当天时钟漂),
// 模板/样式只做源码字符串断言。全程不发请求、不写任何文件。
const {
formatActivityTime, activityStatusOf, buildActivityFeed, diffActivityIds
} = utilsLogged;
// const 常量落在 vm 词法作用域、不会成为上下文属性,必须用 utilConst 取(同 STAGNANT_DEFAULT_DAYS / ALERT_TOP_LIMIT)。
// 卡片固定 5 条是视觉契约:改成 6 会让卡片多出一行,与设计稿的 5 行节奏错位。
assert.equal(utilConst('ACTIVITY_LIMIT'), 5, '最近作业动态卡片必须固定展示 5 条');
// 超时口径必须与待办条(overdueInbound/overdueOutbound/overdueApproval)同源 24 小时,
// 否则同一屏上「待入库单超时」与「入库流水超时」会用两个阈值各说一套。
assert.equal(utilConst('ACTIVITY_OVERDUE_HOURS'), 24, '待* 超时阈值必须与待办条同口径 24 小时');
// 固定 now=本地时间 2026-10-20 15:30,四段文案与超时判定全部由它推出,脚本哪天跑结果都一样。
const activityNow = new Date(2026, 9, 20, 15, 30, 0);
// —— (1) 状态词 → tone 色表:文案与颜色解耦的唯一来源 ——
const activityStyles = utilConst('ACTIVITY_STATUS_STYLES');
const activityToneOf = (word) => (activityStyles[word] || {}).tone;
const activityStyleTones = Object.keys(activityStyles).map((word) => activityToneOf(word));
const ACTIVITY_TONES = ['done', 'waiting', 'doing', 'danger'];
// 表里每个词都必须有合法 tone:漏一个会让模板拼出 'feed-status undefined',那一行状态直接无色。
assert.equal(activityStyleTones.filter((tone) => ACTIVITY_TONES.indexOf(tone) === -1).length, 0, '状态色表里每个状态词都必须映射到 done/waiting/doing/danger 之一(否则模板会拼出无色状态)');
assert.equal(ACTIVITY_TONES.filter((tone) => activityStyleTones.indexOf(tone) === -1).length, 0, '四种 tone 必须都被至少一个状态词使用,否则说明色表被删词');
assert.equal(activityToneOf('已完成'), 'done', '已完成=闭环,必须绿色 done');
assert.equal(activityToneOf('已执行'), 'done', '已执行=闭环(调拨),必须绿色 done');
assert.equal(activityToneOf('待质检'), 'waiting', '待质检=在等质检处理,必须橙色 waiting');
assert.equal(activityToneOf('待审批'), 'waiting', '待审批=在等审批,必须橙色 waiting');
assert.equal(activityToneOf('待执行'), 'waiting', '待执行=还没开始,必须橙色 waiting(超时角标只跟 waiting 走)');
assert.equal(activityToneOf('进行中'), 'doing', '进行中=正在跑,必须青色 doing');
assert.equal(activityToneOf('已驳回'), 'danger', '已驳回=出问题,必须红色 danger');
assert.equal(activityToneOf('异常'), 'danger', '异常=出问题,必须红色 danger');
// —— (2) formatActivityTime 四段:今日 HH:mm / 昨日 / MM-DD / — ——
// 今日必须补前导 0(09:05 不能写 9:5):这是「今日 HH:mm」与后端时间串的唯一视觉差别。
assert.equal(formatActivityTime('2026-10-20 09:05:00', activityNow), '今日 09:05', '今日事件必须显示「今日 HH:mm」,小时与分钟都补前导 0');
assert.equal(formatActivityTime('2026-10-20 23:59:00', activityNow), '今日 23:59', '当天 23:59 仍算今日,不得提前算成昨日');
assert.equal(formatActivityTime('2026-10-19 23:59:00', activityNow), '昨日', '昨日事件只显示「昨日」,不显示时刻');
assert.equal(formatActivityTime('2026-10-18 10:00:00', activityNow), '10-18', '更早事件显示 MM-DD');
assert.equal(formatActivityTime('2026-09-05 08:00:00', activityNow), '09-05', '更早事件的月/日也要补前导 0(09-05 不能写 9-5)');
assert.equal(formatActivityTime('', activityNow), '—', '空时间必须给占位符「—」,不能显示成今日');
assert.equal(formatActivityTime(null, activityNow), '—', 'null 时间必须给占位符「—」');
assert.equal(formatActivityTime('不是时间', activityNow), '—', '非法时间串必须给占位符,绝不拿「现在」兜底——那会把一条陈年流水顶到列表最前面');
// —— (3) activityStatusOf:先按 activityType 分流,再谈 status ——
const activityStatus = (row) => { const status = activityStatusOf(row); return status.text + '/' + status.tone; };
assert.equal(activityStatus({ activityType: 'inventory', status: 0 }), '待执行/waiting', '盘点 status 0 必须「待执行」橙');
assert.equal(activityStatus({ activityType: 'inventory', status: 1 }), '进行中/doing', '盘点 status 1 必须「进行中」青');
assert.equal(activityStatus({ activityType: 'inventory', status: 2 }), '已完成/done', '盘点 status 2 必须「已完成」绿');
// 本卡最容易被改坏的一条:io 行的 status 恒为 null,而 Number(null) === 0。
// 一旦把 status 判断提到类型分流之前,审核通过的入库单会整片显示成「待执行」并挂上超时角标(假待办)。
assert.equal(activityStatus({ activityType: 'io', status: null }), '已完成/done', 'io 的 status 为 null 时必须「已完成」,绝不能因 Number(null)===0 退化成「待执行」');
assert.equal(activityStatus({ activityType: 'io' }), '已完成/done', 'io 完全没有 status 字段时同样「已完成」');
assert.equal(activityStatus({ activityType: 'io', status: 0 }), '已完成/done', 'io 即使带着 0 也仍是「已完成」(后端只把审核通过的流水放进 activities)');
assert.equal(activityStatus({ activityType: 'transfer', status: null }), '已执行/done', '调拨恒为「已执行」,status 为 null 时不得退化成「待执行」');
assert.equal(activityStatus({ activityType: 'transfer' }), '已执行/done', '调拨缺 status 时同样「已执行」');
// 未识别一律中性灰:造 done 会被读成业务结论,造 waiting 会凭空生成一个待办并跟着挂超时角标。
assert.equal(activityStatus({ activityType: '质检单' }), '状态待核实/neutral', '未识别 activityType 必须回落 neutral,不得猜成 done/waiting');
assert.equal(activityStatus({}), '状态待核实/neutral', '完全没有类型的行必须回落 neutral');
assert.equal(activityStatus({ activityType: 'inventory', status: 9 }), '状态待核实/neutral', '未识别盘点 status 必须回落 neutral,绝不猜成某个已知状态');
assert.equal(activityStatus({ activityType: 'inventory', status: null }), '状态待核实/neutral', '盘点缺 status 时回落 neutral,不得默认成「待执行」');
// —— (4) buildActivityFeed:去重(同单号留最新)→ operateTime 倒序 → 截断 5 条 ——
// fixture:8 条入参、含 1 条重复(O6 两条),期望 total=8 / deduped=1 / shown=5 / overflow=2。
// 重复的两条刻意把「旧的一条排在前面」,用来证明去重认的是 operateTime 而不是先到者。
const activityFeedRows = [
{ id: 'A1', orderId: 'O1', orderNo: 'RK-001', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-20 14:00:00', itemCount: 3, warehouseName: '一号库' },
{ id: 'A2', orderId: 'O2', orderNo: 'CK-002', activityType: 'io', direction: 2, status: null, operateTime: '2026-10-20 12:00:00', itemCount: 12, warehouseName: '二号库' },
{ id: 'A3', orderId: 'O3', orderNo: 'DB-003', activityType: 'transfer', status: null, operateTime: '2026-10-20 10:00:00', itemCount: 2, warehouseName: '三号库' },
{ id: 'A4', orderId: 'O4', orderNo: 'PD-004', activityType: 'inventory', operateTime: '2026-10-19 18:00:00', itemCount: 1, warehouseName: '四号库', status: 1, description: '执行中' },
{ id: 'A5', orderId: 'O5', orderNo: 'PD-005', activityType: 'inventory', operateTime: '2026-10-19 09:00:00', itemCount: 1, warehouseName: '五号库', status: 0 },
{ id: 'A6', orderId: 'O6', orderNo: 'RK-006', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-18 09:00:00', itemCount: 4, warehouseName: '六号库' },
{ id: 'A7', orderId: 'O6', orderNo: 'RK-006', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-20 08:00:00', itemCount: 6, warehouseName: '六号库' },
{ id: 'A8', orderId: 'O7', orderNo: 'PD-007', activityType: 'inventory', operateTime: '2026-10-17 09:00:00', itemCount: 1, warehouseName: '七号库', status: 2 }
];
const activityFeed = buildActivityFeed(activityFeedRows, activityNow);
const activityById = (feed, id) => feed.items.filter((item) => item.id === id)[0] || {};
assert.equal(activityFeed.total, 8, 'total 必须是入参条数 8(含重复),用于核对后端到底回了几行');
assert.equal(activityFeed.deduped, 1, 'deduped 必须记 1 条被去掉的重复单据');
assert.equal(activityFeed.items.length, 5, '卡片只展示 5 条,多一条就会顶掉设计稿的 5 行节奏');
assert.equal(activityFeed.overflow, 2, 'overflow=去重后 7 条 − 展示 5 条 = 2,供「全部动态 ›」提示余量');
assert.equal(activityFeed.items.map((item) => item.id).join('|'), 'A1|A2|A3|A7|A4', '必须按 operateTime 倒序截断 5 条:A6 是同单号的旧记录,去重后只留较新的 A7');
assert.equal(activityFeed.items.filter((item) => item.orderId === 'O6').length, 1, '同一 orderId 在卡片上只能出现 1 次,否则同一张单会被看两遍');
// 盘点动作文案:以「盘点」开头 + 带库区 + **绝不出现「项」**。
// 后端盘点行的 itemCount 是占位常量 1(WmsHomeMapper.xml 盘点分支 CAST 成 1),拼成「盘点 1 项」就是伪造明细数。
const activityInventory = activityById(activityFeed, 'A4');
assert.match(activityInventory.actionText, /^盘点/, '盘点行动作文案必须以「盘点」开头(后端 description 里的状态词不得当动作描述)');
assert.doesNotMatch(activityInventory.actionText, /项/, '盘点行不得渲染 itemCount:后端盘点 itemCount 恒为占位常量 1,「盘点 1 项」是伪造数字');
assert.match(activityInventory.actionText, /四号库/, '盘点行必须带库区,否则同一时刻的多条盘点无从区分');
// io/transfer 反而不许丢掉真实明细行数:itemCount 是后端 COUNT(*) 真明细行数,只写「入库」会让人点进去才发现数量。
assert.match(activityById(activityFeed, 'A1').actionText, /\d+ 项/, '入库行动作文案必须带明细项数(后端 COUNT(*) 真明细行数)');
assert.match(activityById(activityFeed, 'A1').actionText, /3 项/, '入库行必须显示后端下发的 3 项,不能拿别处的常量顶替');
assert.match(activityById(activityFeed, 'A1').actionText, /一号库/, '入库行必须带库区');
assert.match(activityById(activityFeed, 'A3').actionText, /2 项/, '调拨行必须显示后端下发的 2 项');
assert.match(activityById(activityFeed, 'A3').actionText, /三号库/, '调拨行必须带库区');
// 下钻参数留在原始字段上:$emit('business','io',item) 依赖 activityType/orderId/orderNo/direction/warehouseId,
// 展示字段一律用 timeText/actionText/statusText/tone 这类新名字,任何一个同名覆盖都等于悄悄丢掉下钻参数。
const activityInbound = activityById(activityFeed, 'A1');
assert.equal(activityInbound.id, 'A1', '原始 id 必须保留(v-for :key 与高亮 indexOf 都用它)');
assert.equal(activityInbound.orderId, 'O1', '原始 orderId 必须保留(去重键与下钻都靠它)');
assert.equal(activityInbound.orderNo, 'RK-001', '原始 orderNo 必须保留(单号段直接渲染它)');
assert.equal(activityInbound.activityType, 'io', '原始 activityType 必须保留,否则下钻落不到 V4 的出入库页签');
assert.equal(activityInbound.direction, 1, '原始 direction 必须保留,否则入库/出库方向在下钻时丢失');
assert.equal(activityInbound.operateTime, '2026-10-20 14:00:00', '原始 operateTime 必须保留,不能被 timeText 覆盖');
assert.equal(activityInbound.status, null, '原始 status 不得被状态文案覆盖(io 行恒为 null)');
assert.equal(activityInbound.timeText, '今日 14:00', '展示字段 timeText 必须由 formatActivityTime 给出');
assert.equal(activityInbound.statusText, '已完成', '展示字段 statusText 用新名字,不占用 status');
assert.equal(activityInbound.tone, 'done', '展示字段 tone 必须落在 done/waiting/doing/danger/neutral 之内');
// 空态:null/非数组入参必须安全返回空列表(后端未下发 activities、或 summary 还没到),绝不能抛错把整屏打黑。
for (const badFeed of [null, undefined, [], 'x', {}, 0]) {
const emptyFeed = buildActivityFeed(badFeed, activityNow);
assert.equal(emptyFeed.items.length, 0, '空/非数组入参必须返回空列表(入参 ' + String(badFeed) + ')');
assert.equal(emptyFeed.total, 0, '空/非数组入参的 total 必须是 0(入参 ' + String(badFeed) + ')');
}
// —— (5) 超时角标:只有橙色「待*」且事件时间早于 now−24h 才标 ——
// 给 done/doing 的行套「超时」没有业务含义,只会让用户以为还有活没干;重复求值也不许漂移(30 秒轮询会反复求值)。
const activityOverdueOf = (row) => buildActivityFeed([row], activityNow).items[0].overdue;
assert.equal(activityOverdueOf({ id: 'w1', orderId: 'w1', activityType: 'inventory', status: 0, operateTime: '2026-10-18 09:00:00' }), true, '待执行超过 24 小时(54.5h)必须标超时');
assert.equal(activityOverdueOf({ id: 'w2', orderId: 'w2', activityType: 'inventory', status: 0, operateTime: '2026-10-19 20:00:00' }), false, '24 小时内的待执行不得标超时(19.5h)');
assert.equal(activityOverdueOf({ id: 'w3', orderId: 'w3', activityType: 'inventory', status: 0, operateTime: '2026-10-19 14:00:00' }), true, '刚过 24 小时(25.5h)必须标超时');
assert.equal(activityOverdueOf({ id: 'd1', orderId: 'd1', activityType: 'io', direction: 1, operateTime: '2026-09-01 09:00:00' }), false, '已完成行再老也不标超时(绿行没有超时语义)');
assert.equal(activityOverdueOf({ id: 'd2', orderId: 'd2', activityType: 'transfer', operateTime: '2026-09-01 09:00:00' }), false, '调拨(已执行)行再老也不标超时');
assert.equal(activityOverdueOf({ id: 'p1', orderId: 'p1', activityType: 'inventory', status: 1, operateTime: '2026-09-01 09:00:00' }), false, '进行中行再老也不标超时(doing 只表达正在跑)');
const activityFeedAgain = buildActivityFeed(activityFeedRows, activityNow);
assert.equal(activityFeedAgain.items.map((item) => item.overdue).join('|'), activityFeed.items.map((item) => item.overdue).join('|'), '重复求值的 overdue 必须完全一致(幂等,轮询不得让角标闪来闪去)');
assert.equal(activityFeedAgain.items.map((item) => item.tone).join('|'), activityFeed.items.map((item) => item.tone).join('|'), '重复求值的 tone 必须完全一致');
// —— (6) diffActivityIds:首屏不整屏高亮、只标新增、同批二次为空 ——
// prev 为空=首次加载:返回空数组,否则首屏 5 行全部高亮,等于什么都没强调,只是让卡片闪一下。
assert.equal(diffActivityIds([], [{ id: 'n1', orderId: 'N1' }]).length, 0, '首次加载(prev 为空)必须返回空数组,首屏不得整屏高亮');
assert.equal(diffActivityIds(null, [{ id: 'n1', orderId: 'N1' }]).length, 0, 'prev 缺失时同样不得高亮');
const activityPrevItems = [{ id: 'x', orderId: 'X' }, { id: 'y', orderId: 'Y' }];
const activityNextItems = [{ id: 'y', orderId: 'Y' }, { id: 'z', orderId: 'Z' }];
assert.equal(diffActivityIds(activityPrevItems, activityNextItems).join('|'), 'z', '只返回 next 里新增的 id,且顺序与 next 一致');
// 同一批数据二次求值必须返回空数组:activities 是计算属性,每次求值都是新数组,
// 按引用比较会让 30 秒轮询把同一批行全部判成「新增」,整屏反复闪。
assert.equal(diffActivityIds(activityNextItems, activityNextItems).join('|'), '', '同一批数据二次调用必须返回空数组(轮询不得让整屏重新高亮)');
assert.equal(diffActivityIds([{ id: 'old-id', orderId: 'Y' }], [{ id: 'new-id', orderId: 'Y' }]).length, 0, '同一单据换了 id 也不算新增:比较键必须与去重键同源(orderId 优先)');
// —— (7) 模板四段结构 + 空态 + 「全部动态 ›」入口(源码字符串断言,防止语义被搬回模板)——
const activitySfc = compiler.parseComponent(overviewSource);
const activityTpl = activitySfc.template.content;
assert.match(activityTpl, /class="tm"\s*:title="item\.timeTitle"/, '第一段必须是时间(.tm 渲染 timeText,title 给完整时刻)');
assert.match(activityTpl, /class="feed-doc"/, '第二段必须是单据类型 + 单号(.feed-doc)');
assert.match(activityTpl, /class="feed-desc"\s*:title="item\.actionText"/, '第三段必须是动作描述(.feed-desc 渲染 actionText)');
// 状态色只认 item.tone:允许拼接写法变化('feed-status ' + item.tone 或数组写法),但必须能读到 item.tone,
// 且模板里绝不能出现写死的 feed-status done/waiting/... —— 写死就等于在模板里又判了一遍业务状态。
assert.match(activityTpl, /:class="[^"]*feed-status[^"]*item\.tone[^"]*"/, '第四段状态色必须由 item.tone 拼成 feed-status 类(模板里不得再判业务状态)');
assert.doesNotMatch(activityTpl, /feed-status\s+(?:done|waiting|doing|danger|neutral)\b/, '模板不得写死 feed-status 的具体 tone 类,颜色只能由 item.tone 决定');
// 单号段必须自己可点,且 @click.prevent.stop:少了 prevent 页面会跳走,少了 stop 会冒泡到整行触发两次下钻。
assert.match(activityTpl, /class="feed-no"[\s\S]{0,260}@click\.prevent\.stop="openActivity\(item\)"/, '单号必须可点击且带 @click.prevent.stop(防跳转 + 防冒泡双触发下钻)');
assert.match(activityTpl, /v-if="item\.overdue"\s+class="badge-overdue">超时, '超时角标必须只在 item.overdue 时渲染「超时」文案');
// 空态必须有一句固定文案:后端不给 activityReason 时页面不能变成一块空白(那会被当成卡片坏了)。
// 模板里不止一处 .empty-panel(库龄卡也有一个),所以先从活动卡那句固定文案反查它自己的容器,
// 直接抓第一个 .empty-panel 会抓到别的卡片、让这条断言永远通过(或永远指向错元素)。
const activityEmptyIndex = activityTpl.indexOf('今日暂无作业动态');
assert.ok(activityEmptyIndex > 0, '空态必须有固定文案「今日暂无作业动态」');
const activityEmptyOpen = activityTpl.lastIndexOf('', activityEmptyOpen) + 1);
assert.match(activityEmptyTag, /class="empty-panel"/, '固定文案必须落在 .empty-panel 空态容器里,不能挂到别的卡片上');
const activityEmptyHead = activityTpl.slice(activityTpl.indexOf('>', activityEmptyOpen) + 1, activityEmptyIndex + 80);
assert.match(activityEmptyHead, /^今日暂无作业动态/, '空态第一句必须是固定文案,不能只渲染后端原因');
assert.match(activityEmptyHead, /v-if="activityReason"/, '后端原因只能作为可选后缀拼在固定文案之后');
assert.doesNotMatch(activityTpl, /class="empty-panel"[^>]*>\s*]*>全部动态 ›<\/a>/) || [])[0] || '';
assert.ok(activityAllLink, '「全部动态 ›」入口必须存在(V4 出入库动态的唯一入口)');
assert.match(activityAllLink, /\$emit\('open', 'io'\)/, '「全部动态 ›」必须仍发 open(\'io\') 落到 V4,不得改成别的目标');
// —— (8) 高亮:3 秒渐隐定时器 + beforeDestroy 清理;轮询不得动滚动 ——
const activityScript = activitySfc.script.content;
assert.match(activityScript, /setTimeout\(\(\) => \{[\s\S]{0,200}?\}, 3000\)/, '新增事件高亮必须是 3000ms 定时器(与 scss 的 3s 渐隐动画同长)');
assert.match(activityScript, /beforeDestroy\(\)\s*\{[\s\S]{0,260}?clearTimeout\(this\.highlightTimer\)/, 'beforeDestroy 必须清理高亮定时器(否则 3 秒后往已销毁实例写响应式数据)');
// 滚动守卫前先剥注释:实现里为了说明「禁止 scrollIntoView/scrollTop」本身就写了这两个词,
// 拿原文匹配会把这条说明当成违规;剥掉注释后剩下的才是真正的代码,守卫挡的就是它。
const stripCodeComments = (source) => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
const activityOverviewCode = stripCodeComments(overviewSource);
const activityThemeCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-theme.scss'), 'utf8'));
// 防呆:剥注释后仍必须读到真实模板与样式,否则下面的「不出现」断言会空转(剥太狠等于什么都没查)。
assert.match(activityOverviewCode, /class="feed-item"/, '剥注释后仍必须能读到 feed-item 模板(否则滚动守卫空转)');
assert.match(activityThemeCode, /\.feed-status\.done\s*\{/, '剥注释后仍必须能读到 .feed-status.done 规则(否则滚动守卫空转)');
for (const [activityFile, activityCode] of [['HomeOverview.vue', activityOverviewCode], ['dashboard-theme.scss', activityThemeCode]]) {
assert.doesNotMatch(activityCode, /scrollIntoView|scrollTop|scrollTo|scrollBy|scrollLeft/, activityFile + ' 不得调用任何滚动 API:30 秒轮询里主动滚动会把用户正在看的位置顶走(只允许注释里提这些词)');
}
// —— (9) 样式契约:五条 tone 规则 + is-new 渐隐关键帧 ——
for (const activityTone of ['done', 'waiting', 'doing', 'danger', 'neutral']) {
assert.match(activityThemeCode, new RegExp('\\.feed-status\\.' + activityTone + '\\s*\\{'), 'dashboard-theme.scss 必须有 .feed-status.' + activityTone + ' 规则:缺一条该状态就无色(neutral 灰是「前端不认识这个状态」的唯一视觉语言)');
}
assert.match(activityThemeCode, /\.feed-item\.is-new\s*\{[\s\S]{0,80}?animation:\s*feed-new-fade\s+3s/, '新增行必须有 is-new 的 3s 渐隐动画,时长与 JS 的 3000ms 定时器一致');
assert.match(activityThemeCode, /@keyframes\s+feed-new-fade\s*\{[\s\S]{0,400}?\}/, 'is-new 的 @keyframes feed-new-fade 必须存在(否则 animation 引用不存在的动画,高亮静默失效)');
console.log('E3 最近作业动态断言通过:ACTIVITY_LIMIT=5 与待办条同口径 24h 超时、状态色表四 tone 全覆盖(已完成/已执行=done、待*=waiting、进行中=doing、已驳回/异常=danger)、formatActivityTime 四段(今日补前导 0 / 昨日 / MM-DD / 空与非法→「—」)、activityStatusOf 先按类型分流(io status=null 仍是「已完成」,不落 Number(null)===0 陷阱;未识别类型与未识别盘点 status 回落 neutral)、buildActivityFeed 同单号留 operateTime 最新且倒序截断 5 条(total 8/deduped 1/overflow 2)、盘点行不带占位 itemCount(无「项」)而 io/transfer 必须带「N 项」、原始 id/orderId/orderNo/activityType/direction/status 全保留、超时只标 waiting 且早于 now−24h(done/doing 再老也不标)并幂等、diffActivityIds 首屏不高亮/只返回新增/同批二次为空、模板四段结构与单号 prevent.stop、超时角标、空态固定文案、「全部动态 ›」仍走 open(io)、3000ms 高亮定时器与 beforeDestroy 清理、HomeOverview.vue 与 dashboard-theme.scss 均无滚动 API、scss 五条 tone 规则与 feed-new-fade 关键帧。');
(async () => {
const state = { receiving: false, preparedReceipts: [{ requestKey: 'a' }, { requestKey: 'b' }], receiveQueue: [], filters: {}, $confirm: async () => {}, route() {}, $refs: { actions: { showResults() {} } }, refresh() {}, $message: { error(message) { throw new Error(message); } } };
await Promise.all([page.methods.submitReceipts.call(state), page.methods.submitReceipts.call(state)]);
assert.equal(calls, 1, '重复点击只能发出一次批量请求');
assert.equal(state.preparedReceipts.length, 1);
assert.equal(state.preparedReceipts[0].requestKey, 'b', '失败申请重试保留原幂等键');
assert.equal(state.receiving, false);
console.log('仓储看板业务断言通过:月份、空值、大整数身份、授权入口、待办范围、分组下钻、收货去重与失败重试、周转/呆滞后端分级映射、呆滞分子分母不可颠倒(3÷70=4.29%)、pp 环比缺失回归、口径条阈值占位符与 180 回落、stagnantDays 命名守卫。');
})().catch(error => { console.error(error); process.exitCode = 1; });