package app import ( "encoding/json" "strings" "testing" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) // approval_test.go covers the approval-ergonomics gaps (BACKLOG §E §3): single-key // approve/reject/steer for low tiers, an arm→confirm gate for T3+, and a navigable // pending-approval queue (never modal-stacked). Deterministic: an unconnected ws // client buffers Sent frames, Drain reads them back. // approvalModel builds an entered session and queues each given approval through the // production applyServer path, so the queue/Pending state matches real event flow. func approvalModel(approvals ...protocol.ServerMessage) (Model, *ws.Client) { client := ws.New("", 0) // unconnected; Send buffers, Drain reads it back m := NewModel(client) m.width, m.height = 120, 40 m.theme = NewTheme(SoftBlue) m.selectedID = "s1" m.sessionEntered = true m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}} for _, a := range approvals { m.applyServer(a) } return m, client } // apprReq builds an approval.required frame for the given request id and tier. func apprReq(reqID, tier string) protocol.ServerMessage { return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) { m.RequestID, m.Tier = reqID, tier m.RiskSummary = &protocol.RiskSummaryDto{Level: "LOW"} }) } // applyKey drives a key through the production handleKey entry and returns the model. func applyKey(m Model, k keyMsg) Model { updated, _ := m.handleKey(k) return updated.(Model) } func runeKey(r rune) keyMsg { return keyMsg{Type: keyRunes, Runes: []rune{r}} } // decodeApproval pulls the single ApprovalResponse frame off the client and returns // its request id + decision. Fails if there isn't exactly one decision frame. func decodeApproval(t *testing.T, client *ws.Client) (requestID, decision string) { t.Helper() var resp map[string]any n := 0 for _, f := range client.Drain() { var raw map[string]any if err := json.Unmarshal(f, &raw); err != nil { t.Fatalf("unmarshal: %v", err) } if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { resp = raw n++ } } if n != 1 { t.Fatalf("expected exactly one ApprovalResponse frame, got %d", n) } rid, _ := resp["requestId"].(string) dec, _ := resp["decision"].(string) return rid, dec } // noApprovalSent asserts the client buffered no ApprovalResponse frame. func noApprovalSent(t *testing.T, client *ws.Client) { t.Helper() for _, f := range client.Drain() { var raw map[string]any _ = json.Unmarshal(f, &raw) if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { t.Fatalf("did not expect an ApprovalResponse frame yet") } } } func TestApprovalLowTierApprovesImmediately(t *testing.T) { m, client := approvalModel(apprReq("req-1", "T1")) if m.displayState() != StateApproval { t.Fatalf("want StateApproval, got %v", m.displayState()) } m = applyKey(m, runeKey('y')) rid, dec := decodeApproval(t, client) if rid != "req-1" || dec != "APPROVE" { t.Fatalf("want approve req-1, got %s/%s", rid, dec) } if s := m.session("s1"); s == nil || s.Pending != nil { t.Fatalf("pending gate should be cleared after a low-tier approve") } if m.approvalArmed { t.Fatalf("low-tier approve must not leave an armed confirm") } } func TestApprovalHighTierRequiresSecondConfirm(t *testing.T) { m, client := approvalModel(apprReq("req-1", "T3")) // First `y` arms; nothing is sent yet. m = applyKey(m, runeKey('y')) if !m.approvalArmed { t.Fatalf("T3 approve should arm a confirm, not send") } noApprovalSent(t, client) if s := m.session("s1"); s == nil || s.Pending == nil { t.Fatalf("gate must remain pending while armed") } // Second `y` confirms and sends. m = applyKey(m, runeKey('y')) rid, dec := decodeApproval(t, client) if rid != "req-1" || dec != "APPROVE" { t.Fatalf("want approve req-1 after confirm, got %s/%s", rid, dec) } if m.approvalArmed { t.Fatalf("arm should clear after the confirming keypress") } } func TestApprovalHighTierArmCancelledByOtherKey(t *testing.T) { m, client := approvalModel(apprReq("req-1", "T3")) m = applyKey(m, runeKey('y')) // arm if !m.approvalArmed { t.Fatalf("expected armed after first y") } // A non-confirm key (here `n` reject) cancels the arm. The arm itself must be // gone, and no spurious APPROVE may slip through. m = applyKey(m, runeKey('n')) if m.approvalArmed { t.Fatalf("arm should be cancelled by a non-confirm key") } rid, dec := decodeApproval(t, client) if dec != "REJECT" || rid != "req-1" { t.Fatalf("non-confirm key should route to its own action (reject), got %s/%s", rid, dec) } } func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) { // Two T3 gates: arming then pressing ↓ must disarm and only move the cursor — // no decision may be emitted. m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3")) m = applyKey(m, runeKey('y')) // arm req-1 m = applyKey(m, keyMsg{Type: keyDown}) if m.approvalArmed { t.Fatalf("nav key should disarm") } noApprovalSent(t, client) if s := m.session("s1"); s == nil || s.PendingIdx != 1 { t.Fatalf("expected cursor advanced to index 1, got %d", s.PendingIdx) } } func TestApprovalQueueNavigatesAndCounts(t *testing.T) { m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"), apprReq("req-3", "T1")) s := m.session("s1") if s == nil || len(s.PendingQueue) != 3 { t.Fatalf("expected 3 queued approvals, got %d", len(s.PendingQueue)) } // The band shows a "1/3" count and the current request, never stacked modals. out := stripANSI(m.renderApprovalBand(m.approvalBandHeight())) if !strings.Contains(out, "1/3") { t.Fatalf("band should show queue count 1/3:\n%s", out) } // ↓ advances the selection; Pending follows the cursor. m = applyKey(m, keyMsg{Type: keyDown}) s = m.session("s1") if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" { t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID) } out = stripANSI(m.renderApprovalBand(m.approvalBandHeight())) if !strings.Contains(out, "2/3") { t.Fatalf("band should show 2/3 after ↓:\n%s", out) } // ↑ wraps back. m = applyKey(m, keyMsg{Type: keyUp}) if m.session("s1").PendingIdx != 0 { t.Fatalf("↑ should return to index 0") } } func TestApprovalResolveFrontAdvancesQueue(t *testing.T) { m, client := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1")) // Approve the front gate (low tier → immediate). m = applyKey(m, runeKey('y')) if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "APPROVE" { t.Fatalf("want approve req-1, got %s/%s", rid, dec) } s := m.session("s1") if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-2" { t.Fatalf("resolving the front gate should advance to req-2, got %+v", s.PendingQueue) } // A server-side resolve of the remaining gate empties the queue and exits approval. m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) { mm.RequestID, mm.Outcome = "req-2", "APPROVED" })) if s := m.session("s1"); s == nil || s.Pending != nil || len(s.PendingQueue) != 0 { t.Fatalf("server resolve should clear the last gate") } if m.displayState() != StateInSession { t.Fatalf("empty queue should leave approval state, got %v", m.displayState()) } } func TestApprovalServerResolveRemovesSpecificGate(t *testing.T) { // A resolve for the *back* gate must drop only that one and keep the front shown. m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1")) m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) { mm.RequestID, mm.Outcome = "req-2", "APPROVED" })) s := m.session("s1") if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-1" { t.Fatalf("resolving req-2 should leave req-1 shown, got %+v", s.PendingQueue) } } func TestApprovalRejectAndSteerRoute(t *testing.T) { // Reject is single-key for a low tier. m, client := approvalModel(apprReq("req-1", "T1")) m = applyKey(m, runeKey('n')) if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "REJECT" { t.Fatalf("want reject req-1, got %s/%s", rid, dec) } // Steer enters the existing note-input path (no decision sent yet). m2, client2 := approvalModel(apprReq("req-2", "T1")) m2 = applyKey(m2, runeKey('e')) if !m2.steering || m2.editMode != ModeInsert { t.Fatalf("steer key should enter the steering-note input path") } noApprovalSent(t, client2) // Typing a note then Enter approves with the note riding along. for _, r := range "use sudo" { m2 = applyKey(m2, runeKey(r)) } m2 = applyKey(m2, keyMsg{Type: keyEnter}) frames := client2.Drain() var note string for _, f := range frames { var raw map[string]any _ = json.Unmarshal(f, &raw) if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { note, _ = raw["steeringNote"].(string) if raw["decision"] != "APPROVE" { t.Fatalf("steer+enter should approve, got %v", raw["decision"]) } } } if note != "use sudo" { t.Fatalf("steering note should ride along with the decision, got %q", note) } }