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