refactor(flight-state): 按 flight-state.md 审计定稿全量重构脚手架与 SQL (ACM2-31)
- SQL 基线 V1__flight_state_baseline.sql 整体取代 V1.0.0–V1.4.0: PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD + 8 张资源明细表 + FLIGHT_ROUTE_POINT + SCHD_SNAP_LOG 留痕层 - 废除 FDAY 日代/SCHD_GEN/名单差删:OPERATION_DAY 不可变(应用层校验 + 条件更新强化 §7.4),STATE 仅 ACTIVE/DELETED,物理清除只在历史归档后 - 处理器化:applyScheduleRecords(§5.1 七步同一事务,重放判定/整包 DEAD(PROTOCOL)/归属冲突不落地)+ FLOP/FDEL/ADFT(tombstone 仅 ACTIVE→DELETED,重复 FDEL 幂等不推进版本) - 投递:KAFKA_SCHD 同 FLID 按最新 STATE_VERSION 合并,被压掉事件关闭, TOMBSTONE 发 null 值消息(键缺失=删除旧值 §7.3) - 回填待办改为业务事务内预登记,消除提交后写待办的崩溃窗口(§7.2/§10) - XML 解码改为 jackson-dataformat-xml 数据类直接映射(SIS 信封强类型, FLTR 开放标签泛型承载) - 历史归档/物理清除顺序不可颠倒:归档确认成功集才物理删除,未接通删 0 条 - 移除 PUMP_JOB 队列/ReferenceService/FlightStoreDiffTool 等旧机制与测试, 新增运营日/引擎/快照/FDEL/归档顺序不变性回归测试
This commit is contained in:
@@ -1,202 +1,113 @@
|
||||
package com.gzzn.omms.msgexchange.domain.flight
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
|
||||
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
|
||||
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
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* 航班状态引擎(docs/flight-state.md §3.3/§5.2/§5.4/§6.1)不变量:
|
||||
* 快照整体替换(缺失=清除)、DELETED 不被 SCHD 恢复、FLOP 增量保留缺失字段、
|
||||
* §5.2 五项校验整包拒绝语义。
|
||||
*/
|
||||
class FlightStateEngineTest {
|
||||
|
||||
private val opDay = OperationDayCalculator(ZoneId.of("Asia/Shanghai"), 0)
|
||||
private val day = LocalDate.of(2026, 12, 15)
|
||||
|
||||
@Test
|
||||
fun `scalar unchanged does not modify existing value`() {
|
||||
val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 1L, "m1")
|
||||
val next = FlightStateEngine.apply(
|
||||
fun `snapshot replaces scalars and clears absent ones`() {
|
||||
val current = FlightSnapshot(
|
||||
"121", day, FlightState.ACTIVE, 5,
|
||||
scalars = mapOf("FLNO" to "CA001", "REMC" to "old-note"),
|
||||
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
|
||||
)
|
||||
val next = FlightStateEngine.snapshotState(
|
||||
current,
|
||||
FlightFieldCommands("F1", scalars = emptyMap()),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
ScheduleRecord("121", scalars = mapOf("FLNO" to "CA002")),
|
||||
operationDay = day,
|
||||
keepDeleted = false,
|
||||
)
|
||||
assertEquals("CA100", next.scalars["FLNO"])
|
||||
assertEquals(2L, next.stateVersion)
|
||||
|
||||
assertEquals("CA002", next.scalars["FLNO"])
|
||||
assertFalse(next.scalars.containsKey("REMC")) // 缺失 = Clear(§5.4)
|
||||
assertEquals(emptyList<Map<String, String>>(), next.collections["GTDT"]) // 缺失 = Replace 空集
|
||||
assertEquals(6, next.stateVersion) // 每次成功写入 +1(§4)
|
||||
assertEquals(FlightState.ACTIVE, next.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection replace preserves order and source sequence`() {
|
||||
val items = listOf(
|
||||
mapOf("GTNO" to "1", "GATE" to "A1"),
|
||||
mapOf("GTNO" to "3", "GATE" to "B2"),
|
||||
mapOf("GTNO" to "7", "GATE" to "C3"),
|
||||
fun `snapshot keeps DELETED state and flags revive conflict by caller`() {
|
||||
val current = FlightSnapshot("121", day, FlightState.DELETED, 3, mapOf(), emptyMap())
|
||||
val next = FlightStateEngine.snapshotState(
|
||||
current, ScheduleRecord("121", mapOf("FLNO" to "CA001")), day, keepDeleted = true,
|
||||
)
|
||||
val next = FlightStateEngine.apply(
|
||||
null,
|
||||
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Replace(items))),
|
||||
"m1",
|
||||
bumpVersion = true,
|
||||
)
|
||||
assertEquals(3, next.collections["GTDT"]!!.size)
|
||||
assertEquals("3", next.collections["GTDT"]!![1]["GTNO"])
|
||||
assertEquals("C3", next.collections["GTDT"]!![2]["GATE"])
|
||||
assertEquals(FlightState.DELETED, next.state) // §5.1 步骤 6:普通 SCHD 不恢复
|
||||
assertEquals(4, next.stateVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection clear removes prior entries`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
emptyMap(),
|
||||
mapOf("GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "A1"))),
|
||||
1L,
|
||||
"m1",
|
||||
fun `flop merge retains absent scalars and collections`() {
|
||||
val current = FlightSnapshot(
|
||||
"121", day, FlightState.ACTIVE, 1,
|
||||
scalars = mapOf("FLNO" to "CA001", "ESTT" to "15DEC261807"),
|
||||
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
|
||||
)
|
||||
val next = FlightStateEngine.apply(
|
||||
val next = FlightStateEngine.mergedState(
|
||||
current,
|
||||
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Clear)),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
MergeChange("121", scalars = mapOf("ESTT" to "15DEC261900")),
|
||||
)
|
||||
assertFalse(next.collections.containsKey("GTDT"))
|
||||
|
||||
assertEquals("15DEC261900", next.scalars["ESTT"])
|
||||
assertEquals("CA001", next.scalars["FLNO"]) // 缺失 = 保留(§6.1)
|
||||
assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"])
|
||||
assertEquals(2, next.stateVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same gate number different attributes both retained on replace`() {
|
||||
val items = listOf(
|
||||
mapOf("GTNO" to "1", "GATE" to "A1", "GTYP" to "D"),
|
||||
mapOf("GTNO" to "1", "GATE" to "A1", "GTYP" to "I"),
|
||||
fun `validation rejects RECS mismatch and duplicate flid and day outside scope`() {
|
||||
val ok = FlightStateEngine.validateMessage(
|
||||
1, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
|
||||
)
|
||||
val next = FlightStateEngine.apply(
|
||||
null,
|
||||
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Replace(items))),
|
||||
"m1",
|
||||
bumpVersion = true,
|
||||
)
|
||||
assertEquals("D", next.collections["GTDT"]!![0]["GTYP"])
|
||||
assertEquals("I", next.collections["GTDT"]!![1]["GTYP"])
|
||||
}
|
||||
assertTrue(ok is SnapshotValidation.Ok)
|
||||
|
||||
@Test
|
||||
fun `incremental apply bumps state version from persisted value`() {
|
||||
val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 5L, "m1")
|
||||
val next = FlightStateEngine.apply(
|
||||
current,
|
||||
FlightFieldCommands("F1", scalars = mapOf("STAT" to ScalarCommand.Set("DEP"))),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
)
|
||||
assertEquals(6L, next.stateVersion)
|
||||
}
|
||||
val recsMismatch = FlightStateEngine.validateMessage(
|
||||
2, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
|
||||
) as SnapshotValidation.Invalid
|
||||
assertTrue(recsMismatch.flags.contains(SnapshotFlag.RECS_DROP))
|
||||
|
||||
@Test
|
||||
fun `apply by source sequence updates single row`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
emptyMap(),
|
||||
mapOf("GTDT" to listOf(mapOf("GTNO" to "2", "GATE" to "OLD"))),
|
||||
1L,
|
||||
"m1",
|
||||
)
|
||||
val next = FlightStateEngine.apply(
|
||||
current,
|
||||
FlightFieldCommands(
|
||||
"F1",
|
||||
collections = mapOf(
|
||||
"GTDT" to CollectionCommand.Apply(mapOf("GTNO" to "2", "GATE" to "NEW"), "2"),
|
||||
),
|
||||
val duplicate = FlightStateEngine.validateMessage(
|
||||
2,
|
||||
listOf(
|
||||
ScheduleRecord("121", mapOf("SODT" to "15DEC261723")),
|
||||
ScheduleRecord("121", mapOf("SODT" to "15DEC261723")),
|
||||
),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
day, day, opDay,
|
||||
)
|
||||
assertEquals("NEW", next.collections["GTDT"]!![0]["GATE"])
|
||||
assertTrue(duplicate is SnapshotValidation.Invalid)
|
||||
|
||||
val outOfScope = FlightStateEngine.validateMessage(
|
||||
1, listOf(ScheduleRecord("121", mapOf("SODT" to "20DEC261723"))), day, day, opDay,
|
||||
) as SnapshotValidation.Invalid
|
||||
assertTrue(outOfScope.flags.contains(SnapshotFlag.DAY_MISMATCH)) // §5.2 覆盖范围校验
|
||||
|
||||
val empty = FlightStateEngine.validateMessage(0, emptyList(), day, day, opDay) as SnapshotValidation.Ok
|
||||
assertTrue(empty.flags.contains(SnapshotFlag.EMPTY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero sequence marker alone translates to explicit clear`() {
|
||||
val commands = FlightStateEngine.commandsFromFields(
|
||||
"F1",
|
||||
mapOf("GTDT" to """[{"GTNO":"0"}]"""),
|
||||
fun `validation rejects non numeric or oversized flid`() {
|
||||
val bad = FlightStateEngine.validateMessage(
|
||||
1, listOf(ScheduleRecord("ABC", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
|
||||
)
|
||||
assertEquals(CollectionCommand.Clear, commands.collections["GTDT"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero sequence marker mixed with regular items fails fast instead of guessing`() {
|
||||
val error = runCatching {
|
||||
FlightStateEngine.commandsFromFields(
|
||||
"F1",
|
||||
mapOf("GTDT" to """[{"GTNO":"1","GATE":"A1"},{"GTNO":"0"}]"""),
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
assertTrue(error is IllegalArgumentException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty array replaces collection with empty set keeping key semantics`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
emptyMap(),
|
||||
mapOf("GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "A1"))),
|
||||
1L,
|
||||
"m1",
|
||||
assertTrue(bad is SnapshotValidation.Invalid)
|
||||
val tooLong = FlightStateEngine.validateMessage(
|
||||
1, listOf(ScheduleRecord("1234567890123", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
|
||||
)
|
||||
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("GTDT" to "[]"))
|
||||
val next = FlightStateEngine.apply(current, commands, "m2", bumpVersion = true)
|
||||
assertEquals(emptyList<Map<String, String>>(), next.collections["GTDT"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply on DELY fails fast because protocol defines no sequence attribute`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
emptyMap(),
|
||||
mapOf("DELY" to listOf(mapOf("CODE" to "YY"))),
|
||||
1L,
|
||||
"m1",
|
||||
)
|
||||
val error = runCatching {
|
||||
FlightStateEngine.apply(
|
||||
current,
|
||||
FlightFieldCommands(
|
||||
"F1",
|
||||
collections = mapOf("DELY" to CollectionCommand.Apply(mapOf("CODE" to "ZZ"), "1")),
|
||||
),
|
||||
"m2",
|
||||
bumpVersion = true,
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
assertTrue(error is IllegalArgumentException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exception null payload translates to explicit clear and apply records cleared key`() {
|
||||
val current = FlightNextState(
|
||||
"F1",
|
||||
mapOf("FLNO" to "CA100", "FRET" to """{"REID":"R1","RSN":"diverted"}"""),
|
||||
emptyMap(),
|
||||
3L,
|
||||
"m1",
|
||||
)
|
||||
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("FRET" to "null"))
|
||||
assertEquals(ScalarCommand.Clear, commands.scalars["FRET"])
|
||||
|
||||
val next = FlightStateEngine.apply(current, commands, "m2", bumpVersion = true)
|
||||
assertFalse(next.scalars.containsKey("FRET"))
|
||||
assertTrue(next.clearedKeys.contains("FRET"))
|
||||
assertEquals("CA100", next.scalars["FLNO"])
|
||||
|
||||
// 非空载荷仍为 Set,且清空后的下一跳 Set 恢复键(clearedKeys 不跨消息携带)
|
||||
val restored = FlightStateEngine.apply(
|
||||
next,
|
||||
FlightStateEngine.commandsFromFields("F1", mapOf("FRET" to """{"REID":"R2"}""")),
|
||||
"m3",
|
||||
bumpVersion = true,
|
||||
)
|
||||
assertEquals("""{"REID":"R2"}""", restored.scalars["FRET"])
|
||||
assertFalse(restored.clearedKeys.contains("FRET"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exception clear accepts null literal empty object and empty string`() {
|
||||
for (payload in listOf("null", "{}", "")) {
|
||||
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("FDIV" to payload))
|
||||
assertEquals(ScalarCommand.Clear, commands.scalars["FDIV"], "payload=[$payload] must be Clear")
|
||||
}
|
||||
assertTrue(tooLong is SnapshotValidation.Invalid) // SIS §3.16.2 Number(1-12)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user