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