import { describe, expect, it } from "vitest"; import { createCueRuntimeState } from "@goodgrief/cue-engine"; import { createEmptyRepositoryState, defaultCueStack, defaultSceneDefinitions, type Cue, type PhotoAsset, type Submission } from "@goodgrief/shared-types"; import { adminReducer, createCueDraft, createMetadataDraft, initialAdminUiState, sortCues } from "./admin-state"; const scene = defaultSceneDefinitions[0]!; const otherScene = defaultSceneDefinitions.find((entry) => entry.id !== scene.id)!; const cue = (id: string, orderIndex: number, notes: string): Cue => ({ ...defaultCueStack[0]!, id, orderIndex, sceneDefinitionId: scene.id, notes }); const submission = (id: string, caption: string): Submission => ({ id, source: "live", submittedAt: "2026-04-10T00:00:00.000Z", status: "approved_all", consentId: `consent-${id}`, displayName: `Display ${id}`, caption }); const asset = ( id: string, submissionId: string, moderationStatus: PhotoAsset["moderationStatus"] = "approved" ): PhotoAsset => ({ id, submissionId, originalKey: `/uploads/${id}.jpg`, mimeType: "image/jpeg", processingStatus: "ready", moderationStatus, createdAt: "2026-04-10T00:00:00.000Z" }); const repositoryWithCues = (cues: Cue[]) => ({ ...createEmptyRepositoryState(), scenes: defaultSceneDefinitions, cues: sortCues(cues), photoAssets: [], submissions: [] }); const bootState = (cues: Cue[], selectedCueId = cues[0]?.id ?? null) => { const repository = repositoryWithCues(cues); const selectedCue = selectedCueId ? repository.cues.find((entry) => entry.id === selectedCueId) : null; return adminReducer(initialAdminUiState, { type: "bootstrapLoaded", payload: repository, initial: { cueState: selectedCue ? createCueRuntimeState(repository.cues) : createCueRuntimeState([]), selectedSceneId: scene.id, selectedAssetIds: [], previewParams: scene.defaultParams, activePresetId: scene.defaultPresetId, cueDraft: createCueDraft(selectedCue, scene) } }); }; describe("adminReducer cue reconciliation", () => { it("moves a cue immediately while keeping the moved cue selected and dirty draft intact", () => { const cues = [cue("cue-a", 0, "A"), cue("cue-b", 1, "B"), cue("cue-c", 2, "C")]; const dirtyState = { ...bootState(cues, "cue-b"), cueState: createCueRuntimeState(cues), cueDraft: { ...createCueDraft(cues[1], scene), notes: "Unsaved label" }, cueDraftDirty: true }; const moving = adminReducer(dirtyState, { type: "cueMoveOptimistic", cueId: "cue-b", direction: "up" }); expect(moving.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]); expect(moving.cueState.previewCueId).toBe("cue-b"); expect(moving.cueState.armedCueId).toBe("cue-b"); expect(moving.cueMoveInFlight).toBe(true); expect(moving.cueDraftDirty).toBe(true); expect(moving.cueDraft.notes).toBe("Unsaved label"); const stalePoll = adminReducer(moving, { type: "liveLoaded", cues, pendingCount: 1, approvedCount: 2 }); expect(stalePoll.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]); expect(stalePoll.pendingCount).toBe(1); expect(stalePoll.approvedCount).toBe(2); const moved = adminReducer(moving, { type: "cueMoveSucceeded", cueId: "cue-b", cues: [cue("cue-b", 0, "B"), cue("cue-a", 1, "A"), cue("cue-c", 2, "C")] }); expect(moved.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]); expect(moved.cueState.previewCueId).toBe("cue-b"); expect(moved.cueState.armedCueId).toBe("cue-b"); expect(moved.cueMoveInFlight).toBe(false); expect(moved.cueDraftDirty).toBe(true); expect(moved.cueDraft.notes).toBe("Unsaved label"); }); it("refreshes clean cue drafts from live polling but preserves dirty drafts", () => { const cues = [cue("cue-a", 0, "A"), cue("cue-b", 1, "B")]; const cleanState = { ...bootState(cues, "cue-b"), cueState: createCueRuntimeState(cues), cueDraft: createCueDraft(cues[1], scene) }; const cleanRefresh = adminReducer(cleanState, { type: "liveLoaded", cues: [cue("cue-a", 0, "A"), cue("cue-b", 1, "B updated")], pendingCount: 0, approvedCount: 0 }); const dirtyRefresh = adminReducer( { ...cleanState, cueDraft: { ...cleanState.cueDraft, notes: "Unsaved local edit" }, cueDraftDirty: true }, { type: "liveLoaded", cues: [cue("cue-a", 0, "A"), cue("cue-b", 1, "B updated")], pendingCount: 0, approvedCount: 0 } ); expect(cleanRefresh.cueDraft.notes).toBe("B updated"); expect(dirtyRefresh.cueDraft.notes).toBe("Unsaved local edit"); }); }); describe("adminReducer library reconciliation", () => { const baseSubmission = submission("submission-a", "Caption"); it("prunes unavailable selected media while preserving dirty metadata drafts", () => { const state = { ...bootState([cue("cue-a", 0, "A")]), selectedAssetIds: ["asset-a", "asset-b"], metadataAssetId: "asset-a", metadataDraft: { ...createMetadataDraft(baseSubmission), caption: "Unsaved caption" }, metadataDirty: true }; const reconciled = adminReducer(state, { type: "libraryLoaded", photoAssets: [asset("asset-a", baseSubmission.id, "approved"), asset("asset-b", baseSubmission.id, "hold")], submissions: [ { ...baseSubmission, caption: "Server caption" } ], collections: state.repository?.collections ?? [] }); expect(reconciled.selectedAssetIds).toEqual(["asset-a"]); expect(reconciled.metadataAssetId).toBe("asset-a"); expect(reconciled.metadataDraft.caption).toBe("Unsaved caption"); expect(reconciled.approvedCount).toBe(1); }); }); describe("adminReducer preview and operator controls", () => { it("selects a scene as one clean preview transition", () => { const state = bootState([cue("cue-a", 0, "A")], "cue-a"); const selected = adminReducer(state, { type: "previewSceneSelected", scene: otherScene, presetId: "preset-test", params: otherScene.defaultParams, assetIds: ["asset-a", "asset-b"] }); expect(selected.selectedSceneId).toBe(otherScene.id); expect(selected.sceneBrowserFilter).toBe(otherScene.sceneFamily); expect(selected.activePresetId).toBe("preset-test"); expect(selected.previewParams).toBe(otherScene.defaultParams); expect(selected.selectedAssetIds).toEqual(["asset-a", "asset-b"]); expect(selected.metadataAssetId).toBe("asset-a"); expect(selected.cueDraft.id).toBeNull(); expect(selected.cueDraftDirty).toBe(false); }); it("keeps media selection ordering deterministic and marks saved cue drafts dirty", () => { const state = { ...bootState([cue("cue-a", 0, "A")], "cue-a"), selectedAssetIds: ["asset-a", "asset-b", "asset-c"], metadataAssetId: "asset-a", cueDraftDirty: false }; const promoted = adminReducer(state, { type: "selectedAssetPromoted", assetId: "asset-b", status: "Anchor image updated." }); const reordered = adminReducer(promoted, { type: "selectedAssetReordered", assetId: "asset-b", direction: "later" }); const removed = adminReducer(reordered, { type: "selectedAssetRemoved", assetId: "asset-b" }); expect(promoted.selectedAssetIds).toEqual(["asset-b", "asset-a", "asset-c"]); expect(promoted.metadataAssetId).toBe("asset-b"); expect(promoted.cueDraftDirty).toBe(true); expect(reordered.selectedAssetIds).toEqual(["asset-a", "asset-b", "asset-c"]); expect(removed.selectedAssetIds).toEqual(["asset-a", "asset-c"]); expect(removed.metadataAssetId).toBeNull(); }); it("marks cue drafts dirty when operator params or cue fields change", () => { const state = { ...bootState([cue("cue-a", 0, "A")], "cue-a"), cueDraftDirty: false }; const paramChanged = adminReducer(state, { type: "previewParamChanged", path: "scenicTreatment.hue", value: 0.42 }); const cueChanged = adminReducer(state, { type: "cueDraftChanged", field: "notes", value: "Operator label" }); expect(paramChanged.previewParams?.scenicTreatment.hue).toBe(0.42); expect(paramChanged.cueDraftDirty).toBe(true); expect(cueChanged.cueDraft.notes).toBe("Operator label"); expect(cueChanged.cueDraftDirty).toBe(true); }); }); describe("adminReducer metadata and upload controls", () => { it("preserves dirty metadata for the same target and hydrates when the target changes", () => { const submissionA = submission("submission-a", "Saved A"); const submissionB = submission("submission-b", "Saved B"); const focused = adminReducer(bootState([cue("cue-a", 0, "A")]), { type: "metadataTargetSelected", assetId: "asset-a", submission: submissionA }); const edited = adminReducer(focused, { type: "metadataDraftChanged", field: "caption", value: "Local edit" }); const sameTargetRefresh = adminReducer(edited, { type: "metadataTargetSelected", assetId: "asset-a", submission: { ...submissionA, caption: "Server refresh" } }); const changedTarget = adminReducer(sameTargetRefresh, { type: "metadataTargetSelected", assetId: "asset-b", submission: submissionB }); expect(focused.metadataDraft.caption).toBe("Saved A"); expect(sameTargetRefresh.metadataDraft.caption).toBe("Local edit"); expect(sameTargetRefresh.metadataDirty).toBe(true); expect(changedTarget.metadataDraft.caption).toBe("Saved B"); expect(changedTarget.metadataDirty).toBe(false); }); it("resets upload fields and optionally adds uploaded media to the current cue draft", () => { const state = { ...bootState([cue("cue-a", 0, "A")], "cue-a"), selectedAssetIds: ["asset-a"], uploadName: "Name", uploadCaption: "Caption", uploadPromptAnswer: "Prompt", uploadAddToSelection: false, cueDraftDirty: false }; const uploaded = adminReducer( { ...state, uploadAddToSelection: true }, { type: "adminUploadSucceeded", assetId: "asset-new", addToSelection: true } ); expect(uploaded.selectedAssetIds).toEqual(["asset-new", "asset-a"]); expect(uploaded.metadataAssetId).toBe("asset-new"); expect(uploaded.uploadName).toBe(""); expect(uploaded.uploadCaption).toBe(""); expect(uploaded.uploadPromptAnswer).toBe(""); expect(uploaded.uploadAddToSelection).toBe(true); expect(uploaded.cueDraftDirty).toBe(true); }); });