diff --git a/apps/ui-admin/src/App.test.tsx b/apps/ui-admin/src/App.test.tsx
index b764c8e16..9cf33304c 100644
--- a/apps/ui-admin/src/App.test.tsx
+++ b/apps/ui-admin/src/App.test.tsx
@@ -276,6 +276,7 @@ describe("AdminApp", () => {
render();
expect(await screen.findByRole("heading", { name: "ED Chest Pain With Nurse Interruption And Family Pressure" })).toBeInTheDocument();
+ expect(screen.queryByLabelText("Authoring preview")).not.toBeInTheDocument();
expect(within(screen.getByLabelText("Scenario environment")).getByText("Emergency department exam bay")).toBeInTheDocument();
expect(within(screen.getByLabelText("Scenario equipment")).getByText("12-lead ECG machine")).toBeInTheDocument();
expect(within(screen.getByLabelText("Scenario actors")).getByText("Robert Hayes")).toBeInTheDocument();
@@ -336,7 +337,10 @@ describe("AdminApp", () => {
reviewerId: "admin_clinical_reviewer",
decision: "APPROVED",
comments: "Clinical rationale from faculty reviewer for local formative only.",
- evidenceRefs: ["evidence:local-admin:peds_asthma_parent_anxiety_v1:clinical"],
+ evidenceRefs: [
+ "evidence:local-admin:peds_asthma_parent_anxiety_v1:clinical",
+ expect.stringMatching(/^authoredContentIdentity:[0-9a-f]{8}$/),
+ ],
});
});
diff --git a/apps/ui-admin/src/CaseAuthoringWorkbench.tsx b/apps/ui-admin/src/CaseAuthoringWorkbench.tsx
index f40566df6..016289587 100644
--- a/apps/ui-admin/src/CaseAuthoringWorkbench.tsx
+++ b/apps/ui-admin/src/CaseAuthoringWorkbench.tsx
@@ -40,12 +40,8 @@ import { AssetNeedsPanel } from "./AssetNeedsPanel.js";
import { EmotionPolicyPanel } from "./EmotionPolicyPanel.js";
import { EquipmentPanel } from "./EquipmentPanel.js";
import { StringListField } from "./StringListField.js";
-import {
- actorFormFromDraft,
- extractScenario,
- extractScenarioList,
- structuredCloneScenario,
-} from "./case-authoring-io.js";
+import { actorFormFromDraft, extractScenario, extractScenarioList, structuredCloneScenario } from "./case-authoring-io.js";
+import { LiveAuthoringPreview } from "./scenario-authoring-preview/LiveAuthoringPreview.js";
const { TextArea } = Input;
@@ -521,6 +517,7 @@ export function CaseAuthoringWorkbench({ initialScenario, apiClient }: CaseAutho
)}
+
diff --git a/apps/ui-admin/src/scenario-authoring-preview/LiveAuthoringPreview.tsx b/apps/ui-admin/src/scenario-authoring-preview/LiveAuthoringPreview.tsx
new file mode 100644
index 000000000..67b238e88
--- /dev/null
+++ b/apps/ui-admin/src/scenario-authoring-preview/LiveAuthoringPreview.tsx
@@ -0,0 +1,28 @@
+import type { Scenario } from "@openclinxr/shared-schemas";
+import { Form } from "antd";
+import { type ReactElement, useMemo } from "react";
+import { mergeFormValuesIntoScenario, type ScenarioFormValues } from "../case-authoring-model.js";
+import { ScenarioAuthoringPreviewPanel } from "./ScenarioAuthoringPreviewPanel.js";
+
+/**
+ * Watches the encounter-case form and previews the current merged draft against
+ * the loaded baseline. Promotion stays fail-closed until a matching reviewed identity
+ * is supplied (authoring does not invent one).
+ */
+export function LiveAuthoringPreview({ approved }: { approved: Scenario }): ReactElement {
+ const form = Form.useFormInstance
();
+ const actors = Form.useWatch("actors", form);
+ const equipment = Form.useWatch("equipment", form);
+ const emotionPolicy = Form.useWatch("emotionPolicy", form);
+ const environmentId = Form.useWatch("environmentId", form);
+ const draft = useMemo(() => {
+ const values = form.getFieldsValue(true) as ScenarioFormValues;
+ // Form.List fields register after first paint; merging an empty actor list
+ // would invent a full actor/dialogue/asset removal versus the loaded case.
+ if (approved.actors.length > 0 && (values.actors?.length ?? 0) === 0) {
+ return approved;
+ }
+ return mergeFormValuesIntoScenario(approved, values);
+ }, [approved, form, actors, equipment, emotionPolicy, environmentId]);
+ return ;
+}
diff --git a/apps/ui-admin/src/scenario-authoring-preview/ScenarioAuthoringPreviewPanel.tsx b/apps/ui-admin/src/scenario-authoring-preview/ScenarioAuthoringPreviewPanel.tsx
new file mode 100644
index 000000000..e7fe51b9f
--- /dev/null
+++ b/apps/ui-admin/src/scenario-authoring-preview/ScenarioAuthoringPreviewPanel.tsx
@@ -0,0 +1,93 @@
+import {
+ type AuthoringPreviewResult,
+ previewAuthoringRevision,
+ STALE_REVIEW_IDENTITY_REFUSAL,
+} from "@openclinxr/ui-route-admin";
+import { Alert, Button, List, Space, Tag, Typography } from "antd";
+import { type ReactElement, useMemo } from "react";
+
+export type ScenarioAuthoringPreviewPanelProps = {
+ draft: unknown;
+ approved?: unknown;
+ reviewIdentity?: string | null;
+ onPromote?: () => void;
+ preview?: AuthoringPreviewResult;
+};
+
+const SURFACE_LABEL: Record = {
+ actor: "Actor",
+ dialogue: "Dialogue",
+ emotion: "Emotion",
+ asset: "Asset",
+};
+
+export function ScenarioAuthoringPreviewPanel({
+ draft,
+ approved,
+ reviewIdentity = null,
+ onPromote,
+ preview: injected,
+}: ScenarioAuthoringPreviewPanelProps): ReactElement {
+ const preview = useMemo(
+ () => injected ?? previewAuthoringRevision({ draft, approved, reviewIdentity }),
+ [injected, draft, approved, reviewIdentity],
+ );
+ const promotionAllowed = preview.promotion.allowed;
+ const reasons = preview.promotion.allowed ? [] : preview.promotion.reasons;
+
+ return (
+
+ Authoring preview
+
+ Compiles this draft through production encounter contracts and shows the exact
+ actor, dialogue, emotion, and asset delta versus the currently approved revision.
+
+
+ {preview.notEvidenceFor.map((flag) => (
+ {flag}
+ ))}
+
+ {!preview.validationOk ? (
+
+ ) : null}
+ {reasons.includes(STALE_REVIEW_IDENTITY_REFUSAL) ? (
+
+ ) : null}
+ (
+
+
+
+ {SURFACE_LABEL[change.surface]}
+ {change.change} {change.path}
+
+ {change.before ? (
+ before: {change.before}
+ ) : null}
+ {change.after ? after: {change.after} : null}
+
+
+ )}
+ />
+
+
+ );
+}
diff --git a/apps/ui-admin/src/scenario-authoring-preview/the-authoring-form-updates-preview-delta.test.tsx b/apps/ui-admin/src/scenario-authoring-preview/the-authoring-form-updates-preview-delta.test.tsx
new file mode 100644
index 000000000..1393c4380
--- /dev/null
+++ b/apps/ui-admin/src/scenario-authoring-preview/the-authoring-form-updates-preview-delta.test.tsx
@@ -0,0 +1,60 @@
+import "@testing-library/jest-dom/vitest";
+import { STALE_REVIEW_IDENTITY_REFUSAL } from "@openclinxr/ui-route-admin";
+import { edChestPainScenario } from "@openclinxr/scenario-fixtures";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { CaseAuthoringWorkbench } from "../CaseAuthoringWorkbench.js";
+
+describe("the authoring form updates the live preview delta", () => {
+ beforeAll(() => {
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+ },
+ );
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("changes equipment through the form and shows the asset delta with stale-review refusal", async () => {
+ render();
+
+ expect(await screen.findByLabelText("Authoring preview")).toBeInTheDocument();
+ await waitFor(
+ () => {
+ expect(screen.getByLabelText("Reviewed runtime delta").textContent).toMatch(
+ /No actor, dialogue, emotion, or asset changes versus the approved revision/,
+ );
+ },
+ { timeout: 15_000 },
+ );
+
+ const equipmentInput = screen.getByRole("combobox", { name: /equipment/i }) as HTMLInputElement;
+ fireEvent.change(equipmentInput, { target: { value: "preview-knee-brace" } });
+ fireEvent.keyDown(equipmentInput, { key: "Enter", code: "Enter", keyCode: 13 });
+
+ await waitFor(
+ () => {
+ expect(screen.getByLabelText("Reviewed runtime delta").textContent).toMatch(/Asset/);
+ },
+ { timeout: 15_000 },
+ );
+ expect(screen.getByText(STALE_REVIEW_IDENTITY_REFUSAL)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Promote scenario" })).toBeDisabled();
+ }, 45_000);
+});
diff --git a/apps/ui-admin/src/scenario-authoring-preview/the-authoring-preview-shows-runtime-delta-and-refuses-stale-promotion.test.tsx b/apps/ui-admin/src/scenario-authoring-preview/the-authoring-preview-shows-runtime-delta-and-refuses-stale-promotion.test.tsx
new file mode 100644
index 000000000..5cb1b6562
--- /dev/null
+++ b/apps/ui-admin/src/scenario-authoring-preview/the-authoring-preview-shows-runtime-delta-and-refuses-stale-promotion.test.tsx
@@ -0,0 +1,99 @@
+import "@testing-library/jest-dom/vitest";
+import { authoredContentIdentity, previewAuthoringRevision, STALE_REVIEW_IDENTITY_REFUSAL } from "@openclinxr/ui-route-admin";
+import { clinicKneePainScenario } from "@openclinxr/scenario-fixtures";
+import type { Scenario } from "@openclinxr/shared-schemas";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { ScenarioAuthoringPreviewPanel } from "./ScenarioAuthoringPreviewPanel.js";
+
+beforeAll(() => {
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+function mutateDraft(approved: Scenario): Scenario {
+ const patient = approved.actors[0];
+ if (!patient) {
+ throw new Error("approved revision has no actors");
+ }
+ return {
+ ...approved,
+ actors: [
+ {
+ ...patient,
+ displayName: `${patient.displayName} (revised)`,
+ openingUtterance: "The knee locked when I landed.",
+ },
+ ...approved.actors.slice(1),
+ ],
+ emotionPolicy: {
+ baseline: "concerned",
+ upperBound: "anxious",
+ lowerBound: "neutral",
+ transitions: [{ from: "concerned", triggeredBy: "learner_empathetic", to: "reassured" }],
+ },
+ equipment: [...(approved.equipment ?? []), "knee_immobilizer"],
+ };
+}
+
+describe("authoring preview panel consumes the ui-route-admin contract", () => {
+ it("renders actor, dialogue, emotion, and asset changes from the package preview", () => {
+ const approved = clinicKneePainScenario;
+ const draft = mutateDraft(approved);
+ const preview = previewAuthoringRevision({
+ draft,
+ approved,
+ reviewIdentity: authoredContentIdentity(approved),
+ });
+ render();
+ expect(screen.getByLabelText("Authoring preview")).toBeInTheDocument();
+ expect(screen.getByLabelText("Reviewed runtime delta").textContent).toMatch(/Actor/);
+ expect(screen.getByLabelText("Reviewed runtime delta").textContent).toMatch(/Dialogue/);
+ expect(screen.getByLabelText("Reviewed runtime delta").textContent).toMatch(/Emotion/);
+ expect(screen.getByLabelText("Reviewed runtime delta").textContent).toMatch(/Asset/);
+ expect(screen.getByRole("button", { name: "Promote scenario" })).toBeDisabled();
+ });
+
+ it("disables promote while review identity is stale and enables it when identity matches", () => {
+ const approved = clinicKneePainScenario;
+ const onPromote = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByText(STALE_REVIEW_IDENTITY_REFUSAL)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Promote scenario" })).toBeDisabled();
+ fireEvent.click(screen.getByRole("button", { name: "Promote scenario" }));
+ expect(onPromote).not.toHaveBeenCalled();
+ cleanup();
+
+ render(
+ ,
+ );
+ const readyButton = screen.getByRole("button", { name: "Promote scenario" });
+ expect(readyButton).toBeEnabled();
+ fireEvent.click(readyButton);
+ expect(onPromote).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/openclinxr/ui-route-admin/package.json b/packages/openclinxr/ui-route-admin/package.json
index 83a6ff846..1989ebfae 100644
--- a/packages/openclinxr/ui-route-admin/package.json
+++ b/packages/openclinxr/ui-route-admin/package.json
@@ -7,6 +7,10 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
+ },
+ "./scenario-authoring-preview": {
+ "types": "./dist/scenario-authoring-preview/preview-authoring-revision.d.ts",
+ "default": "./dist/scenario-authoring-preview/preview-authoring-revision.js"
}
},
"scripts": {
@@ -17,6 +21,9 @@
"clean": "rm -rf dist *.tsbuildinfo"
},
"dependencies": {
+ "@openclinxr/domain": "workspace:*",
+ "@openclinxr/scenario-fixtures": "workspace:*",
+ "@openclinxr/shared-schemas": "workspace:*",
"@openclinxr/ui-route-shared": "workspace:*"
},
"devDependencies": {
diff --git a/packages/openclinxr/ui-route-admin/src/index.ts b/packages/openclinxr/ui-route-admin/src/index.ts
index 6effe6b24..42315b4d9 100644
--- a/packages/openclinxr/ui-route-admin/src/index.ts
+++ b/packages/openclinxr/ui-route-admin/src/index.ts
@@ -41,3 +41,15 @@ export const adminPublicationGates = Object.freeze([
export function findAdminWorkbenchRoute(path: string) {
return findRouteByPath(adminWorkbenchRoutes, path);
}
+
+export {
+ authoredContentIdentity,
+ AUTHORING_PREVIEW_NOT_EVIDENCE_FOR,
+ evaluateScenarioPromotion,
+ previewAuthoringRevision,
+ STALE_REVIEW_IDENTITY_REFUSAL,
+ STALE_VALIDATION_REFUSAL,
+ type AuthoringPreviewChange,
+ type AuthoringPreviewResult,
+ type PromotionDecision,
+} from "./scenario-authoring-preview/preview-authoring-revision.js";
diff --git a/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/evaluate-scenario-promotion.ts b/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/evaluate-scenario-promotion.ts
new file mode 100644
index 000000000..a1942792b
--- /dev/null
+++ b/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/evaluate-scenario-promotion.ts
@@ -0,0 +1,21 @@
+import type { PromotionDecision } from "./types.js";
+import { STALE_REVIEW_IDENTITY_REFUSAL, STALE_VALIDATION_REFUSAL } from "./types.js";
+
+export function evaluateScenarioPromotion(input: {
+ validationOk: boolean;
+ validationErrors?: readonly string[];
+ draftIdentity: string;
+ reviewIdentity: string | null;
+}): PromotionDecision {
+ const reasons: string[] = [];
+ if (!input.validationOk) {
+ reasons.push(STALE_VALIDATION_REFUSAL);
+ for (const error of input.validationErrors ?? []) {
+ reasons.push(error);
+ }
+ }
+ if (input.reviewIdentity === null || input.reviewIdentity !== input.draftIdentity) {
+ reasons.push(STALE_REVIEW_IDENTITY_REFUSAL);
+ }
+ return reasons.length === 0 ? { allowed: true } : { allowed: false, reasons };
+}
diff --git a/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/preview-authoring-revision.ts b/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/preview-authoring-revision.ts
new file mode 100644
index 000000000..4a9e39053
--- /dev/null
+++ b/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/preview-authoring-revision.ts
@@ -0,0 +1,145 @@
+import { authoredContentIdentity } from "@openclinxr/domain";
+import {
+ buildDynamicEncounterFactoryPlanningProjection,
+ createLearnerScenarioView,
+} from "@openclinxr/scenario-fixtures";
+import { type Scenario, validateScenario } from "@openclinxr/shared-schemas";
+import { evaluateScenarioPromotion } from "./evaluate-scenario-promotion.js";
+import type { AuthoringPreviewChange, AuthoringPreviewResult } from "./types.js";
+import { AUTHORING_PREVIEW_NOT_EVIDENCE_FOR } from "./types.js";
+
+export {
+ AUTHORING_PREVIEW_NOT_EVIDENCE_FOR,
+ STALE_REVIEW_IDENTITY_REFUSAL,
+ STALE_VALIDATION_REFUSAL,
+ type AuthoringPreviewChange,
+ type AuthoringPreviewResult,
+ type PromotionDecision,
+} from "./types.js";
+export { evaluateScenarioPromotion } from "./evaluate-scenario-promotion.js";
+export { authoredContentIdentity };
+
+function stable(value: unknown): string {
+ return JSON.stringify(value);
+}
+
+function projectProductionRuntime(scenario: Scenario) {
+ const learner = createLearnerScenarioView(scenario);
+ const factory = buildDynamicEncounterFactoryPlanningProjection([scenario], scenario.scenarioId).scenarios[0];
+ if (!factory) {
+ throw new Error("dynamic encounter factory planning projection returned no scenario row");
+ }
+ return {
+ actors: learner.actors.map((actor) => ({
+ actorId: actor.actorId,
+ role: actor.role,
+ displayName: actor.displayName,
+ demeanor: actor.demeanor ?? null,
+ })),
+ dialogue: learner.actors.map((actor) => ({
+ actorId: actor.actorId,
+ openingUtterance: actor.openingUtterance ?? null,
+ communicationStyle: actor.communicationProfile?.style ?? null,
+ communicationIntensity: actor.communicationProfile?.intensity ?? null,
+ })),
+ emotion: {
+ policy: learner.emotionPolicy ?? null,
+ factoryEmotionStateCount: factory.humanoidPerformanceContract.emotionStateCount,
+ factoryExpressionActorRoles: factory.humanoidPerformanceContract.expressionActorRoles,
+ },
+ assets: {
+ environmentId: factory.environmentId,
+ equipmentCount: factory.equipmentCount,
+ assetNeedTypes: factory.assetNeedTypes,
+ sharedAssetLookupKeys: factory.encounterFactoryInputSummary.sharedAssetLookupKeys,
+ actorAssetWorkOrderCount: factory.encounterFactoryInputSummary.actorAssetWorkOrderCount,
+ environmentAssetWorkOrderCount: factory.encounterFactoryInputSummary.environmentAssetWorkOrderCount,
+ equipmentAssetWorkOrderCount: factory.encounterFactoryInputSummary.equipmentAssetWorkOrderCount,
+ },
+ };
+}
+
+function pushChanged(
+ changes: AuthoringPreviewChange[],
+ surface: AuthoringPreviewChange["surface"],
+ path: string,
+ before: unknown,
+ after: unknown,
+): void {
+ if (stable(before) === stable(after)) {
+ return;
+ }
+ changes.push({
+ surface,
+ change: "changed",
+ path,
+ before: before === undefined ? null : stable(before),
+ after: after === undefined ? null : stable(after),
+ });
+}
+
+function diffKeyed(
+ changes: AuthoringPreviewChange[],
+ surface: AuthoringPreviewChange["surface"],
+ approved: readonly { actorId: string }[],
+ draft: readonly { actorId: string }[],
+): void {
+ const before = new Map(approved.map((row) => [row.actorId, row]));
+ const after = new Map(draft.map((row) => [row.actorId, row]));
+ for (const [key, row] of before) {
+ if (!after.has(key)) {
+ changes.push({ surface, change: "removed", path: key, before: stable(row), after: null });
+ }
+ }
+ for (const [key, row] of after) {
+ const previous = before.get(key);
+ if (!previous) {
+ changes.push({ surface, change: "added", path: key, before: null, after: stable(row) });
+ continue;
+ }
+ pushChanged(changes, surface, key, previous, row);
+ }
+}
+
+function diffProductionRuntime(
+ approved: ReturnType,
+ draft: ReturnType,
+): readonly AuthoringPreviewChange[] {
+ const changes: AuthoringPreviewChange[] = [];
+ diffKeyed(changes, "actor", approved.actors, draft.actors);
+ diffKeyed(changes, "dialogue", approved.dialogue, draft.dialogue);
+ pushChanged(changes, "emotion", "emotion", approved.emotion, draft.emotion);
+ pushChanged(changes, "asset", "assets", approved.assets, draft.assets);
+ return changes;
+}
+
+export function previewAuthoringRevision(input: {
+ draft: unknown;
+ approved?: unknown;
+ reviewIdentity?: string | null;
+}): AuthoringPreviewResult {
+ const schema = validateScenario(input.draft);
+ const draftIdentity = schema.ok ? authoredContentIdentity(input.draft) : null;
+ const approvedSchema =
+ input.approved === undefined ? null : validateScenario(input.approved);
+ const changes =
+ schema.ok && approvedSchema?.ok
+ ? diffProductionRuntime(
+ projectProductionRuntime(input.approved as Scenario),
+ projectProductionRuntime(input.draft as Scenario),
+ )
+ : [];
+ return {
+ validationOk: schema.ok,
+ validationErrors: schema.ok ? [] : schema.errors,
+ draftIdentity,
+ changes,
+ promotion: evaluateScenarioPromotion({
+ validationOk: schema.ok,
+ validationErrors: schema.ok ? [] : schema.errors,
+ draftIdentity: draftIdentity ?? "",
+ reviewIdentity: input.reviewIdentity === undefined ? null : input.reviewIdentity,
+ }),
+ notEvidenceFor: AUTHORING_PREVIEW_NOT_EVIDENCE_FOR,
+ };
+}
diff --git a/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/the-authoring-preview-surfaces-runtime-delta.test.ts b/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/the-authoring-preview-surfaces-runtime-delta.test.ts
new file mode 100644
index 000000000..253dafe6d
--- /dev/null
+++ b/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/the-authoring-preview-surfaces-runtime-delta.test.ts
@@ -0,0 +1,74 @@
+import { authoredContentIdentity } from "@openclinxr/domain";
+import { clinicKneePainScenario } from "@openclinxr/scenario-fixtures";
+import type { Scenario } from "@openclinxr/shared-schemas";
+import { describe, expect, it } from "vitest";
+import { previewAuthoringRevision } from "./preview-authoring-revision.js";
+import { STALE_REVIEW_IDENTITY_REFUSAL, STALE_VALIDATION_REFUSAL } from "./types.js";
+
+function mutateDraft(approved: Scenario): Scenario {
+ const patient = approved.actors[0];
+ if (!patient) {
+ throw new Error("approved revision has no actors");
+ }
+ return {
+ ...approved,
+ actors: [
+ {
+ ...patient,
+ displayName: `${patient.displayName} (revised)`,
+ openingUtterance: "The knee locked when I landed.",
+ },
+ ...approved.actors.slice(1),
+ ],
+ emotionPolicy: {
+ baseline: "concerned",
+ upperBound: "anxious",
+ lowerBound: "neutral",
+ transitions: [{ from: "concerned", triggeredBy: "learner_empathetic", to: "reassured" }],
+ },
+ equipment: [...(approved.equipment ?? []), "knee_immobilizer"],
+ };
+}
+
+describe("authoring preview consumes production scenario/factory projections", () => {
+ it("lists actor, dialogue, emotion, and asset changes versus the approved revision", () => {
+ const approved = clinicKneePainScenario;
+ const draft = mutateDraft(approved);
+ const preview = previewAuthoringRevision({
+ draft,
+ approved,
+ reviewIdentity: authoredContentIdentity(approved),
+ });
+ expect(preview.validationOk).toBe(true);
+ expect(preview.draftIdentity).toBe(authoredContentIdentity(draft));
+ const surfaces = new Set(preview.changes.map((change) => change.surface));
+ expect(surfaces).toEqual(new Set(["actor", "dialogue", "emotion", "asset"]));
+ expect(preview.promotion.allowed).toBe(false);
+ if (preview.promotion.allowed) {
+ return;
+ }
+ expect(preview.promotion.reasons).toContain(STALE_REVIEW_IDENTITY_REFUSAL);
+ });
+
+ it("refuses promotion when validation fails or review identity is stale", () => {
+ const invalid = previewAuthoringRevision({
+ draft: { title: "not a scenario" },
+ approved: clinicKneePainScenario,
+ reviewIdentity: authoredContentIdentity(clinicKneePainScenario),
+ });
+ expect(invalid.validationOk).toBe(false);
+ expect(invalid.promotion.allowed).toBe(false);
+ if (invalid.promotion.allowed) {
+ return;
+ }
+ expect(invalid.promotion.reasons).toContain(STALE_VALIDATION_REFUSAL);
+
+ const matching = previewAuthoringRevision({
+ draft: clinicKneePainScenario,
+ approved: clinicKneePainScenario,
+ reviewIdentity: authoredContentIdentity(clinicKneePainScenario),
+ });
+ expect(matching.changes).toEqual([]);
+ expect(matching.promotion).toEqual({ allowed: true });
+ });
+});
diff --git a/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/types.ts b/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/types.ts
new file mode 100644
index 000000000..a6f5024ed
--- /dev/null
+++ b/packages/openclinxr/ui-route-admin/src/scenario-authoring-preview/types.ts
@@ -0,0 +1,32 @@
+export const AUTHORING_PREVIEW_NOT_EVIDENCE_FOR = [
+ "clinical_validity",
+ "exam_equivalence",
+ "scoring",
+ "quest_readiness",
+] as const;
+
+export const STALE_REVIEW_IDENTITY_REFUSAL =
+ "Promotion refused: review identity is stale for the draft revision.";
+export const STALE_VALIDATION_REFUSAL =
+ "Promotion refused: draft revision failed production encounter-contract validation.";
+
+export type AuthoringPreviewChange = {
+ surface: "actor" | "dialogue" | "emotion" | "asset";
+ change: "added" | "removed" | "changed";
+ path: string;
+ before: string | null;
+ after: string | null;
+};
+
+export type PromotionDecision =
+ | { allowed: true }
+ | { allowed: false; reasons: readonly string[] };
+
+export type AuthoringPreviewResult = {
+ validationOk: boolean;
+ validationErrors: readonly string[];
+ draftIdentity: string | null;
+ changes: readonly AuthoringPreviewChange[];
+ promotion: PromotionDecision;
+ notEvidenceFor: typeof AUTHORING_PREVIEW_NOT_EVIDENCE_FOR;
+};
diff --git a/packages/openclinxr/ui-route-admin/tsconfig.json b/packages/openclinxr/ui-route-admin/tsconfig.json
index 83b7eee02..62ffc4704 100644
--- a/packages/openclinxr/ui-route-admin/tsconfig.json
+++ b/packages/openclinxr/ui-route-admin/tsconfig.json
@@ -22,6 +22,15 @@
"dist"
],
"references": [
+ {
+ "path": "../domain"
+ },
+ {
+ "path": "../scenario-fixtures"
+ },
+ {
+ "path": "../shared-schemas"
+ },
{
"path": "../ui-route-shared"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0f7bc95cc..6e527a584 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1257,6 +1257,15 @@ importers:
packages/openclinxr/ui-route-admin:
dependencies:
+ '@openclinxr/domain':
+ specifier: workspace:*
+ version: link:../domain
+ '@openclinxr/scenario-fixtures':
+ specifier: workspace:*
+ version: link:../scenario-fixtures
+ '@openclinxr/shared-schemas':
+ specifier: workspace:*
+ version: link:../shared-schemas
'@openclinxr/ui-route-shared':
specifier: workspace:*
version: link:../ui-route-shared