diff --git a/.gitignore b/.gitignore index 024ffd5..a9679d2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,6 @@ hs_err_pid* # idea /.idea/ **/target/ +javaTest/outputs/browser-qa/ /log/ diff --git a/javaTest/scripts/visualizer_browser_qa.py b/javaTest/scripts/visualizer_browser_qa.py new file mode 100644 index 0000000..1dc4ea1 --- /dev/null +++ b/javaTest/scripts/visualizer_browser_qa.py @@ -0,0 +1,147 @@ +"""Run repeatable Chromium checks against the real javaTest visualizer server.""" + +import os +import re +import sys +from glob import glob + +from playwright.sync_api import expect, sync_playwright + + +BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:18084" +OUTPUT_DIR = sys.argv[2] if len(sys.argv) > 2 else "outputs/browser-qa" + + +def require(condition, message): + """Fail the QA run with a business-readable assertion message.""" + if not condition: + raise AssertionError(message) + + +def horizontal_overflow(page): + """Return true when the document spills outside the active viewport.""" + return page.evaluate( + "document.documentElement.scrollWidth > document.documentElement.clientWidth + 1" + ) + + +def run_point_to_point(page): + """Execute the real point simulation and verify route evidence is rendered.""" + page.locator("#run-point").click() + page.locator("#task-status").wait_for(state="visible", timeout=120_000) + page.locator("#loading-state").wait_for(state="hidden", timeout=120_000) + expect(page.locator("#task-status")).to_have_text("COMPLETED", timeout=120_000) + require(page.locator("#route-candidate-body tr").count() > 0, + "point simulation did not render route candidates") + require(page.locator("#route-quality-body tr").count() == 6, + "route quality tuple must contain six ordered metrics") + require(page.locator("#route-comparability").inner_text() != "--", + "historical comparability badge was not populated") + + +def verify_map_interactions(page): + """Check full-map authority, route layers, viewport controls and timeline stability.""" + map_edges = page.locator(".map-floor-layer:not([hidden]) .map-edge").count() + dynamic_edges = page.locator("#dynamic-route-layer .route-segment").count() + require(map_edges > 20, "complete floor map is missing") + require(map_edges > dynamic_edges, "map background should exceed one task route") + + full_view = page.locator("#route-map").get_attribute("viewBox") + page.locator("#path-fit").click() + path_view = page.locator("#route-map").get_attribute("viewBox") + require(path_view != full_view, "path fit did not change the map viewport") + page.locator("#zoom-in").click() + zoomed_view = page.locator("#route-map").get_attribute("viewBox") + require(zoomed_view != path_view, "zoom control did not change the viewport") + page.locator("#reset-view").click() + require(page.locator("#route-map").get_attribute("viewBox") == "0 0 1000 560", + "reset did not restore the full-map viewport") + + page.locator("#show-current-route").uncheck() + require(page.locator("#dynamic-route-layer .planned-only").count() == 0, + "current route remained visible after its layer was disabled") + require(page.locator(".map-floor-layer:not([hidden]) .map-edge").count() == map_edges, + "disabling current route changed the authoritative map") + page.locator("#show-current-route").check() + + page.locator("#show-historical").uncheck() + require(page.locator("#dynamic-route-layer .historical-only").count() == 0, + "historical route remained visible after its layer was disabled") + page.locator("#show-historical").check() + + page.locator("#show-initial-route").uncheck() + require(page.locator("#dynamic-route-layer .initial-service").count() == 0, + "initial route remained visible after its layer was disabled") + page.locator("#show-initial-route").check() + + before_tick_view = page.locator("#route-map").get_attribute("viewBox") + slider = page.locator("#timeline-slider") + slider.fill(slider.get_attribute("max")) + slider.dispatch_event("input") + require(page.locator("#route-map").get_attribute("viewBox") == before_tick_view, + "timeline movement unexpectedly changed the user viewport") + page.locator("#show-move-trace").uncheck() + require(page.locator("#dynamic-route-layer .traversed").count() == 0, + "MOVE trace remained visible after its layer was disabled") + page.locator("#show-move-trace").check() + + +def verify_historical_mode(page): + """Run the real 240-task endpoint and verify task selection keeps decision evidence live.""" + page.locator(".mode-tab[data-mode='history']").click() + 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") + page.locator("#history-task-body tr").nth(1).click() + require(page.locator("#route-candidate-body tr").count() > 0, + "selected historical task lost route candidates") + + +def verify_viewport(browser, viewport, filename, run_history): + """Verify one desktop or mobile viewport and save its full-page evidence image.""" + context = browser.new_context(viewport=viewport, locale="zh-CN") + page = context.new_page() + errors = [] + page.on("console", lambda message: errors.append(message.text) + if message.type == "error" else None) + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto(BASE_URL, wait_until="networkidle", timeout=120_000) + page.locator(".map-floor-layer:not([hidden]) .map-edge").first.wait_for( + state="attached", timeout=60_000 + ) + require(not horizontal_overflow(page), "page has horizontal overflow before simulation") + run_point_to_point(page) + verify_map_interactions(page) + if run_history: + verify_historical_mode(page) + 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)) + context.close() + + +def main(): + """Run desktop and mobile checks using one headless Chromium process.""" + os.makedirs(OUTPUT_DIR, exist_ok=True) + with sync_playwright() as playwright: + cached_candidates = sorted(glob(os.path.join( + os.environ.get("LOCALAPPDATA", ""), "ms-playwright", "chromium-*", + "chrome-win64", "chrome.exe" + )), reverse=True) + launch_options = {"headless": True} + if cached_candidates: + 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) + browser.close() + print("visualizer browser QA passed: desktop=1440x1000 mobile=390x844") + + +if __name__ == "__main__": + main() diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java index fc64b12..2d2f707 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java @@ -1064,9 +1064,12 @@ public final class HistoricalDispatchSimulator { current.execution.pathVersion = nextVersion; current.execution.rerouteCount++; current.execution.planningEvidence.addAll(newEvidence); - events.add(event(tick, now, "REROUTE", taskId, vehicle.getVehicleId(), - "STRICTLY_BETTER_TRAFFIC_QUALITY", oldRemaining.get(0), - newRemaining.get(newRemaining.size() - 1))); + // 只有预约和活动路线都提交成功后才发布前后证据,页面看到的版本一定真实生效。 + events.add(new SimulationTickEvent(tick, now, "REROUTE", taskId, + vehicle.getVehicleId(), "STRICTLY_BETTER_TRAFFIC_QUALITY", + oldRemaining.get(0), newRemaining.get(newRemaining.size() - 1), + new RerouteDecisionEvidence(nextVersion, oldRemaining, newRemaining, + oldQuality, newQuality))); return true; } diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/RerouteDecisionEvidence.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/RerouteDecisionEvidence.java new file mode 100644 index 0000000..2fe3b87 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/RerouteDecisionEvidence.java @@ -0,0 +1,63 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.demo.traffic.RouteQuality; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 成功替换交通预约时保存的重规划前后剩余路线和质量证据。 */ +public final class RerouteDecisionEvidence { + private final int pathVersion; + private final List oldRemainingPath; + private final List newRemainingPath; + private final RouteQuality oldQuality; + private final RouteQuality newQuality; + + /** + * 固化一次已经提交的路线替换,供报告和地图检查器还原当时的业务判断。 + * + *

