From 1cd9a7c1c001cbf813ef24b3ca6fdb87eeb0d1ac Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 6 Aug 2026 19:54:33 +0800 Subject: [PATCH] fix(javaTest): validate route quality inputs --- ...8-06-javaTest-dispatch-completion-audit.md | 10 +++---- .../demo/traffic/RouteQualityEvaluator.java | 17 +++++++++-- .../demo/traffic/TrafficRouteSnapshot.java | 17 +++++++++++ .../traffic/RouteQualityEvaluatorTest.java | 29 +++++++++++++++---- 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-08-06-javaTest-dispatch-completion-audit.md b/docs/superpowers/plans/2026-08-06-javaTest-dispatch-completion-audit.md index 81be39b..400c546 100644 --- a/docs/superpowers/plans/2026-08-06-javaTest-dispatch-completion-audit.md +++ b/docs/superpowers/plans/2026-08-06-javaTest-dispatch-completion-audit.md @@ -31,11 +31,11 @@ - `TrafficRouteSnapshot` 新增包内查询 `hasOppositeActiveRoute(String from, String to, String taskId)`。 - `RouteQualityEvaluator.evaluate(...)` 继续返回 `RouteQuality`;非法节点、非邻接边或距离不一致时抛 `IllegalArgumentException`。 -- [ ] **Step 1: 写失败测试** +- [x] **Step 1: 写失败测试** 增加三个测试:活动路线 `D -> B -> A` 对候选 `A -> B -> D` 计入对向冲突;候选包含未知站点或断边时被拒绝;候选声明距离与地图距离不一致时被拒绝。现有同向活动路线仍只计重合,不重复计对向。 -- [ ] **Step 2: 运行 RED** +- [x] **Step 2: 运行 RED** ```bash mvn -f javaTest/pom.xml -Dtest=RouteQualityEvaluatorTest test @@ -43,11 +43,11 @@ mvn -f javaTest/pom.xml -Dtest=RouteQualityEvaluatorTest test 预期新增断言失败或编译失败,证明测试没有被旧实现意外满足。 -- [ ] **Step 3: 实现最小修复** +- [x] **Step 3: 实现最小修复** 在快照中遍历其他活动路线的有向相邻边,只有 `to -> from` 命中时返回对向;在评价入口调用 `RouteCandidatePlanner.pathDistance(map, path)` 校验路径连续性和距离,使用数值容差 `1e-6`,再执行现有质量元组计算。对已识别的对向边跳过不兼容预约重复计数。 -- [ ] **Step 4: 运行 GREEN** +- [x] **Step 4: 运行 GREEN** ```bash mvn -f javaTest/pom.xml -Dtest=RouteQualityEvaluatorTest,RouteCandidatePlannerTest test @@ -55,7 +55,7 @@ mvn -f javaTest/pom.xml -Dtest=RouteQualityEvaluatorTest,RouteCandidatePlannerTe 预期所有路线单测通过,并保留原有确定性、110%边界和候选上限行为。 -- [ ] **Step 5: 提交** +- [x] **Step 5: 提交** ```bash git add javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java index 5f98b1b..41b4dd3 100644 --- a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluator.java @@ -9,6 +9,7 @@ import java.util.List; /** 使用同一个实时交通快照为有界候选排序并选择业务质量最优路线。 */ public final class RouteQualityEvaluator { + private static final double DISTANCE_EPSILON = 0.000001d; /** * 评价全部候选并按固定质量元组选择第一名。 @@ -54,6 +55,7 @@ public final class RouteQualityEvaluator { throw new IllegalArgumentException("map, candidate, snapshot and owners are required"); } List path = candidate.getPath(); + validateCandidatePath(map, candidate); int opposite = 0; int occupied = 0; int incompatible = 0; @@ -64,8 +66,8 @@ public final class RouteQualityEvaluator { String from = path.get(index - 1); String to = path.get(index); if (snapshot.isOccupiedByOther(to, vehicleId)) occupied++; - boolean oppositeOnEdge = snapshot.hasOppositeDirectionOwnedByOther( - from, to, taskId); + boolean oppositeOnEdge = snapshot.hasOppositeDirectionOwnedByOther(from, to, taskId) + || snapshot.hasOppositeActiveRoute(from, to, taskId); if (oppositeOnEdge) opposite++; // 同一物理边已计为对向风险时不在“不兼容边预约”中重复处罚。 if (!oppositeOnEdge @@ -79,6 +81,17 @@ public final class RouteQualityEvaluator { countTurns(map, path), candidate.getPhysicalDistance(), path); } + /** + * 在公开评价入口重新确认候选仍然属于权威地图,防止调用方绕过候选规划器注入非法路径。 + */ + private void validateCandidatePath(GridMap map, RouteCandidate candidate) { + double actualDistance = new RouteCandidatePlanner().pathDistance(map, candidate.getPath()); + if (Math.abs(actualDistance - candidate.getPhysicalDistance()) > DISTANCE_EPSILON) { + throw new IllegalArgumentException("candidate distance does not match map path: " + + candidate.getPhysicalDistance() + " != " + actualDistance); + } + } + /** 计算连续边方向向量改变次数;同一直线上的不同边长不算转弯。 */ private int countTurns(GridMap map, List path) { int turns = 0; diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java index d2a3455..1d2e499 100644 --- a/javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java +++ b/javaTest/src/main/java/com/ft546/wcs/demo/traffic/TrafficRouteSnapshot.java @@ -56,6 +56,23 @@ public final class TrafficRouteSnapshot { return containsOther(directedReservations.get(directedEdge(to, from)), taskId); } + /** + * 判断候选边是否与其他活动任务的剩余路线反向重叠;活动路线还未走完,因此也属于对向风险。 + */ + boolean hasOppositeActiveRoute(String from, String to, String taskId) { + for (Map.Entry> entry : activeRoutes.entrySet()) { + if (entry.getKey().equals(taskId)) continue; + List route = entry.getValue(); + for (int index = 1; index < route.size(); index++) { + if (directedEdge(from, to).equals( + directedEdge(route.get(index), route.get(index - 1)))) { + return true; + } + } + } + return false; + } + /** 判断候选物理边是否与其他活动任务尚未执行的路线重合。 */ boolean overlapsOtherActiveRoute(String from, String to, String taskId) { String edge = canonicalEdge(from, to); diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java index b383631..dabdf1f 100644 --- a/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/demo/traffic/RouteQualityEvaluatorTest.java @@ -7,6 +7,7 @@ import java.util.Arrays; import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class RouteQualityEvaluatorTest { @@ -19,7 +20,7 @@ class RouteQualityEvaluatorTest { .occupyNode("B", "AGV-2") .reserveDirection("D", "B", "TASK-2") .reserveEdge("B", "D", "TASK-2") - .activeRoute("TASK-2", Arrays.asList("D", "B")) + .activeRoute("TASK-2", Arrays.asList("B", "D")) .reserveEdge("A", "B", "TASK-1") .build(); @@ -39,7 +40,7 @@ class RouteQualityEvaluatorTest { RouteCandidate shortestWithOppositeConflict = new RouteCandidate(Arrays.asList("A", "B", "D"), 2.0d); RouteCandidate longerWithoutConflict = - new RouteCandidate(Arrays.asList("A", "C", "D"), 2.8d); + new RouteCandidate(Arrays.asList("A", "C", "D"), 2.0d * Math.sqrt(2.0d)); RouteCandidateSet candidates = new RouteCandidateSet( shortestWithOppositeConflict.getPath(), 2.0d, Arrays.asList(shortestWithOppositeConflict, longerWithoutConflict), false); @@ -53,14 +54,15 @@ class RouteQualityEvaluatorTest { assertEquals(Arrays.asList("A", "C", "D"), decision.getSelectedPath()); assertEquals("FEWER_OPPOSITE_DIRECTION_CONFLICTS", decision.getDecisionReason()); assertEquals(2, decision.getEvaluations().size()); - assertEquals(1.4d, decision.getDetourRatio(), 0.000001d); + assertEquals(Math.sqrt(2.0d), decision.getDetourRatio(), 0.000001d); } @Test void lowerTupleFieldsDecideOnlyWhenAllHigherFieldsAreEqual() { GridMap map = map(); RouteCandidate occupied = new RouteCandidate(Arrays.asList("A", "B", "D"), 2.0d); - RouteCandidate reserved = new RouteCandidate(Arrays.asList("A", "C", "D"), 2.8d); + RouteCandidate reserved = new RouteCandidate(Arrays.asList("A", "C", "D"), + 2.0d * Math.sqrt(2.0d)); TrafficRouteSnapshot snapshot = TrafficRouteSnapshot.builder() .occupyNode("B", "OTHER-AGV") .reserveEdge("A", "C", "OTHER-TASK") @@ -95,16 +97,33 @@ class RouteQualityEvaluatorTest { map, turning, snapshot, "TASK-1", "AGV-1"); assertEquals(1, quality.getTurnCount()); + assertEquals(1, quality.getOppositeDirectionConflictCount()); assertEquals(1, quality.getActiveRouteOverlapEdgeCount()); } + @Test + void rejectsUnknownNonAdjacentOrMislabeledDistanceCandidates() { + GridMap map = map(); + RouteQualityEvaluator evaluator = new RouteQualityEvaluator(); + + assertThrows(IllegalArgumentException.class, () -> evaluator.evaluate( + map, new RouteCandidate(Arrays.asList("A", "UNKNOWN", "D"), 2.0d), + TrafficRouteSnapshot.empty(), "TASK-1", "AGV-1")); + assertThrows(IllegalArgumentException.class, () -> evaluator.evaluate( + map, new RouteCandidate(Arrays.asList("A", "D"), 2.0d), + TrafficRouteSnapshot.empty(), "TASK-1", "AGV-1")); + assertThrows(IllegalArgumentException.class, () -> evaluator.evaluate( + map, new RouteCandidate(Arrays.asList("A", "B", "D"), 3.0d), + TrafficRouteSnapshot.empty(), "TASK-1", "AGV-1")); + } + private GridMap map() { GridMap map = new GridMap(); add(map, "A", 0, 0); add(map, "B", 1, 0); add(map, "C", 1, 1); add(map, "D", 2, 0); - add(map, "E", 1, -1); + add(map, "E", 1, 0); add(map, "F", 1, 1); map.connect("A", "B"); map.connect("B", "D");