From af0b52d6901fd5a88e93e1bdc57f6b4fff4e1583 Mon Sep 17 00:00:00 2001
From: zgw <13623675548@163.com>
Date: Thu, 30 Jul 2026 17:44:10 +0800
Subject: [PATCH] feat: replay real FT546 queue history
---
.gitattributes | 1 +
javaTest/README.md | 23 ++++
javaTest/pom.xml | 3 +-
.../wcs/shadow/history/HistoricalPath.java | 39 +++++++
.../history/HistoricalQueueDataset.java | 30 +++++
.../shadow/history/HistoricalQueueLoader.java | 103 ++++++++++++++++
.../history/HistoricalQueueNormalizer.java | 59 ++++++++++
.../shadow/history/HistoricalQueueRecord.java | 56 +++++++++
.../history/HistoricalReplayAnalyzer.java | 110 ++++++++++++++++++
.../history/HistoricalReplayApplication.java | 32 +++++
.../history/HistoricalReplayCategory.java | 9 ++
.../history/HistoricalReplayReportWriter.java | 110 ++++++++++++++++++
.../history/HistoricalReplayResult.java | 55 +++++++++
.../HistoricalQueueDatabaseReplayTest.java | 70 +++++++++++
.../wcs/shadow/HistoricalQueueLoaderTest.java | 33 ++++++
.../shadow/HistoricalQueueNormalizerTest.java | 52 +++++++++
.../shadow/HistoricalReplayAnalyzerTest.java | 84 +++++++++++++
17 files changed, 868 insertions(+), 1 deletion(-)
create mode 100644 .gitattributes
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueDataset.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueLoader.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueRecord.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayAnalyzer.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayCategory.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java
create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayResult.java
create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java
create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java
create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java
create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..4d74d4d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+javaTest/src/test/resources/shadow/history/*.json text eol=lf
diff --git a/javaTest/README.md b/javaTest/README.md
index 3b00813..1516c1c 100644
--- a/javaTest/README.md
+++ b/javaTest/README.md
@@ -37,6 +37,9 @@
- JSON 黄金决策事件、SHA-256 输入哈希和离线回放。
- 固定种子 `546001` 至 `546010` 的容量、无环和确定性不变量回归。
- `shadow-simulation-60.json` 提供 60 条独立模拟数据,覆盖 12 类资源、决策、快照和反馈场景;每条数据由参数化测试独立计数。
+- `shadow/history` 保存从 `FT546-wcs.dbo.wcs_srgv_queue_finish` 只读抽取的 240 条真实队列历史样本,保留原始任务号、车辆号、站点号和路径。
+- 真实历史数据覆盖车辆 `1/2/3` 和任务类型 `1/2/3`;数据库全量扫描口径为 17,136 条,其中 11,943 条带有非空路径。
+- 历史回放使用 `[create_date, finish_date)` 推导潜在并发窗口;数据库“已完成”事实与当前 SHADOW 的 `GRANT/HOLD/INSUFFICIENT_DATA` 推演结果分栏记录。
`com.ft546.wcs.demo` 是 LEGACY 演示;新增能力位于 `com.ft546.wcs.shadow`,交通区段状态不会写入 LEGACY 业务队列。
@@ -48,6 +51,26 @@ mvn clean test
测试必须由 Maven Surefire 实际发现并执行;验收标准为测试数大于 0、Failures 为 0、Errors 为 0。
+真实历史专项测试:
+
+```bash
+mvn -Dtest=HistoricalQueueDatabaseReplayTest test
+```
+
+生成真实历史离线回放证据:
+
+```bash
+mvn exec:java \
+ -Dexec.mainClass=com.ft546.wcs.shadow.history.HistoricalReplayApplication \
+ -Dexec.args="outputs/wcs-shadow-history-replay-local"
+```
+
+输出包括 `summary.json`、`replay-results.csv`、`data-quality-issues.csv` 和
+`manifest.sha256`。报告保留数据库原始业务编号,方便回查。
+
+> 这些结果是“真实历史队列离线回放”,不是现场 SHADOW 验收。历史队列表没有保存
+> 当时每一轮 PLC 快照、资源账本或 LEGACY 决策,因此不能把最终完成状态当作算法正确率。
+
## 运行控制台模拟
```bash
diff --git a/javaTest/pom.xml b/javaTest/pom.xml
index d4b9b92..6d01dc7 100644
--- a/javaTest/pom.xml
+++ b/javaTest/pom.xml
@@ -15,6 +15,7 @@
1.8
5.10.2
2.17.2
+ com.ft546.wcs.demo.DemoApplication
@@ -51,7 +52,7 @@
exec-maven-plugin
3.1.0
- com.ft546.wcs.demo.DemoApplication
+ ${exec.mainClass}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java
new file mode 100644
index 0000000..a54f954
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalPath.java
@@ -0,0 +1,39 @@
+package com.ft546.wcs.shadow.history;
+
+import com.ft546.wcs.shadow.resource.ResourceKey;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+public final class HistoricalPath {
+ private final HistoricalQueueRecord record;
+ private final LocalDateTime createdAt;
+ private final LocalDateTime finishedAt;
+ private final List nodes;
+ private final Set resources;
+ private final List issues;
+
+ HistoricalPath(HistoricalQueueRecord record, LocalDateTime createdAt, LocalDateTime finishedAt,
+ List nodes, Set resources, List issues) {
+ this.record = record;
+ this.createdAt = createdAt;
+ this.finishedAt = finishedAt;
+ this.nodes = Collections.unmodifiableList(new ArrayList(nodes));
+ this.resources = Collections.unmodifiableSet(new LinkedHashSet(resources));
+ this.issues = Collections.unmodifiableList(new ArrayList(issues));
+ }
+
+ public HistoricalQueueRecord getRecord() { return record; }
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public LocalDateTime getFinishedAt() { return finishedAt; }
+ public List getNodes() { return nodes; }
+ public Set getResources() { return resources; }
+ public List getIssues() { return issues; }
+ public boolean isReplayable() {
+ return createdAt != null && finishedAt != null && !nodes.isEmpty() && issues.isEmpty();
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueDataset.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueDataset.java
new file mode 100644
index 0000000..4b9c56f
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueDataset.java
@@ -0,0 +1,30 @@
+package com.ft546.wcs.shadow.history;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+public final class HistoricalQueueDataset {
+ private final List records;
+ private final Set mapSites;
+ private final int manifestSampleRows;
+ private final int fullScanTotalRows;
+ private final String extractedAt;
+
+ public HistoricalQueueDataset(List records, Set mapSites,
+ int manifestSampleRows, int fullScanTotalRows, String extractedAt) {
+ this.records = Collections.unmodifiableList(new ArrayList(records));
+ this.mapSites = Collections.unmodifiableSet(new LinkedHashSet(mapSites));
+ this.manifestSampleRows = manifestSampleRows;
+ this.fullScanTotalRows = fullScanTotalRows;
+ this.extractedAt = extractedAt;
+ }
+
+ public List getRecords() { return records; }
+ public Set getMapSites() { return mapSites; }
+ public int getManifestSampleRows() { return manifestSampleRows; }
+ public int getFullScanTotalRows() { return fullScanTotalRows; }
+ public String getExtractedAt() { return extractedAt; }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueLoader.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueLoader.java
new file mode 100644
index 0000000..8e77699
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueLoader.java
@@ -0,0 +1,103 @@
+package com.ft546.wcs.shadow.history;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.type.CollectionType;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+public final class HistoricalQueueLoader {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ public HistoricalQueueDataset load(ClassLoader loader, String basePath) {
+ try {
+ byte[] manifestBytes = read(loader, basePath + "/manifest.json");
+ JsonNode manifest = MAPPER.readTree(manifestBytes);
+ byte[] queueBytes = read(loader, basePath + "/queue-history-200.json");
+ byte[] mapBytes = read(loader, basePath + "/map-sites.json");
+ byte[] summaryBytes = read(loader, basePath + "/full-scan-summary.json");
+ verifySha256(manifest.path("sha256").path("queue-history-200.json").asText(), queueBytes);
+ verifySha256(manifest.path("sha256").path("map-sites.json").asText(), mapBytes);
+ verifySha256(manifest.path("sha256").path("full-scan-summary.json").asText(), summaryBytes);
+ CollectionType rowsType = MAPPER.getTypeFactory()
+ .constructCollectionType(List.class, HistoricalQueueRecord.class);
+ List rows = MAPPER.readValue(queueBytes, rowsType);
+ Set sites = new LinkedHashSet();
+ for (JsonNode node : MAPPER.readTree(mapBytes)) {
+ String site = node.path("site_id").asText();
+ if (!site.isEmpty()) sites.add(site);
+ }
+ int sampleRows = manifest.path("sampleRows").asInt();
+ if (sampleRows != rows.size()) {
+ throw new IllegalArgumentException("history sample row count mismatch");
+ }
+ if (rows.size() < 200) {
+ throw new IllegalArgumentException("history sample must contain at least 200 rows");
+ }
+ int totalRows = MAPPER.readTree(summaryBytes).path("total_rows").asInt();
+ if (totalRows != manifest.path("totalRows").asInt()) {
+ throw new IllegalArgumentException("history full scan row count mismatch");
+ }
+ return new HistoricalQueueDataset(rows, sites, sampleRows, totalRows,
+ manifest.path("extractedAt").asText());
+ } catch (IOException ex) {
+ throw new IllegalArgumentException("cannot load history snapshot", ex);
+ }
+ }
+
+ public HistoricalQueueDataset load(Path basePath) {
+ if (basePath == null || basePath.getParent() == null || basePath.getParent().getParent() == null) {
+ throw new IllegalArgumentException("invalid history snapshot path");
+ }
+ try {
+ URLClassLoader loader = new URLClassLoader(
+ new java.net.URL[]{basePath.getParent().getParent().toUri().toURL()});
+ try {
+ return load(loader, "shadow/history");
+ } finally {
+ loader.close();
+ }
+ } catch (IOException ex) {
+ throw new IllegalArgumentException("cannot load filesystem history snapshot", ex);
+ }
+ }
+
+ public static void verifySha256(String expected, byte[] bytes) {
+ String actual = sha256(bytes);
+ if (expected == null || !expected.equalsIgnoreCase(actual)) {
+ throw new IllegalArgumentException("history snapshot sha256 mismatch");
+ }
+ }
+
+ private static String sha256(byte[] bytes) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
+ StringBuilder value = new StringBuilder();
+ for (byte item : digest) value.append(String.format("%02x", item & 0xff));
+ return value.toString();
+ } catch (Exception ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static byte[] read(ClassLoader loader, String name) throws IOException {
+ InputStream input = loader.getResourceAsStream(name);
+ if (input == null) throw new IllegalArgumentException("missing classpath resource " + name);
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = input.read(buffer)) >= 0) output.write(buffer, 0, read);
+ input.close();
+ return output.toByteArray();
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java
new file mode 100644
index 0000000..7836063
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueNormalizer.java
@@ -0,0 +1,59 @@
+package com.ft546.wcs.shadow.history;
+
+import com.ft546.wcs.shadow.resource.ResourceKey;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+public final class HistoricalQueueNormalizer {
+ private static final DateTimeFormatter TIME = DateTimeFormatter
+ .ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
+
+ public HistoricalPath normalize(HistoricalQueueRecord record, Set mapSites) {
+ List issues = new ArrayList();
+ LocalDateTime created = parse(record.getCreateDate(), "INVALID_CREATE_DATE", issues);
+ LocalDateTime finished = parse(record.getFinishDate(), "INVALID_FINISH_DATE", issues);
+ if (created != null && finished != null && finished.isBefore(created)) {
+ issues.add("REVERSED_INTERVAL");
+ }
+ List nodes = new ArrayList();
+ if (record.getPathInfo() != null) {
+ for (String raw : record.getPathInfo().split(",")) {
+ String node = raw.trim();
+ if (!node.isEmpty()) nodes.add(node);
+ }
+ }
+ if (nodes.isEmpty()) issues.add("EMPTY_PATH");
+ Set resources = new LinkedHashSet();
+ for (int i = 0; i < nodes.size(); i++) {
+ String node = nodes.get(i);
+ if (!mapSites.contains(node)) issues.add("UNKNOWN_MAP_SITE:" + node);
+ resources.add(ResourceKey.node(node));
+ if (i > 0) {
+ String previous = nodes.get(i - 1);
+ if (previous.equals(node)) issues.add("ADJACENT_DUPLICATE_NODE:" + node);
+ else resources.add(ResourceKey.edge(previous, node));
+ }
+ }
+ return new HistoricalPath(record, created, finished, nodes, resources, issues);
+ }
+
+ private LocalDateTime parse(String value, String issue, List issues) {
+ if (value == null || value.trim().isEmpty()) {
+ issues.add(issue);
+ return null;
+ }
+ try {
+ return LocalDateTime.parse(value.trim(), TIME);
+ } catch (DateTimeParseException ex) {
+ issues.add(issue);
+ return null;
+ }
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueRecord.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueRecord.java
new file mode 100644
index 0000000..f7a8670
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalQueueRecord.java
@@ -0,0 +1,56 @@
+package com.ft546.wcs.shadow.history;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final class HistoricalQueueRecord {
+ @JsonProperty("guid") private String guid;
+ @JsonProperty("task_no") private String taskNo;
+ @JsonProperty("task_type") private String taskType;
+ @JsonProperty("priority") private Integer priority;
+ @JsonProperty("cargo") private String cargo;
+ @JsonProperty("issued") private String issued;
+ @JsonProperty("execution_status") private String executionStatus;
+ @JsonProperty("start_site") private String startSite;
+ @JsonProperty("end_site") private String endSite;
+ @JsonProperty("the_floor") private String floor;
+ @JsonProperty("path_info") private String pathInfo;
+ @JsonProperty("create_date") private String createDate;
+ @JsonProperty("start_date") private String startDate;
+ @JsonProperty("finish_date") private String finishDate;
+ @JsonProperty("agv_id") private String agvId;
+
+ public HistoricalQueueRecord() { }
+
+ public HistoricalQueueRecord(String guid, String taskNo, String taskType, Integer priority,
+ String startSite, String endSite, String pathInfo,
+ String createDate, String finishDate, String agvId) {
+ this.guid = guid;
+ this.taskNo = taskNo;
+ this.taskType = taskType;
+ this.priority = priority;
+ this.startSite = startSite;
+ this.endSite = endSite;
+ this.pathInfo = pathInfo;
+ this.createDate = createDate;
+ this.finishDate = finishDate;
+ this.agvId = agvId;
+ }
+
+ public String getGuid() { return guid; }
+ public String getTaskNo() { return taskNo; }
+ public String getTaskType() { return taskType; }
+ public Integer getPriority() { return priority; }
+ public String getCargo() { return cargo; }
+ public String getIssued() { return issued; }
+ public String getExecutionStatus() { return executionStatus; }
+ public String getStartSite() { return startSite; }
+ public String getEndSite() { return endSite; }
+ public String getFloor() { return floor; }
+ public String getPathInfo() { return pathInfo; }
+ public String getCreateDate() { return createDate; }
+ public String getStartDate() { return startDate; }
+ public String getFinishDate() { return finishDate; }
+ public String getAgvId() { return agvId; }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayAnalyzer.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayAnalyzer.java
new file mode 100644
index 0000000..a1c7e36
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayAnalyzer.java
@@ -0,0 +1,110 @@
+package com.ft546.wcs.shadow.history;
+
+import com.ft546.wcs.shadow.resource.ResourceKey;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+public final class HistoricalReplayAnalyzer {
+ public boolean overlaps(HistoricalPath left, HistoricalPath right) {
+ if (left.getCreatedAt() == null || left.getFinishedAt() == null
+ || right.getCreatedAt() == null || right.getFinishedAt() == null) return false;
+ return left.getCreatedAt().isBefore(right.getFinishedAt())
+ && right.getCreatedAt().isBefore(left.getFinishedAt());
+ }
+
+ public List analyze(List input) {
+ List ordered = new ArrayList(input);
+ Collections.sort(ordered, stableOrder());
+ List results = new ArrayList();
+ List accepted = new ArrayList();
+ for (HistoricalPath path : ordered) {
+ if (!path.isReplayable()) {
+ results.add(result(path, HistoricalReplayCategory.INSUFFICIENT_DATA, "INSUFFICIENT_DATA",
+ null, Collections.emptyList(), "路径或时间数据不足,按失效关闭处理"));
+ continue;
+ }
+ HistoricalReplayResult conflict = null;
+ for (HistoricalPath prior : accepted) {
+ if (!overlaps(prior, path)) continue;
+ if (same(path.getRecord().getAgvId(), prior.getRecord().getAgvId())) {
+ conflict = result(path, HistoricalReplayCategory.SAME_VEHICLE_OVERLAP, "HOLD", prior,
+ Collections.emptyList(), "同一车辆的历史生命周期发生重叠");
+ break;
+ }
+ List opposite = oppositeEdges(prior, path);
+ if (!opposite.isEmpty()) {
+ conflict = result(path, HistoricalReplayCategory.POTENTIAL_OPPOSITE_EDGE, "HOLD",
+ prior, opposite, "潜在并发窗口内存在对向边资源");
+ break;
+ }
+ List nodes = sharedNodes(prior, path);
+ if (!nodes.isEmpty()) {
+ conflict = result(path, HistoricalReplayCategory.POTENTIAL_NODE_CONFLICT, "HOLD",
+ prior, nodes, "潜在并发窗口内存在共享节点");
+ break;
+ }
+ }
+ if (conflict == null) {
+ accepted.add(path);
+ results.add(result(path, HistoricalReplayCategory.NO_CONFLICT, "GRANT", null,
+ Collections.emptyList(), "按历史生命周期推导窗口未发现资源冲突"));
+ } else {
+ results.add(conflict);
+ }
+ }
+ return Collections.unmodifiableList(results);
+ }
+
+ private Comparator stableOrder() {
+ return Comparator
+ .comparing((HistoricalPath p) -> p.getRecord().getPriority() == null
+ ? Integer.MAX_VALUE : p.getRecord().getPriority())
+ .thenComparing(HistoricalPath::getCreatedAt,
+ Comparator.nullsLast(Comparator.naturalOrder()))
+ .thenComparing(p -> text(p.getRecord().getTaskNo()))
+ .thenComparing(p -> text(p.getRecord().getAgvId()))
+ .thenComparing(p -> text(p.getRecord().getGuid()));
+ }
+
+ private List sharedNodes(HistoricalPath left, HistoricalPath right) {
+ Set values = new LinkedHashSet();
+ Set rightNodes = new LinkedHashSet(right.getNodes());
+ for (String node : left.getNodes()) if (rightNodes.contains(node)) values.add("NODE:" + node);
+ return new ArrayList(values);
+ }
+
+ private List oppositeEdges(HistoricalPath left, HistoricalPath right) {
+ Set rightDirections = new LinkedHashSet();
+ for (int i = 1; i < right.getNodes().size(); i++) {
+ rightDirections.add(right.getNodes().get(i - 1) + "\u0000" + right.getNodes().get(i));
+ }
+ Set result = new LinkedHashSet();
+ for (int i = 1; i < left.getNodes().size(); i++) {
+ String from = left.getNodes().get(i - 1);
+ String to = left.getNodes().get(i);
+ if (rightDirections.contains(to + "\u0000" + from)) {
+ result.add(ResourceKey.edge(from, to).value());
+ }
+ }
+ return new ArrayList(result);
+ }
+
+ private HistoricalReplayResult result(HistoricalPath path, HistoricalReplayCategory category,
+ String decision, HistoricalPath related,
+ List resources, String reason) {
+ return new HistoricalReplayResult(path, category, decision,
+ related == null ? null : related.getRecord().getAgvId(),
+ related == null ? null : related.getRecord().getTaskNo(), resources, reason);
+ }
+
+ private boolean same(String left, String right) {
+ return left != null && left.equals(right);
+ }
+
+ private String text(String value) { return value == null ? "" : value; }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java
new file mode 100644
index 0000000..489d48d
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayApplication.java
@@ -0,0 +1,32 @@
+package com.ft546.wcs.shadow.history;
+
+import java.nio.file.Paths;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+public final class HistoricalReplayApplication {
+ private HistoricalReplayApplication() { }
+
+ public static void main(String[] args) throws Exception {
+ if (args.length != 1) {
+ throw new IllegalArgumentException("usage: HistoricalReplayApplication ");
+ }
+ Path snapshot = Paths.get("javaTest/src/test/resources/shadow/history");
+ if (!Files.isDirectory(snapshot)) snapshot = Paths.get("src/test/resources/shadow/history");
+ HistoricalQueueDataset dataset = new HistoricalQueueLoader().load(snapshot);
+ HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer();
+ List paths = new ArrayList();
+ for (HistoricalQueueRecord record : dataset.getRecords()) {
+ paths.add(normalizer.normalize(record, dataset.getMapSites()));
+ }
+ HistoricalReplayAnalyzer analyzer = new HistoricalReplayAnalyzer();
+ List first = analyzer.analyze(paths);
+ List second = analyzer.analyze(paths);
+ if (!first.equals(second)) throw new IllegalStateException("history replay is not deterministic");
+ new HistoricalReplayReportWriter().write(Paths.get(args[0]), dataset, paths, first);
+ System.out.println("HISTORY-REPLAY source=" + dataset.getFullScanTotalRows()
+ + " sample=" + dataset.getRecords().size() + " output=" + args[0]);
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayCategory.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayCategory.java
new file mode 100644
index 0000000..a9d4a64
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayCategory.java
@@ -0,0 +1,9 @@
+package com.ft546.wcs.shadow.history;
+
+public enum HistoricalReplayCategory {
+ NO_CONFLICT,
+ POTENTIAL_NODE_CONFLICT,
+ POTENTIAL_OPPOSITE_EDGE,
+ SAME_VEHICLE_OVERLAP,
+ INSUFFICIENT_DATA
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java
new file mode 100644
index 0000000..11a4a53
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayReportWriter.java
@@ -0,0 +1,110 @@
+package com.ft546.wcs.shadow.history;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class HistoricalReplayReportWriter {
+ private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
+
+ public void write(Path outputDir, HistoricalQueueDataset dataset,
+ List paths, List results) throws IOException {
+ Files.createDirectories(outputDir);
+ writeCsv(outputDir.resolve("replay-results.csv"), replayRows(results));
+ writeCsv(outputDir.resolve("data-quality-issues.csv"), issueRows(paths));
+ Map summary = new LinkedHashMap();
+ summary.put("mode", "REAL_HISTORY_OFFLINE_REPLAY");
+ summary.put("title", "真实历史队列离线回放");
+ summary.put("boundary", "不是现场 SHADOW 验收");
+ summary.put("sourceRowCount", dataset.getFullScanTotalRows());
+ summary.put("sampleRowCount", dataset.getRecords().size());
+ summary.put("mapSiteCount", dataset.getMapSites().size());
+ summary.put("replayableRows", paths.stream().filter(HistoricalPath::isReplayable).count());
+ summary.put("qualityIssueRows", paths.stream().filter(p -> !p.getIssues().isEmpty()).count());
+ Map counts = new LinkedHashMap();
+ for (HistoricalReplayCategory category : HistoricalReplayCategory.values()) counts.put(category.name(), 0);
+ for (HistoricalReplayResult result : results) {
+ counts.put(result.getCategory().name(), counts.get(result.getCategory().name()) + 1);
+ }
+ summary.put("categoryCounts", counts);
+ summary.put("deterministic", true);
+ JSON.writeValue(outputDir.resolve("summary.json").toFile(), summary);
+ writeManifest(outputDir);
+ }
+
+ private List> replayRows(List results) {
+ List> rows = new ArrayList>();
+ rows.add(Arrays.asList("创建时间", "完成时间", "车辆", "任务号", "起点", "终点",
+ "历史状态", "路径", "同期车辆", "同期任务", "共享资源", "SHADOW推演",
+ "分类", "中文原因", "数据口径"));
+ for (HistoricalReplayResult result : results) {
+ HistoricalQueueRecord record = result.getPath().getRecord();
+ rows.add(Arrays.asList(value(record.getCreateDate()), value(record.getFinishDate()),
+ value(record.getAgvId()), value(record.getTaskNo()), value(record.getStartSite()),
+ value(record.getEndSite()), value(record.getExecutionStatus()),
+ value(record.getPathInfo()), value(result.getRelatedAgvId()),
+ value(result.getRelatedTaskNo()), String.join("|", result.getSharedResources()),
+ result.getDecision(), result.getCategory().name(), result.getReason(),
+ "生命周期按[create_date,finish_date)推导"));
+ }
+ return rows;
+ }
+
+ private List> issueRows(List paths) {
+ List> rows = new ArrayList>();
+ rows.add(Arrays.asList("GUID", "车辆", "任务号", "问题类型", "原始路径", "是否影响回放", "处理方式"));
+ for (HistoricalPath path : paths) {
+ for (String issue : path.getIssues()) {
+ HistoricalQueueRecord record = path.getRecord();
+ rows.add(Arrays.asList(value(record.getGuid()), value(record.getAgvId()),
+ value(record.getTaskNo()), issue, value(record.getPathInfo()), "是", "INSUFFICIENT_DATA"));
+ }
+ }
+ return rows;
+ }
+
+ private void writeCsv(Path file, List> rows) throws IOException {
+ StringBuilder csv = new StringBuilder("\uFEFF");
+ for (List row : rows) {
+ for (int i = 0; i < row.size(); i++) {
+ if (i > 0) csv.append(',');
+ csv.append('"').append(value(row.get(i)).replace("\"", "\"\"")).append('"');
+ }
+ csv.append("\r\n");
+ }
+ Files.write(file, csv.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ private void writeManifest(Path outputDir) throws IOException {
+ List names = Arrays.asList("data-quality-issues.csv", "replay-results.csv", "summary.json");
+ StringBuilder manifest = new StringBuilder();
+ for (String name : names) {
+ manifest.append(sha256(Files.readAllBytes(outputDir.resolve(name)))).append(" ").append(name).append('\n');
+ }
+ Files.write(outputDir.resolve("manifest.sha256"), manifest.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ private String sha256(byte[] bytes) {
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-256").digest(bytes);
+ StringBuilder text = new StringBuilder();
+ for (byte item : hash) text.append(String.format("%02x", item & 0xff));
+ return text.toString();
+ } catch (Exception ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private String value(String value) { return value == null ? "" : value; }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayResult.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayResult.java
new file mode 100644
index 0000000..5ad5aaf
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/HistoricalReplayResult.java
@@ -0,0 +1,55 @@
+package com.ft546.wcs.shadow.history;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+public final class HistoricalReplayResult {
+ private final HistoricalPath path;
+ private final HistoricalReplayCategory category;
+ private final String decision;
+ private final String relatedAgvId;
+ private final String relatedTaskNo;
+ private final List sharedResources;
+ private final String reason;
+
+ HistoricalReplayResult(HistoricalPath path, HistoricalReplayCategory category, String decision,
+ String relatedAgvId, String relatedTaskNo,
+ List sharedResources, String reason) {
+ this.path = path;
+ this.category = category;
+ this.decision = decision;
+ this.relatedAgvId = relatedAgvId;
+ this.relatedTaskNo = relatedTaskNo;
+ this.sharedResources = Collections.unmodifiableList(new ArrayList(sharedResources));
+ this.reason = reason;
+ }
+
+ public HistoricalPath getPath() { return path; }
+ public HistoricalReplayCategory getCategory() { return category; }
+ public String getDecision() { return decision; }
+ public String getRelatedAgvId() { return relatedAgvId; }
+ public String getRelatedTaskNo() { return relatedTaskNo; }
+ public List getSharedResources() { return sharedResources; }
+ public String getReason() { return reason; }
+
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof HistoricalReplayResult)) return false;
+ HistoricalReplayResult that = (HistoricalReplayResult) other;
+ return Objects.equals(path.getRecord().getGuid(), that.path.getRecord().getGuid())
+ && category == that.category
+ && Objects.equals(decision, that.decision)
+ && Objects.equals(relatedAgvId, that.relatedAgvId)
+ && Objects.equals(relatedTaskNo, that.relatedTaskNo)
+ && Objects.equals(sharedResources, that.sharedResources)
+ && Objects.equals(reason, that.reason);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(path.getRecord().getGuid(), category, decision,
+ relatedAgvId, relatedTaskNo, sharedResources, reason);
+ }
+}
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java
new file mode 100644
index 0000000..d3f7179
--- /dev/null
+++ b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueDatabaseReplayTest.java
@@ -0,0 +1,70 @@
+package com.ft546.wcs.shadow;
+
+import com.ft546.wcs.shadow.history.HistoricalPath;
+import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
+import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
+import com.ft546.wcs.shadow.history.HistoricalQueueNormalizer;
+import com.ft546.wcs.shadow.history.HistoricalQueueRecord;
+import com.ft546.wcs.shadow.history.HistoricalReplayAnalyzer;
+import com.ft546.wcs.shadow.history.HistoricalReplayResult;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HistoricalQueueDatabaseReplayTest {
+ private static final HistoricalQueueDataset DATA = new HistoricalQueueLoader().load(
+ HistoricalQueueDatabaseReplayTest.class.getClassLoader(), "shadow/history");
+
+ @ParameterizedTest(name = "{index} task={0} agv={1} {2}->{3}")
+ @MethodSource("realHistoryCases")
+ void replaysRealHistoryRecord(String taskNo, String agvId,
+ String startSite, String endSite, HistoricalPath path) {
+ assertFalse(taskNo.trim().isEmpty());
+ assertFalse(agvId.trim().isEmpty());
+ assertNotNull(startSite);
+ assertNotNull(endSite);
+ assertNotNull(path);
+ if (path.isReplayable()) {
+ assertFalse(path.getNodes().isEmpty());
+ assertFalse(path.getResources().isEmpty());
+ } else {
+ assertFalse(path.getIssues().isEmpty());
+ }
+ }
+
+ static Stream realHistoryCases() {
+ HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer();
+ return DATA.getRecords().stream().map(record -> Arguments.of(
+ record.getTaskNo(), record.getAgvId(), record.getStartSite(), record.getEndSite(),
+ normalizer.normalize(record, DATA.getMapSites())));
+ }
+
+ @Test
+ void snapshotCoverageAndReplayAreDeterministic() {
+ assertTrue(DATA.getRecords().size() >= 200);
+ assertEquals(3, DATA.getRecords().stream()
+ .map(HistoricalQueueRecord::getAgvId).distinct().count());
+ assertEquals(3, DATA.getRecords().stream()
+ .map(HistoricalQueueRecord::getTaskType).distinct().count());
+ assertEquals(17136, DATA.getFullScanTotalRows());
+ HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer();
+ List paths = new ArrayList();
+ for (HistoricalQueueRecord record : DATA.getRecords()) {
+ paths.add(normalizer.normalize(record, DATA.getMapSites()));
+ }
+ HistoricalReplayAnalyzer analyzer = new HistoricalReplayAnalyzer();
+ List first = analyzer.analyze(paths);
+ assertEquals(first, analyzer.analyze(paths));
+ assertEquals(DATA.getRecords().size(), first.size());
+ }
+}
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java
new file mode 100644
index 0000000..f9d7cfa
--- /dev/null
+++ b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueLoaderTest.java
@@ -0,0 +1,33 @@
+package com.ft546.wcs.shadow;
+
+import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
+import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HistoricalQueueLoaderTest {
+ @Test
+ void loadsVersionedDatabaseSnapshotAndPreservesOriginalIds() {
+ HistoricalQueueDataset data = new HistoricalQueueLoader().load(
+ getClass().getClassLoader(), "shadow/history");
+ assertEquals(240, data.getRecords().size());
+ assertEquals(17136, data.getFullScanTotalRows());
+ assertEquals(data.getManifestSampleRows(), data.getRecords().size());
+ assertFalse(data.getRecords().get(0).getTaskNo().isEmpty());
+ assertFalse(data.getRecords().get(0).getAgvId().isEmpty());
+ assertTrue(data.getMapSites().size() > 1000);
+ }
+
+ @Test
+ void rejectsHashMismatch() {
+ assertThrows(IllegalArgumentException.class, () ->
+ HistoricalQueueLoader.verifySha256(
+ "wrong", "abc".getBytes(StandardCharsets.UTF_8)));
+ }
+}
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java
new file mode 100644
index 0000000..57e9ff8
--- /dev/null
+++ b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalQueueNormalizerTest.java
@@ -0,0 +1,52 @@
+package com.ft546.wcs.shadow;
+
+import com.ft546.wcs.shadow.history.HistoricalPath;
+import com.ft546.wcs.shadow.history.HistoricalQueueNormalizer;
+import com.ft546.wcs.shadow.history.HistoricalQueueRecord;
+import com.ft546.wcs.shadow.resource.ResourceKey;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HistoricalQueueNormalizerTest {
+ private final HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer();
+
+ @Test
+ void parsesTrailingCommaAndProjectsUndirectedEdges() {
+ HistoricalPath path = normalizer.normalize(record("A,B,C,"),
+ new LinkedHashSet(Arrays.asList("A", "B", "C")));
+ assertEquals(Arrays.asList("A", "B", "C"), path.getNodes());
+ assertTrue(path.getResources().contains(ResourceKey.node("B")));
+ assertTrue(path.getResources().contains(ResourceKey.edge("B", "A")));
+ assertTrue(path.isReplayable());
+ }
+
+ @Test
+ void unknownMapSiteIsNotReplayable() {
+ HistoricalPath path = normalizer.normalize(record("A,UNKNOWN,"),
+ new LinkedHashSet(Arrays.asList("A")));
+ assertFalse(path.isReplayable());
+ assertTrue(path.getIssues().contains("UNKNOWN_MAP_SITE:UNKNOWN"));
+ }
+
+ @Test
+ void reversedIntervalIsNotReplayable() {
+ HistoricalQueueRecord record = new HistoricalQueueRecord(
+ "G", "T", "1", 2, "A", "B", "A,B,",
+ "2026-07-21 14:05:00", "2026-07-21 14:04:59", "1");
+ HistoricalPath path = normalizer.normalize(record,
+ new LinkedHashSet(Arrays.asList("A", "B")));
+ assertFalse(path.isReplayable());
+ assertTrue(path.getIssues().contains("REVERSED_INTERVAL"));
+ }
+
+ private HistoricalQueueRecord record(String path) {
+ return new HistoricalQueueRecord("G", "T", "1", 2, "A", "C", path,
+ "2026-07-21 14:00:00", "2026-07-21 14:05:00", "1");
+ }
+}
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java
new file mode 100644
index 0000000..239f7dc
--- /dev/null
+++ b/javaTest/src/test/java/com/ft546/wcs/shadow/HistoricalReplayAnalyzerTest.java
@@ -0,0 +1,84 @@
+package com.ft546.wcs.shadow;
+
+import com.ft546.wcs.shadow.history.HistoricalPath;
+import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
+import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
+import com.ft546.wcs.shadow.history.HistoricalQueueNormalizer;
+import com.ft546.wcs.shadow.history.HistoricalQueueRecord;
+import com.ft546.wcs.shadow.history.HistoricalReplayAnalyzer;
+import com.ft546.wcs.shadow.history.HistoricalReplayCategory;
+import com.ft546.wcs.shadow.history.HistoricalReplayReportWriter;
+import com.ft546.wcs.shadow.history.HistoricalReplayResult;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HistoricalReplayAnalyzerTest {
+ private final HistoricalQueueNormalizer normalizer = new HistoricalQueueNormalizer();
+ private final HistoricalReplayAnalyzer analyzer = new HistoricalReplayAnalyzer();
+ private final Set sites = new LinkedHashSet(Arrays.asList("A", "B", "C", "D"));
+
+ @Test
+ void touchingIntervalsDoNotOverlap() {
+ HistoricalPath left = path("G1", "T1", "V1", "A,B,", "10:00:00", "10:01:00");
+ HistoricalPath right = path("G2", "T2", "V2", "C,D,", "10:01:00", "10:02:00");
+ assertFalse(analyzer.overlaps(left, right));
+ }
+
+ @Test
+ void detectsOppositeUseOfSameUndirectedEdge() {
+ List results = analyzer.analyze(Arrays.asList(
+ path("G1", "T1", "V1", "A,B,", "10:00:00", "10:02:00"),
+ path("G2", "T2", "V2", "B,A,", "10:00:30", "10:03:00")));
+ assertTrue(results.stream().anyMatch(result ->
+ result.getCategory() == HistoricalReplayCategory.POTENTIAL_OPPOSITE_EDGE));
+ }
+
+ @Test
+ void identicalInputProducesIdenticalResults() {
+ List sample = Arrays.asList(
+ path("G1", "T1", "V1", "A,B,", "10:00:00", "10:02:00"),
+ path("G2", "T2", "V2", "C,D,", "10:00:30", "10:03:00"));
+ assertEquals(analyzer.analyze(sample), analyzer.analyze(sample));
+ }
+
+ @Test
+ void writesReadableEvidenceWithOriginalIdsAndBoundaryText(@TempDir Path temp) throws Exception {
+ HistoricalQueueDataset dataset = new HistoricalQueueLoader().load(
+ getClass().getClassLoader(), "shadow/history");
+ List paths = new ArrayList();
+ for (HistoricalQueueRecord record : dataset.getRecords()) {
+ paths.add(normalizer.normalize(record, dataset.getMapSites()));
+ }
+ new HistoricalReplayReportWriter().write(temp, dataset, paths, analyzer.analyze(paths));
+ String csv = new String(Files.readAllBytes(temp.resolve("replay-results.csv")),
+ StandardCharsets.UTF_8);
+ String summary = new String(Files.readAllBytes(temp.resolve("summary.json")),
+ StandardCharsets.UTF_8);
+ assertTrue(csv.contains(dataset.getRecords().get(0).getTaskNo()));
+ assertTrue(csv.contains(dataset.getRecords().get(0).getAgvId()));
+ assertTrue(summary.contains("真实历史队列离线回放"));
+ assertTrue(summary.contains("不是现场 SHADOW 验收"));
+ assertTrue(Files.size(temp.resolve("manifest.sha256")) > 0);
+ }
+
+ private HistoricalPath path(String guid, String task, String agv, String nodes,
+ String start, String finish) {
+ HistoricalQueueRecord record = new HistoricalQueueRecord(guid, task, "1", 2,
+ nodes.substring(0, 1), nodes.substring(2, 3), nodes,
+ "2026-07-21 " + start, "2026-07-21 " + finish, agv);
+ return normalizer.normalize(record, sites);
+ }
+}