| 1 | package completion |
| 2 | |
| 3 | import ( |
| 4 | "slices" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/evidence" |
| 9 | ) |
| 10 | |
| 11 | func claimed(status string, completion string) evidence.Receipt { |
| 12 | args := `{"status":"` + status + `","completion":` + completion + `}` |
| 13 | return evidence.Receipt{ToolName: "update_goal", Success: true, Args: []byte(args)} |
| 14 | } |
| 15 | |
| 16 | func TestClaimedVerificationThatNeverRanIsUnbacked(t *testing.T) { |
| 17 | rep := Build(nil, ledgerOf( |
| 18 | wrote("parser.go"), |
| 19 | read("parser.go"), |
| 20 | claimed("complete", `{"verified":["go test ./..."]}`), |
| 21 | )) |
| 22 | if got := gapKinds(rep); !slices.Contains(got, "unbacked_claim") { |
| 23 | t.Fatalf("gap kinds = %v, want the fabricated verification caught", got) |
| 24 | } |
| 25 | if !strings.Contains(rep.Gaps[0].Detail, "no run of it was recorded") { |
| 26 | t.Fatalf("gap detail = %q, want the reason named", rep.Gaps[0].Detail) |
| 27 | } |
| 28 | if rep.Gaps[0].Kind != GapUnbackedClaim { |
| 29 | t.Fatalf("an unbacked claim must lead the gap list, got %v", rep.Gaps[0].Kind) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestClaimedVerificationThatFailedIsUnbacked(t *testing.T) { |
| 34 | rep := Build(nil, ledgerOf( |
| 35 | wrote("parser.go"), |
| 36 | read("parser.go"), |
| 37 | ran("go test ./...", false), |
| 38 | claimed("complete", `{"verified":["go test ./..."]}`), |
| 39 | )) |
| 40 | if !strings.Contains(rep.Gaps[0].Detail, "its last run failed") { |
| 41 | t.Fatalf("gap = %+v, want the failed run named", rep.Gaps[0]) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestClaimedVerificationBeforeTheLatestChangeIsUnbacked(t *testing.T) { |
| 46 | rep := Build(nil, ledgerOf( |
| 47 | ran("go test ./...", true), |
| 48 | wrote("parser.go"), |
| 49 | read("parser.go"), |
| 50 | claimed("complete", `{"verified":["go test ./..."]}`), |
| 51 | )) |
| 52 | if !strings.Contains(rep.Gaps[0].Detail, "before the latest change") { |
| 53 | t.Fatalf("gap = %+v, want the stale claim named", rep.Gaps[0]) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | func TestBackedClaimAddsNoGap(t *testing.T) { |
| 58 | rep := Build(nil, ledgerOf( |
| 59 | wrote("parser.go"), |
| 60 | read("parser.go"), |
| 61 | ran("go test ./...", true), |
| 62 | claimed("complete", `{"verified":["go test ./..."]}`), |
| 63 | )) |
| 64 | if len(rep.Gaps) != 0 { |
| 65 | t.Fatalf("gaps = %+v, want none: the claim matches a fresh successful receipt", rep.Gaps) |
| 66 | } |
| 67 | if rep.Verdict != VerdictUnknown { |
| 68 | t.Fatalf("verdict = %v, want unknown", rep.Verdict) |
| 69 | } |
| 70 | if len(rep.Claimed.Verified) != 1 { |
| 71 | t.Fatalf("claim not recorded: %+v", rep.Claimed) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // A cited command need not be byte-identical to the one that ran; the shared |
| 76 | // segment matcher decides, exactly as complete_step does. |
| 77 | func TestClaimMatchesTheCommandAsItActuallyRan(t *testing.T) { |
| 78 | rep := Build(nil, ledgerOf( |
| 79 | wrote("parser.go"), |
| 80 | read("parser.go"), |
| 81 | ran("cd /repo && go test ./...", true), |
| 82 | claimed("complete", `{"verified":["go test ./..."]}`), |
| 83 | )) |
| 84 | if len(rep.Gaps) != 0 { |
| 85 | t.Fatalf("gaps = %+v, want none: a cd prefix is not a different command", rep.Gaps) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestDeclaredUnverifiedAndRisksSurvive(t *testing.T) { |
| 90 | rep := Build(nil, ledgerOf( |
| 91 | wrote("parser.go"), |
| 92 | read("parser.go"), |
| 93 | ran("go test ./...", true), |
| 94 | claimed("complete", `{"unverified":["desktop UI never exercised"],"risks":["the migration is one-way"]}`), |
| 95 | )) |
| 96 | if got := gapKinds(rep); !slices.Equal(got, []string{"declared_unverified"}) { |
| 97 | t.Fatalf("gap kinds = %v, want the self-declared gap kept", got) |
| 98 | } |
| 99 | if rep.Verdict != VerdictUnknown { |
| 100 | t.Fatalf("verdict = %v, want unknown: declarations are not a quality verdict", rep.Verdict) |
| 101 | } |
| 102 | if !slices.Equal(rep.Risks, []string{"the migration is one-way"}) { |
| 103 | t.Fatalf("risks = %v", rep.Risks) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // The invariant that makes the claim safe to accept at all. |
| 108 | func TestClaimCannotClearFailedExecution(t *testing.T) { |
| 109 | rep := Build(nil, ledgerOf(wrote("parser.go"), ran("go test ./...", false), claimed("complete", `{"verified":["go test ./..."]}`))) |
| 110 | if len(rep.Verifications) != 1 || rep.Verifications[0].Passed || !slices.Contains(gapKinds(rep), "unbacked_claim") { |
| 111 | t.Fatalf("claim changed facts: %+v", rep) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestFailedUpdateGoalClaimsNothing(t *testing.T) { |
| 116 | rejected := evidence.Receipt{ |
| 117 | ToolName: "update_goal", Success: false, |
| 118 | Args: []byte(`{"status":"complete","completion":{"verified":["go test ./..."]}}`), |
| 119 | } |
| 120 | rep := Build(nil, ledgerOf(wrote("parser.go"), read("parser.go"), rejected)) |
| 121 | if !rep.Claimed.Empty() { |
| 122 | t.Fatalf("a rejected update_goal must claim nothing, got %+v", rep.Claimed) |
| 123 | } |
| 124 | if got := gapKinds(rep); slices.Contains(got, "unbacked_claim") { |
| 125 | t.Fatalf("gap kinds = %v, want no claim gap from a failed call", got) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func TestLatestCompleteClaimIgnoresContinueAndFailedCalls(t *testing.T) { |
| 130 | led := ledgerOf( |
| 131 | wrote("parser.go"), |
| 132 | claimed("continue", `{"unverified":["nothing run yet"]}`), |
| 133 | claimed("complete", `{"unverified":["real-engine e2e"]}`), |
| 134 | ) |
| 135 | got, ok := LatestCompleteClaim(led) |
| 136 | if !ok || len(got.Unverified) != 1 || got.Unverified[0] != "real-engine e2e" { |
| 137 | t.Fatalf("LatestCompleteClaim = (%+v, %v)", got, ok) |
| 138 | } |
| 139 | rejected := evidence.Receipt{ |
| 140 | ToolName: "update_goal", Success: false, |
| 141 | Args: []byte(`{"status":"complete","completion":{"unverified":["should not count"]}}`), |
| 142 | } |
| 143 | if _, ok := LatestCompleteClaim(ledgerOf(rejected)); ok { |
| 144 | t.Fatal("a failed update_goal must not yield a complete claim") |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestLatestClaimWins(t *testing.T) { |
| 149 | rep := Build(nil, ledgerOf( |
| 150 | wrote("parser.go"), |
| 151 | read("parser.go"), |
| 152 | claimed("continue", `{"unverified":["nothing run yet"]}`), |
| 153 | ran("go test ./...", true), |
| 154 | claimed("complete", `{"verified":["go test ./..."]}`), |
| 155 | )) |
| 156 | if len(rep.Gaps) != 0 { |
| 157 | t.Fatalf("gaps = %+v, want the superseded claim dropped", rep.Gaps) |
| 158 | } |
| 159 | } |
| 160 |