diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlightSchdReadAssembler.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlightSchdReadAssembler.kt new file mode 100644 index 0000000..b5343c3 --- /dev/null +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlightSchdReadAssembler.kt @@ -0,0 +1,200 @@ +package com.gzzn.omms.msgexchange.infra.persistence.jdbc + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.gzzn.omms.msgexchange.infra.persistence.FlightFields + +/** + * FLIGHT_SCHD 读侧视图组装(v2 双读对拍 FS7)。 + * + * - [assembleMerged]:生产读路径,明细表优先、槽位列回退(与 legacy flightInfo hash 同构)。 + * - [assembleDetailOnly]:仅标量 + 明细表集合(v2 权威读)。 + * - [assembleLegacySlotsOnly]:仅标量 + V1.2.0 槽位/里程碑/航路列重建(legacy 回退读)。 + */ +object FlightSchdReadAssembler { + + /** SCHD.FLTR 标量列 + legacy 派生列(与 V1.1.0 迁移一致)。 */ + val SCALAR_COLUMNS: List = listOf( + "ALCD", "ALSC", "FLNO", "MVIN", "SODT", "FLTY", "FLIN", "ACFT", "RENO", + "TAOP", "TAFL", "TAID", "TRML", "MAXP", "CSOP", "CSFT", "MAID", + "ESTT", "ACTT", "STND", "PHAG", "CNCL", "REMC", "BOTM", "LACL", "FINT", + "APPT", "EGSR", "EGST", "FHAG", "MHAG", "VIPP", "VIPR", "LBNO", "LBWT", + "PAXC", "EXSC", "EXSR", "FTSS", "PEDT", "NEAT", "PADT", "NAAT", + "ABDG", "LPSDT", "ABN", + ) + + val EXCEPTION_COLUMNS: List = listOf( + "FDIV_DDES", "FDIV_DDIR", "FDIV_REMC", "FRET_REID", "FRET_RSN", + "FLAB_ARES", "FLAB_RSN", + ) + + data class SlotCollection( + val key: String, + val seqAttr: String, + val dedupeAttr: String, + val slots: List>, + ) + + private fun slotCollection( + key: String, + seqAttr: String, + dedupeAttr: String, + attrs: List, + count: Int, + renames: Map = emptyMap(), + ): SlotCollection = SlotCollection( + key = key, + seqAttr = seqAttr, + dedupeAttr = dedupeAttr, + slots = (1..count).map { i -> attrs.associateWith { attr -> (renames[attr] ?: attr) + i } }, + ) + + val OCCURRENCE_COLLECTIONS: List = listOf( + slotCollection("GTDT", "GTNO", "GATE", listOf("GATE", "PGOT", "PGCT", "GOTM", "GCTM", "GTYP"), 2), + slotCollection( + "CKDT", "CKNO", "CHKC", + listOf("CHKC", "CCLS", "PCOT", "PCCT", "COTM", "CCTM", "CTYP"), 3, + ), + slotCollection( + "CLDT", "CLNO", "BELT", + listOf("BELT", "BCLS", "PCOT", "PCCT", "FBAG", "LBAG", "BTYP"), 2, + renames = mapOf("PCOT" to "BPCOT", "PCCT" to "BPCCT"), + ), + slotCollection("PSDT", "PSNO", "PSST", listOf("PSST", "STST", "STET"), 2), + slotCollection( + "CHDT", "CHNO", "CHUT", + listOf("CHUT", "CCLS", "PCBT", "PCET", "CBTM", "CETM", "CTYP"), 2, + renames = mapOf("CCLS" to "CHCLS", "CTYP" to "CHTYP"), + ), + ) + + val MILESTONE_COLUMNS: List = listOf( + "DELY_CODE", "DELY_STRT", "DELY_DURA", "DELY_REMC", + "ABTM_A", "ABTM_D", "CHOT_ON", "CHOT_OFF", + ) + + val PATH_COLUMNS: List = listOf("ROUT_PATH", "ERUT_PATH") + + val TEXT_COLUMNS: List = listOf("SRVT_TEXT", "VIPF_TEXT", "MAFL_TEXT") + + val ALL_COLUMNS: List = SCALAR_COLUMNS + EXCEPTION_COLUMNS + + OCCURRENCE_COLLECTIONS.flatMap { c -> c.slots.flatMap(Map::values) } + + MILESTONE_COLUMNS + PATH_COLUMNS + TEXT_COLUMNS + + private val mapper = ObjectMapper() + + /** 生产读路径:明细表优先,缺失时由槽位/里程碑列回退重建。 */ + fun assembleMerged( + flid: String, + row: Map, + detailCollections: Map, + ): FlightFields { + val fields = assembleScalarsAndExceptions(flid, row) + val detailKeys = detailCollections.keys + if ("DELY" !in detailKeys) delayView(row)?.let { fields["DELY"] = it } + if ("ABTM" !in detailKeys) airbridgeView(row)?.let { fields["ABTM"] = it } + if ("CHOT" !in detailKeys) chocksView(row)?.let { fields["CHOT"] = it } + if ("ROUT" !in detailKeys) row["ROUT_PATH"]?.let { fields["ROUT"] = routeView(it) } + if ("ERUT" !in detailKeys) row["ERUT_PATH"]?.let { fields["ERUT"] = routeView(it) } + OCCURRENCE_COLLECTIONS.forEach { c -> + if (c.key !in detailKeys) slotView(c, row)?.let { fields[c.key] = it } + } + detailCollections.forEach { (key, value) -> fields[key] = value } + return fields + } + + /** v2 权威读:标量 + 明细表集合,不读槽位/里程碑列。 */ + fun assembleDetailOnly( + flid: String, + row: Map, + detailCollections: Map, + ): FlightFields { + val fields = assembleScalarsAndExceptions(flid, row) + detailCollections.forEach { (key, value) -> fields[key] = value } + return fields + } + + /** legacy 回退读:标量 + 槽位/里程碑/航路列重建,不叠加明细表。 */ + fun assembleLegacySlotsOnly(flid: String, row: Map): FlightFields { + val fields = assembleScalarsAndExceptions(flid, row) + delayView(row)?.let { fields["DELY"] = it } + airbridgeView(row)?.let { fields["ABTM"] = it } + chocksView(row)?.let { fields["CHOT"] = it } + row["ROUT_PATH"]?.let { fields["ROUT"] = routeView(it) } + row["ERUT_PATH"]?.let { fields["ERUT"] = routeView(it) } + OCCURRENCE_COLLECTIONS.forEach { c -> slotView(c, row)?.let { fields[c.key] = it } } + return fields + } + + private fun assembleScalarsAndExceptions(flid: String, row: Map): LinkedHashMap { + val fields = linkedMapOf() + fields["FLID"] = flid + SCALAR_COLUMNS.forEach { column -> row[column]?.let { fields[column] = it } } + TEXT_COLUMNS.forEach { column -> row[column]?.let { fields[column.removeSuffix("_TEXT")] = it } } + exceptionView("FDIV", listOf("DDES" to "FDIV_DDES", "DDIR" to "FDIV_DDIR", "REMC" to "FDIV_REMC"), row) + ?.let { fields["FDIV"] = it } + exceptionView("FRET", listOf("REID" to "FRET_REID", "RSN" to "FRET_RSN"), row) + ?.let { fields["FRET"] = it } + exceptionView("FLAB", listOf("ARES" to "FLAB_ARES", "RSN" to "FLAB_RSN"), row) + ?.let { fields["FLAB"] = it } + return fields + } + + private fun exceptionView(key: String, attrs: List>, row: Map): String? { + val node = mapper.createObjectNode() + attrs.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } } + return if (node.size() == 0) null else mapper.writeValueAsString(node) + } + + private fun delayView(row: Map): String? { + val node = mapper.createObjectNode() + listOf("CODE" to "DELY_CODE", "STRT" to "DELY_STRT", "DURA" to "DELY_DURA", "REMC" to "DELY_REMC") + .forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } } + return if (node.size() == 0) null else mapper.writeValueAsString(arrayOf(node)) + } + + private fun airbridgeView(row: Map): String? { + val items = mutableListOf() + row["ABTM_A"]?.let { + items += mapper.createObjectNode().apply { put("ASNO", "1"); put("ABOP", "A"); put("AOTM", it) } + } + row["ABTM_D"]?.let { + items += mapper.createObjectNode().apply { put("ASNO", "2"); put("ABOP", "D"); put("AOTM", it) } + } + return if (items.isEmpty()) null else mapper.writeValueAsString(items) + } + + private fun chocksView(row: Map): String? { + val items = mutableListOf() + row["CHOT_ON"]?.let { + items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "ON"); put("CHTM", it) } + } + row["CHOT_OFF"]?.let { + items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "OFF"); put("CHTM", it) } + } + return if (items.isEmpty()) null else mapper.writeValueAsString(items) + } + + private fun routeView(path: String): String { + val items = path.split(",").mapIndexed { index, segment -> + val parts = segment.split("/", limit = 3) + mapper.createObjectNode().apply { + put("RTNO", (index + 1).toString()) + listOf("APCD", "SCAT", "SCDT").forEachIndexed { i, attr -> + if (i < parts.size && parts[i].isNotEmpty()) put(attr, parts[i]) + } + } + } + return mapper.writeValueAsString(items) + } + + private fun slotView(c: SlotCollection, row: Map): String? { + val items = c.slots.mapIndexed { index, slot -> + mapper.createObjectNode().apply { + put(c.seqAttr, (index + 1).toString()) + slot.forEach { (attr, column) -> row[column]?.let { put(attr, it) } } + } + }.filterNot { it.size() <= 1 } + return if (items.isEmpty()) null else mapper.writeValueAsString(items) + } +} diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcPgRepositories.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcPgRepositories.kt index d85eac7..5fd4fb8 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcPgRepositories.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcPgRepositories.kt @@ -398,9 +398,7 @@ class JdbcFlightSchdRepository( private val SCALAR_WRITE_COLUMNS: List = SCALAR_COLUMNS + EXCEPTION_COLUMNS + TEXT_COLUMNS /** 库列名全集(读侧 SELECT 与视图重建的稳定顺序,与 V1.2.0 迁移一致)。 */ - private val ALL_COLUMNS: List = SCALAR_COLUMNS + EXCEPTION_COLUMNS + - OCCURRENCE_COLLECTIONS.flatMap { c -> c.slots.flatMap(Map::values) } + - MILESTONE_COLUMNS + PATH_COLUMNS + TEXT_COLUMNS + private val ALL_COLUMNS: List = FlightSchdReadAssembler.ALL_COLUMNS private val SCALAR_KEY_SET: Set = SCALAR_COLUMNS.toSet() @@ -659,102 +657,15 @@ class JdbcFlightSchdRepository( return totalDeleted } - /** 行 → 字段视图:标量列直读,16 个集合键由平铺列重建,与 legacy flightInfo hash 同构 - * (KAFKA_SCHD 线格式与 FS7 对拍逐字段比对均依赖该同构视图)。 */ + /** 行 → 字段视图:标量列直读,集合键由明细表或槽位列重建(FS7 双读对拍见 FlightSchdReadAssembler)。 */ private fun mapFlightRow(rs: java.sql.ResultSet): Pair { val flid = rs.getString("flid") - val fields = linkedMapOf() - fields["FLID"] = flid // 视图与 legacy flightInfo hash 同构:FLID 字段常在 val row = linkedMapOf() for (column in ALL_COLUMNS) { row[column] = rs.getString(column) } - - SCALAR_COLUMNS.forEach { column -> row[column]?.let { fields[column] = it } } - TEXT_COLUMNS.forEach { column -> row[column]?.let { fields[column.removeSuffix("_TEXT")] = it } } - - exceptionView("FDIV", listOf("DDES" to "FDIV_DDES", "DDIR" to "FDIV_DDIR", "REMC" to "FDIV_REMC"), row) - ?.let { fields["FDIV"] = it } - exceptionView("FRET", listOf("REID" to "FRET_REID", "RSN" to "FRET_RSN"), row) - ?.let { fields["FRET"] = it } - exceptionView("FLAB", listOf("ARES" to "FLAB_ARES", "RSN" to "FLAB_RSN"), row) - ?.let { fields["FLAB"] = it } val detailCollections = FlightDetailTables.loadCollections(ds, flid) - val detailKeys = detailCollections.keys - if ("DELY" !in detailKeys) delayView(row)?.let { fields["DELY"] = it } - if ("ABTM" !in detailKeys) airbridgeView(row)?.let { fields["ABTM"] = it } - if ("CHOT" !in detailKeys) chocksView(row)?.let { fields["CHOT"] = it } - if ("ROUT" !in detailKeys) row["ROUT_PATH"]?.let { fields["ROUT"] = routeView(it) } - if ("ERUT" !in detailKeys) row["ERUT_PATH"]?.let { fields["ERUT"] = routeView(it) } - OCCURRENCE_COLLECTIONS.forEach { c -> - if (c.key !in detailKeys) slotView(c, row)?.let { fields[c.key] = it } - } - detailCollections.forEach { (key, value) -> fields[key] = value } - return flid to fields - } - - /** 前缀标量列 → 单值异常对象视图(缺失属性省略;全空 → 无键)。 */ - private fun exceptionView(key: String, attrs: List>, row: Map): String? { - val node = mapper.createObjectNode() - attrs.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } } - return if (node.size() == 0) null else mapper.writeValueAsString(node) - } - - /** 延误列 → 0..1 条延误对象数组视图(legacy DELY 为可重复标签的数组形状)。 */ - private fun delayView(row: Map): String? { - val node = mapper.createObjectNode() - listOf("CODE" to "DELY_CODE", "STRT" to "DELY_STRT", "DURA" to "DELY_DURA", "REMC" to "DELY_REMC") - .forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } } - return if (node.size() == 0) null else mapper.writeValueAsString(arrayOf(node)) - } - - /** 靠撤桥时刻 → 保障里程碑数组视图(ASNO/ABOP 为操作序合成;桥号见派生列 ABDG)。 */ - private fun airbridgeView(row: Map): String? { - val items = mutableListOf() - row["ABTM_A"]?.let { - items += mapper.createObjectNode().apply { put("ASNO", "1"); put("ABOP", "A"); put("AOTM", it) } - } - row["ABTM_D"]?.let { - items += mapper.createObjectNode().apply { put("ASNO", "2"); put("ABOP", "D"); put("AOTM", it) } - } - return if (items.isEmpty()) null else mapper.writeValueAsString(items) - } - - /** 轮挡时刻 → 保障里程碑数组视图(CHID 标识上/下轮挡;机位见派生列 STND)。 */ - private fun chocksView(row: Map): String? { - val items = mutableListOf() - row["CHOT_ON"]?.let { - items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "ON"); put("CHTM", it) } - } - row["CHOT_OFF"]?.let { - items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "OFF"); put("CHTM", it) } - } - return if (items.isEmpty()) null else mapper.writeValueAsString(items) - } - - /** 紧凑航路字串 → legacy ROUTEDAILY 数组视图(RTNO 按段序合成;空槽位属性省略)。 */ - private fun routeView(path: String): String { - val items = path.split(",").mapIndexed { index, segment -> - val parts = segment.split("/", limit = 3) - mapper.createObjectNode().apply { - put("RTNO", (index + 1).toString()) - listOf("APCD", "SCAT", "SCDT").forEachIndexed { i, attr -> - if (i < parts.size && parts[i].isNotEmpty()) put(attr, parts[i]) - } - } - } - return mapper.writeValueAsString(items) - } - - /** 出现槽位列 → legacy 集合数组视图(空槽位跳过;序号属性按槽位合成)。 */ - private fun slotView(c: SlotCollection, row: Map): String? { - val items = c.slots.mapIndexed { index, slot -> - mapper.createObjectNode().apply { - put(c.seqAttr, (index + 1).toString()) - slot.forEach { (attr, column) -> row[column]?.let { put(attr, it) } } - } - }.filterNot { it.size() <= 1 } - return if (items.isEmpty()) null else mapper.writeValueAsString(items) + return flid to FlightSchdReadAssembler.assembleMerged(flid, row, detailCollections) } override fun findByFlid(flid: String): FlightFields? = diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/tools/FlightStoreDiffTool.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/tools/FlightStoreDiffTool.kt index 118a53c..b7ac58d 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/tools/FlightStoreDiffTool.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/tools/FlightStoreDiffTool.kt @@ -38,6 +38,10 @@ class FlightStoreDiffTool( MISSING_IN_PG, /** 非预期偏离:Redis 缺失且非已知迁移保护行 */ UNEXPECTED_EXTRA_IN_PG, + /** v2 双读:明细表读路径缺失该航班 */ + MISSING_IN_DETAIL, + /** v2 双读:legacy 槽位读路径缺失该航班 */ + MISSING_IN_LEGACY, } data class Deviation( @@ -86,6 +90,83 @@ class FlightStoreDiffTool( } } + + /** + * FS7 v2 双读对拍:明细表组装视图 vs legacy 槽位/里程碑列组装视图。 + * 严格逐字段比对,禁止通过忽略 GTNO/第三资源/ABDG 等规则洗绿。 + */ + fun diffDualRead( + detailViews: Map, + legacySlotViews: Map, + ): DiffReport = diffFlightFieldMaps(detailViews, legacySlotViews, missingInPrimary = DeviationKind.MISSING_IN_DETAIL, missingInReference = DeviationKind.MISSING_IN_LEGACY) + + /** 两侧均为 FlightFields 的严格逐字段比对(供双读对拍与单测复用)。 */ + fun diffFlightFieldMaps( + primaryFlights: Map, + referenceFlights: Map, + missingInPrimary: DeviationKind = DeviationKind.MISSING_IN_PG, + missingInReference: DeviationKind = DeviationKind.UNEXPECTED_EXTRA_IN_PG, + ): DiffReport { + val allFlids = (primaryFlights.keys + referenceFlights.keys).toSortedSet() + var matched = 0 + val unexpected = mutableListOf() + + for (flid in allFlids) { + val primary = primaryFlights[flid] + val reference = referenceFlights[flid] + + if (primary == null) { + unexpected += Deviation( + flid = flid, + kind = missingInPrimary, + legacyValue = reference, + detail = "Flight present in reference view but missing in primary view", + ) + continue + } + if (reference == null) { + unexpected += Deviation( + flid = flid, + kind = missingInReference, + pgValue = primary, + detail = "Flight present in primary view but missing in reference view", + ) + continue + } + + val mismatches = compareFlightFields(flid, primary, reference) + if (mismatches.isEmpty()) matched++ else unexpected.addAll(mismatches) + } + + return DiffReport( + totalPg = primaryFlights.size, + totalLegacy = referenceFlights.size, + matchedCount = matched, + knownDeviations = emptyList(), + unexpectedDeviations = unexpected, + ) + } + + /** 单航班 FlightFields 对称比对(集合值为 JSON 文本时递归解析)。 */ + internal fun compareFlightFields(flid: String, left: FlightFields, right: FlightFields): List { + val mismatches = mutableListOf() + val allKeys = (left.keys + right.keys).toSortedSet() + for (key in allKeys) { + compareFieldValues(flid, key, left[key], right[key], mismatches) + } + return mismatches + } + + private fun compareFieldValues(flid: String, path: String, left: String?, right: String?, acc: MutableList) { + val leftNode = left?.let { parseFieldValue(it) } + val rightNode = right?.let { parseFieldValue(it) } + compareNodes(flid, path, leftNode, rightNode, acc) + } + + private fun parseFieldValue(value: String): JsonNode = + runCatching { mapper.readTree(value) }.getOrNull() + ?: mapper.nodeFactory.textNode(value) + /** * 执行全量比对。 * @param pgFlights nextgen FLIGHT_SCHD 行(flid -> 宽表列值映射) diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/tools/FlightStoreDualReadTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/tools/FlightStoreDualReadTest.kt new file mode 100644 index 0000000..25f7d86 --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/tools/FlightStoreDualReadTest.kt @@ -0,0 +1,156 @@ +package com.gzzn.omms.msgexchange.tools + +import com.gzzn.omms.msgexchange.infra.persistence.jdbc.FlightSchdReadAssembler +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * FS7 v2 双读对拍:明细表组装 vs legacy 槽位列组装。 + * 反例来自 review-flight-state-2026-09-08 §12.1 / SIS §3.34.3(G28/G33/G23)。 + */ +class FlightStoreDualReadTest { + + private val tool = FlightStoreDiffTool() + private val flid = "TEST_DUAL_READ" + + private fun detailView(vararg collections: Pair) = mapOf( + flid to linkedMapOf( + "FLID" to flid, + *collections, + ), + ) + + private fun legacyView(vararg collections: Pair) = mapOf( + flid to linkedMapOf( + "FLID" to flid, + *collections, + ), + ) + + @Test + fun `Dual read GREEN when detail and legacy slot views agree within slot capacity`() { + val gtdt = """[{"GTNO":"1","GATE":"G28","PGOT":"07SEP260800"},{"GTNO":"2","GATE":"G33"}]""" + val detail = detailView("GTDT" to gtdt) + val legacy = legacyView("GTDT" to gtdt) + + val report = tool.diffDualRead(detail, legacy) + assertTrue(report.isGreen) + assertEquals(1, report.matchedCount) + } + + @Test + fun `Dual read RED for SIS three-gate sample G28 G33 G23 - third gate must not be ignored`() { + val detailGtdt = """[ + {"GTNO":"1","GATE":"G28"}, + {"GTNO":"2","GATE":"G33"}, + {"GTNO":"3","GATE":"G23"} + ]""".replace("\n", "").replace(" ", "") + val legacyGtdt = """[{"GTNO":"1","GATE":"G28"},{"GTNO":"2","GATE":"G33"}]""" + + val report = tool.diffDualRead( + detailView("GTDT" to detailGtdt), + legacyView("GTDT" to legacyGtdt), + ) + assertFalse(report.isGreen) + assertTrue(report.unexpectedDeviations.any { it.path == "GTDT" }) + } + + @Test + fun `Dual read RED for non-contiguous GTNO=3 - must not rewrite to GTNO=1`() { + val detailGtdt = """[{"GTNO":"3","GATE":"G28"}]""" + val legacyGtdt = """[{"GTNO":"1","GATE":"G28"}]""" + + val report = tool.diffDualRead( + detailView("GTDT" to detailGtdt), + legacyView("GTDT" to legacyGtdt), + ) + assertFalse(report.isGreen) + assertTrue(report.unexpectedDeviations.any { it.path?.startsWith("GTDT") == true }) + } + + @Test + fun `Dual read RED for duplicate CHKC different cabin classes - legacy dedupe loses row`() { + val detailCkdt = """[ + {"CKNO":"1","CHKC":"01","CCLS":"F"}, + {"CKNO":"2","CHKC":"01","CCLS":"Y"} + ]""".replace("\n", "").replace(" ", "") + val legacyCkdt = """[{"CKNO":"1","CHKC":"01","CCLS":"F"}]""" + + val report = tool.diffDualRead( + detailView("CKDT" to detailCkdt), + legacyView("CKDT" to legacyCkdt), + ) + assertFalse(report.isGreen) + assertTrue(report.unexpectedDeviations.any { it.path == "CKDT" }) + } + + @Test + fun `Dual read RED for two bridge ops with ABDG - legacy milestone view drops bridge id`() { + val detailAbtm = """[ + {"ASNO":"1","ABDG":"B12","ABOP":"A","AOTM":"07SEP260730"}, + {"ASNO":"2","ABDG":"B15","ABOP":"D","AOTM":"07SEP261200"} + ]""".replace("\n", "").replace(" ", "") + val legacyAbtm = """[ + {"ASNO":"1","ABOP":"A","AOTM":"07SEP260730"}, + {"ASNO":"2","ABOP":"D","AOTM":"07SEP261200"} + ]""".replace("\n", "").replace(" ", "") + + val report = tool.diffDualRead( + detailView("ABTM" to detailAbtm), + legacyView("ABTM" to legacyAbtm), + ) + assertFalse(report.isGreen) + assertTrue(report.unexpectedDeviations.any { it.path?.contains("ABDG") == true || it.path?.startsWith("ABTM") == true }) + } + + @Test + fun `Dual read RED for two DELY reasons - legacy keeps only first`() { + val detailDely = """[ + {"DLNO":"1","CODE":"01","STRT":"07SEP260800","DURA":"30"}, + {"DLNO":"2","CODE":"02","STRT":"07SEP260900","DURA":"15"} + ]""".replace("\n", "").replace(" ", "") + val legacyDely = """[{"CODE":"01","STRT":"07SEP260800","DURA":"30"}]""" + + val report = tool.diffDualRead( + detailView("DELY" to detailDely), + legacyView("DELY" to legacyDely), + ) + assertFalse(report.isGreen) + assertTrue(report.unexpectedDeviations.any { it.path == "DELY" }) + } + + @Test + fun `FlightSchdReadAssembler exposes lossy legacy slot view for three-gate SIS sample`() { + val row = mapOf( + "GATE1" to "G28", + "GATE2" to "G33", + ) + val detailCollections = mapOf( + "GTDT" to """[ + {"GTNO":"1","GATE":"G28"}, + {"GTNO":"2","GATE":"G33"}, + {"GTNO":"3","GATE":"G23"} + ]""".replace("\n", "").replace(" ", ""), + ) + + val detailView = FlightSchdReadAssembler.assembleDetailOnly(flid, row, detailCollections) + val legacyView = FlightSchdReadAssembler.assembleLegacySlotsOnly(flid, row) + + val report = tool.diffDualRead(mapOf(flid to detailView), mapOf(flid to legacyView)) + assertFalse(report.isGreen) + assertTrue(legacyView["GTDT"]!!.contains("G23").not()) + assertTrue(detailView["GTDT"]!!.contains("G23")) + } + + @Test + fun `FlightSchdReadAssembler merged view follows detail table when present`() { + val row = mapOf("GATE1" to "OLD") + val detailCollections = mapOf( + "GTDT" to """[{"GTNO":"1","GATE":"G28"}]""", + ) + val merged = FlightSchdReadAssembler.assembleMerged(flid, row, detailCollections) + assertEquals(detailCollections["GTDT"], merged["GTDT"]) + } +}