Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/ui-admin/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ describe("AdminApp", () => {
render(<AdminApp initialPath="/scenarios/ed_chest_pain_priority_v1?version=1" controlPlaneClient={fakeControlPlaneClient()} />);

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();
Expand Down Expand Up @@ -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}$/),
],
});
});

Expand Down
9 changes: 3 additions & 6 deletions apps/ui-admin/src/CaseAuthoringWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -521,6 +517,7 @@ export function CaseAuthoringWorkbench({ initialScenario, apiClient }: CaseAutho
)}
</Form.List>
</Card>
<LiveAuthoringPreview approved={baseDraft} />
</Form>

<div className="case-authoring-output-grid">
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ScenarioFormValues>();
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 <ScenarioAuthoringPreviewPanel draft={draft} approved={approved} />;
}
Original file line number Diff line number Diff line change
@@ -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<AuthoringPreviewResult["changes"][number]["surface"], string> = {
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 (
<section aria-label="Authoring preview">
<Typography.Title level={3}>Authoring preview</Typography.Title>
<Typography.Paragraph>
Compiles this draft through production encounter contracts and shows the exact
actor, dialogue, emotion, and asset delta versus the currently approved revision.
</Typography.Paragraph>
<Space wrap>
{preview.notEvidenceFor.map((flag) => (
<Tag key={flag}>{flag}</Tag>
))}
</Space>
{!preview.validationOk ? (
<Alert
type="error"
showIcon
message="Draft failed production encounter-contract validation"
description={preview.validationErrors.join(" ")}
/>
) : null}
{reasons.includes(STALE_REVIEW_IDENTITY_REFUSAL) ? (
<Alert type="warning" showIcon message={STALE_REVIEW_IDENTITY_REFUSAL} />
) : null}
<List
aria-label="Reviewed runtime delta"
locale={{ emptyText: "No actor, dialogue, emotion, or asset changes versus the approved revision." }}
dataSource={[...preview.changes]}
renderItem={(change) => (
<List.Item>
<Space direction="vertical" size={0}>
<Typography.Text>
<Tag>{SURFACE_LABEL[change.surface]}</Tag>
{change.change} {change.path}
</Typography.Text>
{change.before ? (
<Typography.Text type="secondary">before: {change.before}</Typography.Text>
) : null}
{change.after ? <Typography.Text>after: {change.after}</Typography.Text> : null}
</Space>
</List.Item>
)}
/>
<Button
type="primary"
disabled={!promotionAllowed}
onClick={() => {
if (promotionAllowed) {
onPromote?.();
}
}}
>
Promote scenario
</Button>
</section>
);
}
Original file line number Diff line number Diff line change
@@ -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(<CaseAuthoringWorkbench initialScenario={edChestPainScenario} />);

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);
});
Original file line number Diff line number Diff line change
@@ -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(<ScenarioAuthoringPreviewPanel draft={draft} approved={approved} preview={preview} />);
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(
<ScenarioAuthoringPreviewPanel
draft={approved}
approved={approved}
reviewIdentity={authoredContentIdentity({ ...approved, title: "stale" })}
onPromote={onPromote}
/>,
);
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(
<ScenarioAuthoringPreviewPanel
draft={approved}
approved={approved}
reviewIdentity={authoredContentIdentity(approved)}
onPromote={onPromote}
/>,
);
const readyButton = screen.getByRole("button", { name: "Promote scenario" });
expect(readyButton).toBeEnabled();
fireEvent.click(readyButton);
expect(onPromote).toHaveBeenCalledOnce();
});
});
7 changes: 7 additions & 0 deletions packages/openclinxr/ui-route-admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions packages/openclinxr/ui-route-admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading