From 49c374b4b36b014820562064ecd21c2994f5ce9b Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Mon, 10 Aug 2026 12:28:14 +0800 Subject: [PATCH] feat(javaTest): visualize source-aligned task phases --- javaTest/scripts/visualizer_browser_qa.py | 101 +++++++-- ...storicalDispatchVisualizerApplication.java | 7 +- .../HistoricalDispatchVisualizerServer.java | 43 ++-- .../HistoricalSimulationRequest.java | 33 +++ .../HistoricalWorkloadSimulationService.java | 3 +- .../SimulationVisualizationMapper.java | 59 +++++ .../main/resources/visualizer/assets/app.css | 116 +++++++++- .../main/resources/visualizer/assets/app.js | 211 ++++++++++++++++-- .../src/main/resources/visualizer/index.html | 35 ++- ...istoricalDispatchVisualizerServerTest.java | 55 ++++- .../PointToPointSimulationServiceTest.java | 47 ++++ .../SimulationVisualizationMapperTest.java | 24 ++ 12 files changed, 660 insertions(+), 74 deletions(-) create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalSimulationRequest.java diff --git a/javaTest/scripts/visualizer_browser_qa.py b/javaTest/scripts/visualizer_browser_qa.py index 14b6952..bcf676f 100644 --- a/javaTest/scripts/visualizer_browser_qa.py +++ b/javaTest/scripts/visualizer_browser_qa.py @@ -91,17 +91,71 @@ def verify_map_interactions(page): page.locator("#show-move-trace").check() -def verify_historical_mode(page): - """Run 240 tasks and inspect one real cross-floor dispatch without drawing a fake edge.""" +def select_task_with_phase(page, phase_name): + """Select the first visible business task that exposes the requested backend phase.""" + rows = page.locator("#history-task-body tr") + for index in range(rows.count()): + rows.nth(index).click() + if page.locator("#phase-list [data-phase='%s']" % phase_name).count(): + return + raise AssertionError("no task exposed business phase " + phase_name) + + +def assert_no_cross_floor_route_segments(page): + """Verify that transfer jobs use port markers rather than fake map edges.""" + 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_historical_mode(page, switch_raw=True): + """Run business replay, inspect phase/transfer paths, then verify RAW compatibility.""" page.locator(".mode-tab[data-mode='history']").click() + require(page.locator("#simulation-mode-business").is_checked(), + "business replay must be the default historical mode") page.locator("#run-history").click() expect(page.locator("#history-run-state")).to_have_text( re.compile(r"^最近运行"), timeout=180_000 ) - require("240" in page.locator("#task-filter-count").inner_text(), - "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") + require(page.locator("#raw-snapshot-count").inner_text() == "240", + "input audit did not expose 240 raw snapshots") + require(page.locator("#business-task-count").inner_text() == "76", + "input audit did not expose 76 business parent tasks") + require("76" in page.locator("#task-filter-count").inner_text(), + "business replay did not expose 76 parent tasks") + require(page.locator("#history-task-body tr").count() == 76, + "business replay table row count is not 76") + + select_task_with_phase(page, "EMPTY_RETURN_TO_TARGET_FRONT") + require(page.locator("#phase-list [data-phase]").count() >= 2, + "business task did not expose its multi-stage route evidence") + require(page.locator("#business-completed-at").inner_text() != "--", + "business completion time is missing") + require("车辆已释放" in page.locator("#vehicle-released-at").inner_text(), + "vehicle release evidence is incomplete") + selected_phase = page.locator( + "#phase-list [data-phase='EMPTY_RETURN_TO_TARGET_FRONT']" + ) + selected_phase.click() + require(page.locator("#dynamic-route-layer .business-phase").count() > 0, + "selected business phase did not render a same-floor route") + require(page.locator(".map-floor-layer:not([hidden]) .map-edge").count() > 20, + "phase selection replaced the authoritative full map") + assert_no_cross_floor_route_segments(page) + transfer_rows = page.locator( "#history-task-body tr[data-assignment-mode='FLOOR_TRANSFER']" ) @@ -132,22 +186,21 @@ def verify_historical_mode(page): ).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)) + assert_no_cross_floor_route_segments(page) + + if not switch_raw: + return + page.locator("label[for='simulation-mode-raw']").click() + expect(page.locator("#task-filter-count")).to_have_text("240 条", timeout=180_000) + require(page.locator("#history-task-body tr").count() == 240, + "RAW compatibility replay did not expose all 240 snapshots") + require(page.locator("#replay-mode-readout").inner_text() == "RAW 快照", + "replay mode readout did not switch to RAW") + require(not page.locator("#show-historical").is_disabled(), + "RAW mode did not restore the historical path layer") + page.locator("#history-task-body tr").first.click() + require(page.locator("#route-difference").inner_text() != "共同 -- / 新增 -- / 历史 --", + "RAW task did not render historical/new path difference evidence") def verify_viewport(browser, viewport, filename, run_history): @@ -166,7 +219,7 @@ def verify_viewport(browser, viewport, filename, run_history): run_point_to_point(page) verify_map_interactions(page) if run_history: - verify_historical_mode(page) + verify_historical_mode(page, switch_raw=viewport["width"] > 580) require(not horizontal_overflow(page), "page has horizontal overflow after simulation") page.screenshot(path=os.path.join(OUTPUT_DIR, filename), full_page=True) require(not errors, "browser console/page errors: " + " | ".join(errors)) @@ -186,7 +239,7 @@ def main(): launch_options["executable_path"] = cached_candidates[0] browser = playwright.chromium.launch(**launch_options) verify_viewport(browser, {"width": 1440, "height": 1000}, "desktop.png", True) - verify_viewport(browser, {"width": 390, "height": 844}, "mobile.png", False) + verify_viewport(browser, {"width": 390, "height": 844}, "mobile.png", True) browser.close() print("visualizer browser QA passed: desktop=1440x1000 mobile=390x844") diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerApplication.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerApplication.java index ceab0c8..1adfa92 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerApplication.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerApplication.java @@ -23,9 +23,12 @@ public final class HistoricalDispatchVisualizerApplication { HistoricalQueueDataset dataset = classLoader.getResource("shadow/history/manifest.json") != null ? loader.load(classLoader, "shadow/history") : loader.load(Paths.get("src/test/resources/shadow/history")); - HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset); + HistoricalSimulationInputFactory factory = new HistoricalSimulationInputFactory(); + HistoricalSimulationInput businessInput = factory.create(dataset); + HistoricalSimulationInput rawInput = factory.createRawSnapshots(dataset); HistoricalDispatchVisualizerServer server = - new HistoricalDispatchVisualizerServer(port, dataset, input); + new HistoricalDispatchVisualizerServer( + port, dataset, businessInput, rawInput); CountDownLatch shutdown = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.stop(); diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServer.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServer.java index 9b86b83..3d2b066 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServer.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServer.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ft546.wcs.shadow.history.HistoricalQueueDataset; import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult; import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput; +import com.ft546.wcs.shadow.history.simulation.SimulationExecutionMode; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; @@ -35,25 +36,35 @@ public final class HistoricalDispatchVisualizerServer { private final HttpServer httpServer; private final ExecutorService executor; private final ObjectMapper objectMapper; - private final HistoricalSimulationInput input; + private final HistoricalSimulationInput businessInput; + private final HistoricalSimulationInput rawInput; private final PointToPointSimulationService pointService; - private final HistoricalWorkloadSimulationService historicalService; + private final HistoricalWorkloadSimulationService businessHistoricalService; + private final HistoricalWorkloadSimulationService rawHistoricalService; private final SimulationVisualizationMapper mapper; private final AtomicBoolean simulationBusy = new AtomicBoolean(false); private final AtomicBoolean started = new AtomicBoolean(false); public HistoricalDispatchVisualizerServer( - int port, HistoricalQueueDataset dataset, HistoricalSimulationInput input) + int port, HistoricalQueueDataset dataset, + HistoricalSimulationInput businessInput, HistoricalSimulationInput rawInput) throws IOException { if (port < 0 || port > 65_535) { throw new IllegalArgumentException("port must be between 0 and 65535"); } - if (dataset == null || input == null) { - throw new IllegalArgumentException("dataset and input are required"); + if (dataset == null || businessInput == null || rawInput == null) { + throw new IllegalArgumentException("dataset, business input and raw input are required"); } - this.input = input; - this.pointService = new PointToPointSimulationService(input); - this.historicalService = new HistoricalWorkloadSimulationService(input); + if (businessInput.getExecutionMode() + != SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS + || rawInput.getExecutionMode() != SimulationExecutionMode.RAW_SNAPSHOT) { + throw new IllegalArgumentException("historical inputs do not match their replay modes"); + } + this.businessInput = businessInput; + this.rawInput = rawInput; + this.pointService = new PointToPointSimulationService(businessInput); + this.businessHistoricalService = new HistoricalWorkloadSimulationService(businessInput); + this.rawHistoricalService = new HistoricalWorkloadSimulationService(rawInput); this.mapper = new SimulationVisualizationMapper(dataset); this.objectMapper = new ObjectMapper(); this.httpServer = HttpServer.create(new InetSocketAddress( @@ -131,14 +142,14 @@ public final class HistoricalDispatchVisualizerServer { Map health = new LinkedHashMap(); health.put("status", "UP"); health.put("mode", "OFFLINE_IN_MEMORY_SIMULATION"); - health.put("mapLoaded", !input.getGridMap().getSites().isEmpty()); + health.put("mapLoaded", !businessInput.getGridMap().getSites().isEmpty()); health.put("simulationBusy", simulationBusy.get()); writeJson(exchange, 200, health); return; } if ("/api/bootstrap".equals(path)) { requireMethod(method, "GET"); - writeJson(exchange, 200, mapper.bootstrap(input)); + writeJson(exchange, 200, mapper.bootstrap(businessInput)); return; } if ("/api/simulations/point-to-point".equals(path)) { @@ -147,15 +158,19 @@ public final class HistoricalDispatchVisualizerServer { PointToPointSimulationRequest request = objectMapper.readValue( body, PointToPointSimulationRequest.class); writeJson(exchange, 200, runSimulation(() -> - mapper.result(input, pointService.run(request)))); + mapper.result(businessInput, pointService.run(request)))); return; } if ("/api/simulations/historical".equals(path)) { requireMethod(method, "POST"); - readBody(exchange); + HistoricalSimulationRequest request = objectMapper.readValue( + readBody(exchange), HistoricalSimulationRequest.class); writeJson(exchange, 200, runSimulation(() -> { - HistoricalDispatchSimulationResult result = historicalService.run(); - return mapper.result(input, result); + boolean raw = request.getMode() == SimulationExecutionMode.RAW_SNAPSHOT; + HistoricalSimulationInput selectedInput = raw ? rawInput : businessInput; + HistoricalDispatchSimulationResult result = raw + ? rawHistoricalService.run() : businessHistoricalService.run(); + return mapper.result(selectedInput, result); })); return; } diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalSimulationRequest.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalSimulationRequest.java new file mode 100644 index 0000000..31d7ad4 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalSimulationRequest.java @@ -0,0 +1,33 @@ +package com.ft546.wcs.shadow.history.visualizer; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.ft546.wcs.shadow.history.simulation.SimulationExecutionMode; + +/** + * 选择历史回放的审计口径。 + * + *

业务模式把重复队列快照归并为真实父任务并执行二段式阶段;RAW 模式保留原先 + * 240 行逐条运行的兼容结果。空请求默认进入业务模式,避免页面误把快照数当成业务任务数。 + */ +public final class HistoricalSimulationRequest { + @JsonProperty("mode") + private String mode; + + /** 供 Jackson 反序列化请求体使用。 */ + public HistoricalSimulationRequest() { + } + + /** 返回经过校验的回放口径;未传值时默认按生产源码语义回放。 */ + public SimulationExecutionMode getMode() { + String normalized = mode == null || mode.trim().isEmpty() + ? SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS.name() + : mode.trim().toUpperCase(); + try { + return SimulationExecutionMode.valueOf(normalized); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + "INVALID_EXECUTION_MODE: mode must be SOURCE_ALIGNED_BUSINESS or RAW_SNAPSHOT", + exception); + } + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalWorkloadSimulationService.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalWorkloadSimulationService.java index 124be05..d454734 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalWorkloadSimulationService.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/HistoricalWorkloadSimulationService.java @@ -7,7 +7,7 @@ import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput; import com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway; import com.ft546.wcs.shadow.history.simulation.SimulationConfig; -/** 重新运行由 240 条历史快照组装出的业务父任务,并保留既定的确定性 PLC 故障注入。 */ +/** 按调用方选择的不可变输入运行历史回放,并保留既定的确定性 PLC 故障注入。 */ public final class HistoricalWorkloadSimulationService { private final HistoricalSimulationInput input; @@ -17,6 +17,7 @@ public final class HistoricalWorkloadSimulationService { } public HistoricalDispatchSimulationResult run() { + // 每种模式都对自身任务序列稳定注入故障,保证业务/RAW 对比可重复而不共享运行状态。 DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder(); for (int i = 0; i < input.getTasks().size(); i += 37) { faults.plcFailure(input.getTasks().get(i).getSimulationTaskId(), 1, 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 8258d08..945d80a 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.HistoricalTaskSnapshotEvidence; import com.ft546.wcs.shadow.history.simulation.FloorTransferJob; import com.ft546.wcs.shadow.history.simulation.RoutePlanningEvidence; import com.ft546.wcs.shadow.history.simulation.RerouteDecisionEvidence; @@ -16,6 +17,7 @@ import com.ft546.wcs.shadow.history.simulation.SimulationMetrics; import com.ft546.wcs.shadow.history.simulation.SimulationTaskResult; import com.ft546.wcs.shadow.history.simulation.SimulationTaskStatus; import com.ft546.wcs.shadow.history.simulation.SimulationTickEvent; +import com.ft546.wcs.shadow.history.simulation.TaskPhaseEvidence; import com.ft546.wcs.demo.traffic.RouteCandidateEvaluation; import com.ft546.wcs.demo.traffic.RoutePlanDecision; import com.ft546.wcs.demo.traffic.RouteQuality; @@ -121,6 +123,7 @@ public final class SimulationVisualizationMapper { payload.put("plannerVersion", PLANNER_VERSION); payload.put("mapSha256", dataset.getFullMapSha256()); payload.put("pathInfoUsedForPlanning", false); + payload.put("executionMode", result.getExecutionMode().name()); payload.put("summary", summary(result, definitions, input.getGridMap())); payload.put("tasks", tasks); payload.put("events", events); @@ -135,6 +138,13 @@ public final class SimulationVisualizationMapper { GridMap gridMap) { Map value = new LinkedHashMap(); value.put("inputCount", result.getTaskResults().size()); + value.put("rawSnapshotCount", result.getInputAudit().getRawSnapshotCount()); + value.put("uniqueQueueCount", result.getInputAudit().getUniqueQueueCount()); + value.put("duplicateSnapshotCount", result.getInputAudit().getDuplicateSnapshotCount()); + value.put("businessTaskCount", result.getInputAudit().getBusinessTaskCount()); + value.put("operationalQueueCount", result.getInputAudit().getOperationalQueueCount()); + value.put("incompleteVehicleReleaseCount", + result.getIncompleteVehicleReleaseCount()); value.put("acceleration", result.getAcceleration()); value.put("completedCount", result.getCompletedCount()); value.put("waitingCount", result.getWaitingCount()); @@ -199,6 +209,8 @@ public final class SimulationVisualizationMapper { value.put("guid", record.getGuid()); value.put("taskType", definition == null ? record.getTaskType() : definition.getTaskType().name()); + value.put("businessType", definition == null + ? record.getTaskType() : definition.getTaskType().name()); value.put("priority", record.getPriority()); value.put("businessStartSite", record.getStartSite()); value.put("businessEndSite", record.getEndSite()); @@ -206,6 +218,10 @@ public final class SimulationVisualizationMapper { ? record.getStartSite() : definition.getPhysicalSource()); value.put("physicalEndSite", definition == null ? record.getEndSite() : definition.getPhysicalTarget()); + value.put("sourceFrontSite", definition == null + ? null : definition.getSourceFrontSite()); + value.put("targetFrontSite", definition == null + ? null : definition.getTargetFrontSite()); value.put("historicalVehicleId", record.getAgvId()); value.put("simulatedVehicleId", task.getSimulatedVehicleId()); value.put("assignmentMode", task.getAssignmentMode() == null @@ -224,6 +240,13 @@ public final class SimulationVisualizationMapper { value.put("arrivalMillis", task.getArrivalMillis()); value.put("firstAssignedMillis", task.getFirstAssignedMillis()); value.put("completedMillis", task.getCompletedMillis()); + value.put("businessCompletedMillis", task.getBusinessCompletedMillis()); + value.put("vehicleReleasedMillis", task.getVehicleReleasedMillis()); + value.put("vehicleReleaseStatus", task.getVehicleReleaseStatus().name()); + value.put("cargoState", task.getCargoState().name()); + value.put("skippedStageReasons", task.getSkippedStageReasons()); + value.put("snapshotEvidence", snapshotEvidence(definition)); + value.put("phaseEvidence", phaseEvidence(task.getPhaseEvidence())); value.put("waitingMillis", task.getWaitingMillis()); value.put("simulationDurationMillis", simulatedDuration); value.put("historicalDurationMillis", historicalDuration); @@ -259,6 +282,42 @@ public final class SimulationVisualizationMapper { return value; } + /** 输出父任务关联的原始空车、载货和重复扫描记录,供页面追溯 GUID,而不参与重新规划。 */ + private List> snapshotEvidence(HistoricalSimulationTask definition) { + List> values = new ArrayList>(); + if (definition == null) return values; + for (HistoricalTaskSnapshotEvidence evidence : definition.getSnapshotEvidence()) { + Map value = new LinkedHashMap(); + value.put("snapshotRow", evidence.getSnapshotRow()); + value.put("guid", evidence.getGuid()); + value.put("queueTaskType", evidence.getQueueTaskType()); + value.put("default02", evidence.getDefault02()); + value.put("historicalVehicleId", evidence.getHistoricalVehicleId()); + value.put("historicalPath", historicalPath(evidence.getHistoricalPath())); + values.add(value); + } + return values; + } + + /** 输出仿真器冻结的逐阶段真实路径;前端只选择和绘制,不重新推导业务阶段。 */ + private List> phaseEvidence(List evidence) { + List> values = new ArrayList>(); + for (TaskPhaseEvidence phase : evidence) { + Map value = new LinkedHashMap(); + value.put("phase", phase.getPhase().name()); + value.put("fromSite", phase.getFromSite()); + value.put("toSite", phase.getToSite()); + value.put("plannedPath", phase.getPlannedPath()); + value.put("startedMillis", phase.getStartedMillis()); + value.put("completedMillis", phase.getCompletedMillis()); + value.put("holdCount", phase.getHoldCount()); + value.put("retryCount", phase.getRetryCount()); + value.put("completionReason", phase.getCompletionReason()); + values.add(value); + } + return values; + } + /** * 汇总父任务换层作业经过的楼层,供页面楼层选择器标记整条业务链涉及的地图。 */ diff --git a/javaTest/src/main/resources/visualizer/assets/app.css b/javaTest/src/main/resources/visualizer/assets/app.css index c2f1619..b7d9300 100644 --- a/javaTest/src/main/resources/visualizer/assets/app.css +++ b/javaTest/src/main/resources/visualizer/assets/app.css @@ -189,6 +189,60 @@ h3 { font-size: 14px; line-height: 1.3; } .history-actions { justify-content: space-between; gap: 16px; } .history-actions > div:first-child { display: grid; gap: 3px; } .history-actions span { color: var(--color-muted); font-size: 12px; } +.replay-mode-control { + display: inline-grid; + grid-template-columns: 1fr 1fr; + min-width: 220px; + padding: 3px; + background: #e3e9e7; + border: 1px solid var(--color-line-strong); + border-radius: var(--radius); +} +.replay-mode-control input { position: absolute; opacity: 0; pointer-events: none; } +.replay-mode-control label { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + color: var(--color-muted); + border: 1px solid transparent; + border-radius: 4px; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} +.replay-mode-control input:checked + label { + color: var(--color-ink); + background: var(--color-surface); + border-color: var(--color-line); + box-shadow: 0 1px 3px rgba(22, 33, 31, .1); +} +.replay-mode-control input:focus-visible + label { + outline: 3px solid rgba(20, 125, 157, .22); +} +.audit-count-strip { + display: grid; + grid-template-columns: repeat(6, minmax(90px, 1fr)); + border: 1px solid var(--color-line); + background: var(--color-surface); +} +.audit-count-strip > div { + min-width: 0; + display: grid; + gap: 2px; + padding: 7px 10px; + border-right: 1px solid var(--color-line); +} +.audit-count-strip > div:last-child { border-right: 0; } +.audit-count-strip span { color: var(--color-muted); font-size: 10px; } +.audit-count-strip strong { + overflow: hidden; + font-family: var(--font-mono); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} .button-row { gap: 8px; } .kpi-strip { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: 1px; background: var(--color-line); border: 1px solid var(--color-line); } .kpi-item { min-height: 62px; padding: 9px 12px; background: var(--color-surface); display: grid; gap: 2px; } @@ -277,6 +331,7 @@ h3 { font-size: 14px; line-height: 1.3; } .legend-line.traversed { border-color: var(--color-traversed-path); border-top-width: 5px; } .legend-line.reroute-old { border-color: var(--color-red); border-top-style: dashed; } .legend-line.reroute-new { border-color: var(--color-blue); border-top-width: 5px; } +.legend-line.business-phase { border-color: #6a4c93; border-top-width: 6px; } .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); } @@ -299,6 +354,10 @@ h3 { font-size: 14px; line-height: 1.3; } .route-segment.traversed { stroke: var(--color-traversed-path); stroke-width: 8; } .route-segment.reroute-old { stroke: var(--color-red); stroke-width: 5; stroke-dasharray: 12 8; opacity: .82; } .route-segment.reroute-new { stroke: var(--color-blue); stroke-width: 5; } +.route-segment.business-phase { stroke: #6a4c93; stroke-width: 7; } +.route-segment.phase-loaded-return-to-source-front { stroke: var(--color-amber); } +.route-segment.phase-loaded-to-place { stroke: var(--color-service-path); } +.route-segment.phase-empty-return-to-target-front { stroke: var(--color-blue); } .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); } @@ -342,7 +401,9 @@ 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, .floor-transfer-panel { border-bottom: 1px solid var(--color-line); } +.decision-audit, .reroute-audit, .floor-transfer-panel, .business-evidence-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; } @@ -356,6 +417,46 @@ h3 { font-size: 14px; line-height: 1.3; } .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; } +.business-evidence-grid { + margin: 0; + display: grid; + grid-template-columns: 1fr 1fr; +} +.business-evidence-grid > div { + min-width: 0; + display: grid; + gap: 4px; + padding: 8px 12px; + border-top: 1px solid #e4eae8; +} +.business-evidence-grid > div:nth-child(odd) { border-right: 1px solid #e4eae8; } +.business-evidence-grid > div.span-all { grid-column: 1 / -1; border-right: 0; } +.business-evidence-grid dt { color: var(--color-muted); font-size: 10px; } +.business-evidence-grid dd { + margin: 0; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.phase-list { display: grid; gap: 1px; background: var(--color-line); } +.phase-item { + min-width: 0; + display: grid; + grid-template-columns: minmax(112px, .75fr) minmax(0, 1.25fr); + gap: 3px 10px; + padding: 9px 12px; + color: var(--color-ink); + background: var(--color-surface); + border: 0; + border-left: 4px solid transparent; + text-align: left; +} +.phase-item:hover { background: var(--color-surface-muted); } +.phase-item.is-active { background: #f0ebf6; border-left-color: #6a4c93; } +.phase-item-name { font-size: 11px; font-weight: 750; } +.phase-item-route { min-width: 0; font-family: var(--font-mono); font-size: 10px; overflow-wrap: anywhere; } +.phase-item-meta { grid-column: 1 / -1; color: var(--color-muted); font-size: 9px; } .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; } @@ -437,6 +538,9 @@ h3 { font-size: 14px; line-height: 1.3; } .field-fault { grid-column: span 2; } .point-form .primary-button { grid-column: span 1; } .kpi-strip { grid-template-columns: repeat(3, 1fr); } + .audit-count-strip { grid-template-columns: repeat(3, 1fr); } + .audit-count-strip > div:nth-child(3) { border-right: 0; } + .audit-count-strip > div:nth-child(-n+3) { border-bottom: 1px solid var(--color-line); } .audit-grid { grid-template-columns: repeat(3, 1fr); } } @@ -469,9 +573,15 @@ h3 { font-size: 14px; line-height: 1.3; } .field-site { grid-column: 1; } #swap-sites { grid-column: 2; grid-row: 1 / span 2; align-self: center; } .field:not(.field-site), .field-fault, .point-form .primary-button { grid-column: 1 / -1; } - .history-actions { align-items: flex-start; } + .history-actions { align-items: flex-start; flex-wrap: wrap; } + .history-actions > div:first-child { flex: 1 1 120px; } + .replay-mode-control { order: 3; width: 100%; min-width: 0; } .button-row { flex-wrap: wrap; justify-content: flex-end; } .kpi-strip { grid-template-columns: repeat(2, 1fr); } + .audit-count-strip { grid-template-columns: repeat(2, 1fr); } + .audit-count-strip > div { border-right: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); } + .audit-count-strip > div:nth-child(even) { border-right: 0; } + .audit-count-strip > div:nth-last-child(-n+2) { border-bottom: 0; } .section-bar { align-items: flex-start; padding: 10px 12px; } .route-tools { display: grid; grid-template-columns: auto auto; gap: 5px 7px; justify-items: end; } .view-mode { grid-column: 1; } @@ -484,6 +594,8 @@ h3 { font-size: 14px; line-height: 1.3; } .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; } + .phase-item { grid-template-columns: 1fr; } + .phase-item-meta { 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 c1bd6a4..93a3bbe 100644 --- a/javaTest/src/main/resources/visualizer/assets/app.js +++ b/javaTest/src/main/resources/visualizer/assets/app.js @@ -7,7 +7,9 @@ pointResult: null, historyResult: null, mode: "point", + historicalMode: "SOURCE_ALIGNED_BUSINESS", selectedTaskId: null, + selectedPhaseIndex: 0, selectedPlanningStage: "SERVICE", selectedPathVersion: null, selectedRerouteIndex: null, @@ -37,8 +39,11 @@ "point-controls", "history-controls", "point-form", "source-site", "target-site", "site-options", "task-type", "priority", "acceleration", "fault-preset", "swap-sites", "run-point", "run-history", "history-run-state", "history-kpis", + "simulation-mode-business", "simulation-mode-raw", "raw-snapshot-count", + "unique-queue-count", "duplicate-snapshot-count", "business-task-count", + "operational-queue-count", "replay-mode-readout", "task-search", "task-status-filter", "task-filter-count", "history-task-body", - "task-workspace", "route-map", "route-empty", "loading-state", "loading-label", + "task-workspace", "route-title", "route-map", "route-empty", "loading-state", "loading-label", "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", @@ -48,6 +53,10 @@ "evidence-hold-retry", "evidence-plc", "route-difference", "invalid-history-segments", "planned-path-output", "reason-output", "floor-transfer-panel", "floor-transfer-count", "floor-transfer-list", + "business-evidence-panel", "cargo-state", "source-front-site", + "target-front-site", "business-completed-at", "vehicle-released-at", + "snapshot-guid-list", "phase-list", "current-business-phase", + "vehicle-release-status", "route-comparability", "route-quality-comparison", "route-stage-select", "route-version-select", "decision-shortest-distance", "decision-selected-distance", "decision-detour-ratio", "decision-candidate-count", @@ -68,6 +77,17 @@ elements["point-form"].addEventListener("submit", runPointSimulation); elements["swap-sites"].addEventListener("click", swapSites); elements["run-history"].addEventListener("click", runHistoricalSimulation); + document.querySelectorAll("input[name='simulation-mode']").forEach(function (input) { + input.addEventListener("change", function () { + if (!input.checked) return; + state.historicalMode = input.value; + state.selectedPhaseIndex = 0; + updateReplayModePresentation(); + if (state.mode === "history" && state.historyResult && !state.busy) { + runHistoricalSimulation(); + } + }); + }); elements["task-search"].addEventListener("input", renderHistoryTable); elements["task-status-filter"].addEventListener("change", renderHistoryTable); elements["floor-select"].addEventListener("change", function () { @@ -198,12 +218,13 @@ } async function runHistoricalSimulation() { - setBusy(true, "正在重新运行 240 条历史任务"); + var businessMode = state.historicalMode === "SOURCE_ALIGNED_BUSINESS"; + setBusy(true, businessMode ? "正在运行 76 个业务父任务" : "正在运行 240 条 RAW 快照"); elements["history-run-state"].textContent = "运行中"; try { state.historyResult = await api("/api/simulations/historical", { method: "POST", - body: "{}" + body: JSON.stringify({ mode: state.historicalMode }) }); elements["history-run-state"].textContent = "最近运行:" + new Date().toLocaleTimeString("zh-CN", { hour12: false }); renderHistorySummary(); @@ -211,7 +232,8 @@ selectResult(state.historyResult, state.historyResult.tasks[0].simulationTaskId); elements["export-result"].disabled = false; elements["export-safety"].disabled = false; - showToast("240 条历史任务仿真完成"); + updateReplayModePresentation(); + showToast(state.historyResult.summary.completedCount + " 条任务回放完成"); } catch (error) { elements["history-run-state"].textContent = "运行失败"; handleRunError(error); @@ -243,11 +265,28 @@ } else if (mode === "safety") { renderSafety(); } + updateReplayModePresentation(); + } + + /** 同步回放口径、按钮状态和地图图层含义,避免业务阶段与 RAW 路径差异混为一谈。 */ + function updateReplayModePresentation() { + var business = state.historicalMode === "SOURCE_ALIGNED_BUSINESS"; + elements["simulation-mode-business"].checked = business; + elements["simulation-mode-raw"].checked = !business; + if (!state.historyResult) { + setText("replay-mode-readout", business ? "业务任务" : "RAW 快照"); + } + var businessHistory = state.mode === "history" && business; + elements["show-historical"].disabled = businessHistory; + elements["show-initial-route"].disabled = businessHistory; + elements["route-title"].textContent = businessHistory + ? "业务阶段规划路径" : "算法规划路径"; } function selectResult(result, taskId) { state.selectedTaskId = taskId; state.tickIndex = 0; + state.selectedPhaseIndex = 0; state.selectedPlanningStage = "SERVICE"; state.selectedPathVersion = null; state.selectedRerouteIndex = null; @@ -322,11 +361,14 @@ setEvidenceBadge("route-quality-comparison", task.routeQualityComparison, qualityCategoryLabel(task.routeQualityComparison)); renderFloorTransferJobs(task); + renderBusinessEvidence(task); elements["route-stage-select"].value = state.selectedPlanningStage; renderRouteDecision(task); renderRerouteComparison(task); - elements["planned-path-output"].textContent = task.plannedPath.length - ? task.plannedPath.join(" → ") : "无规划路径"; + var displayedPath = isBusinessHistoryMode() + ? selectedPhasePath(task) : (task.plannedPath || []); + elements["planned-path-output"].textContent = displayedPath.length + ? displayedPath.join(" → ") : "无规划路径"; elements["reason-output"].textContent = task.reason || task.status; } @@ -413,6 +455,96 @@ return "已提交"; } + /** 展示后端冻结的业务状态和阶段路线;页面不根据站点命名自行推断通道阶段。 */ + function renderBusinessEvidence(task) { + var phases = (task && task.phaseEvidence) || []; + var panel = elements["business-evidence-panel"]; + panel.hidden = phases.length === 0; + if (!phases.length) { + elements["phase-list"].replaceChildren(); + return; + } + if (state.selectedPhaseIndex >= phases.length) state.selectedPhaseIndex = 0; + setEvidenceBadge("cargo-state", task.cargoState, + cargoStateLabel(task.cargoState)); + setText("current-business-phase", phaseLabel(phases[state.selectedPhaseIndex].phase)); + setText("vehicle-release-status", vehicleReleaseLabel(task.vehicleReleaseStatus)); + setText("source-front-site", task.sourceFrontSite || "无通道阶段"); + setText("target-front-site", task.targetFrontSite || "无通道阶段"); + setText("business-completed-at", formatMillis(task.businessCompletedMillis)); + setText("vehicle-released-at", formatMillis(task.vehicleReleasedMillis) + + " · " + vehicleReleaseLabel(task.vehicleReleaseStatus)); + var guids = unique((task.snapshotEvidence || []).map(function (item) { + return item.guid; + })); + setText("snapshot-guid-list", guids.length ? guids.join(" / ") : "无历史快照"); + + var fragment = document.createDocumentFragment(); + phases.forEach(function (phase, index) { + var button = document.createElement("button"); + button.type = "button"; + button.className = "phase-item"; + button.dataset.phase = phase.phase; + button.dataset.phaseIndex = String(index); + button.classList.toggle("is-active", index === state.selectedPhaseIndex); + var heading = textNode("span", phaseLabel(phase.phase), "phase-item-name"); + var endpoints = textNode("code", phase.fromSite + " → " + phase.toSite, + "phase-item-route"); + var metrics = textNode("small", phase.plannedPath.length + " 点 · HOLD " + + phase.holdCount + " · 重试 " + phase.retryCount, "phase-item-meta"); + button.append(heading, endpoints, metrics); + button.addEventListener("click", function () { + state.selectedPhaseIndex = index; + var start = siteById(phase.fromSite); + if (start) state.selectedFloor = start.floor; + renderBusinessEvidence(task); + elements["planned-path-output"].textContent = phase.plannedPath.join(" → "); + populateFloors(); + renderRoute(); + fitCurrentViewMode(); + }); + fragment.appendChild(button); + }); + elements["phase-list"].replaceChildren(fragment); + } + + function selectedPhase(task) { + var phases = (task && task.phaseEvidence) || []; + return phases[state.selectedPhaseIndex] || null; + } + + function selectedPhasePath(task) { + var phase = selectedPhase(task); + return phase ? (phase.plannedPath || []) : []; + } + + function isBusinessHistoryMode() { + var result = currentResult(); + return state.mode === "history" && result + && result.executionMode === "SOURCE_ALIGNED_BUSINESS"; + } + + function cargoStateLabel(value) { + return value === "DEPOSITED" ? "货物已入位" + : value === "LOADED" ? "货物在车" : "车辆空载"; + } + + function vehicleReleaseLabel(value) { + return value === "COMPLETED" ? "车辆已释放" + : value === "PENDING" ? "车辆待释放" + : value === "FAILED" ? "车辆释放失败" : "无需释放"; + } + + function phaseLabel(value) { + var labels = { + EMPTY_TO_PICKUP: "空车前往取货", + LOADED_RETURN_TO_SOURCE_FRONT: "载货返回源通道前端", + LOADED_TO_PLACE: "载货前往放货", + EMPTY_RETURN_TO_TARGET_FRONT: "空车返回目标通道前端" + }; + return labels[value] || value; + } + function textNode(tag, value, className) { var node = document.createElement(tag); if (className) node.className = className; @@ -572,6 +704,9 @@ var vehicleFloor = null; if (task) { var ids = [].concat(task.plannedPath || [], task.historicalPath || []); + (task.phaseEvidence || []).forEach(function (phase) { + ids = ids.concat(phase.plannedPath || []); + }); var events = eventsForTask(currentResult(), task.simulationTaskId); events.forEach(function (event) { if (event.fromSite) ids.push(event.fromSite); @@ -633,9 +768,12 @@ var approach = approachPath(task, moves); var historical = elements["show-historical"].checked ? task.historicalPath : []; var initialService = initialServicePath(task); + var businessPhase = isBusinessHistoryMode() ? selectedPhase(task) : null; + var businessPhasePath = businessPhase ? businessPhase.plannedPath : []; var rerouteDecision = selectedRerouteDecision(task); var currentSite = currentVehicleSite(task, traversedMoves); var allIds = unique([].concat(task.plannedPath, historical, approach, initialService, + businessPhasePath, rerouteDecision ? rerouteDecision.oldRemainingPath : [], rerouteDecision ? rerouteDecision.newRemainingPath : [], moves.reduce(function (values, event) { @@ -653,16 +791,23 @@ } hideMapHint(); - if (elements["show-current-route"].checked) { - drawPath(layer, approach, "approach", project); - } - if (elements["show-initial-route"].checked) { - drawPath(layer, initialService, "initial-service", project); - } - drawRouteComparison(layer, task, project); - if (rerouteDecision) { - drawPath(layer, rerouteDecision.oldRemainingPath, "reroute-old", project); - drawPath(layer, rerouteDecision.newRemainingPath, "reroute-new", project); + if (businessPhase) { + if (elements["show-current-route"].checked) { + drawPath(layer, businessPhasePath, + "business-phase phase-" + businessPhase.phase.toLowerCase(), project); + } + } else { + if (elements["show-current-route"].checked) { + drawPath(layer, approach, "approach", project); + } + if (elements["show-initial-route"].checked) { + drawPath(layer, initialService, "initial-service", project); + } + drawRouteComparison(layer, task, project); + if (rerouteDecision) { + drawPath(layer, rerouteDecision.oldRemainingPath, "reroute-old", project); + drawPath(layer, rerouteDecision.newRemainingPath, "reroute-new", project); + } } if (elements["show-move-trace"].checked) { drawMoveSegments(layer, traversedMoves, "traversed", project); @@ -681,16 +826,18 @@ var holdSite = elements["show-hold-points"].checked && lastEvent && isHold(lastEvent.eventType) ? (lastEvent.toSite || lastEvent.fromSite) : null; - var comparisonNodes = comparisonNodeIds(task, - elements["show-historical"].checked); + var comparisonNodes = businessPhase ? new Set(businessPhasePath) + : comparisonNodeIds(task, elements["show-historical"].checked); + var displayStart = businessPhase ? businessPhase.fromSite : task.physicalStartSite; + var displayTarget = businessPhase ? businessPhase.toSite : task.physicalEndSite; sites.forEach(function (site) { drawNode(layer, site, project, { passed: passedIds.has(site.siteId), - start: site.siteId === task.physicalStartSite, - target: site.siteId === task.physicalEndSite, + start: site.siteId === displayStart, + target: site.siteId === displayTarget, hold: site.siteId === holdSite, label: comparisonNodes.has(site.siteId) || site.siteId === currentSite - || site.siteId === task.physicalStartSite || site.siteId === task.physicalEndSite + || site.siteId === displayStart || site.siteId === displayTarget }); }); var vehicleSite = siteById(currentSite); @@ -992,6 +1139,7 @@ if (!task) return []; var reroute = selectedRerouteDecision(task); var ids = [].concat(task.plannedPath || [], task.historicalPath || [], + isBusinessHistoryMode() ? selectedPhasePath(task) : [], initialServicePath(task), reroute ? reroute.oldRemainingPath : [], reroute ? reroute.newRemainingPath : [], [task.physicalStartSite, task.physicalEndSite]); @@ -1255,13 +1403,21 @@ function renderHistorySummary() { if (!state.historyResult) return; var summary = state.historyResult.summary; + setText("raw-snapshot-count", summary.rawSnapshotCount); + setText("unique-queue-count", summary.uniqueQueueCount); + setText("duplicate-snapshot-count", summary.duplicateSnapshotCount); + setText("business-task-count", summary.businessTaskCount); + setText("operational-queue-count", summary.operationalQueueCount); + setText("replay-mode-readout", state.historyResult.executionMode === "RAW_SNAPSHOT" + ? "RAW 快照" : "业务任务"); var values = [ ["完成", summary.completedCount, summary.completedCount === summary.inputCount], ["等待", summary.waitingCount, summary.waitingCount === 0], ["失败", summary.failedCount, summary.failedCount === 0], ["重新选车", summary.reassignmentCount, true], ["路径变化", summary.replannedPathDifferenceCount, true], - ["总 tick", summary.tickCount, true] + ["车辆未释放", summary.incompleteVehicleReleaseCount, + summary.incompleteVehicleReleaseCount === 0] ]; var fragment = document.createDocumentFragment(); values.forEach(function (entry) { @@ -1310,7 +1466,9 @@ appendCell(row, task.historicalVehicleId || "--", true); appendCell(row, task.simulatedVehicleId || "--", true); appendCell(row, historyAssignmentMode(task)); - appendCell(row, task.pathChanged ? "已重规划" : "一致"); + appendCell(row, state.historyResult.executionMode === "SOURCE_ALIGNED_BUSINESS" + ? (task.phaseEvidence || []).length + " 个阶段" + : (task.pathChanged ? "已重规划" : "一致")); appendCell(row, task.status); appendCell(row, formatDuration(task.waitingMillis), true); appendCell(row, formatDuration(task.simulationDurationMillis), true); @@ -1504,6 +1662,13 @@ elements["floor-transfer-panel"].hidden = true; elements["floor-transfer-list"].replaceChildren(); setText("floor-transfer-count", "0 个"); + elements["business-evidence-panel"].hidden = true; + elements["phase-list"].replaceChildren(); + ["cargo-state", "source-front-site", "target-front-site", + "business-completed-at", "vehicle-released-at", "snapshot-guid-list"] + .forEach(function (id) { setText(id, "--"); }); + setText("current-business-phase", "--"); + setText("vehicle-release-status", "--"); 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 fd7574c..0f99609 100644 --- a/javaTest/src/main/resources/visualizer/index.html +++ b/javaTest/src/main/resources/visualizer/index.html @@ -27,7 +27,7 @@

@@ -82,11 +82,25 @@ 真实历史负载 尚未运行 +
+ + + + +
- +
+
+
原始快照--
+
唯一队列--
+
重复快照--
+
业务父任务--
+
运行作业--
+
当前口径业务任务
+
@@ -143,6 +157,7 @@ 已通过 换路前 换路后 + 业务阶段 HOLD 换层端口
@@ -194,6 +209,22 @@
+
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java index 2830541..54c3e53 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/HistoricalDispatchVisualizerServerTest.java @@ -30,6 +30,7 @@ class HistoricalDispatchVisualizerServerTest { private static final ObjectMapper JSON = new ObjectMapper(); private static HistoricalQueueDataset dataset; private static HistoricalSimulationInput input; + private static HistoricalSimulationInput rawInput; private static HistoricalSimulationTask sampleTask; private HistoricalDispatchVisualizerServer server; @@ -39,6 +40,7 @@ class HistoricalDispatchVisualizerServerTest { dataset = new HistoricalQueueLoader().load( HistoricalDispatchVisualizerServerTest.class.getClassLoader(), "shadow/history"); input = new HistoricalSimulationInputFactory().create(dataset); + rawInput = new HistoricalSimulationInputFactory().createRawSnapshots(dataset); sampleTask = input.getTasks().stream() .filter(task -> !task.getPhysicalSource().equals(task.getPhysicalTarget())) .findFirst() @@ -47,7 +49,7 @@ class HistoricalDispatchVisualizerServerTest { @BeforeEach void startServer() throws IOException { - server = new HistoricalDispatchVisualizerServer(0, dataset, input); + server = new HistoricalDispatchVisualizerServer(0, dataset, input, rawInput); server.start(); } @@ -83,20 +85,52 @@ class HistoricalDispatchVisualizerServerTest { } @Test - void historicalEndpointRunsAllCurrentTasks() throws Exception { - HttpResult response = request("POST", "/api/simulations/historical", "{}"); + void historicalBusinessEndpointReturnsDualCountsAndPhaseEvidence() throws Exception { + HttpResult response = request("POST", "/api/simulations/historical", + "{\"mode\":\"SOURCE_ALIGNED_BUSINESS\"}"); Map summary = map(map(response.body).get("summary")); + Map task = map(list(map(response.body).get("tasks")).get(0)); assertEquals(200, response.status); - // 历史接口默认运行去重组装后的业务父任务;240 条原始快照由输入审计单独展示。 - assertEquals(input.getTasks().size(), summary.get("inputCount")); - assertEquals(input.getTasks().size(), summary.get("completedCount")); + assertEquals("SOURCE_ALIGNED_BUSINESS", map(response.body).get("executionMode")); + assertEquals(240, summary.get("rawSnapshotCount")); + assertEquals(98, summary.get("uniqueQueueCount")); + assertEquals(142, summary.get("duplicateSnapshotCount")); + assertEquals(76, summary.get("businessTaskCount")); + assertEquals(17, summary.get("operationalQueueCount")); + assertEquals(76, summary.get("inputCount")); + assertEquals(76, summary.get("completedCount")); assertEquals(0, summary.get("waitingCount")); assertEquals(0, summary.get("failedCount")); + assertEquals(0, summary.get("incompleteVehicleReleaseCount")); + assertFalse(list(task.get("phaseEvidence")).isEmpty()); + assertFalse(list(task.get("snapshotEvidence")).isEmpty()); + assertTrue(task.containsKey("businessType")); + assertTrue(task.containsKey("cargoState")); + assertTrue(task.containsKey("sourceFrontSite")); + assertTrue(task.containsKey("targetFrontSite")); + assertTrue(task.containsKey("businessCompletedMillis")); + assertTrue(task.containsKey("vehicleReleasedMillis")); + assertEquals("COMPLETED", task.get("vehicleReleaseStatus")); assertEquals(Boolean.FALSE, map(response.body).get("pathInfoUsedForPlanning")); assertEquals(0, map(map(response.body).get("invariants")).get("totalSafetyViolations")); } + @Test + void rawModeStillReturns240CompatibilityResultsAndEmptyBodyDefaultsToBusiness() + throws Exception { + HttpResult raw = request("POST", "/api/simulations/historical", + "{\"mode\":\"RAW_SNAPSHOT\"}"); + HttpResult defaultMode = request("POST", "/api/simulations/historical", "{}"); + + assertEquals(200, raw.status); + assertEquals("RAW_SNAPSHOT", map(raw.body).get("executionMode")); + assertEquals(240, map(map(raw.body).get("summary")).get("completedCount")); + assertEquals(200, defaultMode.status); + assertEquals("SOURCE_ALIGNED_BUSINESS", map(defaultMode.body).get("executionMode")); + assertEquals(76, map(map(defaultMode.body).get("summary")).get("completedCount")); + } + @Test void servesTheVisualizerShellAndAssets() throws Exception { HttpResult page = request("GET", "/", null); @@ -129,6 +163,12 @@ class HistoricalDispatchVisualizerServerTest { assertTrue(page.body.contains("id=\"route-candidate-body\"")); assertTrue(page.body.contains("id=\"reroute-select\"")); assertTrue(page.body.contains("id=\"reroute-comparison\"")); + assertTrue(page.body.contains("id=\"simulation-mode-business\"")); + assertTrue(page.body.contains("id=\"simulation-mode-raw\"")); + assertTrue(page.body.contains("id=\"raw-snapshot-count\"")); + assertTrue(page.body.contains("id=\"business-task-count\"")); + assertTrue(page.body.contains("id=\"phase-list\"")); + assertTrue(page.body.contains("id=\"business-completed-at\"")); assertEquals(200, stylesheet.status); assertEquals("text/css; charset=utf-8", stylesheet.contentType); assertTrue(stylesheet.body.contains("--color-service-path")); @@ -145,6 +185,9 @@ class HistoricalDispatchVisualizerServerTest { assertTrue(script.body.contains("approachPlanningEvidence")); assertTrue(script.body.contains("servicePlanningEvidence")); assertTrue(script.body.contains("rerouteEvents")); + assertTrue(script.body.contains("SOURCE_ALIGNED_BUSINESS")); + assertTrue(script.body.contains("RAW_SNAPSHOT")); + assertTrue(script.body.contains("phaseEvidence")); } @Test diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationServiceTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationServiceTest.java index 8befa3b..14adca5 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationServiceTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationServiceTest.java @@ -87,6 +87,42 @@ class PointToPointSimulationServiceTest { .anyMatch(event -> "PLC_RETRY".equals(event.getType()))); } + @Test + void inboundChannelTaskExposesPhaseEvidenceAndCompletedVehicleRelease() { + HistoricalSimulationTask channelTask = baseInput.getTasks().stream() + .filter(task -> task.getTargetFrontSite() != null) + .filter(task -> !task.getPhysicalSource().equals(task.getPhysicalTarget())) + .findFirst() + .orElseThrow(() -> new AssertionError("an inbound channel task is required")); + PointToPointSimulationRequest request = PointToPointSimulationRequest.valid( + channelTask.getPhysicalSource(), channelTask.getPhysicalTarget(), + "INBOUND", 1, 100, null, Collections.emptyList(), "NONE"); + + SimulationTaskResult task = service().run(request).getTaskResults().get(0); + + assertEquals(SimulationTaskStatus.COMPLETED, task.getStatus()); + assertFalse(task.getPhaseEvidence().isEmpty()); + assertTrue(task.getPhaseEvidence().stream().anyMatch(phase -> + "EMPTY_RETURN_TO_TARGET_FRONT".equals(phase.getPhase().name()))); + assertEquals("COMPLETED", task.getVehicleReleaseStatus().name()); + assertTrue(task.getVehicleReleasedMillis() >= task.getBusinessCompletedMillis()); + } + + @Test + void ordinarySitesWithoutCargoGroupingRemainNormalPickupDelivery() { + String[] endpoints = ordinaryAdjacentEndpoints(); + PointToPointSimulationRequest request = PointToPointSimulationRequest.valid( + endpoints[0], endpoints[1], "INBOUND", 1, 100, + null, Collections.emptyList(), "NONE"); + + SimulationTaskResult task = service().run(request).getTaskResults().get(0); + + assertEquals(SimulationTaskStatus.COMPLETED, task.getStatus()); + assertEquals(Collections.singletonList("NO_CHANNEL_OPERATION"), + task.getSkippedStageReasons()); + assertEquals(2, task.getPhaseEvidence().size()); + } + @Test void validatesPriorityAccelerationTaskTypeAndFaultPreset() { assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid( @@ -106,4 +142,15 @@ class PointToPointSimulationServiceTest { private PointToPointSimulationService service() { return new PointToPointSimulationService(baseInput); } + + private String[] ordinaryAdjacentEndpoints() { + return baseInput.getGridMap().getSites().keySet().stream() + .filter(site -> !baseInput.getChannelFrontTopology().frontFor(site).isPresent()) + .flatMap(site -> baseInput.getGridMap().getNeighbors(site).stream() + .filter(neighbor -> !baseInput.getChannelFrontTopology() + .frontFor(neighbor).isPresent()) + .map(neighbor -> new String[]{site, neighbor})) + .findFirst() + .orElseThrow(() -> new AssertionError("ordinary adjacent sites are required")); + } } diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java index 7c8e0ad..6685718 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/SimulationVisualizationMapperTest.java @@ -107,6 +107,24 @@ class SimulationVisualizationMapperTest { // A/B 审计也走二段式业务链,每个重新规划的业务阶段都必须作为独立路径版本保留下来。 assertEquals(Arrays.asList(1, 2, 3), tasks.get(0).get("pathVersions")); assertTrue(tasks.get(0).containsKey("rerouteEvents")); + assertEquals("SOURCE_ALIGNED_BUSINESS", payload.get("executionMode")); + assertEquals(sourceTask.getCargoState().name(), tasks.get(0).get("cargoState")); + assertEquals(sourceTask.getBusinessCompletedMillis(), + tasks.get(0).get("businessCompletedMillis")); + assertEquals(sourceTask.getVehicleReleasedMillis(), + tasks.get(0).get("vehicleReleasedMillis")); + assertEquals(sourceTask.getVehicleReleaseStatus().name(), + tasks.get(0).get("vehicleReleaseStatus")); + List> phaseEvidence = + listOfMaps(tasks.get(0).get("phaseEvidence")); + assertFalse(phaseEvidence.isEmpty()); + assertEquals(sourceTask.getPhaseEvidence().get(0).getPhase().name(), + phaseEvidence.get(0).get("phase")); + assertEquals(sourceTask.getPhaseEvidence().get(0).getPlannedPath(), + phaseEvidence.get(0).get("plannedPath")); + assertTrue(phaseEvidence.get(0).containsKey("fromSite")); + assertTrue(phaseEvidence.get(0).containsKey("toSite")); + assertTrue(phaseEvidence.get(0).containsKey("completionReason")); Map comparison = map(tasks.get(0).get("pathComparison")); assertEquals(0, comparison.get("sharedEdgeCount")); assertEquals(sourceTask.getPlannedPath().size() - 1, @@ -122,6 +140,12 @@ class SimulationVisualizationMapperTest { assertEquals(1, summary.get("completedCount")); assertEquals(0, summary.get("waitingCount")); assertEquals(0, summary.get("failedCount")); + assertEquals(1, summary.get("rawSnapshotCount")); + assertEquals(1, summary.get("uniqueQueueCount")); + assertEquals(0, summary.get("duplicateSnapshotCount")); + assertEquals(1, summary.get("businessTaskCount")); + assertEquals(0, summary.get("operationalQueueCount")); + assertEquals(0, summary.get("incompleteVehicleReleaseCount")); assertEquals(1, map(summary.get("routeComparabilityCounts")) .get("INVALID_HISTORY_PATH")); assertEquals(0, invariants.get("totalSafetyViolations"));