feat(tools): FS7 v2 dual-read diff — detail tables vs legacy slots (P2-1)

Extract FlightSchdReadAssembler for detail-only, legacy-slot, and merged
read paths. Extend FlightStoreDiffTool with diffDualRead strict field
comparison. Add counterexample tests (G28/G33/G23, GTNO=3, CKDT dedupe,
ABTM ABDG, dual DELY) per review-flight-state-2026-09-08.
This commit is contained in:
windyboy
2026-09-08 11:30:09 +08:00
parent c432492a81
commit 2af71e8fd8
4 changed files with 440 additions and 92 deletions
@@ -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<String> = 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<String> = 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<Map<String, String>>,
)
private fun slotCollection(
key: String,
seqAttr: String,
dedupeAttr: String,
attrs: List<String>,
count: Int,
renames: Map<String, String> = 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<SlotCollection> = 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<String> = listOf(
"DELY_CODE", "DELY_STRT", "DELY_DURA", "DELY_REMC",
"ABTM_A", "ABTM_D", "CHOT_ON", "CHOT_OFF",
)
val PATH_COLUMNS: List<String> = listOf("ROUT_PATH", "ERUT_PATH")
val TEXT_COLUMNS: List<String> = listOf("SRVT_TEXT", "VIPF_TEXT", "MAFL_TEXT")
val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS +
OCCURRENCE_COLLECTIONS.flatMap { c -> c.slots.flatMap(Map<String, String>::values) } +
MILESTONE_COLUMNS + PATH_COLUMNS + TEXT_COLUMNS
private val mapper = ObjectMapper()
/** 生产读路径:明细表优先,缺失时由槽位/里程碑列回退重建。 */
fun assembleMerged(
flid: String,
row: Map<String, String?>,
detailCollections: Map<String, String>,
): 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<String, String?>,
detailCollections: Map<String, String>,
): FlightFields {
val fields = assembleScalarsAndExceptions(flid, row)
detailCollections.forEach { (key, value) -> fields[key] = value }
return fields
}
/** legacy 回退读:标量 + 槽位/里程碑/航路列重建,不叠加明细表。 */
fun assembleLegacySlotsOnly(flid: String, row: Map<String, String?>): 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<String, String?>): LinkedHashMap<String, String> {
val fields = linkedMapOf<String, String>()
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<Pair<String, String>>, row: Map<String, String?>): 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, String?>): 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, String?>): String? {
val items = mutableListOf<ObjectNode>()
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, String?>): String? {
val items = mutableListOf<ObjectNode>()
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, String?>): 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)
}
}
@@ -398,9 +398,7 @@ class JdbcFlightSchdRepository(
private val SCALAR_WRITE_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS + TEXT_COLUMNS
/** 库列名全集(读侧 SELECT 与视图重建的稳定顺序,与 V1.2.0 迁移一致)。 */
private val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS +
OCCURRENCE_COLLECTIONS.flatMap { c -> c.slots.flatMap(Map<String, String>::values) } +
MILESTONE_COLUMNS + PATH_COLUMNS + TEXT_COLUMNS
private val ALL_COLUMNS: List<String> = FlightSchdReadAssembler.ALL_COLUMNS
private val SCALAR_KEY_SET: Set<String> = 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<String, FlightFields> {
val flid = rs.getString("flid")
val fields = linkedMapOf<String, String>()
fields["FLID"] = flid // 视图与 legacy flightInfo hash 同构:FLID 字段常在
val row = linkedMapOf<String, String?>()
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<Pair<String, String>>, row: Map<String, String?>): 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, String?>): 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, String?>): String? {
val items = mutableListOf<ObjectNode>()
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, String?>): String? {
val items = mutableListOf<ObjectNode>()
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, String?>): 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? =
@@ -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<String, FlightFields>,
legacySlotViews: Map<String, FlightFields>,
): DiffReport = diffFlightFieldMaps(detailViews, legacySlotViews, missingInPrimary = DeviationKind.MISSING_IN_DETAIL, missingInReference = DeviationKind.MISSING_IN_LEGACY)
/** 两侧均为 FlightFields 的严格逐字段比对(供双读对拍与单测复用)。 */
fun diffFlightFieldMaps(
primaryFlights: Map<String, FlightFields>,
referenceFlights: Map<String, FlightFields>,
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<Deviation>()
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<Deviation> {
val mismatches = mutableListOf<Deviation>()
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<Deviation>) {
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 -> 宽表列值映射)
@@ -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.3G28/G33/G23)。
*/
class FlightStoreDualReadTest {
private val tool = FlightStoreDiffTool()
private val flid = "TEST_DUAL_READ"
private fun detailView(vararg collections: Pair<String, String>) = mapOf(
flid to linkedMapOf(
"FLID" to flid,
*collections,
),
)
private fun legacyView(vararg collections: Pair<String, String>) = 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"])
}
}