Update collision logging and route assignment handling

This commit is contained in:
sladro 2026-04-30 09:15:51 +08:00
parent 1cac6fb811
commit 43bf9488d6
30 changed files with 3602 additions and 189 deletions

View File

@ -6,6 +6,30 @@
这是开发环境运行环境在centos7的容器里分别为
qaup-app
qaup-redis
qaup-postgis
### Docker/日志/数据库定位
- 后端应用容器:`qaup-app`。
- Redis 容器:`qaup-redis`。
- PostGIS/PostgreSQL 容器:`qaup-postgis`。
- 后端主日志优先看宿主机路径:`/home/project_20250804/offline-deploy/logs/app/sys-info.log`,历史滚动日志类似 `sys-info.2026-04-28.2.log`
- 容器 overlay 中也可能有同一份日志:`/var/lib/docker/overlay2/.../merged/logs/sys-info*.log`。
- 碰撞测试独立日志在 qaup-app 容器内:`/logs/collision-tests/`。启动时主日志会打印:`Collision test session file log initialized: enabled=true, directory=/logs/collision-tests`。
- 按车牌或航班查测试日志:`docker exec qaup-app sh -c 'grep -R -l "TEST003" /logs/collision-tests 2>/dev/null'`。
- `/home/project_20250804/qaup/logs` 下看到的 `mock_traffic_light.log`、`mock_unmanned_vehicle.log` 是模拟器日志,不是 qaup-app 主应用日志。
- qaup-app 容器内通常没有 `psql`,查库优先用:`docker exec qaup-postgis psql -U postgres -d qaup -c "SQL"`。
- 已确认数据库环境变量示例:`DB_HOST=172.17.0.4`、`DB_PORT=5432`、`DB_USERNAME=postgres`、`DB_PASSWORD=123456`、`DB_NAME=qaup`。
### 碰撞测试流程排查要点
- `/api/VehicleRegistry` 是全量覆盖注册,不是增量追加。
- 测试会话日志关键字:`collision test session started`、`collision test session replaced`、`collision-diagnostic`。
- 诊断日志中重点看:`collisionManagedObjects`、`pairsSupported`、`missingRoute`、`routeDeviation`、`speedTooLow`、`thresholdNotReached`、`eventsPublished`。
- 如果 `pairsTotal=0`,说明参与碰撞检测的对象不足两个,先查注册对象和实时位置对象是否匹配。
- 如果 `routeDeviation>0`,说明实时位置距离后端绑定路线过远,优先查本轮路线绑定是否正确。
- 路径绑定表是 `object_route_assignments`,真实字段为:`id`、`object_type`、`object_name`、`assigned_route_id`、`assigned_at`。不要使用 `object_id`、`route_id`、`route_name` 这些不存在的字段。
- 查对象路线绑定示例:`docker exec qaup-postgis psql -U postgres -d qaup -c "select object_type, object_name, assigned_route_id, assigned_at from object_route_assignments where object_name in ('MU2465','TEST003') order by assigned_at desc;"`
- 如需看路线名称,连接 `transport_routes``docker exec qaup-postgis psql -U postgres -d qaup -c "select a.object_type, a.object_name, a.assigned_route_id, r.name, a.assigned_at from object_route_assignments a left join transport_routes r on r.id = a.assigned_route_id where a.object_name in ('MU2465','TEST003') order by a.assigned_at desc;"`
- 历史上曾出现同一对象编号跨类型或旧路线残留导致误判,注册覆盖时需要同步清理旧路径绑定。
### 红绿灯接入说明
- 正式环境中qaup-app 的红绿灯接入方式为 MQTT不是 HTTP。

View File

