package osd import ( "os" "path/filepath" "testing" "time" "git.piksar.eu/piotr/groundstation/internal/protocol" ) func TestTableSelectionRequiresAnIdentifier(t *testing.T) { if (Table{}).Selected() { t.Fatal("zero generation and hash mean no table is selected yet") } if !(Table{Gen: 3}).Selected() || !(Table{Hash: 9}).Selected() { t.Fatal("either identifier alone selects a table") } } func TestCeilingFromChannelScalesTheKnobLinearly(t *testing.T) { // The temporary operator maxPower ceiling maps the full CH6 channel // range onto the 0..127 level domain. It is operator intent only. cases := []struct { channel int16 want uint8 }{ {-1024, 0}, {-1023, 0}, {0, 63}, {512, 95}, {1023, 126}, {1024, 127}, } for _, c := range cases { if got := CeilingFromChannel(c.channel); got != c.want { t.Fatalf("CeilingFromChannel(%d)=%d want %d", c.channel, got, c.want) } } if CeilingFromChannel(-2000) != 0 || CeilingFromChannel(2000) != 127 { t.Fatal("out-of-range channels must clamp to the level domain") } } func TestSnapshotCarriesFourDistinctContractValues(t *testing.T) { // The four contract values must stay distinct: every field survives a // roundtrip at its own offset, so no value can silently alias another // (for example a mode/power collapse of CH6 ceiling and applied VTX // state). snapshot := Snapshot{ Sequence: 7, TimestampMS: 99, Armed: true, Video: true, OpConfig: OperatorConfig{ Table: Table{Gen: 3, Hash: 0xdeadbeefcafe}, MaxPower: 95, }, Policy: GSPolicy{ Pending: false, Level: 42, Reason: [8]byte{'a', 'd', 'a', 'p', 't'}, TimestampMS: 1234, }, Ack: VTXAck{ Pending: false, Table: Table{Gen: 2, Hash: 0xfeed}, Level: 40, Lifecycle: VTXLifecycleApplied, }, Link: GSObservedLink{ Known: true, AgeMS: 250, }, PublishedMS: 555, } snapshot.Channels[5] = 100 got, err := Decode(Encode(snapshot)) if err != nil { t.Fatal(err) } if got != snapshot { t.Fatalf("roundtrip lost contract state: %+v != %+v", got, snapshot) } } func TestSnapshotCarriesExplicitPendingStates(t *testing.T) { // Until the policy, acknowledgment and link producers exist the // snapshot must carry their explicit pending/unknown states, never // fabricated levels or metrics. snapshot := Snapshot{ Armed: true, OpConfig: OperatorConfig{ MaxPower: 63, }, Policy: GSPolicy{Pending: true}, Ack: VTXAck{Pending: true, Lifecycle: VTXLifecyclePending}, Link: GSObservedLink{Known: false}, } got, err := Decode(Encode(snapshot)) if err != nil { t.Fatal(err) } if !got.Policy.Pending || got.Policy.Level != 0 { t.Fatalf("policy=%+v must stay explicitly pending", got.Policy) } if !got.Ack.Pending || got.Ack.Level != 0 || got.Ack.Lifecycle != VTXLifecyclePending { t.Fatalf("ack=%+v must stay explicitly pending", got.Ack) } if got.Link.Known || got.Link.AgeMS != 0 { t.Fatalf("link=%+v must stay explicitly unknown", got.Link) } if got.OpConfig != snapshot.OpConfig { t.Fatalf("operator config=%+v must survive", got.OpConfig) } } func TestDecodeRejectsV1Snapshots(t *testing.T) { // A v1-sized record cannot carry the contract; a v2-sized record that // claims v1 must be rejected by version, not parsed loosely. v1Shaped := make([]byte, Size) v1Shaped[0], v1Shaped[1], v1Shaped[2] = Magic0, Magic1, Version-1 if _, err := Decode(v1Shaped); err == nil { t.Fatal("a v1-marked record must be rejected") } } func TestVTXLifecycleStatesAreDistinct(t *testing.T) { if VTXLifecyclePending == VTXLifecycleApplied || VTXLifecycleApplied == VTXLifecycleError || VTXLifecyclePending == VTXLifecycleError { t.Fatal("pending, applied and error must be distinct lifecycle states") } } func TestSnapshotDomainsAreEncodedInDisjointByteRanges(t *testing.T) { // The four contract domains — operator configuration (table identity // and ceiling), the requested policy level, the applied acknowledgment // level and the observed link — must occupy disjoint byte ranges: a // change to one domain may never alias another domain's bytes, so // future extensions land behind a version bump instead of colliding. base := Snapshot{ Sequence: 1, TimestampMS: 2, Channels: [protocol.Channels]int16{}, Armed: false, Video: false, OpConfig: OperatorConfig{ Table: Table{Gen: 1, Hash: 1}, MaxPower: 1, }, Policy: GSPolicy{Level: 1}, Ack: VTXAck{ Table: Table{Gen: 1}, Level: 1, Lifecycle: VTXLifecycleApplied, }, Link: GSObservedLink{Known: false}, } deltas := map[string]struct { rangeStart, rangeEnd int mutate func(*Snapshot) }{ "operator-config": {opMaxPowerOffset, policyLevelOffset, func(s *Snapshot) { s.OpConfig.MaxPower = 2 }}, "policy-level": {policyLevelOffset, ackTableGenOffset, func(s *Snapshot) { s.Policy.Level = 2 }}, "ack-level": {ackTableGenOffset, linkAgeOffset, func(s *Snapshot) { s.Ack.Level = 2 }}, "observed-link": {linkAgeOffset, crcOffset, func(s *Snapshot) { s.Link.AgeMS = 2 }}, } reference := Encode(base) for name, delta := range deltas { modified := base delta.mutate(&modified) encoded := Encode(modified) if len(encoded) != len(reference) { t.Fatalf("%s: encoded length changed", name) } for i := range encoded { if encoded[i] == reference[i] { continue } // The CRC covers the whole record and necessarily changes with // any domain change; every payload byte must stay inside the // domain's own range. if i >= crcOffset { continue } if i < delta.rangeStart || i >= delta.rangeEnd { t.Fatalf("%s: byte %d changed outside the domain range [%d,%d)", name, i, delta.rangeStart, delta.rangeEnd) } } if _, err := Decode(encoded); err != nil { t.Fatalf("%s: modified snapshot must still decode: %v", name, err) } } } func TestDecodeRejectsCorruptionAtomically(t *testing.T) { // Malformed input must never yield a partially decoded snapshot that // could clear or corrupt one domain while keeping others: any flipped // byte anywhere in the record rejects the whole snapshot, and the // publisher keeps its last valid state until a new valid one arrives. encoded := Encode(Snapshot{ OpConfig: OperatorConfig{Table: Table{Gen: 5, Hash: 5}, MaxPower: 5}, Policy: GSPolicy{Level: 5}, Ack: VTXAck{Level: 5, Lifecycle: VTXLifecycleApplied}, Link: GSObservedLink{Known: true, AgeMS: 5}, }) // Sample corruption positions across the whole record — magic, body // regions and CRC — derived from the actual encoded length, so the // atomicity contract holds across future version bumps and layout // extensions without coupling to today's offsets. positions := []int{ 0, len(encoded) / 4, len(encoded) / 2, 3 * len(encoded) / 4, len(encoded) - 1, } for _, offset := range positions { corrupt := append([]byte(nil), encoded...) corrupt[offset] ^= 0xff got, err := Decode(corrupt) if err == nil { t.Fatalf("flipped byte at %d must reject the record", offset) } if got != (Snapshot{}) { t.Fatalf("flipped byte at %d must not emit a partial snapshot", offset) } } } func TestFileCorruptionDoesNotClearLastValidSnapshot(t *testing.T) { // A corrupted state file reports no snapshot for the moment, but the // publisher retains its last valid snapshot and the next heartbeat // rewrite restores exactly those values: corruption never clears // last valid data, and nothing is fabricated in between. path := filepath.Join(t.TempDir(), "osd-state.bin") now := time.UnixMilli(10_000_000) source := NewFileSource(path) source.now = func() time.Time { return now } want := testSnapshot() want.PublishedMS = now.UnixMilli() if err := os.WriteFile(path, Encode(want), 0644); err != nil { t.Fatal(err) } if _, state := source.Latest(); state != SnapshotFresh { t.Fatalf("state=%v want fresh before corruption", state) } corrupt := append([]byte(nil), Encode(want)...) corrupt[len(corrupt)/2] ^= 0xff if err := os.WriteFile(path, corrupt, 0644); err != nil { t.Fatal(err) } if _, state := source.Latest(); state != SnapshotNone { t.Fatalf("state=%v want none while the file is corrupt", state) } // The publisher's heartbeat rewrites its retained last valid snapshot. if err := os.WriteFile(path, Encode(want), 0644); err != nil { t.Fatal(err) } got, state := source.Latest() if state != SnapshotFresh { t.Fatalf("state=%v want fresh after the heartbeat restore", state) } if got != want { t.Fatalf("restored snapshot=%+v want the untouched last valid %+v", got, want) } }