276 lines
8.5 KiB
Go
276 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|