@ -569,7 +569,7 @@ public class DataCollectorService {
upsertActiveMovingObject(movingObject);
log.debug("处理航空器数据并更新缓存: (航班号: {}, 位置: {}, {}, 速度: {})",
log.trace("处理航空器数据并更新缓存: (航班号: {}, 位置: {}, {}, 速度: {})",
aircraft.getObjectId(),
currentPosition.getX(),
currentPosition.getY(),
@ -580,7 +580,7 @@ public class DataCollectorService {
}
}
log.info("航空器数据处理和事件发布完成,处理数量: {}", deduplicatedAircrafts.size());
log.trace("航空器数据处理和事件发布完成,处理数量: {}", deduplicatedAircrafts.size());
} catch (Exception e) {
log.error("采集航空器数据异常", e);
@ -612,7 +612,7 @@ public class DataCollectorService {
}
int originalCount = vehicles.size();
log.info("采集到 {} 条机场车辆数据,开始过滤处理", originalCount);
log.trace("采集到 {} 条机场车辆数据,开始过滤处理", originalCount);
// 应用车辆位置过滤器
List<AirportVehicle> filteredVehicles = vehicleLocationFilter.filterVehicles(vehicles);
@ -656,7 +656,7 @@ public class DataCollectorService {
// 将最新数据更新到缓存不发送WebSocket消息统一在周期性检测中发送
upsertActiveMovingObject(movingObject);
log.debug("处理机场车辆数据并更新缓存: (车牌号: {}, 位置: {}, {}, 速度: {})",
log.trace("处理机场车辆数据并更新缓存: (车牌号: {}, 位置: {}, {}, 速度: {})",
vehicle.getObjectId(),
currentPosition.getX(),
currentPosition.getY(),
@ -667,7 +667,7 @@ public class DataCollectorService {
}
}
log.info("机场车辆数据处理和事件发布完成,处理数量: {}", filteredVehicles.size());
log.trace("机场车辆数据处理和事件发布完成,处理数量: {}", filteredVehicles.size());
} catch (Exception e) {
log.error("采集机场车辆数据异常", e);
@ -710,7 +710,7 @@ public class DataCollectorService {
return;
}
log.debug("开始采集 {} 辆无人车的状态数据HTTP轮询", unmannedVehicleIds.size());
log.trace("开始采集 {} 辆无人车的状态数据HTTP轮询", unmannedVehicleIds.size());
int successCount = 0;
for (String vehicleId : unmannedVehicleIds) {
@ -838,7 +838,7 @@ public class DataCollectorService {
}
successCount++;
log.debug("处理无人车完整状态数据并更新缓存: (车辆ID: {}, 位置: {}, {}, 任务ID: {}, 里程: {}米, 电量: {}%)",
log.trace("处理无人车完整状态数据并更新缓存: (车辆ID: {}, 位置: {}, {}, 任务ID: {}, 里程: {}米, 电量: {}%)",
vehicleId,
currentPosition.getX(),
currentPosition.getY(),
@ -851,7 +851,7 @@ public class DataCollectorService {
}
}
log.debug("无人车状态数据采集完成,成功处理: {}/{}", successCount, unmannedVehicleIds.size());
log.trace("无人车状态数据采集完成,成功处理: {}/{}", successCount, unmannedVehicleIds.size());
} catch (Exception e) {
log.error("采集无人车数据异常", e);

View File

@ -201,7 +201,10 @@ public class RoutePersistenceService {
// 如果路由ID完全相同则跳过重复记录
if (existingRouteId.equals(routeId)) {
log.info("路由分配未变更,跳过重复记录: objectName={}, routeType={}, routeId={}",
ObjectRouteAssignment assignment = existingAssignment.get();
assignment.setAssignedAt(LocalDateTime.now());
objectRouteAssignmentRepository.save(assignment);
log.info("路由分配未变更,已刷新分配时间: objectName={}, routeType={}, routeId={}",
objectName, routeType, routeId);
return;
}
@ -210,7 +213,11 @@ public class RoutePersistenceService {
Optional<TransportRoute> existingRoute = transportRouteRepository.findById(existingRouteId);
if (existingRoute.isPresent() &&
existingRoute.get().getRouteName().equals(currentRouteName)) {
log.info("路由名称相同,跳过重复记录: objectName={}, routeName={}, existingRouteId={}, currentRouteId={}",
ObjectRouteAssignment assignment = existingAssignment.get();
assignment.setAssignedRouteId(routeId);
assignment.setAssignedAt(LocalDateTime.now());
objectRouteAssignmentRepository.save(assignment);
log.info("路由名称相同,已刷新分配记录: objectName={}, routeName={}, existingRouteId={}, currentRouteId={}",
objectName, currentRouteName, existingRouteId, routeId);
return;
}

View File

@ -135,6 +135,9 @@ public class RoutePreparationService {
);
if (existingAssignment.isPresent() && Objects.equals(existingAssignment.get().getAssignedRouteId(), routeId)) {
ObjectRouteAssignment assignment = existingAssignment.get();
assignment.setAssignedAt(LocalDateTime.now());
objectRouteAssignmentRepository.save(assignment);
return;
}

View File

@ -10,7 +10,10 @@ import org.locationtech.jts.geom.Coordinate;
public class AirportCoordinateSystem {
private final CoordinateReferenceSystem sourceCRS;
private final CoordinateReferenceSystem targetCRS;
private final CoordinateReferenceSystem airportProjectedCRS;
private final MathTransform transform;
private final MathTransform airportProjectedTransform;
private final MathTransform airportProjectedToWgs84Transform;
// 本地坐标系原点
private final double[] origin;
@ -19,9 +22,15 @@ public class AirportCoordinateSystem {
this.sourceCRS = CRS.decode("EPSG:4326",true);
String utmCode = calculateUtmZone(centerLon, centerLat);
this.targetCRS = CRS.decode(utmCode);
int gaussKrugerZone = (int) Math.round(centerLon / 3.0);
double centralMeridian = gaussKrugerZone * 3.0;
double falseEasting = gaussKrugerZone * 1_000_000.0 + 500_000.0;
this.airportProjectedCRS = CRS.parseWKT(buildGaussKrugerWkt(centralMeridian, falseEasting));
// 创建转换器并计算原点
this.transform = CRS.findMathTransform(sourceCRS, targetCRS);
this.airportProjectedTransform = CRS.findMathTransform(sourceCRS, airportProjectedCRS);
this.airportProjectedToWgs84Transform = CRS.findMathTransform(airportProjectedCRS, sourceCRS);
this.origin = transformCoordinate(centerLon, centerLat);
}
@ -34,6 +43,20 @@ public class AirportCoordinateSystem {
};
}
public double[] convertToAirportProjected(double lon, double lat) throws Exception {
Coordinate source = new Coordinate(lon, lat);
Coordinate target = new Coordinate();
JTS.transform(source, target, airportProjectedTransform);
return new double[]{target.x, target.y};
}
public double[] convertAirportProjectedToWgs84(double x, double y) throws Exception {
Coordinate source = new Coordinate(x, y);
Coordinate target = new Coordinate();
JTS.transform(source, target, airportProjectedToWgs84Transform);
return new double[]{target.x, target.y};
}
private double[] transformCoordinate(double lon, double lat) throws Exception {
Coordinate source = new Coordinate(lon, lat);
Coordinate target = new Coordinate();
@ -46,4 +69,20 @@ public class AirportCoordinateSystem {
// 北半球编码
return "EPSG:326" + (zone < 10 ? "0" : "") + zone;
}
private static String buildGaussKrugerWkt(double centralMeridian, double falseEasting) {
return "PROJCS[\"Airport_Gauss_Kruger\","
+ "GEOGCS[\"WGS 84\","
+ "DATUM[\"WGS_1984\","
+ "SPHEROID[\"WGS 84\",6378137,298.257223563]],"
+ "PRIMEM[\"Greenwich\",0],"
+ "UNIT[\"degree\",0.0174532925199433]],"
+ "PROJECTION[\"Transverse_Mercator\"],"
+ "PARAMETER[\"latitude_of_origin\",0],"
+ "PARAMETER[\"central_meridian\"," + centralMeridian + "],"
+ "PARAMETER[\"scale_factor\",1],"
+ "PARAMETER[\"false_easting\"," + falseEasting + "],"
+ "PARAMETER[\"false_northing\",0],"
+ "UNIT[\"metre\",1]]";
}
}

View File

@ -28,4 +28,12 @@ public class CoordinateSystemService {
public double[] convertToLocalCoordinate(double longitude, double latitude) throws Exception {
return airportCoordinateSystem.convertToLocal(longitude, latitude);
}
public double[] convertToAirportProjectedCoordinate(double longitude, double latitude) throws Exception {
return airportCoordinateSystem.convertToAirportProjected(longitude, latitude);
}
public double[] convertAirportProjectedToWgs84Coordinate(double x, double y) throws Exception {
return airportCoordinateSystem.convertAirportProjectedToWgs84(x, y);
}
}

View File

@ -15,6 +15,7 @@ import com.qaup.collision.websocket.message.VehicleStatusUpdatePayload;
import com.qaup.collision.websocket.event.VehicleStatusUpdateEvent;
import com.qaup.collision.datacollector.service.DataCollectorService;
import com.qaup.collision.common.model.FlightNotification;
import com.qaup.collision.service.CollisionTestSessionLogService;
import com.qaup.collision.service.PlatformRuntimeStateService;
import com.qaup.collision.websocket.event.FlightNotificationEvent;
import com.qaup.common.core.redis.RedisCache;
@ -101,6 +102,9 @@ public class DataProcessingService {
@Autowired
private PlatformRuntimeStateService platformRuntimeStateService;
@Autowired(required = false)
private CollisionTestSessionLogService collisionTestSessionLogService;
// 从DataCollectorService获取缓存的引用
private Map<String, MovingObject> activeMovingObjectsCache;
private final Map<String, Long> lastPublishedPositionSampleTimestamps = new ConcurrentHashMap<>();
@ -143,7 +147,7 @@ public class DataProcessingService {
return;
}
log.info("开始周期性数据处理,活跃对象数量: {}", activeMovingObjectsCache.size());
log.trace("开始周期性数据处理,活跃对象数量: {}", activeMovingObjectsCache.size());
// 获取所有活跃对象的快照避免处理线程回写覆盖采集线程最新状态
List<MovingObject> currentActiveObjects = createProcessingSnapshot(activeMovingObjectsCache.values());
@ -182,7 +186,7 @@ public class DataProcessingService {
logCycleDiagnostics(currentActiveObjects.size(), collisionManagedObjects.size(),
positionDispatchSummary, collisionManagedResult, conflictDetectionSummary);
log.info("周期性数据处理完成");
log.trace("周期性数据处理完成");
}
private CollisionManagedObjectsResult filterCollisionManagedObjects(List<MovingObject> activeObjects) {
@ -247,7 +251,7 @@ public class DataProcessingService {
}
long currentTime = System.currentTimeMillis();
log.debug("开始为 {} 个对象计算速度和方向", activeObjects.size());
log.trace("开始为 {} 个对象计算速度和方向", activeObjects.size());
for (MovingObject movingObject : activeObjects) {
try {
@ -269,7 +273,7 @@ public class DataProcessingService {
movingObject.setCurrentSpeed(calculatedSpeed);
movingObject.setCurrentHeading(calculatedDirection);
log.debug("对象 {} 计算完成: 速度={} km/h, 方向={}度",
log.trace("对象 {} 计算完成: 速度={} km/h, 方向={}度",
movingObject.getObjectId(), movingObject.getCurrentSpeed(), movingObject.getCurrentHeading());
} catch (Exception e) {
@ -277,7 +281,7 @@ public class DataProcessingService {
}
}
log.debug("所有对象的速度和方向计算完成");
log.trace("所有对象的速度和方向计算完成");
}
/**
@ -285,12 +289,12 @@ public class DataProcessingService {
*/
private PositionDispatchSummary sendPositionUpdatesForActiveObjects(List<MovingObject> activeObjects) {
if (activeObjects.isEmpty()) {
log.debug("没有活跃对象,跳过位置更新消息发送");
log.trace("没有活跃对象,跳过位置更新消息发送");
return new PositionDispatchSummary(0, 0, 0);
}
long currentTime = System.currentTimeMillis();
log.debug("发送位置更新消息,对象数量: {}", activeObjects.size());
log.trace("发送位置更新消息,对象数量: {}", activeObjects.size());
int publishedCount = 0;
int duplicateSampleSkippedCount = 0;
Set<String> currentObjectIds = new HashSet<>();
@ -329,7 +333,7 @@ public class DataProcessingService {
eventPublisher.publishEvent(new PositionUpdateEvent(payload));
publishedCount++;
log.debug("发送位置更新: {} ({}), 位置: ({}, {}), 速度: {}",
log.trace("发送位置更新: {} ({}), 位置: ({}, {}), 速度: {}",
movingObject.getObjectId(),
movingObject.getObjectType(),
movingObject.getCurrentPosition().getX(),
@ -342,7 +346,7 @@ public class DataProcessingService {
}
cleanupPublishedPositionState(currentObjectIds);
log.info("位置更新消息发送完成,发送数量: {}, 跳过重复采样数量: {}", publishedCount, duplicateSampleSkippedCount);
log.trace("位置更新消息发送完成,发送数量: {}, 跳过重复采样数量: {}", publishedCount, duplicateSampleSkippedCount);
return new PositionDispatchSummary(activeObjects.size(), publishedCount, duplicateSampleSkippedCount);
}
@ -378,7 +382,7 @@ public class DataProcessingService {
log.info(
"[test-session={}] [collision-diagnostic] activeObjects={}, collisionManagedObjects={}, positionPublished={}, "
+ "positionDuplicateSkipped={}, registrationFiltered={}, typeOverrides={}, pairsTotal={}, pairsSupported={}, missingRoute={}, missingPosition={}, "
+ "routeDeviation={}, speedTooLow={}, noIntersection={}, intersectionBehind={}, headingMismatch={}, "
+ "routeDeviation={}, speedTooLow={}, noIntersection={}, intersectionBehind={}, headingMismatch={}, directionLockFailed={}, "
+ "thresholdNotReached={}, detectionErrors={}, eventsPublished={}",
testSessionId != null ? testSessionId : "none",
activeObjectCount,
@ -396,10 +400,36 @@ public class DataProcessingService {
conflictDetectionSummary.noIntersectionPairs(),
conflictDetectionSummary.intersectionBehindPairs(),
conflictDetectionSummary.headingMismatchPairs(),
conflictDetectionSummary.directionLockFailedPairs(),
conflictDetectionSummary.thresholdNotReachedPairs(),
conflictDetectionSummary.errorPairs(),
conflictDetectionSummary.eventsPublished()
);
if (collisionTestSessionLogService != null && testSessionId != null) {
collisionTestSessionLogService.write(
testSessionId,
"CYCLE activeObjects=" + activeObjectCount
+ ", collisionManagedObjects=" + collisionManagedObjectCount
+ ", positionPublished=" + positionDispatchSummary.publishedCount()
+ ", positionDuplicateSkipped=" + positionDispatchSummary.duplicateSampleSkippedCount()
+ ", registrationFiltered=" + collisionManagedResult.registrationFilteredCount()
+ ", typeOverrides=" + collisionManagedResult.typeOverrideCount()
+ ", pairsTotal=" + conflictDetectionSummary.totalPairs()
+ ", pairsSupported=" + conflictDetectionSummary.supportedPairs()
+ ", missingRoute=" + conflictDetectionSummary.missingRoutePairs()
+ ", missingPosition=" + conflictDetectionSummary.missingPositionPairs()
+ ", routeDeviation=" + conflictDetectionSummary.routeDeviationPairs()
+ ", speedTooLow=" + conflictDetectionSummary.speedTooLowPairs()
+ ", noIntersection=" + conflictDetectionSummary.noIntersectionPairs()
+ ", intersectionBehind=" + conflictDetectionSummary.intersectionBehindPairs()
+ ", headingMismatch=" + conflictDetectionSummary.headingMismatchPairs()
+ ", directionLockFailed=" + conflictDetectionSummary.directionLockFailedPairs()
+ ", thresholdNotReached=" + conflictDetectionSummary.thresholdNotReachedPairs()
+ ", detectionErrors=" + conflictDetectionSummary.errorPairs()
+ ", eventsPublished=" + conflictDetectionSummary.eventsPublished()
);
}
}
private List<MovingObject> createProcessingSnapshot(java.util.Collection<MovingObject> sourceObjects) {
@ -413,12 +443,38 @@ public class DataProcessingService {
continue;
}
Point copiedPosition = geometryFactory.createPoint(new org.locationtech.jts.geom.Coordinate(
position.getX(), position.getY()));
if (source instanceof UnmannedVehicle unmannedVehicle) {
snapshot.add(UnmannedVehicle.builder()
.objectId(source.getObjectId())
.objectType(source.getObjectType())
.objectName(source.getObjectName())
.currentPosition(copiedPosition)
.currentSpeed(source.getCurrentSpeed())
.currentHeading(source.getCurrentHeading())
.altitude(source.getAltitude())
.sourceTimestampMs(source.getSourceTimestampMs())
.batteryLevel(unmannedVehicle.getBatteryLevel())
.vehicleStatus(unmannedVehicle.getVehicleStatus())
.missionId(unmannedVehicle.getMissionId())
.missionStatus(unmannedVehicle.getMissionStatus())
.targetPosition(unmannedVehicle.getTargetPosition())
.missionType(unmannedVehicle.getMissionType())
.missionStartTime(unmannedVehicle.getMissionStartTime())
.estimatedEndTime(unmannedVehicle.getEstimatedEndTime())
.progress(unmannedVehicle.getProgress())
.totalMileage(unmannedVehicle.getTotalMileage())
.waypoints(unmannedVehicle.getWaypoints())
.build());
continue;
}
snapshot.add(MovingObject.builder()
.objectId(source.getObjectId())
.objectType(source.getObjectType())
.objectName(source.getObjectName())
.currentPosition(geometryFactory.createPoint(new org.locationtech.jts.geom.Coordinate(
position.getX(), position.getY())))
.currentPosition(copiedPosition)
.currentSpeed(source.getCurrentSpeed())
.currentHeading(source.getCurrentHeading())
.altitude(source.getAltitude())

View File

@ -50,6 +50,13 @@ public class ConflictAlertEventListener {
private void sendWebSocketAlert(ConflictAlertEvent event) {
try {
PathConflictAlertMessage.Position conflictPoint = event.getConflictPointLatitude() != null && event.getConflictPointLongitude() != null
? PathConflictAlertMessage.Position.builder()
.latitude(event.getConflictPointLatitude())
.longitude(event.getConflictPointLongitude())
.build()
: null;
PathConflictAlertMessage message = PathConflictAlertMessage.builder()
.conflictId(event.getConflictId().orElse(null))
.alertType(event.getAlertType().orElse(null))
@ -63,14 +70,19 @@ public class ConflictAlertEventListener {
.objectName(event.getObject2Name())
.objectType(event.getObject2Type().name())
.build())
.position(event.getConflictPointLatitude() != null && event.getConflictPointLongitude() != null
? PathConflictAlertMessage.Position.builder()
.latitude(event.getConflictPointLatitude())
.longitude(event.getConflictPointLongitude())
.build()
: null)
.object1Distance(new BigDecimal(event.getObject1Distance()).setScale(2, RoundingMode.HALF_UP).doubleValue())
.object2Distance(new BigDecimal(event.getObject2Distance()).setScale(2, RoundingMode.HALF_UP).doubleValue())
.position(conflictPoint)
.conflictPoint(conflictPoint)
.aircraftName(event.getAircraftName())
.aircraftObjectType(event.getAircraftObjectType())
.aircraftDistanceToConflictMeters(roundMeters(event.getAircraftDistanceToConflictMeters()))
.aircraftAlertThresholdMeters(event.getAircraftAlertThresholdMeters())
.vehicleName(event.getVehicleName())
.vehicleObjectType(event.getVehicleObjectType())
.vehicleDistanceToConflictMeters(roundMeters(event.getVehicleDistanceToConflictMeters()))
.vehicleAlertThresholdMeters(event.getVehicleAlertThresholdMeters())
.vehicleMovingTowardConflictPoint(event.getVehicleMovingTowardConflictPoint())
.object1Distance(roundMeters(event.getObject1Distance()))
.object2Distance(roundMeters(event.getObject2Distance()))
.timeToConflict1(event.getEstimatedTimeToConflictObj1())
.timeToConflict2(event.getEstimatedTimeToConflictObj2())
.timeGap(event.getTimeGapSeconds())
@ -83,4 +95,11 @@ public class ConflictAlertEventListener {
log.error("Failed to send WebSocket path conflict alert: conflictId={}", event.getConflictId().map(String::valueOf).orElse(null), e);
}
}
private Double roundMeters(Double value) {
if (value == null) {
return null;
}
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
}

View File

@ -31,6 +31,15 @@ public class ConflictAlertEvent {
private Integer estimatedTimeToConflictObj2; // 对象2预计到达冲突点的时间 ()
private Double timeGapSeconds; // 两个对象到达冲突点的时间差 ()
private LocalDateTime eventTime; // 事件发生时间
private String aircraftName;
private String aircraftObjectType;
private Double aircraftDistanceToConflictMeters;
private Double aircraftAlertThresholdMeters;
private String vehicleName;
private String vehicleObjectType;
private Double vehicleDistanceToConflictMeters;
private Double vehicleAlertThresholdMeters;
private Boolean vehicleMovingTowardConflictPoint;
// 辅助方法用于判断告警级别
public boolean isEmergencyAlert() {

View File

@ -2,6 +2,7 @@ package com.qaup.collision.pathconflict.repository;
import com.qaup.collision.pathconflict.model.entity.ObjectRouteAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@ -42,4 +43,13 @@ public interface ObjectRouteAssignmentRepository extends JpaRepository<ObjectRou
@Param("objectName") String objectName,
@Param("objectType") ObjectRouteAssignment.ObjectType objectType
);
@Modifying
void deleteByObjectNameIn(List<String> objectNames);
@Modifying
void deleteByObjectNameAndObjectTypeNot(
String objectName,
ObjectRouteAssignment.ObjectType objectType
);
}

View File

@ -7,11 +7,15 @@ import com.qaup.collision.datacollector.model.dto.ExternalVehicleCommandRequest;
import com.qaup.collision.datacollector.model.enums.CommandReason;
import com.qaup.collision.datacollector.model.enums.CommandType;
import com.qaup.collision.pathconflict.model.dto.ConflictAlertEvent;
import com.qaup.collision.service.CollisionTestSessionLogService;
import com.qaup.collision.service.PlatformRuntimeStateService;
import com.qaup.collision.service.PlatformRuntimeStateService.VehicleRegistryType;
import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher;
import com.qaup.collision.websocket.message.PathConflictAlertMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.Point;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
@ -21,10 +25,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@ -36,10 +42,16 @@ public class VehicleCommandService {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final PlatformRuntimeStateService platformRuntimeStateService;
private final RuleEventWebSocketPublisher webSocketPublisher;
@Autowired(required = false)
private CollisionTestSessionLogService collisionTestSessionLogService;
private final Map<String, ActiveVehicleCommandState> activeCommandStates = new ConcurrentHashMap<>();
private final AtomicLong transIdSequence = new AtomicLong();
private volatile String observedTestSessionId;
private static final String UNIFIED_VEHICLE_COMMAND_URL = "http://10.232.18.23:8020/api/VehicleCommandInfo";
private static final int RESUME_CONFIRMATION_CYCLES = 3;
@Value("${data.collector.vehicle-api.base-url:}")
private String vehicleApiBaseUrl;
@ -48,6 +60,15 @@ public class VehicleCommandService {
private String vehicleCommandEndpoint;
public void synchronizeConflictCommands(List<ConflictAlertEvent> activeConflicts) {
synchronizeConflictCommands(activeConflicts, List.of());
}
public void synchronizeConflictCommands(
List<ConflictAlertEvent> activeConflicts,
List<ConflictRecoveryObservation> recoveryObservations) {
clearActiveCommandStatesOnTestSessionChange();
Map<String, VehicleConflictCommand> currentCommands = new LinkedHashMap<>();
for (ConflictAlertEvent event : activeConflicts) {
VehicleConflictCommand candidate = toVehicleConflictCommand(event);
@ -62,6 +83,29 @@ public class VehicleCommandService {
);
}
Map<String, ConflictRecoveryObservation> observationsByVehicleId = new LinkedHashMap<>();
if (recoveryObservations != null) {
for (ConflictRecoveryObservation observation : recoveryObservations) {
if (observation != null && observation.vehicleId() != null && !observation.vehicleId().isBlank()) {
observationsByVehicleId.put(observation.vehicleId(), observation);
}
}
}
for (String vehicleId : new ArrayList<>(currentCommands.keySet())) {
ActiveVehicleCommandState previousState = activeCommandStates.get(vehicleId);
ConflictRecoveryObservation observation = observationsByVehicleId.get(vehicleId);
if (isRecoveryConfirmedAfterAircraftPassed(previousState, observation)) {
writeTestSessionLog("EVENT PATH_CONFLICT_RELEASE_CONFIRMED"
+ ", vehicleId=" + vehicleId
+ ", aircraftPassedConflictPoint=true"
+ ", aircraftDistanceMeters=" + observation.aircraftDistanceMeters()
+ ", releaseThresholdMeters=" + platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()
+ ", currentCommandSuppressed=true");
currentCommands.remove(vehicleId);
}
}
for (VehicleConflictCommand command : currentCommands.values()) {
synchronizeVehicleCommand(command);
}
@ -69,11 +113,29 @@ public class VehicleCommandService {
List<String> trackedVehicleIds = new ArrayList<>(activeCommandStates.keySet());
for (String vehicleId : trackedVehicleIds) {
if (!currentCommands.containsKey(vehicleId)) {
sendResumeIfNeeded(vehicleId);
sendResumeIfNeeded(vehicleId, observationsByVehicleId.get(vehicleId));
}
}
}
private void clearActiveCommandStatesOnTestSessionChange() {
String currentTestSessionId = platformRuntimeStateService.getCurrentTestSessionId();
if (Objects.equals(observedTestSessionId, currentTestSessionId)) {
return;
}
if (!activeCommandStates.isEmpty()) {
log.info(
"Clearing active vehicle command states for collision test session change: previousSession={}, currentSession={}, activeVehicles={}",
observedTestSessionId,
currentTestSessionId,
activeCommandStates.keySet()
);
}
activeCommandStates.clear();
observedTestSessionId = currentTestSessionId;
}
private void synchronizeVehicleCommand(VehicleConflictCommand currentCommand) {
ActiveVehicleCommandState previousState = activeCommandStates.get(currentCommand.vehicleId());
if (previousState == null) {
@ -81,9 +143,9 @@ public class VehicleCommandService {
return;
}
// De-escalation is intentionally handled by a separate recovery flow. WARNING follow-up behavior is not implemented here yet.
if (previousState.level() == VehicleRiskLevel.WARNING && currentCommand.level() == VehicleRiskLevel.ALERT) {
issueAlertAndParking(currentCommand);
if (currentCommand.level() == VehicleRiskLevel.WARNING) {
writeTestSessionLog("EVENT VEHICLE_COMMAND_SKIP vehicleId=" + currentCommand.vehicleId()
+ ", level=WARNING, reason=warning_not_sent_to_vehicle");
return;
}
@ -95,17 +157,22 @@ public class VehicleCommandService {
if (parkingIssued) {
activeCommandStates.put(
currentCommand.vehicleId(),
new ActiveVehicleCommandState(VehicleRiskLevel.ALERT, true)
ActiveVehicleCommandState.fromCommand(VehicleRiskLevel.ALERT, true, 0, currentCommand)
);
}
return;
}
activeCommandStates.put(
currentCommand.vehicleId(),
ActiveVehicleCommandState.fromCommand(previousState.level(), previousState.parkingIssued(), 0, currentCommand)
);
}
private void issueInitialRiskCommand(VehicleConflictCommand command) {
if (command.level() == VehicleRiskLevel.WARNING) {
if (sendCommand(command, CommandType.WARNING, CommandReason.AIRCRAFT_CROSSING)) {
activeCommandStates.put(command.vehicleId(), new ActiveVehicleCommandState(VehicleRiskLevel.WARNING, true));
}
writeTestSessionLog("EVENT VEHICLE_COMMAND_SKIP vehicleId=" + command.vehicleId()
+ ", level=WARNING, reason=warning_not_sent_to_vehicle");
return;
}
@ -119,26 +186,58 @@ public class VehicleCommandService {
}
boolean parkingIssued = sendCommand(command, CommandType.PARKING, CommandReason.AIRCRAFT_CROSSING);
activeCommandStates.put(command.vehicleId(), new ActiveVehicleCommandState(VehicleRiskLevel.ALERT, parkingIssued));
activeCommandStates.put(command.vehicleId(), ActiveVehicleCommandState.fromCommand(VehicleRiskLevel.ALERT, parkingIssued, 0, command));
}
private void sendResumeIfNeeded(String vehicleId) {
private void sendResumeIfNeeded(String vehicleId, ConflictRecoveryObservation observation) {
ActiveVehicleCommandState previousState = activeCommandStates.get(vehicleId);
if (previousState == null) {
return;
}
if (!isRecoveryConfirmedAfterAircraftPassed(previousState, observation)) {
activeCommandStates.put(vehicleId, previousState.withObservation(observation, 0));
return;
}
writeTestSessionLog("EVENT PATH_CONFLICT_RELEASE_CONFIRMED"
+ ", vehicleId=" + vehicleId
+ ", aircraftPassedConflictPoint=true"
+ ", aircraftDistanceMeters=" + observation.aircraftDistanceMeters()
+ ", releaseThresholdMeters=" + platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft()
+ ", confirmationCycle=" + (previousState.missingConflictCycles() + 1)
+ ", requiredConfirmationCycles=" + RESUME_CONFIRMATION_CYCLES);
int confirmedCycles = previousState.missingConflictCycles() + 1;
if (confirmedCycles < RESUME_CONFIRMATION_CYCLES) {
activeCommandStates.put(vehicleId, previousState.withObservation(observation, confirmedCycles));
return;
}
ActiveVehicleCommandState updatedState = new ActiveVehicleCommandState(
previousState.level(),
previousState.parkingIssued(),
confirmedCycles,
observation.vehicleDistanceMeters(),
observation.aircraftDistanceMeters()
);
VehicleConflictCommand resumeContext = new VehicleConflictCommand(
vehicleId,
previousState.level(),
null,
null,
resolveRegistryType(vehicleId)
resolveRegistryType(vehicleId),
previousState.vehicleDistanceMeters(),
previousState.aircraftDistanceMeters()
);
boolean resumeIssued = sendCommand(resumeContext, CommandType.RESUME, CommandReason.RESUME_TRAFFIC);
if (resumeIssued) {
activeCommandStates.remove(vehicleId);
publishResumeToFrontend(vehicleId, previousState, observation);
} else {
activeCommandStates.put(vehicleId, updatedState);
}
}
@ -156,7 +255,9 @@ public class VehicleCommandService {
event.getObject1Name(),
event.getObject1Type(),
level,
event.getConflictPoint()
event.getConflictPoint(),
event.getObject1Distance(),
event.getObject2Distance()
);
if (leftCandidate != null) {
return leftCandidate;
@ -166,7 +267,9 @@ public class VehicleCommandService {
event.getObject2Name(),
event.getObject2Type(),
level,
event.getConflictPoint()
event.getConflictPoint(),
event.getObject2Distance(),
event.getObject1Distance()
);
}
@ -174,7 +277,9 @@ public class VehicleCommandService {
String vehicleId,
MovingObjectType objectType,
VehicleRiskLevel level,
Point conflictPoint) {
Point conflictPoint,
Double candidateDistance,
Double otherDistance) {
if (vehicleId == null || vehicleId.isBlank()) {
return null;
@ -197,7 +302,18 @@ public class VehicleCommandService {
return null;
}
return new VehicleConflictCommand(vehicleId, level, conflictPoint, objectType, registryType);
Double vehicleDistance = objectType == MovingObjectType.AIRCRAFT ? otherDistance : candidateDistance;
Double aircraftDistance = objectType == MovingObjectType.AIRCRAFT ? candidateDistance : otherDistance;
return new VehicleConflictCommand(
vehicleId,
level,
conflictPoint,
objectType,
registryType,
vehicleDistance,
aircraftDistance
);
}
private VehicleRiskLevel toRiskLevel(ConflictAlertEvent event) {
@ -242,6 +358,9 @@ public class VehicleCommandService {
);
if (response.getStatusCode().value() != 200) {
writeTestSessionLog("EVENT VEHICLE_COMMAND_FAILED vehicleId=" + command.vehicleId()
+ ", commandType=" + commandType
+ ", reason=http_status_" + response.getStatusCode().value());
log.warn(
"Vehicle command rejected by HTTP status: vehicleId={}, commandType={}, status={}",
command.vehicleId(),
@ -253,6 +372,9 @@ public class VehicleCommandService {
JsonNode responseBody = parseResponseBody(response.getBody());
if (responseBody == null || !responseBody.has("code") || !responseBody.has("msg")) {
writeTestSessionLog("EVENT VEHICLE_COMMAND_FAILED vehicleId=" + command.vehicleId()
+ ", commandType=" + commandType
+ ", reason=invalid_response_body");
log.warn(
"Vehicle command response missing required fields: vehicleId={}, commandType={}, body={}",
command.vehicleId(),
@ -264,6 +386,9 @@ public class VehicleCommandService {
int responseCode = responseBody.path("code").asInt(Integer.MIN_VALUE);
if (responseCode != 200) {
writeTestSessionLog("EVENT VEHICLE_COMMAND_FAILED vehicleId=" + command.vehicleId()
+ ", commandType=" + commandType
+ ", reason=upstream_code_" + responseCode);
log.warn(
"Vehicle command rejected by upstream response code: vehicleId={}, commandType={}, code={}, msg={}",
command.vehicleId(),
@ -281,8 +406,18 @@ public class VehicleCommandService {
commandType,
commandReason
);
writeTestSessionLog("EVENT VEHICLE_COMMAND_SENT vehicleId=" + command.vehicleId()
+ ", registryType=" + command.registryType()
+ ", commandType=" + commandType
+ ", reason=" + commandReason
+ ", vehicleDistanceMeters=" + command.vehicleDistanceMeters()
+ ", aircraftDistanceMeters=" + command.aircraftDistanceMeters());
return true;
} catch (Exception e) {
writeTestSessionLog("EVENT VEHICLE_COMMAND_FAILED vehicleId=" + command.vehicleId()
+ ", commandType=" + commandType
+ ", reason=" + e.getClass().getSimpleName()
+ ", message=" + (e.getMessage() == null ? "" : e.getMessage()));
log.error(
"Failed to send vehicle command: vehicleId={}, commandType={}, reason={}",
command.vehicleId(),
@ -428,6 +563,68 @@ public class VehicleCommandService {
return platformRuntimeStateService.getVehicleRegistryType(vehicleId);
}
private boolean isRecoveryConfirmedAfterAircraftPassed(
ActiveVehicleCommandState previousState,
ConflictRecoveryObservation observation) {
if (previousState == null || observation == null) {
return false;
}
return observation.aircraftIntersectionBehind()
&& observation.aircraftDistanceMeters() > platformRuntimeStateService.getCollisionDivergingReleaseDistanceForAircraft();
}
private void publishResumeToFrontend(
String vehicleId,
ActiveVehicleCommandState previousState,
ConflictRecoveryObservation observation) {
try {
PathConflictAlertMessage message = PathConflictAlertMessage.builder()
.messageType("PATH_CONFLICT_RESUME")
.message("Path conflict resolved, traffic resumed")
.object1(PathConflictAlertMessage.ConflictObject.builder()
.objectName(vehicleId)
.objectType(toMovingObjectTypeName(resolveRegistryType(vehicleId)))
.build())
.object1Distance(observation != null ? observation.vehicleDistanceMeters() : previousState.vehicleDistanceMeters())
.object2Distance(observation != null ? observation.aircraftDistanceMeters() : previousState.aircraftDistanceMeters())
.eventTime(LocalDateTime.now())
.build();
webSocketPublisher.publishPathConflictAlert(message);
log.info("WebSocket path conflict resume sent: vehicleId={}", vehicleId);
writeTestSessionLog("EVENT VEHICLE_RESUME_FRONTEND_SENT vehicleId=" + vehicleId
+ ", vehicleDistanceMeters=" + (observation != null ? observation.vehicleDistanceMeters() : previousState.vehicleDistanceMeters())
+ ", aircraftDistanceMeters=" + (observation != null ? observation.aircraftDistanceMeters() : previousState.aircraftDistanceMeters()));
} catch (Exception e) {
log.error("Failed to send WebSocket path conflict resume: vehicleId={}", vehicleId, e);
}
}
private void writeTestSessionLog(String message) {
if (collisionTestSessionLogService == null || platformRuntimeStateService == null) {
return;
}
String testSessionId = platformRuntimeStateService.getCurrentTestSessionId();
if (testSessionId != null) {
collisionTestSessionLogService.write(testSessionId, message);
}
}
private String toMovingObjectTypeName(VehicleRegistryType registryType) {
if (registryType == null) {
return null;
}
return switch (registryType) {
case WUREN -> MovingObjectType.UNMANNED_VEHICLE.name();
case TEQIN -> MovingObjectType.SPECIAL_VEHICLE.name();
case HANGKONG -> MovingObjectType.AIRCRAFT.name();
case PUTONG, JIUYUAN -> null;
};
}
private String nextTransId() {
long serialValue = System.currentTimeMillis() * 1000 + Math.floorMod(transIdSequence.incrementAndGet(), 1000);
return Long.toHexString(serialValue);
@ -438,12 +635,58 @@ public class VehicleCommandService {
VehicleRiskLevel level,
Point conflictPoint,
MovingObjectType objectType,
VehicleRegistryType registryType) {
VehicleRegistryType registryType,
Double vehicleDistanceMeters,
Double aircraftDistanceMeters) {
}
private record ActiveVehicleCommandState(
VehicleRiskLevel level,
boolean parkingIssued) {
boolean parkingIssued,
int missingConflictCycles,
Double vehicleDistanceMeters,
Double aircraftDistanceMeters) {
private static ActiveVehicleCommandState fromCommand(
VehicleRiskLevel level,
boolean parkingIssued,
int missingConflictCycles,
VehicleConflictCommand command) {
return new ActiveVehicleCommandState(
level,
parkingIssued,
missingConflictCycles,
command.vehicleDistanceMeters(),
command.aircraftDistanceMeters()
);
}
private ActiveVehicleCommandState withObservation(
ConflictRecoveryObservation observation,
int missingConflictCycles) {
if (observation == null) {
return new ActiveVehicleCommandState(level, parkingIssued, missingConflictCycles, vehicleDistanceMeters, aircraftDistanceMeters);
}
return new ActiveVehicleCommandState(
level,
parkingIssued,
missingConflictCycles,
observation.vehicleDistanceMeters(),
observation.aircraftDistanceMeters()
);
}
}
public record ConflictRecoveryObservation(
String vehicleId,
double vehicleDistanceMeters,
double aircraftDistanceMeters,
boolean vehicleIntersectionBehind,
boolean aircraftIntersectionBehind,
boolean vehicleHeadingAway,
boolean aircraftHeadingAway) {
}
private enum VehicleRiskLevel {

View File

@ -0,0 +1,135 @@
package com.qaup.collision.service;
import lombok.extern.slf4j.Slf4j;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
public class CollisionTestSessionLogService {
private static final DateTimeFormatter TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private static final Duration MAX_SESSION_LOG_DURATION = Duration.ofMinutes(20);
private final Path logDirectory;
private final Clock clock;
private final Duration maxSessionLogDuration;
private final Map<String, Instant> sessionStartedAt = new ConcurrentHashMap<>();
private final Set<String> expiredSessions = ConcurrentHashMap.newKeySet();
@Value("${qaup.collision.test-log.enabled:true}")
private boolean enabled = true;
public CollisionTestSessionLogService(
@Value("${qaup.collision.test-log.dir:}") String configuredLogDirectory,
@Value("${LOG_PATH:logs}") String logPath) {
this(configuredLogDirectory, logPath, Clock.systemDefaultZone(), MAX_SESSION_LOG_DURATION);
}
CollisionTestSessionLogService(
String configuredLogDirectory,
String logPath,
Clock clock,
Duration maxSessionLogDuration) {
this.clock = clock;
this.maxSessionLogDuration = maxSessionLogDuration;
if (configuredLogDirectory != null && !configuredLogDirectory.isBlank()) {
this.logDirectory = Paths.get(configuredLogDirectory);
} else {
String baseLogPath = logPath == null || logPath.isBlank() ? "logs" : logPath;
this.logDirectory = Paths.get(baseLogPath, "collision-tests");
}
}
@PostConstruct
public void logStartupState() {
log.info("Collision test session file log initialized: enabled={}, directory={}", enabled, logDirectory.toAbsolutePath());
}
public void startSession(String testSessionId, String message) {
sessionStartedAt.put(testSessionId, Instant.now(clock));
expiredSessions.remove(testSessionId);
append(testSessionId, "SESSION_START " + safeMessage(message), true, true);
}
public void endSession(String testSessionId, String message) {
append(testSessionId, "SESSION_END " + safeMessage(message), false, true);
sessionStartedAt.remove(testSessionId);
expiredSessions.remove(testSessionId);
}
public void write(String testSessionId, String message) {
append(testSessionId, safeMessage(message), false, false);
}
private synchronized void append(String testSessionId, String message, boolean truncate, boolean forceWrite) {
if (!enabled || testSessionId == null || testSessionId.isBlank()) {
if (!enabled) {
log.warn("Collision test session file log is disabled; skip write: testSessionId={}", testSessionId);
}
return;
}
if (!forceWrite && isExpired(testSessionId)) {
return;
}
try {
Files.createDirectories(logDirectory);
String line = LocalDateTime.now(clock).format(TIMESTAMP_FORMATTER) + " | " + message + System.lineSeparator();
StandardOpenOption[] options = truncate
? new StandardOpenOption[] {StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE}
: new StandardOpenOption[] {StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE};
Files.writeString(logFilePath(testSessionId), line, StandardCharsets.UTF_8, options);
} catch (Exception e) {
log.error("Failed to write collision test session log: testSessionId={}", testSessionId, e);
}
}
private boolean isExpired(String testSessionId) {
Instant startedAt = sessionStartedAt.computeIfAbsent(testSessionId, ignored -> Instant.now(clock));
if (Duration.between(startedAt, Instant.now(clock)).compareTo(maxSessionLogDuration) <= 0) {
return false;
}
if (expiredSessions.add(testSessionId)) {
append(testSessionId,
"SESSION_LOG_EXPIRED maxDurationMinutes=" + maxSessionLogDuration.toMinutes()
+ ", reason=test_session_not_ended",
false,
true);
}
return true;
}
private Path logFilePath(String testSessionId) {
return logDirectory.resolve(sanitizeFileName(testSessionId) + ".log");
}
private String sanitizeFileName(String value) {
String sanitized = value.replaceAll("[^A-Za-z0-9._-]", "_");
return sanitized.isBlank() ? "unknown-session" : sanitized;
}
private String safeMessage(String message) {
return message == null ? "" : message;
}
}

View File

@ -66,6 +66,9 @@ public class PlatformRuntimeStateService {
@Autowired(required = false)
private ISysConfigService sysConfigService;
@Autowired(required = false)
private CollisionTestSessionLogService collisionTestSessionLogService;
public PlatformRuntimeStateService(
@Value("${qaup.runtime-config.runway.warning-zone-radius.aircraft:200.0}") double runwayWarningZoneRadiusAircraft,
@Value("${qaup.runtime-config.runway.alert-zone-radius.aircraft:100.0}") double runwayAlertZoneRadiusAircraft,
@ -94,6 +97,7 @@ public class PlatformRuntimeStateService {
}
}
@Transactional
public VehicleRegistryUpdateResult updateVehicleRegistry(List<VehicleRegistryEntry> entries) {
Objects.requireNonNull(entries, "entries");
@ -113,16 +117,19 @@ public class PlatformRuntimeStateService {
endedTestSessionId = currentTestSessionId;
if (endedTestSessionId != null) {
log.info("[test-session={}] collision test session ended by empty registry update", endedTestSessionId);
writeTestSessionEnd(endedTestSessionId, "ended by empty registry update");
}
currentTestSessionId = null;
} else {
endedTestSessionId = currentTestSessionId;
if (endedTestSessionId != null) {
log.info("[test-session={}] collision test session replaced by new registry update", endedTestSessionId);
writeTestSessionEnd(endedTestSessionId, "replaced by new registry update");
}
activeTestSessionId = createTestSessionId(entries);
currentTestSessionId = activeTestSessionId;
log.info("[test-session={}] collision test session started: objects={}", activeTestSessionId, entries);
writeTestSessionStart(activeTestSessionId, describeRegisteredObjects(entries));
}
Set<String> controllableVehicleIds = new TreeSet<>();
@ -140,6 +147,8 @@ public class PlatformRuntimeStateService {
}
}
cleanupStaleRouteAssignments(previousVehicleIds, entries);
return new VehicleRegistryUpdateResult(
System.currentTimeMillis(),
entries.size(),
@ -151,6 +160,68 @@ public class PlatformRuntimeStateService {
endedTestSessionId);
}
private void cleanupStaleRouteAssignments(Set<String> removedVehicleIds, List<VehicleRegistryEntry> currentEntries) {
if (objectRouteAssignmentRepository == null) {
return;
}
TreeSet<String> routeAssignmentObjectIdsToDelete = new TreeSet<>();
if (removedVehicleIds != null && !removedVehicleIds.isEmpty()) {
routeAssignmentObjectIdsToDelete.addAll(removedVehicleIds);
}
for (VehicleRegistryEntry entry : currentEntries) {
routeAssignmentObjectIdsToDelete.add(entry.vehicleID());
}
if (!routeAssignmentObjectIdsToDelete.isEmpty()) {
objectRouteAssignmentRepository.deleteByObjectNameIn(new ArrayList<>(routeAssignmentObjectIdsToDelete));
}
}
private void writeTestSessionStart(String testSessionId, String message) {
if (collisionTestSessionLogService != null) {
collisionTestSessionLogService.startSession(testSessionId, message);
} else {
log.warn("[test-session={}] collision test session file log service is not available; start event was not written", testSessionId);
}
}
private void writeTestSessionEnd(String testSessionId, String message) {
if (collisionTestSessionLogService != null) {
collisionTestSessionLogService.endSession(testSessionId, message);
} else {
log.warn("[test-session={}] collision test session file log service is not available; end event was not written", testSessionId);
}
}
private String describeRegisteredObjects(List<VehicleRegistryEntry> entries) {
List<String> flightIds = new ArrayList<>();
List<String> vehicleIds = new ArrayList<>();
List<String> otherIds = new ArrayList<>();
for (VehicleRegistryEntry entry : entries) {
if (entry == null || entry.vehicleID() == null || entry.vehicleID().isBlank()) {
continue;
}
if (entry.vehicleType() == VehicleRegistryType.HANGKONG) {
flightIds.add(entry.vehicleID());
} else if (entry.vehicleType() == VehicleRegistryType.WUREN || entry.vehicleType() == VehicleRegistryType.TEQIN) {
vehicleIds.add(entry.vehicleID());
} else {
otherIds.add(entry.vehicleID() + "(" + entry.vehicleType() + ")");
}
}
Collections.sort(flightIds);
Collections.sort(vehicleIds);
Collections.sort(otherIds);
return "flightIds=" + flightIds
+ ", vehicleIds=" + vehicleIds
+ ", otherIds=" + otherIds
+ ", rawObjects=" + entries;
}
public String getCurrentTestSessionId() {
return currentTestSessionId;
}

View File

@ -319,6 +319,19 @@ public class RuleEventWebSocketPublisher {
}
}
public void publishPathConflictStatus(PathConflictAlertMessage message) {
try {
PathConflictAlertWebSocketEvent webSocketEvent = PathConflictAlertWebSocketEvent.create(message);
eventPublisher.publishEvent(webSocketEvent);
logger.debug("Published path conflict status WebSocket event: vehicle={}, aircraft={}",
message.getVehicleName(), message.getAircraftName());
} catch (Exception e) {
logger.error("Failed to publish path conflict status WebSocket event: vehicle={}, aircraft={}",
message.getVehicleName(), message.getAircraftName(), e);
}
}
/**
* 通过vehicle_id动态查询车牌号
*

View File

@ -332,16 +332,24 @@ public class WebSocketMessageBroadcaster {
@EventListener
public void handlePathConflictAlert(PathConflictAlertWebSocketEvent event) {
PathConflictAlertMessage payload = (PathConflictAlertMessage) event.getPayload();
UniversalMessage<PathConflictAlertMessage> message = UniversalMessage.<PathConflictAlertMessage>builder()
.type(MessageTypeConstants.PATH_CONFLICT_ALERT)
.type(resolvePathConflictMessageType(payload))
.timestamp(event.getTimestamp())
.messageId(generateMessageId())
.payload((PathConflictAlertMessage) event.getPayload())
.payload(payload)
.build();
broadcastMessageInternal(message);
}
private String resolvePathConflictMessageType(PathConflictAlertMessage payload) {
if (payload != null && "PATH_CONFLICT_STATUS".equals(payload.getMessageType())) {
return MessageTypeConstants.PATH_CONFLICT_STATUS;
}
return MessageTypeConstants.PATH_CONFLICT_ALERT;
}
@EventListener
public void handleGeofenceAlert(GeofenceAlertWebSocketEvent event) {
UniversalMessage<GeofenceAlertPayload> message = UniversalMessage.<GeofenceAlertPayload>builder()

View File

@ -68,6 +68,11 @@ public final class MessageTypeConstants {
*/
public static final String PATH_CONFLICT_ALERT = "path_conflict_alert";
/**
* 路径冲突计算状态消息
*/
public static final String PATH_CONFLICT_STATUS = "path_conflict_status";
/**
* 电子围栏告警消息
*/

View File

@ -64,6 +64,63 @@ public class PathConflictAlertMessage {
*/
private Position position;
/**
* Frontend display conflict point. Same coordinates as position, with clearer business meaning.
*/
private Position conflictPoint;
private String aircraftName;
private String aircraftObjectType;
private Double aircraftDistanceToConflictMeters;
private Double aircraftAlertThresholdMeters;
private String vehicleName;
private String vehicleObjectType;
private Double vehicleDistanceToConflictMeters;
private Double vehicleAlertThresholdMeters;
private Boolean vehicleMovingTowardConflictPoint;
private String calculationStatus;
private String directionLockStatus;
private String directionLockReason;
private String object1DirectionLockStatus;
private String object1DirectionLockReason;
private String object1RouteDirection;
private Double object1RouteProgressMeters;
private Double object1ConflictProgressMeters;
private Boolean object1ConflictPointAhead;
private Double object1ForwardDistanceMeters;
private String object2DirectionLockStatus;
private String object2DirectionLockReason;
private String object2RouteDirection;
private Double object2RouteProgressMeters;
private Double object2ConflictProgressMeters;
private Boolean object2ConflictPointAhead;
private Double object2ForwardDistanceMeters;
/**
* 对象1距离冲突点距离 ()
*/

View File

@ -13,7 +13,9 @@ import org.springframework.test.util.ReflectionTestUtils;
import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository;
import com.qaup.collision.pathconflict.repository.TransportRouteRepository;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@ -24,11 +26,13 @@ class PlatformIntegrationControllerTest {
private MockMvc mockMvc;
private PlatformRuntimeStateService runtimeStateService;
private ObjectRouteAssignmentRepository objectRouteAssignmentRepository;
@BeforeEach
void setUp() {
runtimeStateService = new PlatformRuntimeStateService(200.0, 150.0, 40.0);
ReflectionTestUtils.setField(runtimeStateService, "objectRouteAssignmentRepository", mock(ObjectRouteAssignmentRepository.class));
objectRouteAssignmentRepository = mock(ObjectRouteAssignmentRepository.class);
ReflectionTestUtils.setField(runtimeStateService, "objectRouteAssignmentRepository", objectRouteAssignmentRepository);
ReflectionTestUtils.setField(runtimeStateService, "transportRouteRepository", mock(TransportRouteRepository.class));
PlatformIntegrationController controller =
new PlatformIntegrationController(new ObjectMapper(), runtimeStateService);
@ -203,4 +207,21 @@ class PlatformIntegrationControllerTest {
.andExpect(jsonPath("$.testSessionId").doesNotExist())
.andExpect(jsonPath("$.endedTestSessionId").isNotEmpty());
}
@Test
void shouldClearRouteAssignmentsForRegisteredObjectsWhenNewSessionStarts() throws Exception {
mockMvc.perform(post("/api/VehicleRegistry")
.contentType(MediaType.APPLICATION_JSON)
.content("""
[
{ "vehicleID": "WR01", "vehicleType": "WUREN" },
{ "vehicleID": "CD423", "vehicleType": "HANGKONG" }
]
"""))
.andExpect(status().isOk());
verify(objectRouteAssignmentRepository).deleteByObjectNameIn(argThat(objectNames ->
objectNames.contains("WR01") && objectNames.contains("CD423")
));
}
}

View File

@ -0,0 +1,63 @@
package com.qaup.collision.datacollector.service;
import com.qaup.collision.pathconflict.model.entity.ObjectRouteAssignment;
import com.qaup.collision.pathconflict.model.entity.TransportRoute;
import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository;
import com.qaup.collision.pathconflict.repository.TransportRouteRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RoutePersistenceServiceTest {
@Mock
private TransportRouteRepository transportRouteRepository;
@Mock
private ObjectRouteAssignmentRepository objectRouteAssignmentRepository;
private RoutePersistenceService service() {
RoutePersistenceService service = new RoutePersistenceService();
ReflectionTestUtils.setField(service, "transportRouteRepository", transportRouteRepository);
ReflectionTestUtils.setField(service, "objectRouteAssignmentRepository", objectRouteAssignmentRepository);
return service;
}
@Test
void shouldRefreshAssignmentTimeWhenAircraftRouteIdIsUnchanged() {
TransportRoute route = TransportRoute.builder()
.id(1093L)
.routeName("FLIGHT_CD423_OUT")
.routeType(TransportRoute.RouteType.AIRCRAFT)
.build();
LocalDateTime oldAssignedAt = LocalDateTime.now().minusDays(1);
ObjectRouteAssignment assignment = ObjectRouteAssignment.builder()
.id(10L)
.objectName("CD423")
.objectType(ObjectRouteAssignment.ObjectType.AIRCRAFT)
.assignedRouteId(1093L)
.assignedAt(oldAssignedAt)
.build();
when(transportRouteRepository.findById(1093L)).thenReturn(Optional.of(route));
when(objectRouteAssignmentRepository.findFirstByObjectNameAndObjectTypeOrderByAssignedAtDesc(
"CD423",
ObjectRouteAssignment.ObjectType.AIRCRAFT
)).thenReturn(Optional.of(assignment));
service().saveRouteAssignment("CD423", 1093L, "OUT");
assertTrue(assignment.getAssignedAt().isAfter(oldAssignedAt));
verify(objectRouteAssignmentRepository).save(assignment);
}
}

View File

@ -15,6 +15,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@ -180,6 +181,7 @@ class RoutePreparationServiceTest {
.objectName("UV-300")
.objectType(ObjectRouteAssignment.ObjectType.UNMANNED_VEHICLE)
.assignedRouteId(31L)
.assignedAt(LocalDateTime.now().minusHours(1))
.build()));
when(objectRouteAssignmentRepository.findByObjectNameAndObjectTypeOrderByAssignedAtDesc(
"UV-300",
@ -189,7 +191,10 @@ class RoutePreparationServiceTest {
service().synchronizeUnmannedVehicleRoute(vehicle);
verify(transportRouteRepository, never()).save(any(TransportRoute.class));
verify(objectRouteAssignmentRepository, never()).save(any(ObjectRouteAssignment.class));
ArgumentCaptor<ObjectRouteAssignment> assignmentCaptor = ArgumentCaptor.forClass(ObjectRouteAssignment.class);
verify(objectRouteAssignmentRepository).save(assignmentCaptor.capture());
assertEquals(31L, assignmentCaptor.getValue().getAssignedRouteId());
assertTrue(assignmentCaptor.getValue().getAssignedAt().isAfter(LocalDateTime.now().minusMinutes(1)));
}
@Test

View File

@ -1,8 +1,11 @@
package com.qaup.collision.dataprocessing.service;
import com.qaup.collision.common.model.MovingObject;
import com.qaup.collision.common.model.UnmannedVehicle;
import com.qaup.collision.service.PlatformRuntimeStateService;
import org.junit.jupiter.api.Test;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Method;
@ -13,6 +16,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
class DataProcessingServiceCollisionRegistrationTest {
private final GeometryFactory geometryFactory = new GeometryFactory();
@Test
void shouldUseRegisteredCollisionTypeInsteadOfCollectedSourceType() throws Exception {
DataProcessingService service = new DataProcessingService();
@ -54,4 +59,37 @@ class DataProcessingServiceCollisionRegistrationTest {
assertEquals(MovingObject.MovingObjectType.UNMANNED_VEHICLE, wr01.getObjectType());
assertEquals(MovingObject.MovingObjectType.AIRCRAFT, cd423.getObjectType());
}
@Test
void shouldPreserveUnmannedVehicleMissionDataInProcessingSnapshot() throws Exception {
DataProcessingService service = new DataProcessingService();
UnmannedVehicle source = UnmannedVehicle.builder()
.objectId("WR01")
.objectName("WR01")
.objectType(MovingObject.MovingObjectType.UNMANNED_VEHICLE)
.currentPosition(geometryFactory.createPoint(new Coordinate(120.0834104, 36.35406879)))
.missionId("MISSION-1")
.missionStatus(UnmannedVehicle.MissionStatus.IN_PROGRESS)
.waypoints(List.of(
UnmannedVehicle.WaypointInfo.builder()
.waypointId("1")
.latitude(36.35416879)
.longitude(120.0835104)
.status(UnmannedVehicle.WaypointStatus.PENDING)
.build()
))
.build();
Method method = DataProcessingService.class.getDeclaredMethod("createProcessingSnapshot", java.util.Collection.class);
method.setAccessible(true);
@SuppressWarnings("unchecked")
List<MovingObject> snapshot = (List<MovingObject>) method.invoke(service, List.of(source));
assertEquals(1, snapshot.size());
assertEquals(UnmannedVehicle.class, snapshot.get(0).getClass());
UnmannedVehicle copied = (UnmannedVehicle) snapshot.get(0);
assertEquals("MISSION-1", copied.getMissionId());
assertEquals(UnmannedVehicle.MissionStatus.IN_PROGRESS, copied.getMissionStatus());
assertEquals(1, copied.getWaypoints().size());
}
}

View File

@ -0,0 +1,81 @@
package com.qaup.collision.pathconflict.event;
import com.qaup.collision.common.model.MovingObject.MovingObjectType;
import com.qaup.collision.pathconflict.model.dto.ConflictAlertEvent;
import com.qaup.collision.pathconflict.model.entity.ConflictAlertLog;
import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher;
import com.qaup.collision.websocket.message.PathConflictAlertMessage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class ConflictAlertEventListenerTest {
@Mock
private RuleEventWebSocketPublisher webSocketPublisher;
@InjectMocks
private ConflictAlertEventListener listener;
@Test
void shouldPublishFrontendDisplayFieldsByBusinessRole() {
GeometryFactory geometryFactory = new GeometryFactory();
ConflictAlertEvent event = ConflictAlertEvent.builder()
.conflictId(Optional.of(99L))
.alertType(Optional.of(ConflictAlertLog.AlertType.CONFLICT_ALERT))
.alertLevel(Optional.of(ConflictAlertLog.AlertLevel.CRITICAL))
.message("route conflict")
.object1Name("UV-1")
.object1Type(MovingObjectType.UNMANNED_VEHICLE)
.object2Name("AC-1")
.object2Type(MovingObjectType.AIRCRAFT)
.conflictPoint(geometryFactory.createPoint(new Coordinate(120.5, 31.2)))
.object1Distance(42.345)
.object2Distance(188.765)
.estimatedTimeToConflictObj1(8)
.estimatedTimeToConflictObj2(34)
.timeGapSeconds(26.0)
.eventTime(LocalDateTime.of(2026, 4, 29, 10, 0))
.aircraftName("AC-1")
.aircraftObjectType(MovingObjectType.AIRCRAFT.name())
.aircraftDistanceToConflictMeters(188.765)
.aircraftAlertThresholdMeters(200.0)
.vehicleName("UV-1")
.vehicleObjectType(MovingObjectType.UNMANNED_VEHICLE.name())
.vehicleDistanceToConflictMeters(42.345)
.vehicleAlertThresholdMeters(50.0)
.vehicleMovingTowardConflictPoint(true)
.build();
listener.handleConflictAlert(event);
ArgumentCaptor<PathConflictAlertMessage> captor = ArgumentCaptor.forClass(PathConflictAlertMessage.class);
verify(webSocketPublisher).publishPathConflictAlert(captor.capture());
PathConflictAlertMessage message = captor.getValue();
assertEquals(31.2, message.getConflictPoint().getLatitude());
assertEquals(120.5, message.getConflictPoint().getLongitude());
assertEquals("AC-1", message.getAircraftName());
assertEquals("AIRCRAFT", message.getAircraftObjectType());
assertEquals(188.77, message.getAircraftDistanceToConflictMeters());
assertEquals(200.0, message.getAircraftAlertThresholdMeters());
assertEquals("UV-1", message.getVehicleName());
assertEquals("UNMANNED_VEHICLE", message.getVehicleObjectType());
assertEquals(42.35, message.getVehicleDistanceToConflictMeters());
assertEquals(50.0, message.getVehicleAlertThresholdMeters());
assertTrue(message.getVehicleMovingTowardConflictPoint());
}
}

View File

@ -9,6 +9,7 @@ import com.qaup.collision.pathconflict.repository.ConflictAlertLogRepository;
import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository;
import com.qaup.collision.pathconflict.repository.TransportRouteRepository;
import com.qaup.collision.service.PlatformRuntimeStateService;
import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
@ -32,7 +33,8 @@ class PathConflictDetectionServiceRuntimeConfigTest {
mock(ApplicationEventPublisher.class),
mock(CoordinateSystemService.class),
runtimeStateService,
mock(VehicleCommandService.class)
mock(VehicleCommandService.class),
mock(RuleEventWebSocketPublisher.class)
);
@SuppressWarnings("unchecked")
@ -78,7 +80,8 @@ class PathConflictDetectionServiceRuntimeConfigTest {
mock(ApplicationEventPublisher.class),
mock(CoordinateSystemService.class),
runtimeStateService,
mock(VehicleCommandService.class)
mock(VehicleCommandService.class),
mock(RuleEventWebSocketPublisher.class)
);
@SuppressWarnings("unchecked")
@ -108,7 +111,8 @@ class PathConflictDetectionServiceRuntimeConfigTest {
mock(ApplicationEventPublisher.class),
mock(CoordinateSystemService.class),
runtimeStateService,
mock(VehicleCommandService.class)
mock(VehicleCommandService.class),
mock(RuleEventWebSocketPublisher.class)
);
MovingObject aircraft = MovingObject.builder()

View File

@ -13,6 +13,7 @@ import org.locationtech.jts.geom.Point;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher;
import org.springframework.web.client.RestTemplate;
import org.springframework.test.web.client.MockRestServiceServer;
@ -26,6 +27,10 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers.
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
class VehicleCommandServiceTest {
@ -35,39 +40,24 @@ class VehicleCommandServiceTest {
private MockRestServiceServer server;
private PlatformRuntimeStateService runtimeStateService;
private GeometryFactory geometryFactory;
private RuleEventWebSocketPublisher webSocketPublisher;
@BeforeEach
void setUp() {
RestTemplate restTemplate = new RestTemplate();
server = MockRestServiceServer.bindTo(restTemplate).build();
runtimeStateService = new PlatformRuntimeStateService(200.0, 100.0, 40.0);
vehicleCommandService = new VehicleCommandService(restTemplate, new ObjectMapper(), runtimeStateService);
webSocketPublisher = mock(RuleEventWebSocketPublisher.class);
vehicleCommandService = new VehicleCommandService(restTemplate, new ObjectMapper(), runtimeStateService, webSocketPublisher);
geometryFactory = new GeometryFactory();
}
@Test
void shouldSendWarningOnceAndResumeAfterConflictClears() {
void shouldNotSendVehicleCommandForWarning() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("WARNING"))
.andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING"))
.andExpect(jsonPath("$.latitude").value(36.3544))
.andExpect(jsonPath("$.longitude").value(120.085))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("RESUME"))
.andExpect(jsonPath("$.commandReason").value("RESUME_TRAFFIC"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent warningEvent = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
@ -76,9 +66,336 @@ class VehicleCommandServiceTest {
vehicleCommandService.synchronizeConflictCommands(List.of(warningEvent));
vehicleCommandService.synchronizeConflictCommands(List.of(warningEvent));
vehicleCommandService.synchronizeConflictCommands(List.of());
vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation(
"UV-001", 45.0, 45.0, false, false, false, false)));
server.verify();
verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message -> "PATH_CONFLICT_RESUME".equals(message.getMessageType())));
}
@Test
void shouldNotResumeWhenAircraftDistanceIncreasesBeforeConflictPoint() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("ALERT"))
.andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING"))
.andExpect(jsonPath("$.latitude").value(36.3544))
.andExpect(jsonPath("$.longitude").value(120.085))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("PARKING"))
.andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent alertEvent = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL
);
vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent));
for (int i = 0; i < 3; i++) {
vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation(
"UV-001", 12.0, 45.0 + i, false, false, false, false)));
}
server.verify();
verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message ->
"PATH_CONFLICT_RESUME".equals(message.getMessageType())
));
}
@Test
void shouldResumeAfterAircraftPassesConflictPointAndExceedsReleaseDistanceAfterAlert() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
runtimeStateService.updateCollisionDivergingReleaseDistance(25.0);
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("ALERT"))
.andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("PARKING"))
.andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("RESUME"))
.andExpect(jsonPath("$.commandReason").value("RESUME_TRAFFIC"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent alertEvent = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL
);
vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent));
for (int i = 0; i < 3; i++) {
vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation(
"UV-001", 12.0, 30.0 + i, false, true, false, true)));
}
server.verify();
verify(webSocketPublisher).publishPathConflictAlert(argThat(message ->
"PATH_CONFLICT_RESUME".equals(message.getMessageType())
));
}
@Test
void shouldNotResumeAfterAircraftPassesConflictPointButBeforeReleaseDistance() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
runtimeStateService.updateCollisionDivergingReleaseDistance(25.0);
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("ALERT"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("PARKING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent alertEvent = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL
);
vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent));
for (int i = 0; i < 3; i++) {
vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation(
"UV-001", 12.0, 20.0 + i, false, true, false, true)));
}
server.verify();
verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message ->
"PATH_CONFLICT_RESUME".equals(message.getMessageType())
));
}
@Test
void shouldResumeWhenAircraftPassedConflictPointAndReleaseDistanceEvenIfConflictStillReported() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
runtimeStateService.updateCollisionDivergingReleaseDistance(25.0);
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("ALERT"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("PARKING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("RESUME"))
.andExpect(jsonPath("$.commandReason").value("RESUME_TRAFFIC"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent alertEvent = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL
);
vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent));
for (int i = 0; i < 3; i++) {
vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent), List.of(new VehicleCommandService.ConflictRecoveryObservation(
"UV-001", 12.0, 30.0 + i, false, true, false, true)));
}
server.verify();
verify(webSocketPublisher).publishPathConflictAlert(argThat(message ->
"PATH_CONFLICT_RESUME".equals(message.getMessageType())
));
}
@Test
void shouldNotResumeWhenParkedVehicleStillReceivesActiveAlertBeforeIntersection() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
runtimeStateService.updateCollisionDivergingReleaseDistance(25.0);
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("ALERT"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("PARKING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent initialAlert = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL,
12.0,
20.0
);
ConflictAlertEvent activeAlertBeforeIntersection = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL,
62.0,
76.0
);
vehicleCommandService.synchronizeConflictCommands(List.of(initialAlert));
for (int i = 0; i < 3; i++) {
vehicleCommandService.synchronizeConflictCommands(List.of(activeAlertBeforeIntersection));
}
server.verify();
verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message ->
"PATH_CONFLICT_RESUME".equals(message.getMessageType())
));
}
@Test
void shouldNotResumeOnSingleRecoveryObservationAfterAlert() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("ALERT"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("PARKING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent alertEvent = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL
);
vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent));
vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation(
"UV-001", 12.0, 45.0, false, false, false, true)));
server.verify();
verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message ->
"PATH_CONFLICT_RESUME".equals(message.getMessageType())
));
}
@Test
void shouldNotResumeWhenOnlyVehicleMovesAway() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("ALERT"))
.andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("PARKING"))
.andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent alertEvent = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL
);
vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent));
for (int i = 0; i < 5; i++) {
vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation(
"UV-001", 45.0 + i, 35.0, false, false, false, false)));
}
server.verify();
verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message ->
"PATH_CONFLICT_RESUME".equals(message.getMessageType())
));
}
@Test
void shouldClearActiveVehicleCommandStateWhenRegistryStartsNewSession() {
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN),
new PlatformRuntimeStateService.VehicleRegistryEntry("AC-001", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG)
));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("ALERT"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("UV-001"))
.andExpect(jsonPath("$.commandType").value("PARKING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
ConflictAlertEvent alertEvent = buildEvent(
"UV-001",
MovingObject.MovingObjectType.UNMANNED_VEHICLE,
ConflictAlertLog.AlertLevel.CRITICAL
);
vehicleCommandService.synchronizeConflictCommands(List.of(alertEvent));
runtimeStateService.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("UV-001", PlatformRuntimeStateService.VehicleRegistryType.WUREN),
new PlatformRuntimeStateService.VehicleRegistryEntry("AC-001", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG)
));
vehicleCommandService.synchronizeConflictCommands(List.of(), List.of(new VehicleCommandService.ConflictRecoveryObservation(
"UV-001", 12.0, 45.0, false, false, false, true)));
server.verify();
verify(webSocketPublisher, never()).publishPathConflictAlert(argThat(message ->
"PATH_CONFLICT_RESUME".equals(message.getMessageType())
));
}
@Test
@ -87,13 +404,6 @@ class VehicleCommandServiceTest {
new PlatformRuntimeStateService.VehicleRegistryEntry("SP-001", PlatformRuntimeStateService.VehicleRegistryType.TEQIN)
));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("SP-001"))
.andExpect(jsonPath("$.commandType").value("WARNING"))
.andExpect(jsonPath("$.commandReason").value("AIRCRAFT_CROSSING"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"ok\"}", MediaType.APPLICATION_JSON));
server.expect(once(), requestTo(COMMAND_URL))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.vehicleID").value("SP-001"))
@ -144,6 +454,16 @@ class VehicleCommandServiceTest {
MovingObject.MovingObjectType vehicleType,
ConflictAlertLog.AlertLevel alertLevel) {
return buildEvent(vehicleId, vehicleType, alertLevel, 12.0, 35.0);
}
private ConflictAlertEvent buildEvent(
String vehicleId,
MovingObject.MovingObjectType vehicleType,
ConflictAlertLog.AlertLevel alertLevel,
double vehicleDistance,
double aircraftDistance) {
Point conflictPoint = geometryFactory.createPoint(new Coordinate(120.0850, 36.3544));
return ConflictAlertEvent.builder()
@ -160,8 +480,8 @@ class VehicleCommandServiceTest {
.object2Name("CA1234")
.object2Type(MovingObject.MovingObjectType.AIRCRAFT)
.conflictPoint(conflictPoint)
.object1Distance(12.0)
.object2Distance(35.0)
.object1Distance(vehicleDistance)
.object2Distance(aircraftDistance)
.estimatedTimeToConflictObj1(10)
.estimatedTimeToConflictObj2(20)
.timeGapSeconds(10.0)

View File

@ -2,6 +2,7 @@ package com.qaup.collision.pathconflict.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qaup.collision.service.PlatformRuntimeStateService;
import com.qaup.collision.websocket.broadcaster.RuleEventWebSocketPublisher;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
@ -18,6 +19,7 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers.
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.mockito.Mockito.mock;
class VehicleCommandServiceTrafficLightTest {
@ -32,7 +34,12 @@ class VehicleCommandServiceTrafficLightTest {
RestTemplate restTemplate = new RestTemplate();
server = MockRestServiceServer.bindTo(restTemplate).build();
runtimeStateService = new PlatformRuntimeStateService(200.0, 100.0, 40.0);
vehicleCommandService = new VehicleCommandService(restTemplate, new ObjectMapper(), runtimeStateService);
vehicleCommandService = new VehicleCommandService(
restTemplate,
new ObjectMapper(),
runtimeStateService,
mock(RuleEventWebSocketPublisher.class)
);
}
@Test

View File

@ -0,0 +1,97 @@
package com.qaup.collision.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CollisionTestSessionLogServiceTest {
@TempDir
Path tempDir;
@Test
void shouldCreateDedicatedFilePerCollisionTestSession() throws Exception {
CollisionTestSessionLogService service = new CollisionTestSessionLogService(tempDir.toString(), "ignored");
service.startSession("collision-test-1/unsafe", "flightIds=CA123, vehicleIds=V001");
service.write("collision-test-1/unsafe", "direction locked");
service.startSession("collision-test-2", "second session started");
Path firstSessionLog = tempDir.resolve("collision-test-1_unsafe.log");
Path secondSessionLog = tempDir.resolve("collision-test-2.log");
assertTrue(Files.exists(firstSessionLog));
assertTrue(Files.exists(secondSessionLog));
String firstLog = Files.readString(firstSessionLog);
String secondLog = Files.readString(secondSessionLog);
assertTrue(firstLog.contains("SESSION_START"));
assertTrue(firstLog.contains("flightIds=CA123"));
assertTrue(firstLog.contains("vehicleIds=V001"));
assertTrue(firstLog.contains("direction locked"));
assertFalse(firstLog.contains("second session started"));
assertTrue(secondLog.contains("second session started"));
}
@Test
void shouldStopAppendingAfterSessionLogDurationLimit() throws Exception {
MutableClock clock = new MutableClock(Instant.parse("2026-04-29T10:00:00Z"));
CollisionTestSessionLogService service = new CollisionTestSessionLogService(
tempDir.toString(),
"ignored",
clock,
Duration.ofMinutes(20));
service.startSession("collision-test-long", "started");
service.write("collision-test-long", "before expiry");
clock.advance(Duration.ofMinutes(21));
service.write("collision-test-long", "after expiry");
service.write("collision-test-long", "after expiry again");
service.endSession("collision-test-long", "ended");
String log = Files.readString(tempDir.resolve("collision-test-long.log"));
assertTrue(log.contains("SESSION_START"));
assertTrue(log.contains("before expiry"));
assertTrue(log.contains("SESSION_LOG_EXPIRED maxDurationMinutes=20"));
assertFalse(log.contains("after expiry"));
assertFalse(log.contains("after expiry again"));
assertTrue(log.contains("SESSION_END"));
}
private static final class MutableClock extends Clock {
private Instant instant;
private MutableClock(Instant instant) {
this.instant = instant;
}
private void advance(Duration duration) {
instant = instant.plus(duration);
}
@Override
public ZoneId getZone() {
return ZoneId.of("UTC");
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return instant;
}
}
}

View File

@ -1,6 +1,7 @@
package com.qaup.collision.service;
import com.qaup.collision.common.model.MovingObject;
import com.qaup.collision.pathconflict.repository.ObjectRouteAssignmentRepository;
import com.qaup.system.domain.SysConfig;
import com.qaup.system.service.ISysConfigService;
import org.junit.jupiter.api.Test;
@ -12,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ -127,6 +129,39 @@ class PlatformRuntimeStateServiceTest {
assertEquals(List.of("AC001", "TQ001"), second.removedVehicleIDs());
}
@Test
void shouldCleanStaleRouteAssignmentsWhenVehicleRegistryIsResubmitted() {
PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0);
ObjectRouteAssignmentRepository assignmentRepository = mock(ObjectRouteAssignmentRepository.class);
ReflectionTestUtils.setField(service, "objectRouteAssignmentRepository", assignmentRepository);
service.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("WR01", PlatformRuntimeStateService.VehicleRegistryType.WUREN),
new PlatformRuntimeStateService.VehicleRegistryEntry("CD423", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG),
new PlatformRuntimeStateService.VehicleRegistryEntry("TEST003", PlatformRuntimeStateService.VehicleRegistryType.WUREN)
));
service.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("WR01", PlatformRuntimeStateService.VehicleRegistryType.WUREN),
new PlatformRuntimeStateService.VehicleRegistryEntry("CD423", PlatformRuntimeStateService.VehicleRegistryType.HANGKONG)
));
verify(assignmentRepository, times(2)).deleteByObjectNameIn(eq(List.of("CD423", "TEST003", "WR01")));
}
@Test
void shouldCleanRouteAssignmentsForObjectsRegisteredAsNonCollisionTypes() {
PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0);
ObjectRouteAssignmentRepository assignmentRepository = mock(ObjectRouteAssignmentRepository.class);
ReflectionTestUtils.setField(service, "objectRouteAssignmentRepository", assignmentRepository);
service.updateVehicleRegistry(List.of(
new PlatformRuntimeStateService.VehicleRegistryEntry("TEST003", PlatformRuntimeStateService.VehicleRegistryType.PUTONG)
));
verify(assignmentRepository).deleteByObjectNameIn(eq(List.of("TEST003")));
}
@Test
void shouldCreateAndEndCollisionTestSessionFromRegistryUpdates() {
PlatformRuntimeStateService service = new PlatformRuntimeStateService(200.0, 100.0, 40.0);

View File

@ -1,8 +1,10 @@
package com.qaup.collision.websocket.broadcaster;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qaup.collision.websocket.event.PathConflictAlertWebSocketEvent;
import com.qaup.collision.websocket.cache.MessageCacheService;
import com.qaup.collision.websocket.handler.CollisionWebSocketHandler;
import com.qaup.collision.websocket.message.PathConflictAlertMessage;
import com.qaup.collision.websocket.message.PositionUpdatePayload;
import org.junit.jupiter.api.Test;
@ -11,6 +13,7 @@ import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class WebSocketMessageBroadcasterTest {
@ -33,6 +36,24 @@ class WebSocketMessageBroadcasterTest {
assertEquals(120.100100, sanitized.getPosition().getLongitude());
}
@Test
void shouldBroadcastPathConflictStatusWithStatusMessageType() {
CollisionWebSocketHandler handler = mock(CollisionWebSocketHandler.class);
WebSocketMessageBroadcaster broadcaster = new WebSocketMessageBroadcaster(
mock(MessageCacheService.class),
handler,
new ObjectMapper()
);
PathConflictAlertMessage payload = PathConflictAlertMessage.builder()
.messageType("PATH_CONFLICT_STATUS")
.calculationStatus("MONITORING")
.build();
broadcaster.handlePathConflictAlert(PathConflictAlertWebSocketEvent.create(payload));
verify(handler).broadcastMessage(org.mockito.ArgumentMatchers.contains("\"type\":\"path_conflict_status\""));
}
private PositionUpdatePayload invokeSanitize(WebSocketMessageBroadcaster broadcaster,
PositionUpdatePayload payload) throws Exception {
Method method = WebSocketMessageBroadcaster.class.getDeclaredMethod("sanitizePositionPayload", PositionUpdatePayload.class);