import XCTest
import AppKit
import SwiftUI
@testable import PoseCore
@testable import PoseReview

final class ReviewerModelTests: XCTestCase {
    @MainActor func testLeanCanvasPreviewCancelCommitAndReferenceHandle() async throws {
        let (model, folder, _) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        model.setLeanMode(true); model.activate("P01-subject"); model.showMarkers = false
        let frame = try XCTUnwrap(model.frame)
        var a = LeanAnnotation(frame: frame, wheel: LeanLine(a: Point2(x: 100, y: 100), b: Point2(x: 160, y: 20), uncertainty: 3))
        a.reference = LeanLine(a: Point2(x: 20, y: 100), b: Point2(x: 260, y: 100), uncertainty: 3)
        a.basis = .assumedLevel
        model.commitLean(a, target: model.leanTarget(frameID: frame.id))
        let canvas = JointCanvasView(frame: NSRect(x: 0, y: 0, width: 300, height: 200))
        canvas.model = model; canvas.frameID = frame.id; canvas.generation = model.canvasGeneration
        func mouse(_ type: NSEvent.EventType, _ x: Double, _ y: Double = 100) -> NSEvent {
            NSEvent.mouseEvent(with: type, location: NSPoint(x: x, y: y), modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)!
        }
        canvas.mouseDown(with: mouse(.leftMouseDown, 100))
        canvas.mouseDragged(with: mouse(.leftMouseDragged, 130))
        XCTAssertTrue(model.hasPreview)
        XCTAssertEqual(model.lean(frame.id), a)
        XCTAssertNotEqual(LeanAnalysis.measure(try XCTUnwrap(canvas.previewLean)), LeanAnalysis.measure(a))
        canvas.mouseUp(with: mouse(.leftMouseUp, 130))
        XCTAssertFalse(model.hasPreview)
        XCTAssertEqual(model.lean(frame.id)?.wheel?.a.x, 130)
        canvas.mouseDown(with: mouse(.leftMouseDown, 20))
        canvas.mouseDragged(with: mouse(.leftMouseDragged, 20, 120))
        XCTAssertEqual(model.selectedLeanHandle, .referenceA)
        XCTAssertNotEqual(canvas.previewLean?.reference, model.lean(frame.id)?.reference)
        let revision = model.document?.revision
        let escape = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 53)!
        canvas.keyDown(with: escape)
        canvas.mouseUp(with: mouse(.leftMouseUp, 20, 120))
        XCTAssertEqual(model.document?.revision, revision)
        XCTAssertFalse(model.hasPreview)
        XCTAssertNil(canvas.previewLean)
    }
    @MainActor func testLeanApprovalUndoAndSaveConflict() async throws {
        let (model, folder, _) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        model.setLeanMode(true)
        for id in ["P01-subject", "P01-reference"] {
            var a = LeanAnnotation(frame: try XCTUnwrap(model.frame(id)), wheel: LeanLine(a: Point2(x: 100, y: 150), b: Point2(x: 150, y: 30), uncertainty: 3))
            a.state = .reviewed
            model.commitLean(a, target: model.leanTarget(frameID: id))
        }
        var context = LeanPairContext()
        context.commonCue = true; context.comparableView = true; context.comparableHeading = true
        context.comparableReference = true; context.comparableCameraRoll = true
        model.updateLeanContext(context, approve: true)
        let pair = try XCTUnwrap(model.pair)
        XCTAssertNotNil(try LeanAnalysis.compare(model.document!, pair: pair).measurement)
        model.activate("P01-reference")
        model.changeLean { $0.wheel?.a.x += 10; $0.state = .draft }
        XCTAssertNil(try LeanAnalysis.compare(model.document!, pair: pair).measurement)
        model.undo()
        XCTAssertNotNil(try LeanAnalysis.compare(model.document!, pair: pair).measurement)
        let url = folder.appendingPathComponent("joint-review-\(model.document!.reportHash.prefix(12)).json")
        var competing = try ReviewStore.load(from: url); let saved = competing.revision
        competing.revision += 10
        try ReviewStore.save(competing, to: url, expectedRevision: saved)
        let bytes = try Data(contentsOf: url)
        model.changeLean { $0.reference = LeanLine(a: Point2(x: 20, y: 40), b: Point2(x: 250, y: 50), uncertainty: 3); $0.state = .draft }
        XCTAssertTrue(model.isUnsaved)
        XCTAssertNotNil(model.lean("P01-reference")?.reference)
        XCTAssertEqual(try Data(contentsOf: url), bytes)
        model.selectPair("P02")
        XCTAssertEqual(model.pair?.id, "P01")
    }
    @MainActor func testLeanEditUndoReopenAndStaleTarget() async throws {
        let (model, folder, results) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        model.setLeanMode(true)
        let frame = try XCTUnwrap(model.frame("P01-subject"))
        var a = LeanAnnotation(frame: frame, wheel: LeanLine(a: Point2(x: 100, y: 150), b: Point2(x: 150, y: 30), uncertainty: 3))
        let target = model.leanTarget(frameID: frame.id)
        model.commitLean(a, target: target)
        XCTAssertEqual(model.lean(frame.id), a)
        model.edit(point: Point2(x: 20, y: 30), state: .corrected, target: model.target(frameID: frame.id, joint: "left_elbow"))
        a.wheel?.a.x += 10
        model.commitLean(a, target: model.leanTarget(frameID: frame.id))
        model.undo()
        XCTAssertEqual(model.lean(frame.id)?.wheel?.a.x, 100)
        XCTAssertNotNil(model.frame(frame.id)?.edits["left_elbow"])
        model.undo(); XCTAssertNil(model.frame(frame.id)?.edits["left_elbow"])
        let reopened = ReviewerModel()
        reopened.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertEqual(reopened.lean(frame.id), model.lean(frame.id))
        let revision = model.document?.revision
        model.setLeanMode(false)
        model.commitLean(a, target: target)
        XCTAssertEqual(model.document?.revision, revision)
    }
    @MainActor func testRoleLabelsPreserveSourceIdentityAndReviewBytes() async throws {
        let (model, folder, results) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys]
        let before = try encoder.encode(model.document!)
        XCTAssertEqual(model.document?.frames.first?.role, "reference", "Fixture deliberately reverses source order")
        XCTAssertEqual(model.frame("P01-subject")?.riderDisplayName, "Rider")
        XCTAssertEqual(model.frame("P01-reference")?.riderDisplayName, "Reference rider")
        XCTAssertEqual(model.frame("P01-subject")?.rider, "James")
        XCTAssertEqual(model.frame("P01-reference")?.rider, "Damien")
        model.activate("P01-subject"); model.setCompare(false)
        XCTAssertEqual(model.frame?.riderDisplayName, "Rider")
        XCTAssertEqual(try encoder.encode(model.document!), before)
        let reopened = ReviewerModel()
        reopened.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertEqual(reopened.frame("P01-reference")?.riderDisplayName, "Reference rider")
        XCTAssertEqual(try encoder.encode(reopened.document!), before)
        let single = ReviewFrame(id: "single", pairID: "single", role: "subject", rider: "Private name",
            file: "original.png", sourceHash: "unchanged", panel: PixelRect(x: 20, y: 30, width: 100, height: 100), raw3D: nil, raw2D: nil)
        XCTAssertNil(try PairReview.resolve([single]).first?.referenceID)
        XCTAssertEqual(single.riderDisplayName, "Rider")
    }
    @MainActor func testAuthoredPhotoCoachingSurvivesNavigationAndReopen() async throws {
        let (model, folder, results) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        var context = try XCTUnwrap(model.report?.context)
        context.photoCoaching = PhotoCoachingReview(cornerDirection: .left, provenance: "Synthetic photo review",
            points: [PhotoCoachingPoint(title: "Torso cue", detail: "Synthetic source-only note")])
        model.updateContext(context)
        XCTAssertEqual(model.report?.coaching.first?.title, "Torso cue")
        XCTAssertEqual(model.report?.effectiveCornerDirection, .left)
        model.activate("P01-subject"); model.setCompare(false)
        XCTAssertEqual(model.report?.coaching.first?.title, "Torso cue")
        model.selectPair("P02")
        XCTAssertTrue(model.report?.coaching.isEmpty == true)
        let reopened = ReviewerModel()
        reopened.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertEqual(reopened.report?.coaching.first?.title, "Torso cue")
        XCTAssertFalse(reopened.report!.context.subjectConfirmed)
    }
    @MainActor func testCoachingContextPersistsAcrossSingleCompareAndReopen() async throws {
        let (model, folder, results) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        var context = try XCTUnwrap(model.report?.context)
        context.subjectConfirmed = true; context.referenceConfirmed = true; context.cornerDirection = .left
        model.updateContext(context)
        context.kneePlacement = [.leftKnee: .closerToTank]
        model.updateContext(context)
        XCTAssertEqual(model.report?.coaching.count, 1)
        model.activate("P01-subject"); model.setCompare(false)
        XCTAssertEqual(model.report?.coaching.count, 1)
        let reopened = ReviewerModel()
        reopened.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertEqual(reopened.report?.coaching.count, 1)
        context.cornerDirection = .right
        model.updateContext(context)
        XCTAssertTrue(model.report?.coaching.isEmpty == true, "Changing turn direction requires a new placement review")
        model.selectPair("P02")
        XCTAssertTrue(model.report?.coaching.isEmpty == true)
    }
    @MainActor func testFramingSourceBindingAndPresentationIsolation() async throws {
        let (model, folder, results) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        let frame = try XCTUnwrap(model.frame("P01-subject"))
        let bounds = PixelRect(x: 100, y: 40, width: 100, height: 120)
        let entry = PhotoFramingRegion(frameID: frame.id, sourceHash: frame.sourceHash, panel: frame.panel, bounds: bounds)
        let file = folder.appendingPathComponent("photo-framing.json")
        try JSONEncoder().encode([entry]).write(to: file)
        let revision = model.document?.revision
        model.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertEqual(model.framingRegions[frame.id], bounds)
        XCTAssertTrue(model.settings(frame.id).autoFrame)
        var settings = model.settings(frame.id); settings.autoFrame = false; settings.zoom = 2
        model.setSettings(frame.id, settings)
        XCTAssertTrue(model.settings("P01-reference").autoFrame)
        XCTAssertEqual(model.document?.revision, revision)
        let stale = PhotoFramingRegion(frameID: frame.id, sourceHash: "wrong-photo", panel: frame.panel, bounds: bounds)
        try JSONEncoder().encode([stale]).write(to: file)
        model.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertTrue(model.framingRegions.isEmpty)
        XCTAssertNil(model.error)
        XCTAssertTrue(model.message.contains("Framing unavailable"))
        try JSONEncoder().encode([entry, entry]).write(to: file)
        XCTAssertThrowsError(try PhotoFramingRegion.load(from: file, frames: model.document!.frames))
        let outside = PhotoFramingRegion(frameID: frame.id, sourceHash: frame.sourceHash, panel: frame.panel,
                                        bounds: PixelRect(x: 250, y: 40, width: 100, height: 120))
        try JSONEncoder().encode([outside]).write(to: file)
        XCTAssertThrowsError(try PhotoFramingRegion.load(from: file, frames: model.document!.frames))
    }
    @MainActor func testDiscoveryPreservesDirtyReviewAndReportsMixedFolders() async throws {
        let (model, folder, _) = try fixture()
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: folder); try? FileManager.default.removeItem(at: root) }
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        try FileManager.default.copyItem(at: folder, to: root.appendingPathComponent("good"))
        let bad = root.appendingPathComponent("bad")
        try FileManager.default.createDirectory(at: bad, withIntermediateDirectories: true)
        try Data("not a photo".utf8).write(to: bad.appendingPathComponent("broken.jpg"))
        let reviewURL = folder.appendingPathComponent("joint-review-\(model.document!.reportHash.prefix(12)).json")
        var competing = try ReviewStore.load(from: reviewURL); competing.revision += 10
        try ReviewStore.save(competing, to: reviewURL, expectedRevision: 0)
        model.edit(point: Point2(x: 20, y: 30), state: .corrected)
        XCTAssertTrue(model.isUnsaved)
        let revision = model.document?.revision, session = model.sessionID
        let task = try XCTUnwrap(model.discoverSessions(dataDirectory: root, analyze: { _, _ in
            throw PoseError.invalidInput("Unexpected analysis: fixture has valid results")
        }))
        XCTAssertTrue(model.isDiscovering)
        XCTAssertNil(model.discoverSessions(dataDirectory: root))
        await task.value
        XCTAssertFalse(model.isDiscovering)
        XCTAssertEqual(model.presets.count, 1)
        XCTAssertEqual(model.discoveryFailures.count, 1)
        XCTAssertEqual(model.document?.revision, revision)
        XCTAssertEqual(model.sessionID, session)
        XCTAssertTrue(model.isUnsaved)
    }
    @MainActor func testChecklistFollowsSubjectAndResetsAcrossSourcesAndDirections() async throws {
        let (model, folder, results) = try fixture()
        defer { try? FileManager.default.removeItem(at: folder) }
        XCTAssertNil(model.currentCoachingChecklist)
        let frame = try XCTUnwrap(model.frame("P01-subject"))
        var review = CoachingChecklistReview(frame: frame)
        review.cornerDirection = .left
        review.items[0] = .init(topic: .grip, status: .hypothesis, observation: "Synthetic rider report")
        let url = folder.appendingPathComponent("coaching-checklist.json")
        try JSONEncoder().encode([review]).write(to: url)
        let revision = model.document?.revision
        model.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertEqual(model.currentCoachingChecklist, review)
        XCTAssertEqual(model.document?.revision, revision)
        model.setCompare(true); model.activate("P01-reference")
        XCTAssertEqual(model.currentCoachingChecklist?.frameID, "P01-subject")
        model.selectPair("P02"); XCTAssertNil(model.currentCoachingChecklist)
        model.selectPair("P01")
        var context = try XCTUnwrap(model.report?.context)
        context.cornerDirection = .right; model.updateContext(context)
        XCTAssertTrue(model.checklistDirectionConflict)
        XCTAssertNil(model.currentCoachingChecklist)
        context.cornerDirection = .left; model.updateContext(context)
        XCTAssertEqual(model.currentCoachingChecklist, review)
        review.sourceHash = "wrong"
        try JSONEncoder().encode([review]).write(to: url)
        model.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertNil(model.currentCoachingChecklist)
        XCTAssertNotNil(model.coachingChecklistError)
        XCTAssertNil(model.error)
        let (single, singleFolder, singleResults) = try fixture(paired: false)
        defer { try? FileManager.default.removeItem(at: singleFolder) }
        let singleReview = CoachingChecklistReview(frame: try XCTUnwrap(single.frame))
        try JSONEncoder().encode([singleReview]).write(to: singleFolder.appendingPathComponent("coaching-checklist.json"))
        single.load(manifest: singleFolder.appendingPathComponent("session.json"), results: singleResults)
        XCTAssertEqual(single.currentCoachingChecklist, singleReview)
    }
    @MainActor func testSinglePhotoCoachingLoadsWithoutReferenceAndSurvivesReopen() async throws {
        let (model, folder, results) = try fixture(paired: false)
        defer { try? FileManager.default.removeItem(at: folder) }
        let frame = try XCTUnwrap(model.frame)
        let entry = SinglePhotoCoaching(frameID: frame.id, sourceHash: frame.sourceHash, panel: frame.panel,
                                       provenance: "Synthetic single-photo assessment",
                                       points: [PhotoCoachingPoint(title: "Focus", detail: "A source-specific observation")])
        let url = folder.appendingPathComponent("single-photo-coaching.json")
        try JSONEncoder().encode([entry]).write(to: url)
        let revision = model.document?.revision
        model.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertNil(model.report)
        XCTAssertNil(model.pair?.referenceID)
        XCTAssertEqual(model.currentSinglePhotoCoaching?.points.first?.title, "Focus")
        model.selectPair("P02")
        XCTAssertNil(model.currentSinglePhotoCoaching)
        model.selectPair("P01")
        XCTAssertEqual(model.currentSinglePhotoCoaching?.frameID, frame.id)
        model.setCompare(true)
        XCTAssertFalse(model.compare)
        let reopened = ReviewerModel()
        reopened.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertEqual(reopened.currentSinglePhotoCoaching, entry)
        XCTAssertEqual(reopened.document?.revision, revision)
        let view = NSHostingView(rootView: SinglePhotoFeedbackView(model: reopened))
        XCTAssertGreaterThan(view.fittingSize.height, 0)
        try JSONEncoder().encode([entry, entry]).write(to: url)
        reopened.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertNil(reopened.currentSinglePhotoCoaching)
        XCTAssertNotNil(reopened.singlePhotoCoachingError)
        XCTAssertNil(reopened.error)
    }
    @MainActor func testSinglePhotoCoachingRejectsWrongSourceEmptyContentAndPairedPhotos() async throws {
        let (model, folder, _) = try fixture(paired: false)
        defer { try? FileManager.default.removeItem(at: folder) }
        let frame = try XCTUnwrap(model.frame)
        let url = folder.appendingPathComponent("single-photo-coaching.json")
        for entry in [
            SinglePhotoCoaching(frameID: frame.id, sourceHash: "wrong", panel: frame.panel, provenance: "Test", points: [.init(title: "Focus", detail: "Detail")]),
            SinglePhotoCoaching(frameID: frame.id, sourceHash: frame.sourceHash, panel: frame.panel, provenance: "Test", points: [])
        ] {
            try JSONEncoder().encode([entry]).write(to: url)
            XCTAssertThrowsError(try SinglePhotoCoaching.load(from: url, frames: model.document!.frames))
        }
        let (paired, pairedFolder, _) = try fixture()
        defer { try? FileManager.default.removeItem(at: pairedFolder) }
        let pairedFrame = try XCTUnwrap(paired.frame)
        let entry = SinglePhotoCoaching(frameID: pairedFrame.id, sourceHash: pairedFrame.sourceHash, panel: pairedFrame.panel,
                                       provenance: "Test", points: [.init(title: "Focus", detail: "Detail")])
        try JSONEncoder().encode([entry]).write(to: url)
        XCTAssertThrowsError(try SinglePhotoCoaching.load(from: url, frames: paired.document!.frames))
    }
    @MainActor private func fixture(paired: Bool = true) throws -> (ReviewerModel, URL, URL) {
        let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 300, pixelsHigh: 200, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
        let imageData = bitmap.representation(using: .png, properties: [:])!
        let panel = PixelRect(x: 0, y: 0, width: 300, height: 200)
        var inputs: [PairInput] = []; var crops: [CropReport] = []
        for id in ["P01", "P02"] {
            var riders: [RiderInput] = []
            for role in paired ? ["reference", "subject"] : ["subject"] {
                let file = "\(id)-\(role).png"; try imageData.write(to: folder.appendingPathComponent(file))
                let name = role == "subject" ? "James" : "Damien"
                riders.append(RiderInput(role: role, rider: name, file: file, panel: panel, crop: panel, annotations: [], annotationNote: "Synthetic no-pose fixture"))
                crops.append(CropReport(pairID: id, role: role, rider: name, sourceHash: ReviewStore.digest(imageData), crop: panel))
            }
            inputs.append(PairInput(id: id, file: "fallback.png", riders: riders))
        }
        let manifest = SessionManifest(schemaVersion: 1, session: "Synthetic", annotationProvenance: "Synthetic", pairs: inputs)
        let encoder = JSONEncoder(); let bytes = try encoder.encode(manifest)
        let manifestURL = folder.appendingPathComponent("session.json"); try bytes.write(to: manifestURL)
        let results = FeasibilityReport(session: "Synthetic", operatingSystem: "test", machineArchitecture: "test", generatedAt: "test", annotationProvenance: "test", manifestHash: ReviewStore.digest(bytes), crops: crops)
        let resultsURL = folder.appendingPathComponent("results.json"); try encoder.encode(results).write(to: resultsURL)
        let model = ReviewerModel(); model.load(manifest: manifestURL, results: resultsURL); model.setCompare(true)
        XCTAssertNil(model.error); XCTAssertEqual(model.compare, paired)
        return (model, folder, resultsURL)
    }
    @MainActor func testTargetUndoPreviewAndPresentationIsolation() async throws {
        let (model, folder, _) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        let target = model.target(frameID: "P01-reference", joint: "left_elbow")
        model.edit(point: Point2(x: 120, y: 100), state: .corrected, target: target)
        XCTAssertNil(model.frame("P01-subject")?.edits["left_elbow"])
        XCTAssertEqual(model.frame("P01-reference")?.reviewedPoint("left_elbow"), Point2(x: 120, y: 100))
        let report = model.report
        var settings = model.settings("P01-reference"); settings.zoom = 2; settings.kind = "2D"
        model.setSettings("P01-reference", settings); model.highlight(.leftElbow)
        XCTAssertEqual(model.report, report)
        model.previewTarget = model.target(frameID: "P01-reference", joint: "left_elbow")
        XCTAssertEqual(model.report, report)
        model.cancelInteraction(); XCTAssertNil(model.previewTarget)
        model.activate("P01-subject"); model.undo()
        XCTAssertNil(model.frame("P01-reference")?.edits["left_elbow"])
        XCTAssertEqual(model.report?.revision, model.document?.revision)
        let revision = model.document?.revision
        model.edit(point: Point2(x: 10, y: 20), state: .corrected, target: target)
        XCTAssertEqual(model.document?.revision, revision)
    }
    @MainActor func testMissingReferenceKeepsCompletePreviousPair() async throws {
        let (model, folder, _) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        let report = model.report, imageIDs = Set(model.images.keys)
        try FileManager.default.removeItem(at: folder.appendingPathComponent("P02-reference.png"))
        model.selectPair("P02")
        XCTAssertNotNil(model.error); XCTAssertEqual(model.pair?.id, "P01")
        XCTAssertEqual(Set(model.images.keys), imageIDs); XCTAssertEqual(model.report, report)
    }
    @MainActor func testSaveConflictKeepsCurrentReportAndProtectsNavigation() async throws {
        let (model, folder, results) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        let review = folder.appendingPathComponent("joint-review-\(model.document!.reportHash.prefix(12)).json")
        var competing = try ReviewStore.load(from: review); competing.revision += 10
        try ReviewStore.save(competing, to: review, expectedRevision: 0)
        let disk = try Data(contentsOf: review)
        model.edit(point: Point2(x: 20, y: 30), state: .corrected)
        XCTAssertTrue(model.isUnsaved); XCTAssertEqual(model.report?.revision, 1)
        XCTAssertEqual(model.document?.revision, 1); XCTAssertEqual(try Data(contentsOf: review), disk)
        model.selectPair("P02"); XCTAssertEqual(model.pair?.id, "P01")
        model.load(manifest: folder.appendingPathComponent("session.json"), results: results)
        XCTAssertEqual(model.document?.revision, 1, "Direct session loading must also preserve unsaved edits")
        let reopened = ReviewerModel(); reopened.load(manifest: folder.appendingPathComponent("session.json"), results: results); reopened.setCompare(true)
        XCTAssertEqual(reopened.report?.revision, 10)
        XCTAssertNil(reopened.frame("P01-reference")?.edits["left_elbow"])
    }
    @MainActor func testRetryCompletionKeepsPanePresentationAndReviewedReportIsolated() async throws {
        let (model, folder, _) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        let session = model.sessionID
        let before = model.report!.observations
        let pose = PoseResult(kind: "2D", status: "needsReview", requestRevision: 1, durationSeconds: 0,
                              joints: [JointResult(name: "left_elbow", point: Point2(x: 20, y: 30), confidence2D: 1)], candidateCount: 1)
        let retry = DetectionRetry(crop: model.frame!.panel, twoD: pose, threeD: nil)
        model.activate("P01-subject")
        try model.acceptRetry(retry, frameID: "P01-reference", session: session)
        XCTAssertEqual(model.settings("P01-reference").kind, "2D")
        XCTAssertEqual(model.settings("P01-subject").kind, "3D")
        XCTAssertEqual(model.report?.observations, before)
        XCTAssertEqual(model.report?.revision, 1)
        try model.acceptRetry(retry, frameID: "P01-subject", session: UUID())
        XCTAssertEqual(model.report?.revision, 1)
    }

    @MainActor func testCanvasDragPreviewEscapeAndCommit() async throws {
        let (model, folder, _) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        model.activate("P01-subject")
        model.edit(point: Point2(x: 100, y: 100), state: .corrected)
        let canvas = JointCanvasView(frame: NSRect(x: 0, y: 0, width: 300, height: 200))
        canvas.model = model; canvas.frameID = "P01-subject"; canvas.generation = model.canvasGeneration
        func mouse(_ type: NSEvent.EventType, _ x: Double) -> NSEvent {
            NSEvent.mouseEvent(with: type, location: NSPoint(x: x, y: 100), modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)!
        }
        let committed = model.report
        canvas.mouseDown(with: mouse(.leftMouseDown, 100))
        canvas.mouseDragged(with: mouse(.leftMouseDragged, 120))
        XCTAssertNotNil(model.previewTarget)
        XCTAssertEqual(model.report, committed)
        let escape = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 53)!
        canvas.keyDown(with: escape)
        XCTAssertNil(model.previewTarget); XCTAssertEqual(model.report, committed)
        canvas.mouseDown(with: mouse(.leftMouseDown, 100))
        canvas.mouseDragged(with: mouse(.leftMouseDragged, 120))
        canvas.mouseUp(with: mouse(.leftMouseUp, 120))
        XCTAssertNil(model.previewTarget)
        XCTAssertEqual(model.frame?.reviewedPoint("left_elbow")?.x, 120)
        XCTAssertEqual(model.report?.revision, 2)
    }
    @MainActor func testMinimumNativeLayout() async throws {
        let (model, folder, _) = try fixture(); defer { try? FileManager.default.removeItem(at: folder) }
        model.inspectorVisible = false
        let hosting = NSHostingView(rootView: ReviewerView(model: model).frame(width: 1000, height: 650))
        hosting.frame = NSRect(x: 0, y: 0, width: 1000, height: 650)
        hosting.layoutSubtreeIfNeeded()
        XCTAssertEqual(hosting.fittingSize.width, 1000)
        XCTAssertEqual(hosting.fittingSize.height, 650)
        if let path = ProcessInfo.processInfo.environment["POSE_LAYOUT_SNAPSHOT"],
           let bitmap = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) {
            hosting.cacheDisplay(in: hosting.bounds, to: bitmap)
            try bitmap.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: path))
        }
    }

}
