import Foundation
import Testing

@testable import NucleicCore

@Suite struct TranscriptTests {
    func makeEvent(seq: UInt64, kind: AgentEvent.Kind) -> AgentEvent {
        AgentEvent(
            sessionID: SessionID(rawValue: "t-session"),
            seq: seq,
            at: Date(timeIntervalSince1970: 1_750_000_000),
            backend: .claudeCode,
            nativeType: "assistant",
            kind: kind)
    }

    func tempURL() -> URL {
        FileManager.default.temporaryDirectory
            .appendingPathComponent("nucleic-tests-\(UUID().uuidString)/transcript.jsonl")
    }

    @Test func appendReadRoundTrip() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }

        let header = SessionHeader(
            sessionID: SessionID(rawValue: "t-session"),
            backend: .claudeCode,
            worktree: "/tmp/wt",
            model: "claude-fable-5",
            createdAt: Date(timeIntervalSince1970: 1_750_000_000))
        let writer = try TranscriptWriter(url: url, header: header)

        let events: [AgentEvent.Kind] = [
            .sessionStarted(
                SessionStarted(backendSessionID: "native-1", model: "m", cwd: "/tmp/wt", toolNames: [])),
            .assistantText(TextChunk(messageID: "m1", text: "hi", isPartial: false)),
            .runFinished(RunFinished(outcome: .completed, finalText: "hi")),
        ]
        for kind in events {
            try await writer.append(makeEvent(seq: 0, kind: kind))
        }
        try await writer.close()

        let (readHeader, readEvents) = try TranscriptReader(url: url).read()
        #expect(readHeader == header)
        #expect(readEvents.map(\.kind) == events)
    }

    @Test func canonicalSeqIsAssignedAtAppendAndMonotonic() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }

        let writer = try TranscriptWriter(
            url: url,
            header: SessionHeader(
                sessionID: SessionID(rawValue: "t"), backend: .claudeCode, worktree: "/",
                createdAt: Date(timeIntervalSince1970: 0)))

        // Adapter seq is provisional and may collide (synthetic events) — the
        // writer is the authority (RUNTIME §2.1).
        let provisionalSeqs: [UInt64] = [7, 7, 3]
        var canonical: [UInt64] = []
        for provisional in provisionalSeqs {
            let appended = try await writer.append(
                makeEvent(
                    seq: provisional,
                    kind: .assistantText(TextChunk(messageID: "m", text: "x", isPartial: false))))
            canonical.append(appended.seq)
        }
        try await writer.close()
        #expect(canonical == [1, 2, 3])

        let (_, events) = try TranscriptReader(url: url).read()
        #expect(events.map(\.seq) == [1, 2, 3])
    }

    /// Replay invariant (OBSERVABILITY B.3): reading the JSONL back reconstructs
    /// the exact event stream that was ingested live.
    @Test func replayInvariant() async throws {
        let url = tempURL()
        defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }

        let writer = try TranscriptWriter(
            url: url,
            header: SessionHeader(
                sessionID: SessionID(rawValue: "t"), backend: .claudeCode, worktree: "/",
                createdAt: Date(timeIntervalSince1970: 0)))

        let decoder = ClaudeStreamDecoder()
        var live: [AgentEvent] = []
        for line in try Fixtures.lines("02-tool-approval") {
            for decoded in decoder.decode(line: line) {
                let appended = try await writer.append(
                    makeEvent(seq: 0, kind: decoded.kind))
                live.append(appended)
            }
        }
        try await writer.close()

        let (_, replayed) = try TranscriptReader(url: url).read()
        #expect(replayed == live)
    }

    // MARK: - Tool-call coalescing

    private func toolEvent(_ seq: UInt64, _ id: String, _ name: String, _ path: String) -> AgentEvent {
        makeEvent(seq: seq, kind: .toolCallCompleted(
            ToolCall(toolCallID: id, name: name, input: ["file_path": .string(path)])))
    }

    @Test func coalescesConsecutiveToolCallsIntoOneGroup() {
        let items = TranscriptProjection.items([
            toolEvent(0, "t1", "Read", "A.swift"),
            toolEvent(1, "t2", "Read", "B.swift"),
            toolEvent(2, "t3", "Edit", "C.swift"),
        ])
        #expect(items.count == 1)
        guard case .toolGroup(_, _, let calls) = items[0] else {
            Issue.record("expected a .toolGroup"); return
        }
        #expect(calls.count == 3)
    }

    @Test func singleToolCallStaysUngrouped() {
        let items = TranscriptProjection.items([toolEvent(0, "t1", "Read", "A.swift")])
        #expect(items.count == 1)
        if case .toolGroup = items[0] { Issue.record("a lone tool call must not become a group") }
    }

    @Test func nonToolEventBreaksTheRun() {
        let items = TranscriptProjection.items([
            toolEvent(0, "t1", "Read", "A.swift"),
            makeEvent(seq: 1, kind: .assistantText(TextChunk(messageID: "m", text: "hi", isPartial: false))),
            toolEvent(2, "t2", "Read", "B.swift"),
        ])
        // Prose between the two reads splits them — neither run reaches length 2.
        #expect(!items.contains { if case .toolGroup = $0 { return true } else { return false } })
    }

    @Test func debugEventDoesNotBreakAToolGroup() {
        let items = TranscriptProjection.items([
            toolEvent(0, "t1", "Read", "A.swift"),
            // A hidden token-usage tally lands mid-run: it must not split the two reads
            // into separate cards (which would leave a gap where the invisible row was).
            makeEvent(seq: 1, kind: .usage(Usage(outputTokens: 42))),
            toolEvent(2, "t2", "Read", "B.swift"),
        ])
        // The two reads still coalesce; the debug row is held aside (and would render
        // after the group only when "advanced detail" is on).
        guard let group = items.first(where: { if case .toolGroup = $0 { return true } else { return false } }),
              case .toolGroup(_, _, let calls) = group else {
            Issue.record("a debug event between two tool calls must not break the group"); return
        }
        #expect(calls.count == 2)
    }

    @Test func emptyThinkingDoesNotBreakAToolGroup() {
        // Claude streams a finalized empty thinking block whenever the reasoning is
        // redacted; the view renders nothing for it, so it must not split a tool run.
        let items = TranscriptProjection.items([
            toolEvent(0, "t1", "Grep", "A.swift"),
            makeEvent(seq: 1, kind: .thinking(TextChunk(messageID: "m1", text: "", isPartial: false))),
            makeEvent(seq: 2, kind: .raw(Raw(native: ["subtype": "thinking_tokens"]))),
            toolEvent(3, "t2", "Read", "B.swift"),
        ])
        guard let group = items.first(where: { if case .toolGroup = $0 { return true } else { return false } }),
              case .toolGroup(_, _, let calls) = group else {
            Issue.record("an empty thinking block between two tool calls must not break the group"); return
        }
        #expect(calls.count == 2)
    }

    @Test func nonEmptyThinkingStillBreaksTheRun() {
        // A thinking block with real text is a visible row, so it should split the run.
        let items = TranscriptProjection.items([
            toolEvent(0, "t1", "Read", "A.swift"),
            makeEvent(seq: 1, kind: .thinking(TextChunk(messageID: "m1", text: "pondering", isPartial: false))),
            toolEvent(2, "t2", "Read", "B.swift"),
        ])
        #expect(!items.contains { if case .toolGroup = $0 { return true } else { return false } })
    }

    // MARK: - Thinking-tokens meter (advanced detail)

    /// A `system/thinking_tokens` progress ping at `atOffset` seconds into the run, carrying
    /// the cumulative estimate and per-ping delta the projection coalesces into one meter.
    private func tokensEvent(_ seq: UInt64, total: Int, delta: Int, atOffset: TimeInterval) -> AgentEvent {
        AgentEvent(
            sessionID: SessionID(rawValue: "t-session"),
            seq: seq,
            at: Date(timeIntervalSince1970: 1_750_000_000 + atOffset),
            backend: .claudeCode,
            nativeType: "system/thinking_tokens",
            kind: .raw(Raw(native: [
                "subtype": "thinking_tokens",
                "estimated_tokens": .number(Double(total)),
                "estimated_tokens_delta": .number(Double(delta)),
            ])))
    }

    @Test func thinkingTokensPingsCoalesceIntoOneLiveMeter() {
        let items = TranscriptProjection.items([
            tokensEvent(0, total: 50, delta: 50, atOffset: 0),
            tokensEvent(1, total: 320, delta: 270, atOffset: 1.2),
            tokensEvent(2, total: 1450, delta: 1130, atOffset: 4.0),
        ])
        #expect(items.count == 1)
        guard case .thinkingTokens(_, let total, let delta, let elapsed) = items[0] else {
            Issue.record("expected a single coalesced .thinkingTokens meter"); return
        }
        #expect(total == 1450)               // latest cumulative estimate wins
        #expect(delta == 1130)               // latest per-ping delta
        #expect(abs(elapsed - 4.0) < 0.0001)  // elapsed from the run's first ping to its last
    }

    @Test func thinkingTokensMeterIsDebugAndDoesNotBreakAToolGroup() {
        let items = TranscriptProjection.items([
            toolEvent(0, "t1", "Read", "A.swift"),
            tokensEvent(1, total: 80, delta: 80, atOffset: 0),
            toolEvent(2, "t2", "Read", "B.swift"),
        ])
        // The meter is invisible to grouping (held aside), so the two reads still coalesce.
        guard let group = items.first(where: { if case .toolGroup = $0 { return true } else { return false } }),
              case .toolGroup(_, _, let calls) = group else {
            Issue.record("a thinking-tokens meter must not split a tool run"); return
        }
        #expect(calls.count == 2)
        // It re-emits after the group as a debug row (shown only with advanced detail on).
        #expect(items.contains { if case .thinkingTokens = $0 { return $0.isDebug } else { return false } })
    }

    @Test func thinkingDeltasKeepTheTokenMeterRunOpen() {
        let items = TranscriptProjection.items([
            tokensEvent(0, total: 50, delta: 50, atOffset: 0),
            // A thinking delta interleaves; it updates an existing row, so it must not split
            // the meter run into two.
            makeEvent(seq: 1, kind: .thinking(TextChunk(messageID: "m1", text: "pondering", isPartial: true))),
            tokensEvent(2, total: 200, delta: 150, atOffset: 2),
        ])
        let meters = items.filter { if case .thinkingTokens = $0 { return true } else { return false } }
        #expect(meters.count == 1)
        guard case .thinkingTokens(_, let total, _, _) = meters[0] else { return }
        #expect(total == 200)
    }

    @Test func thinkingTokensRunEndsAtABoundaryAndAResetStartsANewMeter() {
        let items = TranscriptProjection.items([
            tokensEvent(0, total: 500, delta: 500, atOffset: 0),
            tokensEvent(1, total: 900, delta: 400, atOffset: 1),
            toolEvent(2, "t1", "Read", "A.swift"),   // a real boundary ends the run
            tokensEvent(3, total: 120, delta: 120, atOffset: 5),  // new session: cumulative drops
        ])
        let totals = items.compactMap { item -> Int? in
            if case .thinkingTokens(_, let total, _, _) = item { return total }
            return nil
        }
        #expect(totals == [900, 120])
    }

    @Test func tokenCountIsCompactedPast999() {
        #expect(TokenFormatting.short(0) == "0")
        #expect(TokenFormatting.short(50) == "50")
        #expect(TokenFormatting.short(999) == "999")
        #expect(TokenFormatting.short(1000) == "1.0k")
        #expect(TokenFormatting.short(1400) == "1.4k")
        #expect(TokenFormatting.short(1450) == "1.4k")  // truncates, never rounds up
        #expect(TokenFormatting.short(1499) == "1.4k")
        #expect(TokenFormatting.short(1500) == "1.5k")
        #expect(TokenFormatting.short(12345) == "12.3k")
    }

    @Test func elapsedTimeFormatting() {
        #expect(TokenFormatting.elapsed(0) == "0.0s")
        #expect(TokenFormatting.elapsed(12.34) == "12.3s")
        #expect(TokenFormatting.elapsed(59.9) == "59.9s")
        #expect(TokenFormatting.elapsed(60) == "1m 00s")
        #expect(TokenFormatting.elapsed(65) == "1m 05s")
        #expect(TokenFormatting.elapsed(125) == "2m 05s")
    }

    @Test func toolGroupSignatureIsStableAndContentAddressed() {
        let a = ToolCall(toolCallID: "x", name: "Read", input: ["file_path": "A.swift"])
        let a2 = ToolCall(toolCallID: "y", name: "Read", input: ["file_path": "A.swift"])  // same content, diff id
        let b = ToolCall(toolCallID: "z", name: "Read", input: ["file_path": "B.swift"])
        #expect(TranscriptProjection.toolGroupSignature([a]) == TranscriptProjection.toolGroupSignature([a2]))
        #expect(TranscriptProjection.toolGroupSignature([a]) != TranscriptProjection.toolGroupSignature([b]))
    }

    @Test func editFileChangeFoldsIntoItsToolCall() {
        let items = TranscriptProjection.items([
            makeEvent(seq: 0, kind: .toolCallCompleted(
                ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "A.swift"]))),
            makeEvent(seq: 1, kind: .fileChange(
                FileChange(path: "A.swift", kind: .update, toolCallID: "t1"))),
        ])
        // The edit row already shows the path; the separate "Update <path>" line is folded away.
        #expect(items.count == 1)
        guard case .tool(_, let call, _, _, _) = items[0] else {
            Issue.record("expected the edit tool row to remain"); return
        }
        #expect(call.name == "Edit")
    }

    @Test func unmatchedFileChangeStaysStandalone() {
        let items = TranscriptProjection.items([
            makeEvent(seq: 0, kind: .fileChange(
                FileChange(path: "A.swift", kind: .update, toolCallID: nil))),
        ])
        #expect(items.count == 1)
        if case .event = items[0] {} else {
            Issue.record("a file change with no matching call should stay a standalone row")
        }
    }

    // MARK: - Subagent (orchestration) nesting

    /// A spawned subagent's inner tool calls arrive in this same stream tagged with a
    /// `parentToolCallID`. They must be scoped out of the top level — so the parent `Task`
    /// spawns stay adjacent and the orchestration card forms from them alone, rather than
    /// interleaving and breaking the all-subagent run — and nested under their spawn as the
    /// agent's own activity stream.
    @Test func subagentInnerCallsNestUnderTheirSpawn() {
        let items = TranscriptProjection.items([
            makeEvent(seq: 0, kind: .toolCallCompleted(
                ToolCall(toolCallID: "task1", name: "Task", input: ["description": "A"]))),
            makeEvent(seq: 1, kind: .toolCallCompleted(
                ToolCall(toolCallID: "task2", name: "Task", input: ["description": "B"]))),
            // Subagent A's internal work — would otherwise land as top-level rows between
            // the spawns and the next turn, breaking the all-subagent run.
            makeEvent(seq: 2, kind: .toolCallCompleted(
                ToolCall(toolCallID: "in1", name: "Read", input: ["file_path": "X.swift"],
                         parentToolCallID: "task1"))),
            makeEvent(seq: 3, kind: .toolResult(
                ToolResult(toolCallID: "in1", content: "...", isError: false))),
            makeEvent(seq: 4, kind: .toolCallCompleted(
                ToolCall(toolCallID: "in2", name: "Bash", input: ["command": "ls"],
                         parentToolCallID: "task2"))),
            makeEvent(seq: 5, kind: .toolResult(
                ToolResult(toolCallID: "in2", content: "x", isError: false))),
        ])
        // Only the two Task spawns survive at the top level, coalesced — exactly the
        // all-subagent group the orchestration card needs.
        #expect(items.count == 1)
        guard case .toolGroup(_, _, let calls) = items[0] else {
            Issue.record("expected the two Task spawns to coalesce into one group"); return
        }
        #expect(calls.count == 2)
        #expect(calls.allSatisfy { $0.call.name == "Task" })
        // Each spawn carries its own inner call as a nested child.
        guard case .tool(_, let inner1, _, _, _)? = calls.first?.children.first else {
            Issue.record("subagent A's Read should nest under its spawn"); return
        }
        #expect(inner1.name == "Read")
        guard case .tool(_, let inner2, let inner2Result, _, _)? = calls.last?.children.first else {
            Issue.record("subagent B's Bash should nest under its spawn"); return
        }
        #expect(inner2.name == "Bash")
        #expect(inner2Result != nil)  // the inner call's result folds into its nested row
    }

    @Test func loneSubagentNestsInnerCallsAndKeepsItsReport() {
        let items = TranscriptProjection.items([
            makeEvent(seq: 0, kind: .toolCallCompleted(
                ToolCall(toolCallID: "task1", name: "Task", input: ["description": "A"]))),
            makeEvent(seq: 1, kind: .toolCallCompleted(
                ToolCall(toolCallID: "in1", name: "Bash", input: ["command": "ls"],
                         parentToolCallID: "task1"))),
            makeEvent(seq: 2, kind: .toolResult(
                ToolResult(toolCallID: "in1", content: "x", isError: false))),
            // The subagent's own result (parent is nil — it's the main agent's Task) stays.
            makeEvent(seq: 3, kind: .toolResult(
                ToolResult(toolCallID: "task1", content: "done", isError: false))),
        ])
        #expect(items.count == 1)
        guard case .tool(_, let call, let result, _, let children) = items[0] else {
            Issue.record("expected a lone subagent tool row"); return
        }
        #expect(call.name == "Task")
        #expect(result != nil)  // the Task's own report is preserved
        #expect(children.count == 1)  // its inner Bash nests beneath it
    }

    @Test func subagentInnerFileChangeDoesNotLeak() {
        let items = TranscriptProjection.items([
            makeEvent(seq: 0, kind: .toolCallCompleted(
                ToolCall(toolCallID: "task1", name: "Task", input: ["description": "A"]))),
            makeEvent(seq: 1, kind: .toolCallCompleted(
                ToolCall(toolCallID: "in1", name: "Edit", input: ["file_path": "X.swift"],
                         parentToolCallID: "task1"))),
            // The decoder infers a file change for the subagent's edit; it folds into the
            // nested edit row, never a top-level standalone "Update <path>" row.
            makeEvent(seq: 2, kind: .fileChange(
                FileChange(path: "X.swift", kind: .update, toolCallID: "in1"))),
        ])
        #expect(items.count == 1)
        guard case .tool(_, let call, _, _, let children) = items[0], call.name == "Task" else {
            Issue.record("expected only the Task spawn at the top level"); return
        }
        // The inner edit nests; the inferred file change folded into it (not a separate row).
        #expect(children.count == 1)
        guard case .tool(_, let inner, _, _, _) = children[0] else {
            Issue.record("expected the inner edit to nest"); return
        }
        #expect(inner.name == "Edit")
    }

    /// A subagent's prose and thinking carry the same parent link, so they nest under the
    /// spawn rather than leaking into the main conversation as top-level rows.
    @Test func subagentProseAndThinkingNestUnderTheirSpawn() {
        let items = TranscriptProjection.items([
            makeEvent(seq: 0, kind: .toolCallCompleted(
                ToolCall(toolCallID: "task1", name: "Task", input: ["description": "A"]))),
            makeEvent(seq: 1, kind: .thinking(
                TextChunk(messageID: "sm1", text: "weighing options", isPartial: false,
                          parentToolCallID: "task1"))),
            makeEvent(seq: 2, kind: .assistantText(
                TextChunk(messageID: "sm2", text: "Found it.", isPartial: false,
                          parentToolCallID: "task1"))),
        ])
        // Nothing leaks: the only top-level item is the spawn.
        #expect(items.count == 1)
        guard case .tool(_, let call, _, _, let children) = items[0], call.name == "Task" else {
            Issue.record("expected only the Task spawn at the top level"); return
        }
        // Its thinking and prose nest beneath it, in order.
        #expect(children.count == 2)
        guard case .thinking(_, let thought, _) = children[0] else {
            Issue.record("expected the subagent's thinking to nest first"); return
        }
        #expect(thought == "weighing options")
        guard case .assistant(_, let prose, _) = children[1] else {
            Issue.record("expected the subagent's prose to nest second"); return
        }
        #expect(prose == "Found it.")
    }

    @Test func editFileChangesDoNotBreakAToolGroup() {
        let items = TranscriptProjection.items([
            makeEvent(seq: 0, kind: .toolCallCompleted(
                ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "A.swift"]))),
            makeEvent(seq: 1, kind: .toolCallCompleted(
                ToolCall(toolCallID: "t2", name: "Edit", input: ["file_path": "B.swift"]))),
            makeEvent(seq: 2, kind: .fileChange(FileChange(path: "A.swift", kind: .update, toolCallID: "t1"))),
            makeEvent(seq: 3, kind: .fileChange(FileChange(path: "B.swift", kind: .update, toolCallID: "t2"))),
        ])
        // Folding the changes away leaves the two edits adjacent, so they still coalesce.
        #expect(items.count == 1)
        guard case .toolGroup(_, _, let calls) = items[0] else {
            Issue.record("expected the two edits to coalesce"); return
        }
        #expect(calls.count == 2)
    }

    // MARK: - Lock-lifecycle folding (LOCKING §4)

    private func lockNote(_ seq: UInt64, _ state: NoteLock.State, _ paths: [String]) -> AgentEvent {
        makeEvent(seq: seq, kind: .note(
            NoteEvent(text: "lock", lockEvent: true, lock: NoteLock(state: state, paths: paths))))
    }

    /// A lock note for a file lands *after* its edit's tool-use start (the lock is acquired at the
    /// permission gate, after the card is anchored), so as a standalone row it always sorts after
    /// the call. The display projection folds it onto that call instead, keyed by tool-call id, so
    /// the lock reads as bracketing the edit. The whole acquired→released trail rides the card.
    @Test func lockNoteFoldsOntoItsEditCard() {
        let (items, lockLines) = TranscriptProjection.items([
            toolEvent(0, "t1", "Edit", "A.swift"),
            lockNote(1, .acquired, ["A.swift"]),
            lockNote(2, .released, ["A.swift"]),
        ], worktreeRoot: nil)
        // The notes fold away; only the edit card remains as a row.
        #expect(items.count == 1)
        guard case .tool(_, let call, _, _, _) = items[0], call.name == "Edit" else {
            Issue.record("expected the edit card to be the only row"); return
        }
        #expect(lockLines["t1"]?.map(\.state) == [.acquired, .released])
    }

    /// A lock whose file was never edited in view (a launch-reconstructed lock, a release for
    /// another session's path) has no card to fold onto, so it stays a standalone row — never lost.
    @Test func unmatchedLockNoteStaysStandalone() {
        let (items, lockLines) = TranscriptProjection.items([
            toolEvent(0, "t1", "Edit", "A.swift"),
            lockNote(1, .released, ["Z.swift"]),
        ], worktreeRoot: nil)
        #expect(lockLines.isEmpty)
        #expect(items.count == 2)
        guard case .event(let event) = items[1], case .note(let note) = event.kind,
              note.lock?.state == .released else {
            Issue.record("expected the unmatched lock note to remain a standalone row"); return
        }
    }

    /// A tool call's `file_path` is often absolute; the note's paths are repo-relative. Stripping
    /// the worktree root makes them comparable, so the fold still finds the card.
    @Test func lockNoteMatchesAbsoluteEditPathViaWorktreeRoot() {
        let (items, lockLines) = TranscriptProjection.items([
            toolEvent(0, "t1", "Edit", "/tmp/wt/Sources/A.swift"),
            lockNote(1, .acquired, ["Sources/A.swift"]),
        ], worktreeRoot: "/tmp/wt")
        #expect(items.count == 1)
        #expect(lockLines["t1"]?.map(\.state) == [.acquired])
    }

    /// One turn-end release covering several files folds per file onto each file's own card, so
    /// every edit brackets cleanly instead of the whole release piling onto one card.
    @Test func multiFileLockNoteSplitsAcrossEachCard() {
        let (items, lockLines) = TranscriptProjection.items([
            toolEvent(0, "t1", "Edit", "A.swift"),
            toolEvent(1, "t2", "Edit", "B.swift"),
            lockNote(2, .released, ["A.swift", "B.swift"]),
        ], worktreeRoot: nil)
        #expect(lockLines["t1"]?.first?.state == .released)
        #expect(lockLines["t1"]?.first?.paths == ["A.swift"])
        #expect(lockLines["t2"]?.first?.paths == ["B.swift"])
        // The two edits still coalesce (the release folded away, not splitting the run).
        #expect(items.count == 1)
        if case .toolGroup = items[0] {} else { Issue.record("expected the two edits to coalesce") }
    }

    /// A partial match (some paths land on a card, some don't) stays a single standalone row rather
    /// than splitting — so no path is silently dropped and nothing double-renders.
    @Test func partiallyMatchedLockNoteStaysStandalone() {
        let (items, lockLines) = TranscriptProjection.items([
            toolEvent(0, "t1", "Edit", "A.swift"),
            lockNote(1, .released, ["A.swift", "Z.swift"]),
        ], worktreeRoot: nil)
        #expect(lockLines.isEmpty)
        #expect(items.count == 2)
        guard case .event = items[1] else {
            Issue.record("a partially-matched lock note should stay standalone"); return
        }
    }
}
