Files
correx/apps/tui-go/internal/app/approval_test.go
T
kami d5afcd345a feat(tui-go): approval ergonomics — queue + tier-gated confirm (E §3)
- pending approvals are a navigable queue (PendingQueue/PendingIdx; ↑↓/jk, count
  shown), no modal stacking; resolve removes the specific gate by request id
- y/n/e keys (approve/reject/steer); T0–T2 act on one key, T3+ requires arm+confirm
  ("press y again"), any other key disarms; reject/steer/auto never gated
- snapshot restore now rehydrates the full pending list, not just the first

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:40:49 +00:00

258 lines
9.2 KiB
Go

package app
import (
"encoding/json"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"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 tea.KeyMsg) Model {
updated, _ := m.handleKey(k)
return updated.(Model)
}
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.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, tea.KeyMsg{Type: tea.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, tea.KeyMsg{Type: tea.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, tea.KeyMsg{Type: tea.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, tea.KeyMsg{Type: tea.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)
}
}