From 744edb6a37b96b908660c4d9d2768b0482de1dc7 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Fri, 7 Aug 2026 11:29:38 +0800 Subject: [PATCH] feat(javaTest): visualize floor transfer evidence --- javaTest/scripts/visualizer_browser_qa.py | 49 ++++- .../SimulationVisualizationMapper.java | 38 ++++ .../main/resources/visualizer/assets/app.css | 29 ++- .../main/resources/visualizer/assets/app.js | 194 +++++++++++++++++- .../src/main/resources/visualizer/index.html | 14 +- .../FloorTransferVisualizationMapperTest.java | 106 ++++++++++ 6 files changed, 416 insertions(+), 14 deletions(-) create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/FloorTransferVisualizationMapperTest.java diff --git a/javaTest/scripts/visualizer_browser_qa.py b/javaTest/scripts/visualizer_browser_qa.py index 1404227..14b6952 100644 --- a/javaTest/scripts/visualizer_browser_qa.py +++ b/javaTest/scripts/visualizer_browser_qa.py @@ -38,6 +38,8 @@ def run_point_to_point(page): "route quality tuple must contain six ordered metrics") require(page.locator("#route-comparability").inner_text() != "--", "historical comparability badge was not populated") + require("DIRECT" in page.locator("#evidence-assignment-mode").inner_text(), + "same-floor point simulation did not expose DIRECT assignment mode") def verify_map_interactions(page): @@ -90,7 +92,7 @@ def verify_map_interactions(page): def verify_historical_mode(page): - """Run the real 240-task endpoint and verify task selection keeps decision evidence live.""" + """Run 240 tasks and inspect one real cross-floor dispatch without drawing a fake edge.""" page.locator(".mode-tab[data-mode='history']").click() page.locator("#run-history").click() expect(page.locator("#history-run-state")).to_have_text( @@ -100,9 +102,52 @@ def verify_historical_mode(page): "historical result did not expose all 240 tasks") require(page.locator("#history-task-body tr").count() == 240, "historical task table row count is not 240") - page.locator("#history-task-body tr").nth(1).click() + transfer_rows = page.locator( + "#history-task-body tr[data-assignment-mode='FLOOR_TRANSFER']" + ) + require(transfer_rows.count() > 0, + "historical result did not expose a FLOOR_TRANSFER task") + transfer_rows.first.click() require(page.locator("#route-candidate-body tr").count() > 0, "selected historical task lost route candidates") + require("FLOOR_TRANSFER" in page.locator("#evidence-assignment-mode").inner_text(), + "selected transfer task did not expose its assignment mode") + expect(page.locator("#floor-transfer-panel")).to_be_visible() + require(page.locator("#floor-transfer-list .floor-transfer-job").count() > 0, + "selected transfer task did not render its transfer job") + require("→" in page.locator("#floor-transfer-list").inner_text() + and "幂等键" in page.locator("#floor-transfer-list").inner_text(), + "transfer job is missing port direction or idempotency evidence") + require(page.locator("#floor-select option", has_text="换层").count() >= 2, + "floor selector did not mark all floors involved in the transfer") + require(page.locator("#dynamic-route-layer .floor-transfer-port-marker").count() > 0, + "selected floor did not render a transfer port marker") + transfer_events = page.locator( + "#event-table-body tr[data-event-type^='FLOOR_TRANSFER']" + ) + require(transfer_events.count() >= 3, + "transfer lifecycle events are missing from the timeline") + require(page.locator( + "#event-type-filter option[value='FLOOR_TRANSFER_COMPLETED']" + ).inner_text() == "换层完成", + "transfer completion event did not receive a business label") + + cross_floor_segments = page.evaluate(""" + async () => { + const bootstrap = await fetch('/api/bootstrap').then(response => response.json()); + const floors = Object.fromEntries( + bootstrap.sites.map(site => [site.siteId, Number(site.floor)])); + return Array.from(document.querySelectorAll( + '#dynamic-route-layer .route-segment title')) + .map(title => title.textContent.split(' → ')) + .filter(parts => parts.length === 2 + && floors[parts[0]] !== undefined + && floors[parts[1]] !== undefined + && floors[parts[0]] !== floors[parts[1]]); + } + """) + require(not cross_floor_segments, + "normal route layer drew cross-floor edges: " + str(cross_floor_segments)) def verify_viewport(browser, viewport, filename, run_history): diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java index 778a9f6..8258d08 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapper.java @@ -8,6 +8,7 @@ import com.ft546.wcs.shadow.history.HistoricalQueueRecord; import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult; import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput; import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationTask; +import com.ft546.wcs.shadow.history.simulation.FloorTransferJob; import com.ft546.wcs.shadow.history.simulation.RoutePlanningEvidence; import com.ft546.wcs.shadow.history.simulation.RerouteDecisionEvidence; import com.ft546.wcs.shadow.history.simulation.SimulationInvariantSummary; @@ -207,6 +208,11 @@ public final class SimulationVisualizationMapper { ? record.getEndSite() : definition.getPhysicalTarget()); value.put("historicalVehicleId", record.getAgvId()); value.put("simulatedVehicleId", task.getSimulatedVehicleId()); + value.put("assignmentMode", task.getAssignmentMode() == null + ? null : task.getAssignmentMode().name()); + value.put("floorTransferCount", task.getFloorTransferCount()); + value.put("involvedFloors", involvedFloors(task.getFloorTransferJobs())); + value.put("floorTransferJobs", floorTransferJobs(task.getFloorTransferJobs())); value.put("historicalPath", historicalPath); value.put("plannedPath", task.getPlannedPath()); // 边差证据解释新旧路线在哪里重合或分叉,但不参与调度中心的规划结果。 @@ -253,6 +259,38 @@ public final class SimulationVisualizationMapper { return value; } + /** + * 汇总父任务换层作业经过的楼层,供页面楼层选择器标记整条业务链涉及的地图。 + */ + private List involvedFloors(List jobs) { + Set floors = new TreeSet(); + for (FloorTransferJob job : jobs) { + floors.add(job.getSourceFloor()); + floors.add(job.getTargetFloor()); + } + return new ArrayList(floors); + } + + /** + * 输出中心提交给现有 WCS 的换层意图;幂等键用于核对重试是否复用了同一个物理作业。 + */ + private List> floorTransferJobs(List jobs) { + List> values = new ArrayList>(); + for (FloorTransferJob job : jobs) { + Map value = new LinkedHashMap(); + value.put("idempotencyKey", job.getIdempotencyKey()); + value.put("parentTaskId", job.getParentTaskId()); + value.put("vehicleId", job.getVehicleId()); + value.put("sourceFloor", job.getSourceFloor()); + value.put("targetFloor", job.getTargetFloor()); + value.put("sourceSite", job.getSourceSite()); + value.put("targetSite", job.getTargetSite()); + value.put("priority", job.getPriority()); + values.add(value); + } + return values; + } + /** 返回当前任务的重规划触发、拒绝和成功事件,供前端按版本还原决策链。 */ private List> rerouteEvents( String taskId, List allEvents) { diff --git a/javaTest/src/main/resources/visualizer/assets/app.css b/javaTest/src/main/resources/visualizer/assets/app.css index f0de1ab..c2f1619 100644 --- a/javaTest/src/main/resources/visualizer/assets/app.css +++ b/javaTest/src/main/resources/visualizer/assets/app.css @@ -279,6 +279,7 @@ h3 { font-size: 14px; line-height: 1.3; } .legend-line.reroute-new { border-color: var(--color-blue); border-top-width: 5px; } .legend-dot { width: 10px; height: 10px; border: 2px solid; border-radius: 50%; } .legend-dot.hold { border-color: var(--color-amber); } +.legend-transfer { width: 9px; height: 9px; border: 2px solid var(--color-blue); background: var(--color-blue-soft); transform: rotate(45deg); } .route-canvas-wrap { position: relative; min-height: 425px; height: calc(100% - 92px); overflow: hidden; } #route-map { display: block; width: 100%; height: 100%; min-height: 425px; background: #fbfcfc; cursor: grab; touch-action: none; user-select: none; } @@ -301,6 +302,10 @@ h3 { font-size: 14px; line-height: 1.3; } .traffic-event-marker { fill: #fff; stroke-width: 4; vector-effect: non-scaling-stroke; } .traffic-event-marker.hold-point { stroke: var(--color-amber); } .traffic-event-marker.reroute-point { stroke: var(--color-red); } +.floor-transfer-port-marker rect { fill: #fff; stroke: var(--color-blue); stroke-width: 3; vector-effect: non-scaling-stroke; } +.floor-transfer-port-marker.source rect { fill: var(--color-amber-soft); stroke: var(--color-amber); } +.floor-transfer-port-marker.target rect { fill: var(--color-blue-soft); stroke: var(--color-blue); } +.floor-transfer-port-label { fill: #31403c; font-family: var(--font-ui); font-size: 11px; font-weight: 750; paint-order: stroke; stroke: #fff; stroke-width: 4px; stroke-linejoin: round; } .map-site { fill: #d8dfdc; stroke: #b6c1bd; stroke-width: 1; vector-effect: non-scaling-stroke; } .map-node { fill: #fff; stroke: #7d8a86; stroke-width: 2; vector-effect: non-scaling-stroke; } .map-node.passed { fill: var(--color-blue-soft); stroke: var(--color-blue); } @@ -337,7 +342,20 @@ h3 { font-size: 14px; line-height: 1.3; } .evidence-list > div { display: grid; grid-template-columns: 96px 1fr; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #e4eae8; } .evidence-list dt { color: var(--color-muted); font-size: 11px; } .evidence-list dd { margin: 0; text-align: right; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; } -.decision-audit, .reroute-audit { border-bottom: 1px solid var(--color-line); } +.decision-audit, .reroute-audit, .floor-transfer-panel { border-bottom: 1px solid var(--color-line); } +.floor-transfer-list { display: grid; } +.floor-transfer-job { display: grid; gap: 9px; padding: 11px 14px; border-top: 1px solid #e4eae8; } +.floor-transfer-job-route { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; } +.floor-transfer-port { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 3px 7px; } +.floor-transfer-port strong { color: var(--color-blue); font-family: var(--font-mono); font-size: 13px; } +.floor-transfer-port code { min-width: 0; font-family: var(--font-mono); font-size: 11px; overflow-wrap: anywhere; } +.floor-transfer-port small { grid-column: 1 / -1; color: var(--color-muted); font-size: 9px; } +.floor-transfer-arrow { color: var(--color-blue); font-family: var(--font-mono); font-size: 17px; } +.floor-transfer-job-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 5px 12px; color: var(--color-muted); font-size: 10px; } +.floor-transfer-status { color: var(--color-green); font-weight: 750; } +.floor-transfer-job-key { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 8px; padding-top: 7px; border-top: 1px dashed var(--color-line); } +.floor-transfer-job-key span { color: var(--color-muted); font-size: 9px; } +.floor-transfer-job-key code { font-family: var(--font-mono); font-size: 9px; line-height: 1.45; overflow-wrap: anywhere; } .inspector-section-bar { min-height: 54px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 14px; background: var(--color-surface-muted); } .comparison-status { max-width: 240px; gap: 5px; justify-content: flex-end; flex-wrap: wrap; } .evidence-badge { min-height: 23px; display: inline-flex; align-items: center; padding: 2px 7px; border: 1px solid var(--color-line); border-radius: 999px; font-size: 10px; font-weight: 700; white-space: nowrap; } @@ -385,7 +403,10 @@ h3 { font-size: 14px; line-height: 1.3; } .event-type { font-weight: 750; } .event-type.HOLD, .event-type.CLEARANCE_HOLD { color: var(--color-amber); } .event-type.PLC_RETRY, .event-type.CLEARANCE_PLC_RETRY, .event-type.REDISPATCH { color: var(--color-red); } -.event-type.COMPLETE, .event-type.ASSIGN { color: var(--color-green); } +.event-type.COMPLETE, .event-type.ASSIGN, .event-type.FLOOR_TRANSFER_COMPLETED { color: var(--color-green); } +.event-type.FLOOR_TRANSFER_SUBMITTED, .event-type.FLOOR_TRANSFER_RUNNING, +.event-type.POST_TRANSFER_REPLAN { color: var(--color-blue); } +.event-type.FLOOR_TRANSFER_HOLD { color: var(--color-amber); } .safety-view { padding: 18px 24px 28px; background: var(--color-bg); } .safety-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } @@ -459,6 +480,10 @@ h3 { font-size: 14px; line-height: 1.3; } .route-legend { gap: 9px; padding: 6px 12px; } .route-canvas-wrap, #route-map { min-height: 340px; } .evidence-list > div { grid-template-columns: 88px 1fr; padding: 9px 12px; } + .floor-transfer-job { padding: 10px 12px; } + .floor-transfer-job-route { grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr); gap: 5px; } + .floor-transfer-port { grid-template-columns: 1fr; } + .floor-transfer-port small { grid-column: 1; } .comparison-status { max-width: 190px; } .decision-summary { grid-template-columns: 1fr; } .decision-summary > div, .decision-summary > div:nth-child(odd) { border-right: 0; } diff --git a/javaTest/src/main/resources/visualizer/assets/app.js b/javaTest/src/main/resources/visualizer/assets/app.js index b221ecc..c1bd6a4 100644 --- a/javaTest/src/main/resources/visualizer/assets/app.js +++ b/javaTest/src/main/resources/visualizer/assets/app.js @@ -42,10 +42,12 @@ "floor-select", "show-historical", "map-fit", "path-fit", "zoom-in", "zoom-out", "reset-view", "show-initial-route", "show-current-route", "show-move-trace", "show-hold-points", "inspector-task-id", "task-status", - "evidence-endpoints", "evidence-vehicle", "evidence-history-vehicle", + "evidence-endpoints", "evidence-assignment-mode", "evidence-involved-floors", + "evidence-vehicle", "evidence-history-vehicle", "evidence-assigned-time", "evidence-waiting", "evidence-completion", "evidence-hold-retry", "evidence-plc", "route-difference", "invalid-history-segments", "planned-path-output", "reason-output", + "floor-transfer-panel", "floor-transfer-count", "floor-transfer-list", "route-comparability", "route-quality-comparison", "route-stage-select", "route-version-select", "decision-shortest-distance", "decision-selected-distance", "decision-detour-ratio", "decision-candidate-count", @@ -299,6 +301,9 @@ elements["inspector-task-id"].textContent = task.taskNo || task.simulationTaskId; setTaskStatus(task.status); setText("evidence-endpoints", task.physicalStartSite + " → " + task.physicalEndSite); + setText("evidence-assignment-mode", assignmentModeEvidence(task.assignmentMode)); + setText("evidence-involved-floors", involvedFloorValues(task) + .map(function (floor) { return floor + "F"; }).join(" → ") || "--"); setText("evidence-vehicle", task.simulatedVehicleId || "未分配"); setText("evidence-history-vehicle", task.historicalVehicleId || "无对照"); setText("evidence-assigned-time", formatMillis(task.firstAssignedMillis)); @@ -316,6 +321,7 @@ comparabilityLabel(task.routeComparability)); setEvidenceBadge("route-quality-comparison", task.routeQualityComparison, qualityCategoryLabel(task.routeQualityComparison)); + renderFloorTransferJobs(task); elements["route-stage-select"].value = state.selectedPlanningStage; renderRouteDecision(task); renderRerouteComparison(task); @@ -324,6 +330,96 @@ elements["reason-output"].textContent = task.reason || task.status; } + /** 把中心调度结果翻译成业务名称,同时保留原始枚举便于日志和接口对照。 */ + function assignmentModeEvidence(mode) { + return mode === "FLOOR_TRANSFER" ? "FLOOR_TRANSFER · 先换层后执行" + : mode === "DIRECT" ? "DIRECT · 同层直接执行" : "未形成分配"; + } + + /** 汇总普通路径与换层作业涉及的真实楼层,不根据站点编号猜测层号。 */ + function involvedFloorValues(task) { + var floors = new Set((task && task.involvedFloors) || []); + [].concat(task && task.plannedPath || [], + task ? [task.physicalStartSite, task.physicalEndSite] : []) + .map(siteById).filter(Boolean).forEach(function (site) { floors.add(site.floor); }); + ((task && task.floorTransferJobs) || []).forEach(function (job) { + floors.add(Number(job.sourceFloor)); + floors.add(Number(job.targetFloor)); + }); + return Array.from(floors).filter(function (floor) { return floor > 0; }) + .sort(function (left, right) { return left - right; }); + } + + /** 展示中心提交给现有 WCS 的独立换层作业,不把提升机动作混入 plannedPath。 */ + function renderFloorTransferJobs(task) { + var jobs = task.floorTransferJobs || []; + elements["floor-transfer-panel"].hidden = jobs.length === 0; + setText("floor-transfer-count", jobs.length + " 个"); + var fragment = document.createDocumentFragment(); + jobs.forEach(function (job) { + var item = document.createElement("article"); + item.className = "floor-transfer-job"; + + var route = document.createElement("div"); + route.className = "floor-transfer-job-route"; + route.append( + transferPort(job.sourceFloor, job.sourceSite, "入口"), + textNode("span", "→", "floor-transfer-arrow"), + transferPort(job.targetFloor, job.targetSite, "出口") + ); + + var meta = document.createElement("div"); + meta.className = "floor-transfer-job-meta"; + meta.append( + textNode("span", "车辆 " + job.vehicleId), + textNode("span", "优先级 " + job.priority), + textNode("span", floorTransferStatus(task, job), "floor-transfer-status") + ); + + var key = document.createElement("div"); + key.className = "floor-transfer-job-key"; + key.append(textNode("span", "幂等键"), textNode("code", job.idempotencyKey)); + item.append(route, meta, key); + fragment.appendChild(item); + }); + elements["floor-transfer-list"].replaceChildren(fragment); + } + + function transferPort(floor, site, role) { + var value = document.createElement("span"); + value.className = "floor-transfer-port"; + value.append( + textNode("strong", Number(floor) + "F"), + textNode("code", site), + textNode("small", role) + ); + return value; + } + + function floorTransferStatus(task, job) { + var events = eventsForTask(currentResult(), task.simulationTaskId).filter(function (event) { + return event.vehicleId === job.vehicleId + && event.fromSite === job.sourceSite && event.toSite === job.targetSite; + }); + if (events.some(function (event) { + return event.eventType === "FLOOR_TRANSFER_COMPLETED"; + })) return "已完成"; + if (events.some(function (event) { + return event.eventType === "FLOOR_TRANSFER_RUNNING"; + })) return "执行中"; + if (events.some(function (event) { + return event.eventType === "FLOOR_TRANSFER_HOLD"; + })) return "等待中"; + return "已提交"; + } + + function textNode(tag, value, className) { + var node = document.createElement(tag); + if (className) node.className = className; + node.textContent = value === null || value === undefined ? "--" : String(value); + return node; + } + /** 使用后端保存的阶段证据渲染候选、距离边界和固定质量元组。 */ function renderRouteDecision(task) { var stage = state.selectedPlanningStage; @@ -472,6 +568,7 @@ var task = selectedTask(); if (!state.bootstrap) return; var pathFloors = new Set(); + var transferFloors = new Set(); var vehicleFloor = null; if (task) { var ids = [].concat(task.plannedPath || [], task.historicalPath || []); @@ -483,6 +580,13 @@ ids.map(siteById).filter(Boolean).forEach(function (site) { pathFloors.add(site.floor); }); + (task.involvedFloors || []).forEach(function (floor) { + transferFloors.add(Number(floor)); + }); + (task.floorTransferJobs || []).forEach(function (job) { + transferFloors.add(Number(job.sourceFloor)); + transferFloors.add(Number(job.targetFloor)); + }); var moves = events.filter(function (event) { return event.eventType === "MOVE" && Number(event.tick) <= tickValue(); }); @@ -498,6 +602,7 @@ option.value = String(floor); var flags = []; if (pathFloors.has(floor)) flags.push("路径"); + if (transferFloors.has(floor)) flags.push("换层"); if (floor === vehicleFloor) flags.push("车辆"); option.textContent = floor + "F" + (flags.length ? " · " + flags.join(" · ") : ""); option.selected = floor === state.selectedFloor; @@ -565,6 +670,7 @@ if (elements["show-hold-points"].checked) { drawTrafficEventMarkers(layer, events, currentTick, project); } + drawFloorTransferPortMarkers(layer, task, project); var passedIds = new Set(); traversedMoves.forEach(function (event) { @@ -724,6 +830,39 @@ }); } + /** + * 在当前楼层分别标记提升机入口和实际目标出口;两端不会用 SVG 线连接。 + */ + function drawFloorTransferPortMarkers(layer, task, project) { + (task.floorTransferJobs || []).forEach(function (job) { + [ + { siteId: job.sourceSite, role: "source", label: "换层入口" }, + { siteId: job.targetSite, role: "target", label: "换层出口" } + ].forEach(function (port) { + var site = siteById(port.siteId); + var point = project(site); + if (!point) return; + var marker = svgElement("g", { + class: "floor-transfer-port-marker " + port.role + }); + var diamond = svgElement("rect", { + x: point.x - 7, y: point.y - 7, width: 14, height: 14, + transform: "rotate(45 " + point.x + " " + point.y + ")" + }); + var title = svgElement("title"); + title.textContent = port.label + " · " + site.floor + "F · " + site.siteId; + diamond.appendChild(title); + marker.appendChild(diamond); + var label = svgElement("text", { + x: point.x + 12, y: point.y - 11, class: "floor-transfer-port-label" + }); + label.textContent = port.label; + marker.appendChild(label); + layer.appendChild(marker); + }); + }); + } + /** 将证据 DTO 中的端点直接画成线,不在浏览器中重新判断或规划邻接关系。 */ function drawEvidenceEdges(layer, edges, className, project) { (edges || []).forEach(function (edge) { @@ -1022,18 +1161,19 @@ var fragment = document.createDocumentFragment(); events.forEach(function (event) { var row = document.createElement("tr"); + row.dataset.eventType = event.eventType; row.classList.toggle("is-current", Number(event.tick) === currentTick); row.addEventListener("click", function () { var index = state.eventTicks.indexOf(Number(event.tick)); if (index >= 0) setTickIndex(index); }); appendCell(row, event.tick, true); - var typeCell = appendCell(row, event.eventType); + var typeCell = appendCell(row, eventTypeLabel(event.eventType)); typeCell.className = "event-type " + event.eventType; + typeCell.title = event.eventType; appendCell(row, event.vehicleId || "--", true); appendCell(row, event.reason || "--"); - appendCell(row, event.fromSite || event.toSite - ? (event.fromSite || "--") + " → " + (event.toSite || "--") : "--", true); + appendCell(row, eventPositionEvidence(event), true); fragment.appendChild(row); }); elements["event-table-body"].replaceChildren(fragment); @@ -1052,13 +1192,38 @@ types.forEach(function (type) { var option = document.createElement("option"); option.value = type; - option.textContent = type; + option.textContent = eventTypeLabel(type); fragment.appendChild(option); }); elements["event-type-filter"].replaceChildren(fragment); elements["event-type-filter"].value = types.indexOf(current) >= 0 ? current : "ALL"; } + /** 用现场业务名称展示换层生命周期,原始事件类型保留在 option value 和 title 中。 */ + function eventTypeLabel(type) { + var labels = { + FLOOR_TRANSFER_SUBMITTED: "换层提交", + FLOOR_TRANSFER_RUNNING: "换层执行", + FLOOR_TRANSFER_HOLD: "换层等待", + FLOOR_TRANSFER_COMPLETED: "换层完成", + POST_TRANSFER_REPLAN: "出口重规划" + }; + return labels[type] || type; + } + + function eventPositionEvidence(event) { + if (!event.fromSite && !event.toSite) return "--"; + var transfer = event.eventType.indexOf("FLOOR_TRANSFER") === 0 + || event.eventType === "POST_TRANSFER_REPLAN"; + if (!transfer) return (event.fromSite || "--") + " → " + (event.toSite || "--"); + return eventSiteWithFloor(event.fromSite) + " → " + eventSiteWithFloor(event.toSite); + } + + function eventSiteWithFloor(siteId) { + var site = siteById(siteId); + return site ? site.floor + "F · " + site.siteId : (siteId || "--"); + } + function startPlayback() { if (!selectedTask() || state.timer) return; if (state.tickIndex >= state.eventTicks.length - 1) setTickIndex(0); @@ -1123,7 +1288,8 @@ var tasks = state.historyResult.tasks.filter(function (task) { if (status !== "ALL" && task.status !== status) return false; var haystack = [task.taskNo, task.simulationTaskId, task.physicalStartSite, - task.physicalEndSite, task.historicalVehicleId, task.simulatedVehicleId] + task.physicalEndSite, task.historicalVehicleId, task.simulatedVehicleId, + task.assignmentMode] .join(" ").toLowerCase(); return !query || haystack.indexOf(query) >= 0; }); @@ -1132,6 +1298,7 @@ tasks.forEach(function (task) { var row = document.createElement("tr"); row.dataset.taskId = task.simulationTaskId; + row.dataset.assignmentMode = task.assignmentMode || "UNASSIGNED"; row.classList.toggle("is-selected", task.simulationTaskId === state.selectedTaskId); row.addEventListener("click", function () { selectResult(state.historyResult, task.simulationTaskId); @@ -1142,6 +1309,7 @@ appendCell(row, task.priority, true); appendCell(row, task.historicalVehicleId || "--", true); appendCell(row, task.simulatedVehicleId || "--", true); + appendCell(row, historyAssignmentMode(task)); appendCell(row, task.pathChanged ? "已重规划" : "一致"); appendCell(row, task.status); appendCell(row, formatDuration(task.waitingMillis), true); @@ -1151,6 +1319,12 @@ elements["history-task-body"].replaceChildren(fragment); } + function historyAssignmentMode(task) { + if (task.assignmentMode !== "FLOOR_TRANSFER") return "直接"; + var job = (task.floorTransferJobs || [])[0]; + return job ? "换层 " + job.sourceFloor + "F→" + job.targetFloor + "F" : "换层"; + } + function renderSafety() { var result = state.historyResult || state.pointResult; if (!result) { @@ -1283,7 +1457,7 @@ function isHold(type) { return type === "HOLD" || type === "CLEARANCE_HOLD" || type === "PLC_RETRY" - || type === "CLEARANCE_PLC_RETRY"; + || type === "CLEARANCE_PLC_RETRY" || type === "FLOOR_TRANSFER_HOLD"; } function tickValue() { @@ -1321,11 +1495,15 @@ } function resetInspector() { - ["inspector-task-id", "evidence-endpoints", "evidence-vehicle", "evidence-history-vehicle", + ["inspector-task-id", "evidence-endpoints", "evidence-assignment-mode", + "evidence-involved-floors", "evidence-vehicle", "evidence-history-vehicle", "evidence-assigned-time", "evidence-waiting", "evidence-completion", "evidence-hold-retry", "evidence-plc", "route-difference", "invalid-history-segments", "planned-path-output", "reason-output"] .forEach(function (id) { setText(id, "--"); }); + elements["floor-transfer-panel"].hidden = true; + elements["floor-transfer-list"].replaceChildren(); + setText("floor-transfer-count", "0 个"); setEvidenceBadge("route-comparability", null, "--"); setEvidenceBadge("route-quality-comparison", null, "--"); elements["route-version-select"].replaceChildren(); diff --git a/javaTest/src/main/resources/visualizer/index.html b/javaTest/src/main/resources/visualizer/index.html index 666d00e..fd7574c 100644 --- a/javaTest/src/main/resources/visualizer/index.html +++ b/javaTest/src/main/resources/visualizer/index.html @@ -100,7 +100,7 @@
- +
任务A → B优先级历史车模拟车路径状态等待完成
任务A → B优先级历史车模拟车调度方式路径状态等待完成
@@ -144,6 +144,7 @@ 换路前 换路后 HOLD + 换层端口
@@ -174,6 +175,8 @@
业务端点
--
+
分配模式
--
+
涉及楼层
--
中心选车
--
历史车辆
--
首次分配
--
@@ -184,6 +187,13 @@
路径边差
共同 -- / 新增 -- / 历史 --
历史无效段
--
+
@@ -263,7 +273,7 @@
- +
tick事件车辆原因移动
tick事件车辆原因位置 / 换层
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/FloorTransferVisualizationMapperTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/FloorTransferVisualizationMapperTest.java new file mode 100644 index 0000000..6477eb4 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/FloorTransferVisualizationMapperTest.java @@ -0,0 +1,106 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.demo.model.Site; +import com.ft546.wcs.demo.model.VehicleSnapshot; +import com.ft546.wcs.demo.traffic.GridMap; +import com.ft546.wcs.shadow.history.HistoricalQueueDataset; +import com.ft546.wcs.shadow.history.HistoricalQueueLoader; +import com.ft546.wcs.shadow.history.visualizer.SimulationVisualizationMapper; +import com.ft546.wcs.shadow.map.EffectiveTrafficMap; +import com.ft546.wcs.shadow.map.TrafficMapCompiler; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FloorTransferVisualizationMapperTest { + /** + * 验证页面能审计换层作业和涉及楼层,同时底图不会把提升机伪造成普通跨层边。 + */ + @Test + void mapsFloorTransferEvidenceWithoutAddingAFakeMapEdge() { + HistoricalQueueDataset dataset = new HistoricalQueueLoader().load( + getClass().getClassLoader(), "shadow/history"); + HistoricalSimulationInput crossFloor = crossFloorInput(); + HistoricalDispatchSimulationResult crossResult = + new HistoricalDispatchSimulator().simulate( + crossFloor, new SimulationConfig(100, 30_000L, 3, 100, 100), + DeterministicFaultPlan.none()); + SimulationVisualizationMapper mapper = new SimulationVisualizationMapper(dataset); + + Map bootstrap = mapper.bootstrap(crossFloor); + List> edges = listOfMaps(bootstrap.get("edges")); + assertTrue(edges.stream().noneMatch(edge -> + ("LIFT-1".equals(edge.get("fromSite")) + && "LIFT-2".equals(edge.get("toSite"))) + || ("LIFT-2".equals(edge.get("fromSite")) + && "LIFT-1".equals(edge.get("toSite"))))); + + Map payload = mapper.result(crossFloor, crossResult); + Map task = listOfMaps(payload.get("tasks")).get(0); + assertEquals("FLOOR_TRANSFER", task.get("assignmentMode")); + assertEquals(1, task.get("floorTransferCount")); + assertEquals(Arrays.asList(1, 2), task.get("involvedFloors")); + List> jobs = listOfMaps(task.get("floorTransferJobs")); + assertEquals(1, jobs.size()); + assertEquals("1", jobs.get(0).get("vehicleId")); + assertEquals(1, jobs.get(0).get("sourceFloor")); + assertEquals(2, jobs.get(0).get("targetFloor")); + assertEquals("LIFT-1", jobs.get(0).get("sourceSite")); + assertEquals("LIFT-2", jobs.get(0).get("targetSite")); + assertTrue(listOfMaps(payload.get("events")).stream().anyMatch(event -> + "FLOOR_TRANSFER_COMPLETED".equals(event.get("eventType")) + && "LIFT-1".equals(event.get("fromSite")) + && "LIFT-2".equals(event.get("toSite")))); + } + + /** 构造两个互不连边的楼层,唯一车辆必须由换层网关到二层后再执行 A 到 B。 */ + private HistoricalSimulationInput crossFloorInput() { + GridMap grid = new GridMap(); + grid.addSite(new Site("V1", 1, 0, 0, "TEST")); + grid.addSite(new Site("LIFT-1", 1, 1, 0, "TRANSFER")); + grid.addSite(new Site("LIFT-2", 2, 1, 0, "TRANSFER")); + grid.addSite(new Site("A", 2, 2, 0, "TEST")); + grid.addSite(new Site("B", 2, 3, 0, "TEST")); + grid.connect("V1", "LIFT-1"); + grid.connect("LIFT-2", "A"); + grid.connect("A", "B"); + LinkedHashSet nodes = new LinkedHashSet( + Arrays.asList("V1", "LIFT-1", "LIFT-2", "A", "B")); + LinkedHashSet edges = + new LinkedHashSet(); + edges.add(new TrafficMapCompiler.EdgeDefinition("V1", "LIFT-1")); + edges.add(new TrafficMapCompiler.EdgeDefinition("LIFT-2", "A")); + edges.add(new TrafficMapCompiler.EdgeDefinition("A", "B")); + EffectiveTrafficMap traffic = new TrafficMapCompiler().compile( + new TrafficMapCompiler.MapDefinition("VISUAL-TRANSFER", 1L, + "TEST", true, "test", "test", nodes, edges, + Collections.emptyMap(), Collections.emptySet(), + Collections.emptySet())); + Map ports = new LinkedHashMap(); + ports.put(1, "LIFT-1"); + ports.put(2, "LIFT-2"); + HistoricalSimulationInput base = new HistoricalSimulationInput( + Collections.emptyList(), + Collections.emptyList(), grid, traffic, + Collections.singletonList(new VehicleSnapshot( + "1", 1, "V1", 5, 1, 100, true, true, false)), + new FloorTransferTopology(ports)); + return new PointToPointSimulationInputFactory().create( + base, "A", "B", DispatchTask.TaskType.INBOUND, 1, + null, Collections.emptyList()); + } + + @SuppressWarnings("unchecked") + private static List> listOfMaps(Object value) { + return (List>) value; + } +}