Merge pull request 'refactor: SignalKindCode 매핑 규칙 개선 — shipName BUOY 검출 + 치환 1회화' (#88) from feature/signal-kind-code-refactor into develop

This commit is contained in:
htlee 2026-03-02 13:45:19 +09:00
커밋 119e8e5238
11개의 변경된 파일208개의 추가작업 그리고 75개의 파일을 삭제

파일 보기

@ -4,6 +4,10 @@
## [Unreleased]
### 변경
- SignalKindCode 매핑 규칙 개선 — aton/tug/tender→DEFAULT, shipName BUOY 검출 추가
- 응답 경로 signal_kind_code 치환 1회화 — 캐시 저장 시 치환, 응답 시 DB/캐시 값 직접 사용
## [2026-03-02]
### 추가

파일 보기

@ -107,7 +107,7 @@ public class ChnPrmShipCacheWarmer implements ApplicationRunner {
entities.forEach(entity -> {
if (entity.getSignalKindCode() == null) {
SignalKindCode kindCode = SignalKindCode.resolve(
entity.getVesselType(), entity.getExtraInfo());
entity.getVesselType(), entity.getExtraInfo(), entity.getName());
entity.setSignalKindCode(kindCode.getCode());
}
});

파일 보기

@ -35,9 +35,10 @@ public class AisTargetCacheWriter implements ItemWriter<AisTargetEntity> {
List<? extends AisTargetEntity> items = chunk.getItems();
log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size());
// 1. SignalKindCode 치환
// 1. SignalKindCode 치환 (vesselType + extraInfo + shipName 기반, 캐시 저장 1회만)
items.forEach(item -> {
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
SignalKindCode kindCode = SignalKindCode.resolve(
item.getVesselType(), item.getExtraInfo(), item.getName());
item.setSignalKindCode(kindCode.getCode());
});

파일 보기

@ -5,7 +5,6 @@ import gc.mda.signal_batch.domain.vessel.dto.TrackResponse;
import gc.mda.signal_batch.domain.vessel.dto.VesselStatsResponse;
import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest;
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
import gc.mda.signal_batch.global.util.SignalKindCode;
import gc.mda.signal_batch.global.util.TrackSimplificationUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
@ -604,9 +603,11 @@ public class GisService {
Map<String, String> vesselInfo = getVesselInfo(mmsi);
String shipName = vesselInfo.get("ship_name");
String shipType = vesselInfo.get("ship_type");
String signalKindCode = vesselInfo.get("signal_kind_code");
String nationalCode = (mmsi != null && mmsi.length() >= 3) ? mmsi.substring(0, 3) : null;
String shipKindCode = SignalKindCode.resolve(shipType, null).getCode();
String shipKindCode = (signalKindCode != null && !signalKindCode.isEmpty())
? signalKindCode : "000027";
return CompactVesselTrack.builder()
.vesselId(mmsi)
@ -628,7 +629,7 @@ public class GisService {
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
try {
String sql = """
SELECT ship_nm as ship_name, vessel_type as ship_type
SELECT ship_nm as ship_name, vessel_type as ship_type, signal_kind_code
FROM signal.t_ais_position
WHERE mmsi = ?
LIMIT 1

파일 보기

@ -3,7 +3,6 @@ package gc.mda.signal_batch.domain.vessel.service;
import gc.mda.signal_batch.domain.ship.service.ShipImageService;
import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary;
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
import gc.mda.signal_batch.global.util.SignalKindCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@ -124,6 +123,7 @@ public class VesselPositionService {
cog,
name as ship_nm,
vessel_type as ship_ty,
signal_kind_code,
last_update
FROM signal.t_ais_position
WHERE last_update >= NOW() - INTERVAL '%d minutes'
@ -145,8 +145,9 @@ public class VesselPositionService {
String mmsi = rs.getString("mmsi");
String shipTy = rs.getString("ship_ty");
// shipKindCode 계산 (vesselType 기반, extraInfo 없음)
String shipKindCode = SignalKindCode.resolve(shipTy, null).getCode();
// shipKindCode: DB에 저장된 치환값 사용
String signalKindCode = rs.getString("signal_kind_code");
String shipKindCode = signalKindCode != null ? signalKindCode : "000027";
// nationalCode 계산 (MMSI 3자리 = MID)
String nationalCode = mmsi != null && mmsi.length() >= 3

파일 보기

@ -6,10 +6,11 @@ import lombok.RequiredArgsConstructor;
/**
* MDA 선종 범례코드
*
* S&P Global AIS API의 vesselType + extraInfo 기반으로
* S&P Global AIS API의 vesselType + extraInfo + shipName을 기반으로
* MDA 범례코드(signalKindCode) 치환한다.
*
* ShipKindCodeConverter를 대체하며, SNP-Batch-1의 치환 로직을 이식.
* 치환은 캐시 저장 (AisTargetCacheWriter) 1회만 수행하며,
* API 응답 시에는 캐시 또는 DB의 signal_kind_code를 직접 사용한다.
*/
@Getter
@RequiredArgsConstructor
@ -28,18 +29,32 @@ public enum SignalKindCode {
private final String koreanName;
/**
* vesselType + extraInfo MDA 범례코드 치환
*
* 치환 우선순위:
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN )
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing )
* 3. fallback DEFAULT (000027)
* vesselType + extraInfo MDA 범례코드 치환 (하위 호환용)
* shipName 기반 BUOY 검출 불가 캐시 저장 시에는 3-파라미터 버전 사용 권장.
*/
public static SignalKindCode resolve(String vesselType, String extraInfo) {
return resolve(vesselType, extraInfo, null);
}
/**
* vesselType + extraInfo + shipName MDA 범례코드 치환
*
* 치환 우선순위:
* 1. shipName 기반 BUOY 검출 ('.' '_' 문자가 2개 이상 부이/항로표지)
* 2. vesselType 단독 매칭 (Cargo, Tanker, Passenger )
* 3. vesselType + extraInfo 조합 매칭 (Vessel + Fishing )
* 4. fallback DEFAULT (000027)
*/
public static SignalKindCode resolve(String vesselType, String extraInfo, String shipName) {
// 1. shipName 기반 BUOY 검출: '.' 또는 '_' 문자가 2개 이상
if (hasBuoyNamePattern(shipName)) {
return BUOY;
}
String vt = normalizeOrEmpty(vesselType);
String ei = normalizeOrEmpty(extraInfo);
// 1. vesselType 단독 매칭
// 2. vesselType 단독 매칭
switch (vt) {
case "cargo":
return CARGO;
@ -48,7 +63,7 @@ public enum SignalKindCode {
case "passenger":
return FERRY;
case "aton":
return BUOY;
return DEFAULT;
case "law enforcement":
return GOV;
case "search and rescue":
@ -60,19 +75,19 @@ public enum SignalKindCode {
}
// vesselType 그룹 매칭
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) {
if (matchesAny(vt, "pilot boat", "anti pollution", "medical transport")) {
return GOV;
}
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
return FERRY;
}
// 2. "Vessel" + extraInfo 조합
// 3. "Vessel" + extraInfo 조합
if ("vessel".equals(vt)) {
return resolveVesselExtraInfo(ei);
}
// 3. "N/A" + extraInfo 조합
// 4. "N/A" + extraInfo 조합
if ("n/a".equals(vt)) {
if (ei.startsWith("hazardous cat")) {
return CARGO;
@ -80,7 +95,7 @@ public enum SignalKindCode {
return DEFAULT;
}
// 4. fallback
// 5. fallback
return DEFAULT;
}
@ -91,18 +106,32 @@ public enum SignalKindCode {
if ("military operations".equals(extraInfo)) {
return GOV;
}
if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) {
return GOV;
}
if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) {
return FISHING;
}
if (extraInfo.startsWith("hazardous cat")) {
return CARGO;
}
return DEFAULT;
}
/**
* shipName에 '.' 또는 '_' 문자가 2개 이상 포함되면 부이/항로표지로 판정
*/
static boolean hasBuoyNamePattern(String shipName) {
if (shipName == null || shipName.isBlank()) {
return false;
}
int count = 0;
for (int i = 0; i < shipName.length(); i++) {
char c = shipName.charAt(i);
if (c == '.' || c == '_') {
count++;
if (count >= 2) {
return true;
}
}
}
return false;
}
private static boolean matchesAny(String value, String... candidates) {
for (String candidate : candidates) {
if (candidate.equals(value)) {

파일 보기

@ -122,16 +122,18 @@ public class VesselTrackToCompactConverter {
int pointCount = geometry.size();
double avgSpeed = pointCount > 0 ? totalDistance / Math.max(1, pointCount) * 60 : 0;
// 선박 정보 설정
// 선박 정보 설정 (캐시에 이미 치환된 signalKindCode 사용)
String shipName = null;
String shipType = null;
String shipKindCode = null;
if (vesselInfo != null) {
shipName = vesselInfo.getName();
shipType = vesselInfo.getVesselType();
shipKindCode = SignalKindCode.resolve(vesselInfo.getVesselType(), vesselInfo.getExtraInfo()).getCode();
shipKindCode = vesselInfo.getSignalKindCode() != null
? vesselInfo.getSignalKindCode()
: SignalKindCode.DEFAULT.getCode();
} else {
shipKindCode = SignalKindCode.resolve(null, null).getCode();
shipKindCode = SignalKindCode.DEFAULT.getCode();
}
String nationalCode = mmsi.length() >= 3 ? mmsi.substring(0, 3) : mmsi;

파일 보기

@ -1,6 +1,5 @@
package gc.mda.signal_batch.global.websocket.service;
import gc.mda.signal_batch.global.util.SignalKindCode;
import gc.mda.signal_batch.global.exception.MemoryBudgetExceededException;
import gc.mda.signal_batch.global.util.TrackMemoryEstimator;
import gc.mda.signal_batch.global.websocket.dto.TrackChunkResponse;
@ -117,11 +116,13 @@ public class ChunkedTrackStreamingService {
private static class VesselInfo {
String shipName;
String shipType;
String signalKindCode;
long cacheTime;
VesselInfo(String shipName, String shipType) {
VesselInfo(String shipName, String shipType, String signalKindCode) {
this.shipName = shipName != null ? shipName : "-";
this.shipType = shipType != null ? shipType : "-";
this.signalKindCode = signalKindCode != null ? signalKindCode : "000027";
this.cacheTime = System.currentTimeMillis();
}
@ -229,15 +230,16 @@ public class ChunkedTrackStreamingService {
return cached;
}
// DB에서 조회
// DB에서 조회 (signal_kind_code는 캐시 저장 치환된 )
try {
String sql = "SELECT ship_nm, vessel_type FROM signal.t_ais_position " +
String sql = "SELECT ship_nm, vessel_type, signal_kind_code FROM signal.t_ais_position " +
"WHERE mmsi = ? LIMIT 1";
VesselInfo info = queryJdbcTemplate.queryForObject(sql,
(rs, rowNum) -> new VesselInfo(
rs.getString("ship_nm"),
rs.getString("vessel_type")
rs.getString("vessel_type"),
rs.getString("signal_kind_code")
),
mmsi
);
@ -249,7 +251,7 @@ public class ChunkedTrackStreamingService {
} catch (Exception e) {
log.debug("No vessel info found for {}, using defaults", mmsi);
VesselInfo defaultInfo = new VesselInfo(null, null);
VesselInfo defaultInfo = new VesselInfo(null, null, null);
vesselInfoCache.put(mmsi, defaultInfo);
return defaultInfo;
}
@ -287,7 +289,7 @@ public class ChunkedTrackStreamingService {
// 캐시에 없는 것들은 DB에서 배치 조회
if (!uncachedIds.isEmpty()) {
try {
String sql = "SELECT mmsi, ship_nm, vessel_type " +
String sql = "SELECT mmsi, ship_nm, vessel_type, signal_kind_code " +
"FROM signal.t_ais_position " +
"WHERE mmsi IN (" +
String.join(",", Collections.nCopies(uncachedIds.size(), "?")) + ")";
@ -296,7 +298,8 @@ public class ChunkedTrackStreamingService {
String vesselId = rs.getString("mmsi");
VesselInfo info = new VesselInfo(
rs.getString("ship_nm"),
rs.getString("vessel_type")
rs.getString("vessel_type"),
rs.getString("signal_kind_code")
);
result.put(vesselId, info);
vesselInfoCache.put(vesselId, info);
@ -308,7 +311,7 @@ public class ChunkedTrackStreamingService {
// 기본값 설정
for (String vesselId : uncachedIds) {
if (!result.containsKey(vesselId)) {
VesselInfo defaultInfo = new VesselInfo(null, null);
VesselInfo defaultInfo = new VesselInfo(null, null, null);
result.put(vesselId, defaultInfo);
vesselInfoCache.put(vesselId, defaultInfo);
}
@ -365,7 +368,7 @@ public class ChunkedTrackStreamingService {
.map(id -> "?")
.collect(Collectors.joining(","));
String sql = "SELECT mmsi, ship_nm, vessel_type " +
String sql = "SELECT mmsi, ship_nm, vessel_type, signal_kind_code " +
"FROM signal.t_ais_position " +
"WHERE mmsi IN (" + placeholders + ")";
@ -375,7 +378,8 @@ public class ChunkedTrackStreamingService {
String visselId = rs.getString("mmsi");
VesselInfo info = new VesselInfo(
rs.getString("ship_nm"),
rs.getString("vessel_type")
rs.getString("vessel_type"),
rs.getString("signal_kind_code")
);
// 세션 캐시와 전역 캐시 모두에 저장
sessionCache.put(visselId, info);
@ -387,7 +391,7 @@ public class ChunkedTrackStreamingService {
// DB에 없는 선박은 기본값으로 세션 캐시에 저장 (전역 캐시에는 저장 안함)
for (String visselId : batch) {
if (!foundIds.contains(visselId)) {
sessionCache.put(visselId, new VesselInfo(null, null));
sessionCache.put(visselId, new VesselInfo(null, null, null));
}
}
@ -395,7 +399,7 @@ public class ChunkedTrackStreamingService {
log.warn("Failed to batch load vessel info: {}", e.getMessage());
for (String visselId : batch) {
if (!sessionCache.containsKey(visselId)) {
sessionCache.put(visselId, new VesselInfo(null, null));
sessionCache.put(visselId, new VesselInfo(null, null, null));
}
}
}
@ -433,7 +437,7 @@ public class ChunkedTrackStreamingService {
.map(id -> "?")
.collect(Collectors.joining(","));
String sql = "SELECT mmsi, ship_nm, vessel_type " +
String sql = "SELECT mmsi, ship_nm, vessel_type, signal_kind_code " +
"FROM signal.t_ais_position " +
"WHERE mmsi IN (" + placeholders + ")";
@ -443,7 +447,8 @@ public class ChunkedTrackStreamingService {
String vesselId = rs.getString("mmsi");
VesselInfo info = new VesselInfo(
rs.getString("ship_nm"),
rs.getString("vessel_type")
rs.getString("vessel_type"),
rs.getString("signal_kind_code")
);
vesselInfoCache.put(vesselId, info);
foundIds.add(vesselId);
@ -452,7 +457,7 @@ public class ChunkedTrackStreamingService {
// DB에 없는 선박은 기본값으로 캐시
for (String vesselId : batch) {
if (!foundIds.contains(vesselId)) {
vesselInfoCache.put(vesselId, new VesselInfo(null, null));
vesselInfoCache.put(vesselId, new VesselInfo(null, null, null));
}
}
@ -461,7 +466,7 @@ public class ChunkedTrackStreamingService {
// 실패 기본값으로 캐시
for (String vesselId : batch) {
if (!vesselInfoCache.containsKey(vesselId)) {
vesselInfoCache.put(vesselId, new VesselInfo(null, null));
vesselInfoCache.put(vesselId, new VesselInfo(null, null, null));
}
}
}
@ -635,7 +640,7 @@ public class ChunkedTrackStreamingService {
VesselInfo vesselInfo = getVesselInfo(mmsi);
accumulator.shipName = vesselInfo.shipName;
accumulator.shipType = vesselInfo.shipType;
accumulator.shipKindCode = SignalKindCode.resolve(vesselInfo.shipType, null).getCode();
accumulator.shipKindCode = vesselInfo.signalKindCode;
vesselMap.put(vesselId, accumulator);
}
@ -1101,7 +1106,7 @@ public class ChunkedTrackStreamingService {
VesselInfo vesselInfo = getVesselInfo(track.getVesselId());
accumulator.shipName = vesselInfo.shipName;
accumulator.shipType = vesselInfo.shipType;
accumulator.shipKindCode = SignalKindCode.resolve(vesselInfo.shipType, null).getCode();
accumulator.shipKindCode = vesselInfo.signalKindCode;
mergedMap.put(vesselId, accumulator);
}
@ -1985,7 +1990,7 @@ public class ChunkedTrackStreamingService {
acc.mmsi = finalMmsi;
acc.shipName = info.shipName;
acc.shipType = info.shipType;
acc.shipKindCode = SignalKindCode.resolve(info.shipType, null).getCode();
acc.shipKindCode = info.signalKindCode;
return acc;
});
@ -2742,13 +2747,13 @@ public class ChunkedTrackStreamingService {
// 세션 캐시에서 조회 (이미 preload됨)
VesselInfo info = sessionVesselCache.get(finalMmsi3);
if (info == null) {
info = new VesselInfo(null, null);
info = new VesselInfo(null, null, null);
}
VesselAccumulator acc = new VesselAccumulator();
acc.mmsi = finalMmsi3;
acc.shipName = info.shipName;
acc.shipType = info.shipType;
acc.shipKindCode = SignalKindCode.resolve(info.shipType, null).getCode();
acc.shipKindCode = info.signalKindCode;
return acc;
});

파일 보기

@ -310,8 +310,9 @@ public class DailyTrackCacheManager {
double avgSpeed = acc.pointCount > 0 ? acc.totalDistance / Math.max(1, acc.pointCount) * 60 : 0;
// shipKindCode 계산
String shipKindCode = SignalKindCode.resolve(acc.shipType, null).getCode();
// shipKindCode: 캐시 저장 치환된 사용 (DB fallback 포함)
String shipKindCode = acc.signalKindCode != null
? acc.signalKindCode : SignalKindCode.DEFAULT.getCode();
// nationalCode 계산 (MMSI 3자리 = MID)
String nationalCode = acc.mmsi.length() >= 3 ? acc.mmsi.substring(0, 3) : acc.mmsi;
@ -723,7 +724,7 @@ public class DailyTrackCacheManager {
try (Connection conn = queryDataSource.getConnection()) {
String placeholders = batch.stream().map(id -> "?").collect(Collectors.joining(","));
String sql = "SELECT mmsi, name as ship_nm, vessel_type as ship_ty " +
String sql = "SELECT mmsi, name as ship_nm, vessel_type as ship_ty, signal_kind_code " +
"FROM signal.t_ais_position " +
"WHERE mmsi IN (" + placeholders + ")";
@ -739,6 +740,7 @@ public class DailyTrackCacheManager {
if (acc != null) {
acc.shipName = rs.getString("ship_nm");
acc.shipType = rs.getString("ship_ty");
acc.signalKindCode = rs.getString("signal_kind_code");
enriched++;
}
}
@ -782,6 +784,7 @@ public class DailyTrackCacheManager {
String mmsi;
String shipName;
String shipType;
String signalKindCode;
List<double[]> geometry = new ArrayList<>(500);
List<String> timestamps = new ArrayList<>(500);
List<Double> speeds = new ArrayList<>(500);

파일 보기

@ -12,6 +12,25 @@ import static org.junit.jupiter.api.Assertions.*;
class SignalKindCodeTest {
@Nested
@DisplayName("shipName 기반 BUOY 검출")
class ShipNameBuoy {
@Test
@DisplayName("'.' 또는 '_' 2개 이상 → BUOY (vesselType 무시)")
void resolve_buoyByName() {
assertEquals("000028", SignalKindCode.resolve("Cargo", null, "BUOY_01_23").getCode());
assertEquals("000028", SignalKindCode.resolve("Tanker", null, "AIS.BUOY.01").getCode());
}
@Test
@DisplayName("'.' 또는 '_' 1개 이하 → vesselType 기준")
void resolve_notBuoyByName() {
assertEquals("000023", SignalKindCode.resolve("Cargo", null, "M.V CARGO").getCode());
assertEquals("000024", SignalKindCode.resolve("Tanker", null, "OIL_TANKER").getCode());
}
}
@Nested
@DisplayName("vesselType 단독 매칭")
class VesselTypeDirect {
@ -21,7 +40,7 @@ class SignalKindCodeTest {
"Cargo, 000023",
"Tanker, 000024",
"Passenger, 000022",
"AtoN, 000028",
"AtoN, 000027",
"Law Enforcement, 000025",
"Search and Rescue, 000021",
"Local Vessel, 000020"
@ -38,11 +57,11 @@ class SignalKindCodeTest {
@ParameterizedTest
@CsvSource({
"Tug, 000025",
"Pilot Boat, 000025",
"Tender, 000025",
"Anti Pollution, 000025",
"Medical Transport, 000025",
"Tug, 000027",
"Tender, 000027",
"High Speed Craft, 000022",
"Wing in Ground-effect, 000022"
})
@ -60,13 +79,13 @@ class SignalKindCodeTest {
@CsvSource({
"Vessel, Fishing, 000020",
"Vessel, Military Operations, 000025",
"Vessel, Towing, 000025",
"Vessel, Towing (Large), 000025",
"Vessel, Dredging/Underwater Ops, 000025",
"Vessel, Diving Operations, 000025",
"Vessel, Pleasure Craft, 000020",
"Vessel, Sailing, 000020",
"Vessel, N/A, 000020",
"Vessel, Towing, 000027",
"Vessel, Towing (Large), 000027",
"Vessel, Dredging/Underwater Ops, 000027",
"Vessel, Diving Operations, 000027",
"Vessel, Pleasure Craft, 000027",
"Vessel, Sailing, 000027",
"Vessel, N/A, 000027",
"Vessel, Hazardous Cat A, 000023",
"Vessel, Hazardous Cat B, 000023",
"Vessel, Unknown, 000027"

파일 보기

@ -14,6 +14,34 @@ import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("SignalKindCode - MDA 선종 범례코드 치환")
class SignalKindCodeTest {
@Nested
@DisplayName("shipName 기반 BUOY 검출 (최우선)")
class ShipNameBuoy {
@ParameterizedTest(name = "shipName={0} → BUOY")
@ValueSource(strings = {"BUOY_01_23", "AIS.BUOY.01", "LIGHT__HOUSE", "A.B.C"})
@DisplayName("'.' 또는 '_' 2개 이상 → BUOY")
void resolve_buoyByName(String shipName) {
assertThat(SignalKindCode.resolve("Cargo", null, shipName))
.isEqualTo(SignalKindCode.BUOY);
}
@ParameterizedTest(name = "shipName={0} → vesselType 기준")
@ValueSource(strings = {"M.V CARGO", "SHIP_ONE", "NORMAL SHIP", "ABC"})
@DisplayName("'.' 또는 '_' 1개 이하 → shipName 무시, vesselType 기준")
void resolve_notBuoyByName(String shipName) {
assertThat(SignalKindCode.resolve("Cargo", null, shipName))
.isEqualTo(SignalKindCode.CARGO);
}
@Test
@DisplayName("shipName null → vesselType 기준")
void resolve_nullShipName() {
assertThat(SignalKindCode.resolve("Cargo", null, null))
.isEqualTo(SignalKindCode.CARGO);
}
}
@Nested
@DisplayName("vesselType 단독 매칭")
class VesselTypeDirect {
@ -23,7 +51,6 @@ class SignalKindCodeTest {
"cargo, CARGO",
"tanker, TANKER",
"passenger, FERRY",
"aton, BUOY",
"law enforcement, GOV",
"search and rescue, KCGV",
"local vessel, FISHING"
@ -33,6 +60,12 @@ class SignalKindCodeTest {
SignalKindCode result = SignalKindCode.resolve(vesselType, null);
assertThat(result.name()).isEqualTo(expectedName);
}
@Test
@DisplayName("aton → DEFAULT (부이가 아닌 일반 장비)")
void resolve_aton() {
assertThat(SignalKindCode.resolve("aton", null)).isEqualTo(SignalKindCode.DEFAULT);
}
}
@Nested
@ -40,12 +73,19 @@ class SignalKindCodeTest {
class VesselTypeGroup {
@ParameterizedTest(name = "vesselType={0} → GOV")
@ValueSource(strings = {"tug", "pilot boat", "tender", "anti pollution", "medical transport"})
@ValueSource(strings = {"pilot boat", "anti pollution", "medical transport"})
@DisplayName("GOV 그룹 매칭")
void resolve_govGroup(String vesselType) {
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.GOV);
}
@ParameterizedTest(name = "vesselType={0} → DEFAULT")
@ValueSource(strings = {"tug", "tender"})
@DisplayName("tug, tender → DEFAULT")
void resolve_tugTenderDefault(String vesselType) {
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.DEFAULT);
}
@ParameterizedTest(name = "vesselType={0} → FERRY")
@ValueSource(strings = {"high speed craft", "wing in ground-effect"})
@DisplayName("FERRY 그룹 매칭")
@ -70,18 +110,18 @@ class SignalKindCodeTest {
assertThat(SignalKindCode.resolve("Vessel", "Military Operations")).isEqualTo(SignalKindCode.GOV);
}
@ParameterizedTest(name = "Vessel + {0} → GOV")
@ParameterizedTest(name = "Vessel + {0} → DEFAULT")
@ValueSource(strings = {"towing", "towing (large)", "dredging/underwater ops", "diving operations"})
@DisplayName("Vessel + 해양작업 → GOV")
@DisplayName("Vessel + 해양작업 → DEFAULT")
void resolve_vesselMarineOps(String extraInfo) {
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.GOV);
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.DEFAULT);
}
@ParameterizedTest(name = "Vessel + {0} → FISHING")
@ParameterizedTest(name = "Vessel + {0} → DEFAULT")
@ValueSource(strings = {"pleasure craft", "sailing", "n/a"})
@DisplayName("Vessel + 레저/기타 → FISHING")
@DisplayName("Vessel + 레저/기타 → DEFAULT")
void resolve_vesselLeisure(String extraInfo) {
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.FISHING);
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.DEFAULT);
}
@Test
@ -164,4 +204,32 @@ class SignalKindCodeTest {
assertThat(SignalKindCode.BUOY.getCode()).isEqualTo("000028");
}
}
@Nested
@DisplayName("shipName BUOY 판정 (resolve 3-param 통합 검증)")
class BuoyNamePattern {
@ParameterizedTest(name = "{0} → BUOY")
@ValueSource(strings = {"A.B.C", "BUOY_01_02", "._", "A.B_C"})
@DisplayName("2개 이상 특수문자 → BUOY")
void resolve_buoyPattern(String name) {
// vesselType과 무관하게 BUOY로 치환
assertThat(SignalKindCode.resolve(null, null, name)).isEqualTo(SignalKindCode.BUOY);
}
@ParameterizedTest(name = "{0} → not BUOY")
@ValueSource(strings = {"ABC", "A.B", "A_B", "NORMAL"})
@DisplayName("1개 이하 특수문자 → shipName 무시")
void resolve_notBuoyPattern(String name) {
assertThat(SignalKindCode.resolve(null, null, name)).isEqualTo(SignalKindCode.DEFAULT);
}
@Test
@DisplayName("null/blank shipName → vesselType 기준")
void resolve_nullBlankName() {
assertThat(SignalKindCode.resolve("Cargo", null, null)).isEqualTo(SignalKindCode.CARGO);
assertThat(SignalKindCode.resolve("Cargo", null, "")).isEqualTo(SignalKindCode.CARGO);
assertThat(SignalKindCode.resolve("Cargo", null, " ")).isEqualTo(SignalKindCode.CARGO);
}
}
}