531 lines
19 KiB
Go
531 lines
19 KiB
Go
package decide
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
)
|
|
|
|
var t0 = time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
|
|
|
|
func TestResolveProbeDecision(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in ProbeInput
|
|
wantStatus domain.SessionStatus
|
|
wantState domain.SessionState
|
|
wantReason domain.SessionReason
|
|
wantDetect bool // expect non-nil Detecting memory
|
|
wantTermNil bool // expect terminal (Detecting must be nil)
|
|
}{
|
|
{
|
|
name: "kill requested short-circuits to terminal killed",
|
|
in: ProbeInput{KillRequested: true, Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0},
|
|
wantStatus: domain.StatusKilled,
|
|
wantState: domain.SessionTerminated,
|
|
wantReason: domain.ReasonManuallyKilled,
|
|
wantTermNil: true,
|
|
},
|
|
{
|
|
name: "kill requested wins even over a dead+dead probe",
|
|
in: ProbeInput{KillRequested: true, Runtime: domain.RuntimeMissing, Process: ProcessDead, Now: t0},
|
|
wantStatus: domain.StatusKilled,
|
|
wantState: domain.SessionTerminated,
|
|
wantReason: domain.ReasonManuallyKilled,
|
|
wantTermNil: true,
|
|
},
|
|
{
|
|
name: "runtime probe failed routes to detecting, never death",
|
|
in: ProbeInput{Runtime: domain.RuntimeMissing, RuntimeFailed: true, Process: ProcessDead, Now: t0},
|
|
wantStatus: domain.StatusDetecting,
|
|
wantState: domain.SessionDetecting,
|
|
wantReason: domain.ReasonProbeFailure,
|
|
wantDetect: true,
|
|
},
|
|
{
|
|
name: "process probe failed routes to detecting",
|
|
in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessDead, ProcessFailed: true, Now: t0},
|
|
wantStatus: domain.StatusDetecting,
|
|
wantState: domain.SessionDetecting,
|
|
wantReason: domain.ReasonProbeFailure,
|
|
wantDetect: true,
|
|
},
|
|
{
|
|
name: "runtime state probe_failed routes to detecting",
|
|
in: ProbeInput{Runtime: domain.RuntimeProbeFailed, Process: ProcessIndeterminate, Now: t0},
|
|
wantStatus: domain.StatusDetecting,
|
|
wantState: domain.SessionDetecting,
|
|
wantReason: domain.ReasonProbeFailure,
|
|
wantDetect: true,
|
|
},
|
|
{
|
|
name: "runtime alive + process alive is working",
|
|
in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0},
|
|
wantStatus: domain.StatusWorking,
|
|
wantState: domain.SessionWorking,
|
|
wantReason: domain.ReasonTaskInProgress,
|
|
},
|
|
{
|
|
name: "runtime alive + process indeterminate leans alive",
|
|
in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessIndeterminate, Now: t0},
|
|
wantStatus: domain.StatusWorking,
|
|
wantState: domain.SessionWorking,
|
|
wantReason: domain.ReasonTaskInProgress,
|
|
},
|
|
{
|
|
name: "runtime alive + process dead disagree -> detecting (agent_process_exited)",
|
|
in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessDead, Now: t0},
|
|
wantStatus: domain.StatusDetecting,
|
|
wantState: domain.SessionDetecting,
|
|
wantReason: domain.ReasonAgentProcessExited,
|
|
wantDetect: true,
|
|
},
|
|
{
|
|
name: "runtime dead + process alive disagree -> detecting (runtime_lost)",
|
|
in: ProbeInput{Runtime: domain.RuntimeExited, Process: ProcessAlive, Now: t0},
|
|
wantStatus: domain.StatusDetecting,
|
|
wantState: domain.SessionDetecting,
|
|
wantReason: domain.ReasonRuntimeLost,
|
|
wantDetect: true,
|
|
},
|
|
{
|
|
name: "runtime dead + recent activity disagree -> detecting (runtime_lost)",
|
|
in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessDead, RecentActivity: true, Now: t0},
|
|
wantStatus: domain.StatusDetecting,
|
|
wantState: domain.SessionDetecting,
|
|
wantReason: domain.ReasonRuntimeLost,
|
|
wantDetect: true,
|
|
},
|
|
{
|
|
name: "runtime dead + process indeterminate cannot confirm -> detecting",
|
|
in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0},
|
|
wantStatus: domain.StatusDetecting,
|
|
wantState: domain.SessionDetecting,
|
|
wantReason: domain.ReasonRuntimeLost,
|
|
wantDetect: true,
|
|
},
|
|
{
|
|
name: "runtime exited + process dead + no activity -> killed terminal",
|
|
in: ProbeInput{Runtime: domain.RuntimeExited, Process: ProcessDead, Now: t0},
|
|
wantStatus: domain.StatusKilled,
|
|
wantState: domain.SessionTerminated,
|
|
wantReason: domain.ReasonRuntimeLost,
|
|
wantTermNil: true,
|
|
},
|
|
{
|
|
name: "runtime missing + process dead + no activity -> killed terminal",
|
|
in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessDead, Now: t0},
|
|
wantStatus: domain.StatusKilled,
|
|
wantState: domain.SessionTerminated,
|
|
wantReason: domain.ReasonRuntimeLost,
|
|
wantTermNil: true,
|
|
},
|
|
{
|
|
name: "runtime unknown is ambiguous -> detecting (runtime_lost)",
|
|
in: ProbeInput{Runtime: domain.RuntimeUnknown, Process: ProcessDead, Now: t0},
|
|
wantStatus: domain.StatusDetecting,
|
|
wantState: domain.SessionDetecting,
|
|
wantReason: domain.ReasonRuntimeLost,
|
|
wantDetect: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ResolveProbeDecision(tt.in)
|
|
if got.Status != tt.wantStatus {
|
|
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
|
|
}
|
|
if got.SessionState != tt.wantState {
|
|
t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState)
|
|
}
|
|
if got.SessionReason != tt.wantReason {
|
|
t.Errorf("SessionReason = %q, want %q", got.SessionReason, tt.wantReason)
|
|
}
|
|
if tt.wantDetect && got.Detecting == nil {
|
|
t.Errorf("expected non-nil Detecting memory, got nil")
|
|
}
|
|
if tt.wantTermNil && got.Detecting != nil {
|
|
t.Errorf("terminal decision must carry nil Detecting, got %+v", got.Detecting)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenPRDecision(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in OpenPRInput
|
|
wantStatus domain.SessionStatus
|
|
wantPR domain.PRReason
|
|
wantState domain.SessionState
|
|
}{
|
|
{
|
|
name: "ci failing dominates everything",
|
|
in: OpenPRInput{CIFailing: true, ChangesRequested: true, Approved: true, Mergeable: true},
|
|
wantStatus: domain.StatusCIFailed,
|
|
wantPR: domain.PRReasonCIFailing,
|
|
wantState: domain.SessionWorking,
|
|
},
|
|
{
|
|
name: "changes requested before approval states",
|
|
in: OpenPRInput{ChangesRequested: true, Approved: true, Mergeable: true},
|
|
wantStatus: domain.StatusChangesRequested,
|
|
wantPR: domain.PRReasonChangesRequested,
|
|
wantState: domain.SessionWorking,
|
|
},
|
|
{
|
|
name: "approved + mergeable -> mergeable",
|
|
in: OpenPRInput{Approved: true, Mergeable: true},
|
|
wantStatus: domain.StatusMergeable,
|
|
wantPR: domain.PRReasonMergeReady,
|
|
wantState: domain.SessionIdle,
|
|
},
|
|
{
|
|
name: "mergeable without formal approval (no required review) -> mergeable",
|
|
in: OpenPRInput{Mergeable: true},
|
|
wantStatus: domain.StatusMergeable,
|
|
wantPR: domain.PRReasonMergeReady,
|
|
wantState: domain.SessionIdle,
|
|
},
|
|
{
|
|
name: "approved but not mergeable -> approved",
|
|
in: OpenPRInput{Approved: true},
|
|
wantStatus: domain.StatusApproved,
|
|
wantPR: domain.PRReasonApproved,
|
|
wantState: domain.SessionIdle,
|
|
},
|
|
{
|
|
name: "review pending",
|
|
in: OpenPRInput{ReviewPending: true},
|
|
wantStatus: domain.StatusReviewPending,
|
|
wantPR: domain.PRReasonReviewPending,
|
|
wantState: domain.SessionIdle,
|
|
},
|
|
{
|
|
name: "idle beyond threshold -> stuck",
|
|
in: OpenPRInput{IdleBeyond: true},
|
|
wantStatus: domain.StatusStuck,
|
|
wantPR: domain.PRReasonInProgress,
|
|
wantState: domain.SessionStuck,
|
|
},
|
|
{
|
|
name: "review pending wins over idle-beyond",
|
|
in: OpenPRInput{ReviewPending: true, IdleBeyond: true},
|
|
wantStatus: domain.StatusReviewPending,
|
|
wantPR: domain.PRReasonReviewPending,
|
|
wantState: domain.SessionIdle,
|
|
},
|
|
{
|
|
name: "nothing set -> plain open",
|
|
in: OpenPRInput{},
|
|
wantStatus: domain.StatusPROpen,
|
|
wantPR: domain.PRReasonInProgress,
|
|
wantState: domain.SessionWorking,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ResolveOpenPRDecision(tt.in)
|
|
if got.Status != tt.wantStatus {
|
|
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
|
|
}
|
|
if got.PRReason != tt.wantPR {
|
|
t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR)
|
|
}
|
|
if got.PRState != domain.PROpen {
|
|
t.Errorf("PRState = %q, want %q", got.PRState, domain.PROpen)
|
|
}
|
|
if got.SessionState != tt.wantState {
|
|
t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenPRDecisionEvidence(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in OpenPRInput
|
|
want string
|
|
}{
|
|
{
|
|
name: "condition with PR number and URL",
|
|
in: OpenPRInput{CIFailing: true, Number: 123, URL: "https://example.com/pr/123"},
|
|
want: "ci_failing #123 https://example.com/pr/123",
|
|
},
|
|
{
|
|
name: "condition with number only",
|
|
in: OpenPRInput{Approved: true, Mergeable: true, Number: 7},
|
|
want: "merge_ready #7",
|
|
},
|
|
{
|
|
name: "no identity falls back to the bare condition",
|
|
in: OpenPRInput{},
|
|
want: "pr_open",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := ResolveOpenPRDecision(tt.in).Evidence; got != tt.want {
|
|
t.Errorf("Evidence = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecidersDeriveConsistently(t *testing.T) {
|
|
// Every decision a decider produces must be self-consistent: the display
|
|
// Status it reports must equal what DeriveLegacyStatus produces from the
|
|
// canonical (session, pr) sub-states it emits. This locks the deciders and
|
|
// the display-derivation against drifting apart.
|
|
//
|
|
// The ResolveTerminalPRStateDecision none/open default is intentionally
|
|
// excluded — it is a documented no-op for misuse, not a real verdict.
|
|
var decisions []LifecycleDecision
|
|
|
|
for _, in := range []OpenPRInput{
|
|
{CIFailing: true},
|
|
{ChangesRequested: true},
|
|
{Approved: true, Mergeable: true},
|
|
{Mergeable: true},
|
|
{Approved: true},
|
|
{ReviewPending: true},
|
|
{IdleBeyond: true},
|
|
{},
|
|
} {
|
|
decisions = append(decisions, ResolveOpenPRDecision(in))
|
|
}
|
|
|
|
decisions = append(decisions,
|
|
ResolveTerminalPRStateDecision(domain.PRMerged),
|
|
ResolveTerminalPRStateDecision(domain.PRClosed),
|
|
)
|
|
|
|
for _, in := range []ProbeInput{
|
|
{KillRequested: true, Now: t0},
|
|
{Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0},
|
|
{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0},
|
|
{Runtime: domain.RuntimeExited, Process: ProcessDead, Now: t0},
|
|
} {
|
|
decisions = append(decisions, ResolveProbeDecision(in))
|
|
}
|
|
|
|
for _, d := range decisions {
|
|
l := domain.CanonicalSessionLifecycle{
|
|
Session: domain.SessionSubstate{State: d.SessionState, Reason: d.SessionReason},
|
|
PR: domain.PRSubstate{State: d.PRState, Reason: d.PRReason},
|
|
}
|
|
if got := domain.DeriveLegacyStatus(l); got != d.Status {
|
|
t.Errorf("decision %+v: Status=%q but DeriveLegacyStatus=%q", d, d.Status, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveTerminalPRStateDecision(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pr domain.PRState
|
|
wantStatus domain.SessionStatus
|
|
wantState domain.SessionState
|
|
wantReason domain.SessionReason
|
|
wantPR domain.PRReason
|
|
}{
|
|
{
|
|
name: "merged parks idle awaiting decision",
|
|
pr: domain.PRMerged,
|
|
wantStatus: domain.StatusMerged,
|
|
wantState: domain.SessionIdle,
|
|
wantReason: domain.ReasonMergedWaitingDecision,
|
|
wantPR: domain.PRReasonMerged,
|
|
},
|
|
{
|
|
name: "closed drops to idle",
|
|
pr: domain.PRClosed,
|
|
wantStatus: domain.StatusIdle,
|
|
wantState: domain.SessionIdle,
|
|
wantReason: domain.ReasonAwaitingUserInput,
|
|
wantPR: domain.PRReasonClosedUnmerged,
|
|
},
|
|
{
|
|
name: "non-terminal none is a working no-op",
|
|
pr: domain.PRNone,
|
|
wantStatus: domain.StatusWorking,
|
|
wantState: domain.SessionWorking,
|
|
wantReason: domain.ReasonTaskInProgress,
|
|
},
|
|
{
|
|
name: "non-terminal open is a working no-op",
|
|
pr: domain.PROpen,
|
|
wantStatus: domain.StatusWorking,
|
|
wantState: domain.SessionWorking,
|
|
wantReason: domain.ReasonTaskInProgress,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ResolveTerminalPRStateDecision(tt.pr)
|
|
if got.Status != tt.wantStatus {
|
|
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
|
|
}
|
|
if got.SessionState != tt.wantState {
|
|
t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState)
|
|
}
|
|
if got.SessionReason != tt.wantReason {
|
|
t.Errorf("SessionReason = %q, want %q", got.SessionReason, tt.wantReason)
|
|
}
|
|
if tt.wantPR != "" && got.PRReason != tt.wantPR {
|
|
t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateDetectingDecision(t *testing.T) {
|
|
const ev = "runtime_lost runtime=missing process=indeterminate"
|
|
hash := HashEvidence(ev)
|
|
|
|
t.Run("first entry records attempt 1 and stays detecting", func(t *testing.T) {
|
|
got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Now: t0})
|
|
if got.Status != domain.StatusDetecting || got.SessionState != domain.SessionDetecting {
|
|
t.Fatalf("want detecting, got Status=%q State=%q", got.Status, got.SessionState)
|
|
}
|
|
if got.Detecting == nil || got.Detecting.Attempts != 1 {
|
|
t.Fatalf("want attempts=1, got %+v", got.Detecting)
|
|
}
|
|
if !got.Detecting.StartedAt.Equal(t0) {
|
|
t.Errorf("StartedAt = %v, want %v", got.Detecting.StartedAt, t0)
|
|
}
|
|
if got.Detecting.EvidenceHash != hash {
|
|
t.Errorf("EvidenceHash = %q, want %q", got.Detecting.EvidenceHash, hash)
|
|
}
|
|
if got.SessionReason != domain.ReasonRuntimeLost {
|
|
t.Errorf("SessionReason = %q, want %q", got.SessionReason, domain.ReasonRuntimeLost)
|
|
}
|
|
})
|
|
|
|
t.Run("unchanged evidence climbs the counter", func(t *testing.T) {
|
|
prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash}
|
|
got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)})
|
|
if got.Detecting == nil || got.Detecting.Attempts != 2 {
|
|
t.Fatalf("want attempts=2, got %+v", got.Detecting)
|
|
}
|
|
if !got.Detecting.StartedAt.Equal(t0) {
|
|
t.Errorf("StartedAt must be preserved, got %v", got.Detecting.StartedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("escalates to stuck on the third unchanged tick", func(t *testing.T) {
|
|
prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: hash}
|
|
got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)})
|
|
if got.Status != domain.StatusStuck || got.SessionState != domain.SessionStuck {
|
|
t.Fatalf("want stuck, got Status=%q State=%q", got.Status, got.SessionState)
|
|
}
|
|
if got.Detecting != nil {
|
|
t.Errorf("stuck decision must drop detecting memory, got %+v", got.Detecting)
|
|
}
|
|
if got.SessionReason != domain.ReasonRuntimeLost {
|
|
t.Errorf("escalation should carry the why, got %q", got.SessionReason)
|
|
}
|
|
})
|
|
|
|
t.Run("changing evidence resets the counter but preserves StartedAt", func(t *testing.T) {
|
|
prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: hash}
|
|
got := CreateDetectingDecision(DetectingInput{Evidence: "different evidence", ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)})
|
|
if got.Status != domain.StatusDetecting {
|
|
t.Fatalf("changed evidence should stay detecting, got %q", got.Status)
|
|
}
|
|
if got.Detecting == nil || got.Detecting.Attempts != 1 {
|
|
t.Fatalf("counter should reset to 1, got %+v", got.Detecting)
|
|
}
|
|
if !got.Detecting.StartedAt.Equal(t0) {
|
|
t.Errorf("StartedAt must survive an evidence change, got %v", got.Detecting.StartedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("duration cap escalates even below the attempt count", func(t *testing.T) {
|
|
prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash}
|
|
got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(DetectingMaxDuration)})
|
|
if got.Status != domain.StatusStuck {
|
|
t.Fatalf("want stuck from duration cap, got %q", got.Status)
|
|
}
|
|
})
|
|
|
|
t.Run("duration cap fires even when evidence keeps flapping", func(t *testing.T) {
|
|
prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash}
|
|
got := CreateDetectingDecision(DetectingInput{Evidence: "ever-changing", ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(DetectingMaxDuration + time.Minute)})
|
|
if got.Status != domain.StatusStuck {
|
|
t.Fatalf("duration cap must override a reset counter, got %q", got.Status)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestProbeDetectingEscalationFlow(t *testing.T) {
|
|
// An unchanging ambiguous probe should escalate to stuck after exactly
|
|
// DetectingMaxAttempts ticks.
|
|
in := ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0}
|
|
d := ResolveProbeDecision(in)
|
|
for i := 1; i < DetectingMaxAttempts; i++ {
|
|
if d.Status != domain.StatusDetecting {
|
|
t.Fatalf("tick %d: expected detecting, got %q", i, d.Status)
|
|
}
|
|
in.Prior = d.Detecting
|
|
in.Now = t0.Add(time.Duration(i) * time.Second)
|
|
d = ResolveProbeDecision(in)
|
|
}
|
|
if d.Status != domain.StatusStuck {
|
|
t.Fatalf("expected escalation to stuck after %d ticks, got %q", DetectingMaxAttempts, d.Status)
|
|
}
|
|
}
|
|
|
|
func TestHashEvidence(t *testing.T) {
|
|
t.Run("identical strings hash identically", func(t *testing.T) {
|
|
if HashEvidence("same input") != HashEvidence("same input") {
|
|
t.Error("identical evidence must hash equal")
|
|
}
|
|
})
|
|
|
|
t.Run("different evidence hashes differently", func(t *testing.T) {
|
|
if HashEvidence("runtime_lost") == HashEvidence("agent_process_exited") {
|
|
t.Error("distinct evidence must hash differently")
|
|
}
|
|
})
|
|
|
|
t.Run("only the timestamp differs -> equal hash", func(t *testing.T) {
|
|
a := "probe failed at 2026-05-26T12:00:00Z runtime=missing"
|
|
b := "probe failed at 2026-05-26T12:05:43.218Z runtime=missing"
|
|
if HashEvidence(a) != HashEvidence(b) {
|
|
t.Errorf("restamped evidence should hash equal:\n a=%q\n b=%q", a, b)
|
|
}
|
|
})
|
|
|
|
t.Run("bare time-of-day stripped", func(t *testing.T) {
|
|
if HashEvidence("idle since 12:00:00") != HashEvidence("idle since 13:30:59") {
|
|
t.Error("time-of-day differences should be stripped")
|
|
}
|
|
})
|
|
|
|
t.Run("unix epoch stripped", func(t *testing.T) {
|
|
if HashEvidence("last seen 1716724800") != HashEvidence("last seen 1716728400") {
|
|
t.Error("epoch differences should be stripped")
|
|
}
|
|
})
|
|
|
|
t.Run("a real content change still changes the hash", func(t *testing.T) {
|
|
a := "probe at 2026-05-26T12:00:00Z runtime=missing"
|
|
b := "probe at 2026-05-26T12:00:00Z runtime=alive"
|
|
if HashEvidence(a) == HashEvidence(b) {
|
|
t.Error("non-timestamp content change must change the hash")
|
|
}
|
|
})
|
|
|
|
t.Run("whitespace differences are normalised", func(t *testing.T) {
|
|
if HashEvidence("runtime=missing process=dead") != HashEvidence("runtime=missing process=dead") {
|
|
t.Error("collapsed whitespace should hash equal")
|
|
}
|
|
})
|
|
}
|