goodgrief/apps/admin/src/app/admin-state.test.ts

340 lines
11 KiB
TypeScript
Raw Normal View History

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);
});
});