340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|