只有预约原子替换成功后才创建该对象,因此它代表实际生效版本,而不是一次尝试候选。 + * @param pathVersion 替换成功后的路线版本。 + * @param oldRemainingPath 替换前从车辆当前位置开始的剩余路线。 + * @param newRemainingPath 替换后从车辆当前位置开始的剩余路线。 + * @param oldQuality 旧剩余路线在触发时交通快照中的质量。 + * @param newQuality 新剩余路线在同一交通快照中的质量。 + */ + public RerouteDecisionEvidence(int pathVersion, + List oldRemainingPath, + List newRemainingPath, + RouteQuality oldQuality, + RouteQuality newQuality) { + if (pathVersion <= 1 || oldRemainingPath == null || oldRemainingPath.isEmpty() + || newRemainingPath == null || newRemainingPath.isEmpty() + || oldQuality == null || newQuality == null) { + throw new IllegalArgumentException("complete reroute evidence is required"); + } + this.pathVersion = pathVersion; + this.oldRemainingPath = immutable(oldRemainingPath); + this.newRemainingPath = immutable(newRemainingPath); + this.oldQuality = oldQuality; + this.newQuality = newQuality; + } + + /** 返回原子替换成功后采用的路线版本号。 */ + public int getPathVersion() { return pathVersion; } + + /** 返回替换前仍待车辆执行的真实路线段。 */ + public List getOldRemainingPath() { return oldRemainingPath; } + + /** 返回替换后已经取得交通预约的真实路线段。 */ + public List getNewRemainingPath() { return newRemainingPath; } + + /** 返回触发重规划时旧剩余路线的质量元组。 */ + public RouteQuality getOldQuality() { return oldQuality; } + + /** 返回同一快照下新剩余路线的质量元组。 */ + public RouteQuality getNewQuality() { return newQuality; } + + /** 对外暴露防御性、不可变路径,防止报告层改写仿真事实。 */ + private List immutable(List values) { + return Collections.unmodifiableList(new ArrayList(values)); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTickEvent.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTickEvent.java index 8e05c8d..ad48f5c 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTickEvent.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTickEvent.java @@ -9,6 +9,7 @@ public final class SimulationTickEvent { private final String reason; private final String fromSite; private final String toSite; + private final RerouteDecisionEvidence rerouteDecision; /** * 创建 {@code SimulationTickEvent} 实例并初始化其内部状态。 @@ -26,6 +27,26 @@ public final class SimulationTickEvent { public SimulationTickEvent(long tick, long simulationMillis, String type, String taskId, String vehicleId, String reason, String fromSite, String toSite) { + this(tick, simulationMillis, type, taskId, vehicleId, reason, + fromSite, toSite, null); + } + + /** + * 创建带结构化重规划决定的审计事件;该扩展仅用于已经成功提交的 {@code REROUTE}。 + * @param tick 事件所在仿真 tick。 + * @param simulationMillis 相对仿真时间。 + * @param type 事件类型。 + * @param taskId 任务标识。 + * @param vehicleId 执行车辆。 + * @param reason 业务原因。 + * @param fromSite 事件起点。 + * @param toSite 事件终点。 + * @param rerouteDecision 重规划前后路径与质量;普通事件为 {@code null}。 + */ + public SimulationTickEvent(long tick, long simulationMillis, String type, + String taskId, String vehicleId, String reason, + String fromSite, String toSite, + RerouteDecisionEvidence rerouteDecision) { this.tick = tick; this.simulationMillis = simulationMillis; this.type = type; @@ -34,6 +55,7 @@ public final class SimulationTickEvent { this.reason = reason; this.fromSite = fromSite; this.toSite = toSite; + this.rerouteDecision = rerouteDecision; } /** @@ -92,4 +114,7 @@ public final class SimulationTickEvent { * @return 本次处理产生的结果;调用方据此继续后续流程。 */ public String getToSite() { return toSite; } + + /** 返回成功重规划事件的结构化决定;非 REROUTE 事件返回 {@code null}。 */ + public RerouteDecisionEvidence getRerouteDecision() { return rerouteDecision; } } 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 7d65773..778a9f6 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 @@ -9,6 +9,7 @@ import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResul import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput; import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationTask; import com.ft546.wcs.shadow.history.simulation.RoutePlanningEvidence; +import com.ft546.wcs.shadow.history.simulation.RerouteDecisionEvidence; import com.ft546.wcs.shadow.history.simulation.SimulationInvariantSummary; import com.ft546.wcs.shadow.history.simulation.SimulationMetrics; import com.ft546.wcs.shadow.history.simulation.SimulationTaskResult; @@ -108,7 +109,7 @@ public final class SimulationVisualizationMapper { List> tasks = new ArrayList>(); for (SimulationTaskResult task : orderedTasks) { tasks.add(task(task, definitions.get(task.getSimulationTaskId()), - result.getAcceleration(), input.getGridMap())); + result.getAcceleration(), input.getGridMap(), result.getEvents())); } List> events = new ArrayList>(); for (SimulationTickEvent event : result.getEvents()) events.add(event(event)); @@ -182,7 +183,8 @@ public final class SimulationVisualizationMapper { private Map task(SimulationTaskResult task, HistoricalSimulationTask definition, int acceleration, - GridMap gridMap) { + GridMap gridMap, + List allEvents) { HistoricalQueueRecord record = task.getRecord(); List historicalPath = historicalPath(record.getPathInfo()); Long historicalDuration = historicalDuration(task); @@ -242,9 +244,27 @@ public final class SimulationVisualizationMapper { value.put("servicePlanningEvidence", planningEvidence(task, RoutePlanningEvidence.PlanningStage.SERVICE)); value.put("rerouteCount", task.getRerouteCount()); + Set pathVersions = new TreeSet(); + for (RoutePlanningEvidence evidence : task.getPlanningEvidence()) { + pathVersions.add(evidence.getPathVersion()); + } + value.put("pathVersions", new ArrayList(pathVersions)); + value.put("rerouteEvents", rerouteEvents(task.getSimulationTaskId(), allEvents)); return value; } + /** 返回当前任务的重规划触发、拒绝和成功事件,供前端按版本还原决策链。 */ + private List> rerouteEvents( + String taskId, List allEvents) { + List> values = new ArrayList>(); + for (SimulationTickEvent event : allEvents) { + if (!taskId.equals(event.getTaskId()) + || !event.getType().startsWith("REROUTE")) continue; + values.add(event(event)); + } + return values; + } + /** 按阶段转换每次实际采用的候选、质量、距离边界和版本证据。 */ private List> planningEvidence( SimulationTaskResult task, RoutePlanningEvidence.PlanningStage stage) { @@ -347,6 +367,19 @@ public final class SimulationVisualizationMapper { value.put("reason", event.getReason()); value.put("fromSite", event.getFromSite()); value.put("toSite", event.getToSite()); + value.put("rerouteDecision", rerouteDecision(event.getRerouteDecision())); + return value; + } + + /** 把成功路线替换转换为明确的旧/新剩余路径和同快照质量。 */ + private Map rerouteDecision(RerouteDecisionEvidence source) { + if (source == null) return null; + Map value = new LinkedHashMap(); + value.put("pathVersion", source.getPathVersion()); + value.put("oldRemainingPath", source.getOldRemainingPath()); + value.put("newRemainingPath", source.getNewRemainingPath()); + value.put("oldQuality", quality(source.getOldQuality())); + value.put("newQuality", quality(source.getNewQuality())); return value; } diff --git a/javaTest/src/main/resources/visualizer/assets/app.css b/javaTest/src/main/resources/visualizer/assets/app.css index b9ec42b..f0de1ab 100644 --- a/javaTest/src/main/resources/visualizer/assets/app.css +++ b/javaTest/src/main/resources/visualizer/assets/app.css @@ -59,7 +59,8 @@ button:focus-visible, input:focus-visible, select:focus-visible { } .brand-block, .header-status, .button-row, .playback-buttons, .route-tools, .map-actions, -.history-actions, .timeline-controls, .table-toolbar, .inline-field, .toggle-field { +.history-actions, .timeline-controls, .table-toolbar, .inline-field, .toggle-field, +.layer-toggles, .comparison-status, .decision-selectors { display: flex; align-items: center; } @@ -224,7 +225,7 @@ h3 { font-size: 14px; line-height: 1.3; } .workspace { display: grid; - grid-template-columns: minmax(0, 1fr) 320px; + grid-template-columns: minmax(0, 1fr) minmax(360px, 410px); min-height: 520px; background: var(--color-surface); border-bottom: 1px solid var(--color-line); @@ -252,6 +253,7 @@ h3 { font-size: 14px; line-height: 1.3; } .inline-field select { width: auto; min-width: 74px; height: 32px; } .toggle-field { gap: 6px; } .toggle-field input { width: 16px; height: 16px; accent-color: var(--color-green); } +.layer-toggles { gap: 8px; padding-left: 8px; border-left: 1px solid var(--color-line); } .route-legend { min-height: 34px; display: flex; @@ -271,7 +273,10 @@ h3 { font-size: 14px; line-height: 1.3; } .legend-line.shared { height: 6px; border-top: 6px solid var(--color-service-path); border-bottom: 2px dotted var(--color-historical-path); } .legend-line.planned-only { border-color: var(--color-service-path); border-top-width: 5px; } .legend-line.historical-only { border-color: var(--color-historical-path); border-top-style: dotted; } +.legend-line.initial-service { border-color: #50736a; border-top-style: dashed; } .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-dot { width: 10px; height: 10px; border: 2px solid; border-radius: 50%; } .legend-dot.hold { border-color: var(--color-amber); } @@ -287,9 +292,15 @@ h3 { font-size: 14px; line-height: 1.3; } .route-segment.historical { stroke: var(--color-historical-path); stroke-width: 3; stroke-dasharray: 3 7; opacity: .82; } .route-segment.planned-only { stroke: var(--color-service-path); stroke-width: 6; } .route-segment.historical-only { stroke: var(--color-historical-path); stroke-width: 4; stroke-dasharray: 3 7; } +.route-segment.initial-service { stroke: #50736a; stroke-width: 3; stroke-dasharray: 11 7; opacity: .9; } .route-segment.shared-base { stroke: var(--color-service-path); stroke-width: 7; } .route-segment.shared-overlay { stroke: var(--color-historical-path); stroke-width: 3; stroke-dasharray: 2 8; } .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; } +.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); } .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); } @@ -326,6 +337,38 @@ 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); } +.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; } +.evidence-badge.is-neutral { color: var(--color-muted); background: #eef2f0; } +.evidence-badge.is-pass { color: #0e674c; background: var(--color-green-soft); border-color: #b8dfd0; } +.evidence-badge.is-warning { color: #8b5108; background: var(--color-amber-soft); border-color: #efd09d; } +.evidence-badge.is-fail { color: #9e3030; background: var(--color-red-soft); border-color: #edb9b5; } +.decision-selectors { gap: 10px; padding: 8px 14px; border-top: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); } +.decision-selectors .inline-field { flex: 1; } +.decision-selectors select { min-width: 0; flex: 1; } +.decision-summary { margin: 0; display: grid; grid-template-columns: 1fr 1fr; } +.decision-summary > div { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #e4eae8; } +.decision-summary > div:nth-child(odd) { border-right: 1px solid #e4eae8; } +.decision-summary > div.span-all { grid-column: 1 / -1; border-right: 0; } +.decision-summary dt { color: var(--color-muted); font-size: 10px; } +.decision-summary dd { margin: 0; text-align: right; font-family: var(--font-mono); font-size: 11px; overflow-wrap: anywhere; } +.compact-table-scroll { overflow: auto; } +.candidate-scroll { max-height: 210px; border-top: 1px solid var(--color-line); } +.compact-evidence-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 10px; } +.compact-evidence-table th { padding: 6px 8px; color: var(--color-muted); background: #f0f4f2; text-align: left; font-weight: 700; } +.compact-evidence-table td { padding: 6px 8px; border-top: 1px solid #e5ebe8; vertical-align: top; overflow-wrap: anywhere; } +.compact-evidence-table code { font-family: var(--font-mono); font-size: 10px; white-space: normal; } +.quality-table th:first-child { width: 44%; } +.candidate-table th:first-child { width: 58%; } +.candidate-table th:nth-child(2) { width: 18%; } +.candidate-table tr.is-selected-candidate { background: var(--color-green-soft); box-shadow: inset 3px 0 var(--color-green); } +#reroute-select { min-width: 130px; max-width: 210px; height: 30px; padding: 0 7px; border: 1px solid var(--color-line-strong); border-radius: var(--radius); background: var(--color-surface); font-size: 10px; } +.reroute-comparison { display: grid; } +.reroute-comparison > div { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 8px; padding: 8px 12px; border-top: 1px solid #e4eae8; } +.reroute-comparison span { color: var(--color-muted); font-size: 10px; } +.reroute-comparison output { font-family: var(--font-mono); font-size: 10px; line-height: 1.55; overflow-wrap: anywhere; } .path-proof, .reason-proof { display: grid; gap: 7px; padding: 13px 16px; border-bottom: 1px solid var(--color-line); } .path-proof span, .reason-proof span { color: var(--color-muted); font-size: 11px; } .path-proof output, .reason-proof output { font-family: var(--font-mono); font-size: 11px; line-height: 1.7; overflow-wrap: anywhere; } @@ -412,9 +455,14 @@ h3 { font-size: 14px; line-height: 1.3; } .route-tools { display: grid; grid-template-columns: auto auto; gap: 5px 7px; justify-items: end; } .view-mode { grid-column: 1; } .map-actions { grid-column: 2; } + .layer-toggles { grid-column: 1 / -1; padding: 5px 0 0; border-left: 0; border-top: 1px solid var(--color-line); flex-wrap: wrap; justify-content: flex-end; } .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; } + .comparison-status { max-width: 190px; } + .decision-summary { grid-template-columns: 1fr; } + .decision-summary > div, .decision-summary > div:nth-child(odd) { border-right: 0; } + .decision-summary > div.span-all { grid-column: 1; } .timeline-controls { padding: 10px 12px; } .playback-buttons .icon-button { width: 34px; } .tick-readout { min-width: 76px; } diff --git a/javaTest/src/main/resources/visualizer/assets/app.js b/javaTest/src/main/resources/visualizer/assets/app.js index f264e52..b14bcfe 100644 --- a/javaTest/src/main/resources/visualizer/assets/app.js +++ b/javaTest/src/main/resources/visualizer/assets/app.js @@ -8,6 +8,9 @@ historyResult: null, mode: "point", selectedTaskId: null, + selectedPlanningStage: "SERVICE", + selectedPathVersion: null, + selectedRerouteIndex: null, eventTicks: [0], tickIndex: 0, timer: null, @@ -37,11 +40,17 @@ "task-search", "task-status-filter", "task-filter-count", "history-task-body", "task-workspace", "route-map", "route-empty", "loading-state", "loading-label", "floor-select", "show-historical", "map-fit", "path-fit", "zoom-in", "zoom-out", - "reset-view", "inspector-task-id", "task-status", + "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-assigned-time", "evidence-waiting", "evidence-completion", "evidence-hold-retry", "evidence-plc", "route-difference", "invalid-history-segments", "planned-path-output", "reason-output", + "route-comparability", "route-quality-comparison", "route-stage-select", + "route-version-select", "decision-shortest-distance", + "decision-selected-distance", "decision-detour-ratio", "decision-candidate-count", + "decision-reason", "route-quality-body", "route-candidate-body", + "reroute-select", "reroute-reason", "reroute-old-path", "reroute-new-path", "timeline-panel", "step-back", "play-timeline", "pause-timeline", "step-forward", "tick-readout", "timeline-slider", "playback-speed", "event-type-filter", "event-count", "event-table-body", "safety-view", "safety-metrics", @@ -64,7 +73,24 @@ renderRoute(); fitCurrentViewMode(); }); - elements["show-historical"].addEventListener("change", renderRoute); + ["show-historical", "show-initial-route", "show-current-route", "show-move-trace", + "show-hold-points"].forEach(function (id) { + elements[id].addEventListener("change", renderRoute); + }); + elements["route-stage-select"].addEventListener("change", function () { + state.selectedPlanningStage = elements["route-stage-select"].value; + state.selectedPathVersion = null; + renderRouteDecision(selectedTask()); + }); + elements["route-version-select"].addEventListener("change", function () { + state.selectedPathVersion = Number(elements["route-version-select"].value); + renderRouteDecision(selectedTask()); + }); + elements["reroute-select"].addEventListener("change", function () { + state.selectedRerouteIndex = Number(elements["reroute-select"].value); + renderRerouteComparison(selectedTask()); + renderRoute(); + }); elements["map-fit"].addEventListener("click", function () { setViewMode("map"); }); elements["path-fit"].addEventListener("click", function () { setViewMode("path"); }); elements["zoom-in"].addEventListener("click", function () { zoomRoute(0.8); }); @@ -220,6 +246,9 @@ function selectResult(result, taskId) { state.selectedTaskId = taskId; state.tickIndex = 0; + state.selectedPlanningStage = "SERVICE"; + state.selectedPathVersion = null; + state.selectedRerouteIndex = null; var selected = result.tasks.find(function (task) { return task.simulationTaskId === taskId; }); @@ -283,11 +312,162 @@ + " / 历史 " + comparison.historicalOnlyEdgeCount); setText("invalid-history-segments", formatInvalidSegments( comparison.invalidHistoricalSegments)); + setEvidenceBadge("route-comparability", task.routeComparability, + comparabilityLabel(task.routeComparability)); + setEvidenceBadge("route-quality-comparison", task.routeQualityComparison, + qualityCategoryLabel(task.routeQualityComparison)); + elements["route-stage-select"].value = state.selectedPlanningStage; + renderRouteDecision(task); + renderRerouteComparison(task); elements["planned-path-output"].textContent = task.plannedPath.length ? task.plannedPath.join(" → ") : "无规划路径"; elements["reason-output"].textContent = task.reason || task.status; } + /** 使用后端保存的阶段证据渲染候选、距离边界和固定质量元组。 */ + function renderRouteDecision(task) { + var stage = state.selectedPlanningStage; + var evidence = task ? planningEvidence(task, stage) : []; + var versions = evidence.map(function (item) { return Number(item.pathVersion); }) + .filter(function (value, index, values) { return values.indexOf(value) === index; }) + .sort(function (left, right) { return left - right; }); + var versionSelect = elements["route-version-select"]; + var options = document.createDocumentFragment(); + versions.forEach(function (version) { + var option = document.createElement("option"); + option.value = String(version); + option.textContent = "v" + version + (version === 1 ? " · 初始" : " · 重规划"); + options.appendChild(option); + }); + versionSelect.replaceChildren(options); + versionSelect.disabled = !versions.length; + if (!versions.length) { + state.selectedPathVersion = null; + clearRouteDecision(); + return; + } + if (versions.indexOf(state.selectedPathVersion) < 0) { + state.selectedPathVersion = versions[versions.length - 1]; + } + versionSelect.value = String(state.selectedPathVersion); + var decision = evidence.find(function (item) { + return Number(item.pathVersion) === state.selectedPathVersion; + }); + if (!decision) { + clearRouteDecision(); + return; + } + + setText("decision-shortest-distance", formatDistance(decision.shortestPhysicalDistance)); + setText("decision-selected-distance", formatDistance(decision.selectedPhysicalDistance)); + setText("decision-detour-ratio", formatDetour(decision.detourRatio)); + setText("decision-candidate-count", String(decision.candidateCount) + + (decision.candidateLimitReached ? " · 达上限" : "")); + setText("decision-reason", decision.decisionReason || "PHYSICAL_SHORTEST"); + renderQualityTable(decision.quality, decision.historicalReferenceQuality); + renderCandidateTable(decision.candidates || []); + } + + /** 接近段和服务段分别读取各自的已采用版本,不在浏览器中重新运行规划。 */ + function planningEvidence(task, stage) { + return stage === "APPROACH" + ? (task.approachPlanningEvidence || []) + : (task.servicePlanningEvidence || []); + } + + /** 质量表固定使用调度器字典序中的六项业务指标。 */ + function renderQualityTable(selected, historical) { + var rows = [ + ["对向冲突", "oppositeDirectionConflicts"], + ["占用节点", "occupiedNodes"], + ["不兼容预约", "incompatibleReservedEdges"], + ["活动路线重叠", "activeRouteOverlapEdges"], + ["转弯", "turns"], + ["坐标距离", "physicalDistance"] + ]; + var fragment = document.createDocumentFragment(); + rows.forEach(function (metric) { + var row = document.createElement("tr"); + appendCell(row, metric[0], false); + appendCell(row, qualityValue(selected, metric[1]), true); + appendCell(row, qualityValue(historical, metric[1]), true); + fragment.appendChild(row); + }); + elements["route-quality-body"].replaceChildren(fragment); + } + + /** 候选表展示 Java 已评价的全部有界候选,并突出真实采用项。 */ + function renderCandidateTable(candidates) { + var fragment = document.createDocumentFragment(); + candidates.forEach(function (candidate, index) { + var row = document.createElement("tr"); + if (candidate.selected) row.className = "is-selected-candidate"; + appendCell(row, (candidate.selected ? "✓ " : "") + + (candidate.path || []).join(" → "), true); + appendCell(row, formatDistance(candidate.physicalDistance), true); + appendCell(row, compactQuality(candidate.quality), true); + row.dataset.candidateIndex = String(index); + fragment.appendChild(row); + }); + if (!candidates.length) { + var empty = document.createElement("tr"); + var cell = appendCell(empty, "无候选证据", false); + cell.colSpan = 3; + fragment.appendChild(empty); + } + elements["route-candidate-body"].replaceChildren(fragment); + } + + /** 重规划记录直接消费事件中的旧/新剩余路线,用于地图双层叠加。 */ + function renderRerouteComparison(task) { + var events = task ? (task.rerouteEvents || []) : []; + var select = elements["reroute-select"]; + var options = document.createDocumentFragment(); + events.forEach(function (event, index) { + var option = document.createElement("option"); + option.value = String(index); + option.textContent = "tick " + event.tick + " · " + event.eventType; + options.appendChild(option); + }); + select.replaceChildren(options); + select.disabled = !events.length; + if (!events.length) { + state.selectedRerouteIndex = null; + setText("reroute-reason", "无重规划记录"); + setText("reroute-old-path", "--"); + setText("reroute-new-path", "--"); + return; + } + if (state.selectedRerouteIndex === null + || state.selectedRerouteIndex >= events.length) { + state.selectedRerouteIndex = latestSuccessfulReroute(events); + } + select.value = String(state.selectedRerouteIndex); + var event = events[state.selectedRerouteIndex]; + var detail = event.rerouteDecision; + setText("reroute-reason", event.eventType + " · " + (event.reason || "--")); + setText("reroute-old-path", detail + ? detail.oldRemainingPath.join(" → ") : "未发生路线替换"); + setText("reroute-new-path", detail + ? detail.newRemainingPath.join(" → ") : "未发生路线替换"); + } + + function latestSuccessfulReroute(events) { + for (var index = events.length - 1; index >= 0; index -= 1) { + if (events[index].rerouteDecision) return index; + } + return events.length - 1; + } + + function clearRouteDecision() { + ["decision-shortest-distance", "decision-selected-distance", "decision-detour-ratio", + "decision-candidate-count", "decision-reason"].forEach(function (id) { + setText(id, "--"); + }); + elements["route-quality-body"].replaceChildren(); + elements["route-candidate-body"].replaceChildren(); + } + function populateFloors() { var task = selectedTask(); if (!state.bootstrap) return; @@ -347,8 +527,12 @@ var traversedMoves = moves.filter(function (event) { return Number(event.tick) <= currentTick; }); var approach = approachPath(task, moves); var historical = elements["show-historical"].checked ? task.historicalPath : []; + var initialService = initialServicePath(task); + var rerouteDecision = selectedRerouteDecision(task); var currentSite = currentVehicleSite(task, traversedMoves); - var allIds = unique([].concat(task.plannedPath, historical, approach, + var allIds = unique([].concat(task.plannedPath, historical, approach, initialService, + rerouteDecision ? rerouteDecision.oldRemainingPath : [], + rerouteDecision ? rerouteDecision.newRemainingPath : [], moves.reduce(function (values, event) { if (event.fromSite) values.push(event.fromSite); if (event.toSite) values.push(event.toSite); @@ -365,8 +549,20 @@ hideMapHint(); drawPath(layer, approach, "approach", project); + if (elements["show-initial-route"].checked) { + drawPath(layer, initialService, "initial-service", project); + } drawRouteComparison(layer, task, project); - drawMoveSegments(layer, traversedMoves, "traversed", 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); + } + if (elements["show-hold-points"].checked) { + drawTrafficEventMarkers(layer, events, currentTick, project); + } var passedIds = new Set(); traversedMoves.forEach(function (event) { @@ -374,7 +570,8 @@ if (event.toSite) passedIds.add(event.toSite); }); var lastEvent = lastEventAt(events, currentTick); - var holdSite = lastEvent && isHold(lastEvent.eventType) + var holdSite = elements["show-hold-points"].checked + && lastEvent && isHold(lastEvent.eventType) ? (lastEvent.toSite || lastEvent.fromSite) : null; var comparisonNodes = comparisonNodeIds(task, elements["show-historical"].checked); @@ -466,24 +663,65 @@ /** 按后端给出的边集合差异叠加共同、仅新规划和仅历史路线。 */ function drawRouteComparison(layer, task, project) { var comparison = task.pathComparison; + var showCurrent = elements["show-current-route"].checked; + var showHistorical = elements["show-historical"].checked; if (!comparison) { - if (elements["show-historical"].checked) { + if (showHistorical) { drawPath(layer, task.historicalPath || [], "historical", project); } - drawPath(layer, task.plannedPath || [], "service", project); + if (showCurrent) drawPath(layer, task.plannedPath || [], "service", project); return; } - drawEvidenceEdges(layer, comparison.sharedEdges, - "shared-base planned-only", project); - drawEvidenceEdges(layer, comparison.plannedOnlyEdges, "planned-only", project); - if (elements["show-historical"].checked) { + if (showCurrent) { drawEvidenceEdges(layer, comparison.sharedEdges, - "shared-overlay historical-only", project); + "shared-base planned-only", project); + drawEvidenceEdges(layer, comparison.plannedOnlyEdges, "planned-only", project); + } + if (showHistorical) { + drawEvidenceEdges(layer, comparison.sharedEdges, + showCurrent ? "shared-overlay historical-only" : "historical-only", project); drawEvidenceEdges(layer, comparison.historicalOnlyEdges, "historical-only", project); } } + /** 返回初次采用的完整服务路径,专门与运行中的最终版本叠加。 */ + function initialServicePath(task) { + var initial = (task.servicePlanningEvidence || []).find(function (evidence) { + return Number(evidence.pathVersion) === 1 && !evidence.reroute; + }); + return initial ? initial.selectedPath : (task.plannedPath || []); + } + + /** 返回检查器当前选择的成功换路证据;触发或拒绝事件没有路线叠加。 */ + function selectedRerouteDecision(task) { + var events = task.rerouteEvents || []; + if (state.selectedRerouteIndex === null) return null; + var selected = events[state.selectedRerouteIndex]; + return selected ? selected.rerouteDecision : null; + } + + /** 在事件实际发生的节点绘制 HOLD 与 REROUTE 点,不将目标站误画成触发位置。 */ + function drawTrafficEventMarkers(layer, events, currentTick, project) { + events.filter(function (event) { + return Number(event.tick) <= currentTick + && (isHold(event.eventType) || event.eventType.indexOf("REROUTE") === 0); + }).forEach(function (event) { + var site = siteById(event.fromSite || event.toSite); + var point = project(site); + if (!point) return; + var reroute = event.eventType.indexOf("REROUTE") === 0; + var marker = svgElement("circle", { + cx: point.x, cy: point.y, r: reroute ? 8 : 6, + class: "traffic-event-marker " + (reroute ? "reroute-point" : "hold-point") + }); + var title = svgElement("title"); + title.textContent = event.eventType + " · " + (event.reason || "--"); + marker.appendChild(title); + layer.appendChild(marker); + }); + } + /** 将证据 DTO 中的端点直接画成线,不在浏览器中重新判断或规划邻接关系。 */ function drawEvidenceEdges(layer, edges, className, project) { (edges || []).forEach(function (edge) { @@ -611,7 +849,10 @@ function selectedRouteSiteIds() { var task = selectedTask(); if (!task) return []; + var reroute = selectedRerouteDecision(task); var ids = [].concat(task.plannedPath || [], task.historicalPath || [], + initialServicePath(task), reroute ? reroute.oldRemainingPath : [], + reroute ? reroute.newRemainingPath : [], [task.physicalStartSite, task.physicalEndSite]); eventsForTask(currentResult(), task.simulationTaskId).forEach(function (event) { if (event.fromSite) ids.push(event.fromSite); @@ -1083,9 +1324,71 @@ "evidence-hold-retry", "evidence-plc", "route-difference", "invalid-history-segments", "planned-path-output", "reason-output"] .forEach(function (id) { setText(id, "--"); }); + setEvidenceBadge("route-comparability", null, "--"); + setEvidenceBadge("route-quality-comparison", null, "--"); + elements["route-version-select"].replaceChildren(); + elements["route-version-select"].disabled = true; + elements["reroute-select"].replaceChildren(); + elements["reroute-select"].disabled = true; + setText("reroute-reason", "--"); + setText("reroute-old-path", "--"); + setText("reroute-new-path", "--"); + clearRouteDecision(); setTaskStatus("--"); } + /** 将历史路径可比性和路线质量类别转换为紧凑状态徽标。 */ + function setEvidenceBadge(id, value, label) { + var passValues = ["COMPARABLE", "EXACT_MATCH", "SHORTER_PHYSICAL_DISTANCE", + "SAME_DISTANCE_BETTER_QUALITY", "BOUNDED_DETOUR_FEWER_CONFLICTS"]; + var warningValues = ["ENDPOINT_MISMATCH", "WORSE_QUALITY"]; + var className = passValues.indexOf(value) >= 0 ? "is-pass" + : warningValues.indexOf(value) >= 0 ? "is-warning" + : value === "INVALID_HISTORY_PATH" ? "is-fail" : "is-neutral"; + elements[id].textContent = label; + elements[id].className = "evidence-badge " + className; + elements[id].title = value || "无比较结论"; + } + + function comparabilityLabel(value) { + return value === "COMPARABLE" ? "可公平比较" + : value === "ENDPOINT_MISMATCH" ? "端点不一致" + : value === "INVALID_HISTORY_PATH" ? "历史路径无效" : "无比较"; + } + + function qualityCategoryLabel(value) { + var labels = { + EXACT_MATCH: "路径一致", + SHORTER_PHYSICAL_DISTANCE: "物理距离更短", + SAME_DISTANCE_BETTER_QUALITY: "同距质量更优", + BOUNDED_DETOUR_FEWER_CONFLICTS: "有界绕行降冲突", + WORSE_QUALITY: "未优于历史" + }; + return labels[value] || "不参与质量比较"; + } + + function qualityValue(quality, field) { + if (!quality || quality[field] === null || quality[field] === undefined) return "--"; + return field === "physicalDistance" + ? Number(quality[field]).toFixed(2) : String(quality[field]); + } + + function compactQuality(quality) { + if (!quality) return "--"; + return [quality.oppositeDirectionConflicts, quality.occupiedNodes, + quality.incompatibleReservedEdges, quality.activeRouteOverlapEdges, + quality.turns].join("/"); + } + + function formatDistance(value) { + return value === null || value === undefined ? "--" : Number(value).toFixed(2); + } + + function formatDetour(value) { + if (value === null || value === undefined) return "--"; + return ((Number(value) - 1) * 100).toFixed(1) + "%"; + } + function setTaskStatus(status) { var className = status === "COMPLETED" ? "status-pass" : status === "FAILED" ? "status-fail" diff --git a/javaTest/src/main/resources/visualizer/index.html b/javaTest/src/main/resources/visualizer/index.html index 2b0cbcf..666d00e 100644 --- a/javaTest/src/main/resources/visualizer/index.html +++ b/javaTest/src/main/resources/visualizer/index.html @@ -124,7 +124,13 @@ - +

+ + + + + +
@@ -133,7 +139,10 @@ 共同路段 仅新规划 仅历史 + 初始规划 已通过 + 换路前 + 换路后 HOLD
@@ -175,6 +184,52 @@
路径边差
共同 -- / 新增 -- / 历史 --
历史无效段
--
+
+
+
+ 路线决策 +

候选与质量

+
+
+ -- + -- +
+
+
+ + +
+
+
最短距离
--
+
选中距离
--
+
绕行比例
--
+
候选数量
--
+
选择原因
--
+
+
+ + + +
质量项选中历史
+
+
+ + + +
候选路径距离质量
+
+
+
+
+
交通 HOLD

重规划记录

+ +
+
+
触发 / 结果--
+
换路前--
+
换路后--
+
+
调度中心 plannedPath -- diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTest.java index 47a5931..a670bea 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTest.java @@ -124,8 +124,16 @@ class HistoricalDispatchSimulatorTest { assertEquals(1, task.getRerouteCount()); assertTrue(result.getEvents().stream().anyMatch(event -> "REROUTE_TRIGGER".equals(event.getType()))); - assertTrue(result.getEvents().stream().anyMatch(event -> - "REROUTE".equals(event.getType()))); + SimulationTickEvent reroute = result.getEvents().stream() + .filter(event -> "REROUTE".equals(event.getType())) + .findFirst().orElseThrow(AssertionError::new); + assertEquals(Arrays.asList("A", "B", "D"), + reroute.getRerouteDecision().getOldRemainingPath()); + assertEquals(Arrays.asList("A", "C", "D"), + reroute.getRerouteDecision().getNewRemainingPath()); + assertEquals(2, reroute.getRerouteDecision().getPathVersion()); + assertTrue(reroute.getRerouteDecision().getNewQuality() + .compareTo(reroute.getRerouteDecision().getOldQuality()) < 0); assertEquals(2, task.getPlanningEvidence().stream() .map(RoutePlanningEvidence::getPathVersion) .distinct().count()); 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 ae54cae..00efb50 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 @@ -116,6 +116,18 @@ class HistoricalDispatchVisualizerServerTest { assertTrue(page.body.contains("id=\"reset-view\"")); assertTrue(page.body.contains("id=\"route-difference\"")); assertTrue(page.body.contains("id=\"invalid-history-segments\"")); + assertTrue(page.body.contains("id=\"show-initial-route\"")); + assertTrue(page.body.contains("id=\"show-current-route\"")); + assertTrue(page.body.contains("id=\"show-move-trace\"")); + assertTrue(page.body.contains("id=\"show-hold-points\"")); + assertTrue(page.body.contains("id=\"route-comparability\"")); + assertTrue(page.body.contains("id=\"route-quality-comparison\"")); + assertTrue(page.body.contains("id=\"route-stage-select\"")); + assertTrue(page.body.contains("id=\"route-version-select\"")); + assertTrue(page.body.contains("id=\"route-quality-body\"")); + assertTrue(page.body.contains("id=\"route-candidate-body\"")); + assertTrue(page.body.contains("id=\"reroute-select\"")); + assertTrue(page.body.contains("id=\"reroute-comparison\"")); assertEquals(200, stylesheet.status); assertEquals("text/css; charset=utf-8", stylesheet.contentType); assertTrue(stylesheet.body.contains("--color-service-path")); @@ -123,9 +135,15 @@ class HistoricalDispatchVisualizerServerTest { assertTrue(stylesheet.body.contains(".planned-only")); assertTrue(stylesheet.body.contains(".historical-only")); assertTrue(stylesheet.body.contains(".shared-overlay")); + assertTrue(stylesheet.body.contains(".initial-service")); + assertTrue(stylesheet.body.contains(".reroute-old")); + assertTrue(stylesheet.body.contains(".reroute-new")); assertEquals(200, script.status); assertEquals("application/javascript; charset=utf-8", script.contentType); assertTrue(script.body.contains("/api/simulations/point-to-point")); + assertTrue(script.body.contains("approachPlanningEvidence")); + assertTrue(script.body.contains("servicePlanningEvidence")); + assertTrue(script.body.contains("rerouteEvents")); } @Test 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 c56e92e..e9a179c 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 @@ -104,6 +104,8 @@ class SimulationVisualizationMapperTest { assertTrue(serviceEvidence.containsKey("quality")); assertTrue(serviceEvidence.containsKey("candidates")); assertEquals(sourceTask.getRerouteCount(), tasks.get(0).get("rerouteCount")); + assertEquals(Collections.singletonList(1), tasks.get(0).get("pathVersions")); + assertTrue(tasks.get(0).containsKey("rerouteEvents")); Map comparison = map(tasks.get(0).get("pathComparison")); assertEquals(0, comparison.get("sharedEdgeCount")); assertEquals(sourceTask.getPlannedPath().size() - 1,