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,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"])
}
}