feat(javaTest): visualize source-aligned task phases

This commit is contained in:
zgw
2026-08-10 12:28:14 +08:00
parent c8c4498ed1
commit 49c374b4b3
12 changed files with 660 additions and 74 deletions

View File

@@ -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")

View File

@@ -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();

View File

@@ -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<String, Object> health = new LinkedHashMap<String, Object>();
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;
}

View File

@@ -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;
/**
* 选择历史回放的审计口径。
*
* <p>业务模式把重复队列快照归并为真实父任务并执行二段式阶段;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);
}
}
}

View File

@@ -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,

View File

@@ -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<String, Object> value = new LinkedHashMap<String, Object>();
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<Map<String, Object>> snapshotEvidence(HistoricalSimulationTask definition) {
List<Map<String, Object>> values = new ArrayList<Map<String, Object>>();
if (definition == null) return values;
for (HistoricalTaskSnapshotEvidence evidence : definition.getSnapshotEvidence()) {
Map<String, Object> value = new LinkedHashMap<String, Object>();
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<Map<String, Object>> phaseEvidence(List<TaskPhaseEvidence> evidence) {
List<Map<String, Object>> values = new ArrayList<Map<String, Object>>();
for (TaskPhaseEvidence phase : evidence) {
Map<String, Object> value = new LinkedHashMap<String, Object>();
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;
}
/**
* 汇总父任务换层作业经过的楼层,供页面楼层选择器标记整条业务链涉及的地图。
*/

View File

@@ -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; }

View File

@@ -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();

View File

@@ -27,7 +27,7 @@
<nav id="mode-tabs" class="mode-tabs" aria-label="审计模式">
<button type="button" class="mode-tab is-active" data-mode="point" aria-selected="true">A → B 单任务</button>
<button type="button" class="mode-tab" data-mode="history" aria-selected="false">240 条历史任务</button>
<button type="button" class="mode-tab" data-mode="history" aria-selected="false">历史任务回放</button>
<button type="button" class="mode-tab" data-mode="safety" aria-selected="false">安全审计</button>
</nav>
@@ -82,11 +82,25 @@
<strong>真实历史负载</strong>
<span id="history-run-state">尚未运行</span>
</div>
<div class="replay-mode-control" role="radiogroup" aria-label="历史回放口径">
<input id="simulation-mode-business" name="simulation-mode" type="radio" value="SOURCE_ALIGNED_BUSINESS" checked>
<label for="simulation-mode-business">业务任务</label>
<input id="simulation-mode-raw" name="simulation-mode" type="radio" value="RAW_SNAPSHOT">
<label for="simulation-mode-raw">RAW 快照</label>
</div>
<div class="button-row">
<button id="export-result" type="button" class="secondary-button" disabled>导出证据</button>
<button id="run-history" type="button" class="primary-button"><span aria-hidden="true">▶</span>运行 240 条</button>
<button id="run-history" type="button" class="primary-button"><span aria-hidden="true">▶</span>运行回放</button>
</div>
</div>
<div class="audit-count-strip" aria-label="历史输入审计口径">
<div><span>原始快照</span><strong id="raw-snapshot-count">--</strong></div>
<div><span>唯一队列</span><strong id="unique-queue-count">--</strong></div>
<div><span>重复快照</span><strong id="duplicate-snapshot-count">--</strong></div>
<div><span>业务父任务</span><strong id="business-task-count">--</strong></div>
<div><span>运行作业</span><strong id="operational-queue-count">--</strong></div>
<div><span>当前口径</span><strong id="replay-mode-readout">业务任务</strong></div>
</div>
<div id="history-kpis" class="kpi-strip"></div>
<div class="table-toolbar">
<label class="search-field"><span class="sr-only">搜索任务</span><input id="task-search" type="search" placeholder="任务号 / 站点 / 车辆"></label>
@@ -143,6 +157,7 @@
<span><i class="legend-line traversed"></i>已通过</span>
<span><i class="legend-line reroute-old"></i>换路前</span>
<span><i class="legend-line reroute-new"></i>换路后</span>
<span><i class="legend-line business-phase"></i>业务阶段</span>
<span><i class="legend-dot hold"></i>HOLD</span>
<span><i class="legend-transfer"></i>换层端口</span>
</div>
@@ -194,6 +209,22 @@
</div>
<div id="floor-transfer-list" class="floor-transfer-list"></div>
</section>
<section id="business-evidence-panel" class="business-evidence-panel" aria-labelledby="business-evidence-title" hidden>
<div class="inspector-section-bar">
<div><span class="section-kicker">父任务执行链</span><h3 id="business-evidence-title">业务阶段路径</h3></div>
<span id="cargo-state" class="evidence-badge is-neutral">--</span>
</div>
<dl class="business-evidence-grid">
<div><dt>当前查看阶段</dt><dd id="current-business-phase">--</dd></div>
<div><dt>车辆释放状态</dt><dd id="vehicle-release-status">--</dd></div>
<div><dt>源通道前端</dt><dd id="source-front-site">--</dd></div>
<div><dt>目标通道前端</dt><dd id="target-front-site">--</dd></div>
<div><dt>业务完成</dt><dd id="business-completed-at">--</dd></div>
<div><dt>车辆释放</dt><dd id="vehicle-released-at">--</dd></div>
<div class="span-all"><dt>原始 GUID</dt><dd id="snapshot-guid-list">--</dd></div>
</dl>
<div id="phase-list" class="phase-list" aria-label="选择业务阶段路径"></div>
</section>
<section class="decision-audit" aria-labelledby="decision-audit-title">
<div class="inspector-section-bar">
<div>

View File

@@ -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<String, Object> summary = map(map(response.body).get("summary"));
Map<String, Object> 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

View File

@@ -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"));
}
}

View File

@@ -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<Map<String, Object>> 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<String, Object> 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"));