@@ -95,11 +64,22 @@ const DrugReferencePage: React.FC
= ({ setScreen }) => {
{/* Cross-Reactivity & Alternatives */}
-
- Cross-Reactivity & Alternatives
-
+
+
+ Cross-Reactivity & Alternatives
+
+ {CROSS_REACTIVITY_GOVERNANCE.under_review && (
+
+
+ Clinical review pending
+
+ )}
+
- {crossReactivityInfo.map((item, idx) => (
+ {CROSS_REACTIVITY_ITEMS.map((item, idx) => (
{
+ it('supports schema versions 1.0 and 1.1', () => {
+ expect(SUPPORTED_SCHEMA_VERSIONS).toContain('1.0');
+ expect(SUPPORTED_SCHEMA_VERSIONS).toContain('1.1');
+ expect(SUPPORTED_SCHEMA_VERSIONS).toEqual(['1.0', '1.1']);
+ });
+
+ it('accepts schema version 1.0 and 1.1 via isSupportedSchemaVersion', () => {
+ expect(isSupportedSchemaVersion('1.0')).toBe(true);
+ expect(isSupportedSchemaVersion('1.1')).toBe(true);
+ });
+
+ it('rejects unrecognised or malformed schema versions', () => {
+ expect(isSupportedSchemaVersion('0.9')).toBe(false);
+ expect(isSupportedSchemaVersion('1.2')).toBe(false);
+ expect(isSupportedSchemaVersion('2.0')).toBe(false);
+ expect(isSupportedSchemaVersion('')).toBe(false);
+ expect(isSupportedSchemaVersion(undefined as unknown as string)).toBe(false);
+ });
+});
+
+describe('sync-protocols dose level diff review gate', () => {
+ it('detects no diff when drug protocols are identical', () => {
+ const snapshot = {
+ schema_version: '1.1',
+ drugs: [
+ {
+ slug: 'test-drug',
+ title: 'Test Drug',
+ version: '1.0',
+ last_reviewed: 'review-marker-before',
+ protocols: [
+ {
+ id: 'iv',
+ label: 'IV',
+ test_type: 'skin',
+ presentation: 'presentation-before',
+ diluent: 'diluent-placeholder',
+ under_review: false,
+ needs_pharmacy_verification: false,
+ spt: { dilution: 'dilution-before', concentration: 'concentration-before' },
+ idt: [{ dilution: 'dilution-before', concentration: 'concentration-before' }],
+ challenge: { interval: 'interval-placeholder', steps: [{ dose: 'dose-placeholder' }] },
+ },
+ ],
+ },
+ ],
+ };
+
+ const diffs = computeDoseLevelDiff(snapshot, snapshot);
+ expect(diffs).toEqual([]);
+ });
+
+ it('detects modifications in drug protocols', () => {
+ const oldSnapshot = {
+ drugs: [
+ {
+ slug: 'test-drug',
+ title: 'Test Drug',
+ version: '1.0',
+ last_reviewed: 'review-marker-before',
+ protocols: [
+ {
+ id: 'iv',
+ label: 'IV',
+ test_type: 'skin',
+ spt: { dilution: 'dilution-before', concentration: 'concentration-before' },
+ },
+ ],
+ },
+ ],
+ };
+
+ const newSnapshot = {
+ drugs: [
+ {
+ slug: 'test-drug',
+ title: 'Test Drug',
+ version: '1.1',
+ last_reviewed: 'review-marker-after',
+ protocols: [
+ {
+ id: 'iv',
+ label: 'IV',
+ test_type: 'skin',
+ spt: { dilution: 'dilution-after', concentration: 'concentration-after' },
+ },
+ ],
+ },
+ ],
+ };
+
+ const diffs = computeDoseLevelDiff(oldSnapshot, newSnapshot);
+ expect(diffs).toHaveLength(1);
+ expect(diffs[0].type).toBe('DRUG_MODIFIED');
+ });
+});
diff --git a/src/shared/data/crossReactivity.test.ts b/src/shared/data/crossReactivity.test.ts
new file mode 100644
index 0000000..fcd4c25
--- /dev/null
+++ b/src/shared/data/crossReactivity.test.ts
@@ -0,0 +1,307 @@
+import { describe, expect, it } from 'vitest';
+import snapshot from './protocols.snapshot.json';
+import {
+ CROSS_REACTIVITY_ITEMS,
+ CROSS_REACTIVITY_GOVERNANCE,
+ parseCrossReactivitySnapshot,
+} from './crossReactivity';
+
+describe('crossReactivity data adapter', () => {
+ it('exposes exactly six ordered items matching the pinned snapshot', () => {
+ expect(CROSS_REACTIVITY_ITEMS).toHaveLength(6);
+ expect(CROSS_REACTIVITY_ITEMS).toEqual(snapshot.cross_reactivity.items);
+ });
+
+ it('exposes the snapshot pending governance metadata accurately', () => {
+ expect(CROSS_REACTIVITY_GOVERNANCE).toEqual({
+ version: snapshot.cross_reactivity.version,
+ last_reviewed: snapshot.cross_reactivity.last_reviewed,
+ reviewed_by: snapshot.cross_reactivity.reviewed_by,
+ under_review: snapshot.cross_reactivity.under_review,
+ provenance: snapshot.cross_reactivity.provenance,
+ });
+ expect(CROSS_REACTIVITY_GOVERNANCE.under_review).toBe(true);
+ expect(CROSS_REACTIVITY_GOVERNANCE.last_reviewed).toBe('');
+ expect(CROSS_REACTIVITY_GOVERNANCE.reviewed_by).toBe('');
+ });
+
+ it('validates every item contains non-empty category, info, and alternatives', () => {
+ for (const item of CROSS_REACTIVITY_ITEMS) {
+ expect(typeof item.category).toBe('string');
+ expect(item.category.trim().length).toBeGreaterThan(0);
+ expect(typeof item.info).toBe('string');
+ expect(item.info.trim().length).toBeGreaterThan(0);
+ expect(typeof item.alternatives).toBe('string');
+ expect(item.alternatives.trim().length).toBeGreaterThan(0);
+ }
+ });
+
+ it('preserves the exact order of categories from snapshot', () => {
+ const categories = CROSS_REACTIVITY_ITEMS.map((item) => item.category);
+ const expectedCategories = snapshot.cross_reactivity.items.map((item) => item.category);
+ expect(categories).toEqual(expectedCategories);
+ });
+});
+
+describe('parseCrossReactivitySnapshot invariant error handling', () => {
+ const createValidSnapshot = () => ({
+ schema_version: '1.1',
+ cross_reactivity: {
+ version: '1.0',
+ last_reviewed: '',
+ reviewed_by: '',
+ under_review: true,
+ provenance: 'Test provenance',
+ items: [
+ {
+ category: 'Test category',
+ info: 'Test information',
+ alternatives: 'Test alternative',
+ },
+ ],
+ },
+ });
+
+ describe('snapshot and schema_version validation', () => {
+ it('throws if snapshot is null or not an object', () => {
+ expect(() => parseCrossReactivitySnapshot(null as any)).toThrow(
+ /expected a non-null snapshot object/i
+ );
+ expect(() => parseCrossReactivitySnapshot(undefined as any)).toThrow(
+ /expected a non-null snapshot object/i
+ );
+ expect(() => parseCrossReactivitySnapshot('string' as any)).toThrow(
+ /expected a non-null snapshot object/i
+ );
+ });
+
+ it('throws if schema_version is missing, not a string, or not exactly "1.1"', () => {
+ const snapMissing = createValidSnapshot();
+ delete (snapMissing as any).schema_version;
+ expect(() => parseCrossReactivitySnapshot(snapMissing)).toThrow(
+ /schema_version/i
+ );
+
+ const snapWrongType = { ...createValidSnapshot(), schema_version: 1.1 as any };
+ expect(() => parseCrossReactivitySnapshot(snapWrongType)).toThrow(
+ /schema_version/i
+ );
+
+ const snapWrongVersion = { ...createValidSnapshot(), schema_version: '1.0' };
+ expect(() => parseCrossReactivitySnapshot(snapWrongVersion)).toThrow(
+ /schema_version/i
+ );
+
+ const snapEmptyVersion = { ...createValidSnapshot(), schema_version: '' };
+ expect(() => parseCrossReactivitySnapshot(snapEmptyVersion)).toThrow(
+ /schema_version/i
+ );
+ });
+
+ it('throws if cross_reactivity is missing or not an object', () => {
+ expect(() =>
+ parseCrossReactivitySnapshot({ schema_version: '1.1' } as any)
+ ).toThrow(/missing or invalid cross_reactivity/i);
+ expect(() =>
+ parseCrossReactivitySnapshot({
+ schema_version: '1.1',
+ cross_reactivity: null,
+ } as any)
+ ).toThrow(/missing or invalid cross_reactivity/i);
+ expect(() =>
+ parseCrossReactivitySnapshot({
+ schema_version: '1.1',
+ cross_reactivity: 'invalid',
+ } as any)
+ ).toThrow(/missing or invalid cross_reactivity/i);
+ });
+ });
+
+ describe('governance fields validation', () => {
+ it('throws if under_review is not a boolean', () => {
+ const snap = createValidSnapshot();
+ (snap.cross_reactivity as any).under_review = 'true';
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /under_review.*boolean/i
+ );
+
+ (snap.cross_reactivity as any).under_review = null;
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /under_review.*boolean/i
+ );
+ });
+
+ it('throws if version is missing, not a string, or empty/whitespace', () => {
+ const snapMissing = createValidSnapshot();
+ delete (snapMissing.cross_reactivity as any).version;
+ expect(() => parseCrossReactivitySnapshot(snapMissing)).toThrow(
+ /version.*string/i
+ );
+
+ const snapNonString = createValidSnapshot();
+ (snapNonString.cross_reactivity as any).version = 1.0;
+ expect(() => parseCrossReactivitySnapshot(snapNonString)).toThrow(
+ /version.*string/i
+ );
+
+ const snapEmpty = createValidSnapshot();
+ snapEmpty.cross_reactivity.version = '';
+ expect(() => parseCrossReactivitySnapshot(snapEmpty)).toThrow(
+ /version.*non-empty/i
+ );
+
+ const snapWhitespace = createValidSnapshot();
+ snapWhitespace.cross_reactivity.version = ' ';
+ expect(() => parseCrossReactivitySnapshot(snapWhitespace)).toThrow(
+ /version.*non-empty/i
+ );
+ });
+
+ it('throws if provenance is missing, not a string, or empty/whitespace', () => {
+ const snapMissing = createValidSnapshot();
+ delete (snapMissing.cross_reactivity as any).provenance;
+ expect(() => parseCrossReactivitySnapshot(snapMissing)).toThrow(
+ /provenance.*string/i
+ );
+
+ const snapNonString = createValidSnapshot();
+ (snapNonString.cross_reactivity as any).provenance = 123;
+ expect(() => parseCrossReactivitySnapshot(snapNonString)).toThrow(
+ /provenance.*string/i
+ );
+
+ const snapEmpty = createValidSnapshot();
+ snapEmpty.cross_reactivity.provenance = '';
+ expect(() => parseCrossReactivitySnapshot(snapEmpty)).toThrow(
+ /provenance.*non-empty/i
+ );
+
+ const snapWhitespace = createValidSnapshot();
+ snapWhitespace.cross_reactivity.provenance = ' \t ';
+ expect(() => parseCrossReactivitySnapshot(snapWhitespace)).toThrow(
+ /provenance.*non-empty/i
+ );
+ });
+
+ it('throws if last_reviewed or reviewed_by are not strings', () => {
+ const snapLastReviewedType = createValidSnapshot();
+ (snapLastReviewedType.cross_reactivity as any).last_reviewed = null;
+ expect(() => parseCrossReactivitySnapshot(snapLastReviewedType)).toThrow(
+ /last_reviewed.*string/i
+ );
+
+ const snapReviewedByType = createValidSnapshot();
+ (snapReviewedByType.cross_reactivity as any).reviewed_by = 42;
+ expect(() => parseCrossReactivitySnapshot(snapReviewedByType)).toThrow(
+ /reviewed_by.*string/i
+ );
+ });
+
+ it('allows last_reviewed and reviewed_by to be empty when under_review is true', () => {
+ const snap = createValidSnapshot();
+ snap.cross_reactivity.under_review = true;
+ snap.cross_reactivity.last_reviewed = '';
+ snap.cross_reactivity.reviewed_by = '';
+ const result = parseCrossReactivitySnapshot(snap);
+ expect(result.governance.under_review).toBe(true);
+ expect(result.governance.last_reviewed).toBe('');
+ expect(result.governance.reviewed_by).toBe('');
+ });
+
+ it('requires last_reviewed and reviewed_by to be non-empty when under_review is false', () => {
+ const snap = createValidSnapshot();
+ snap.cross_reactivity.under_review = false;
+ snap.cross_reactivity.last_reviewed = '';
+ snap.cross_reactivity.reviewed_by = 'Test reviewer';
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /last_reviewed.*non-empty/i
+ );
+
+ snap.cross_reactivity.last_reviewed = ' ';
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /last_reviewed.*non-empty/i
+ );
+
+ snap.cross_reactivity.last_reviewed = 'review-marker';
+ snap.cross_reactivity.reviewed_by = '';
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /reviewed_by.*non-empty/i
+ );
+
+ snap.cross_reactivity.reviewed_by = ' ';
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /reviewed_by.*non-empty/i
+ );
+
+ snap.cross_reactivity.reviewed_by = 'Test reviewer';
+ const result = parseCrossReactivitySnapshot(snap);
+ expect(result.governance.under_review).toBe(false);
+ expect(result.governance.last_reviewed).toBe('review-marker');
+ expect(result.governance.reviewed_by).toBe('Test reviewer');
+ });
+
+ it('preserves exact strings without mutating or trimming returned governance values', () => {
+ const snap = createValidSnapshot();
+ snap.cross_reactivity.version = ' 1.0 ';
+ snap.cross_reactivity.provenance = ' Provenance string with whitespace ';
+ snap.cross_reactivity.under_review = false;
+ snap.cross_reactivity.last_reviewed = ' review-marker ';
+ snap.cross_reactivity.reviewed_by = ' Test reviewer ';
+ const result = parseCrossReactivitySnapshot(snap);
+ expect(result.governance.version).toBe(' 1.0 ');
+ expect(result.governance.provenance).toBe(' Provenance string with whitespace ');
+ expect(result.governance.last_reviewed).toBe(' review-marker ');
+ expect(result.governance.reviewed_by).toBe(' Test reviewer ');
+ });
+ });
+
+ describe('items validation', () => {
+ it('throws if cross_reactivity.items is missing or not an array', () => {
+ const snap = createValidSnapshot();
+ (snap.cross_reactivity as any).items = 'not an array';
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /items.*array/i
+ );
+ });
+
+ it('throws if cross_reactivity.items is empty', () => {
+ const snap = createValidSnapshot();
+ snap.cross_reactivity.items = [];
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /items.*empty/i
+ );
+ });
+
+ it('throws if an item is null or not an object', () => {
+ const snap = createValidSnapshot();
+ (snap.cross_reactivity as any).items = [null];
+ expect(() => parseCrossReactivitySnapshot(snap)).toThrow(
+ /expected an object/i
+ );
+ });
+
+ it('throws if an item is missing required fields or has empty fields', () => {
+ const snapMissing = createValidSnapshot();
+ snapMissing.cross_reactivity.items = [{ category: 'Test category', info: '' } as any];
+ expect(() => parseCrossReactivitySnapshot(snapMissing)).toThrow(
+ /malformed item/i
+ );
+
+ const snapEmptyCat = createValidSnapshot();
+ snapEmptyCat.cross_reactivity.items = [
+ { category: ' ', info: 'Test information', alternatives: 'Test alternative' },
+ ];
+ expect(() => parseCrossReactivitySnapshot(snapEmptyCat)).toThrow(
+ /malformed item/i
+ );
+
+ const snapEmptyAlt = createValidSnapshot();
+ snapEmptyAlt.cross_reactivity.items = [
+ { category: 'Test category', info: 'Test information', alternatives: ' ' },
+ ];
+ expect(() => parseCrossReactivitySnapshot(snapEmptyAlt)).toThrow(
+ /malformed item/i
+ );
+ });
+ });
+});
diff --git a/src/shared/data/crossReactivity.ts b/src/shared/data/crossReactivity.ts
new file mode 100644
index 0000000..be0133f
--- /dev/null
+++ b/src/shared/data/crossReactivity.ts
@@ -0,0 +1,129 @@
+import rawSnapshot from './protocols.snapshot.json';
+
+export interface CrossReactivityItem {
+ readonly category: string;
+ readonly info: string;
+ readonly alternatives: string;
+}
+
+export interface CrossReactivityGovernance {
+ readonly version: string;
+ readonly last_reviewed: string;
+ readonly reviewed_by: string;
+ readonly under_review: boolean;
+ readonly provenance: string;
+}
+
+export interface ParsedCrossReactivity {
+ readonly governance: CrossReactivityGovernance;
+ readonly items: readonly CrossReactivityItem[];
+}
+
+export function parseCrossReactivitySnapshot(snapshot: unknown): ParsedCrossReactivity {
+ if (!snapshot || typeof snapshot !== 'object') {
+ throw new Error('Missing or invalid cross_reactivity: expected a non-null snapshot object.');
+ }
+
+ const raw = snapshot as Record;
+ if (typeof raw.schema_version !== 'string' || raw.schema_version !== '1.1') {
+ throw new Error(
+ `Unsupported or missing schema_version "${String(raw.schema_version)}". Cross-reactivity requires schema_version "1.1".`
+ );
+ }
+
+ if (!raw.cross_reactivity || typeof raw.cross_reactivity !== 'object') {
+ throw new Error('Missing or invalid cross_reactivity object in protocols snapshot.');
+ }
+
+ const cr = raw.cross_reactivity as Record;
+
+ if (typeof cr.under_review !== 'boolean') {
+ throw new Error('Malformed cross_reactivity: under_review must be a boolean.');
+ }
+
+ if (typeof cr.version !== 'string') {
+ throw new Error('Malformed cross_reactivity: version must be a string.');
+ }
+ if (!cr.version.trim()) {
+ throw new Error('Malformed cross_reactivity: version must be a non-empty string.');
+ }
+
+ if (typeof cr.provenance !== 'string') {
+ throw new Error('Malformed cross_reactivity: provenance must be a string.');
+ }
+ if (!cr.provenance.trim()) {
+ throw new Error('Malformed cross_reactivity: provenance must be a non-empty string.');
+ }
+
+ if (typeof cr.last_reviewed !== 'string') {
+ throw new Error('Malformed cross_reactivity: last_reviewed must be a string.');
+ }
+ if (!cr.under_review && !cr.last_reviewed.trim()) {
+ throw new Error(
+ 'Malformed cross_reactivity: last_reviewed must be a non-empty string when under_review is false.'
+ );
+ }
+
+ if (typeof cr.reviewed_by !== 'string') {
+ throw new Error('Malformed cross_reactivity: reviewed_by must be a string.');
+ }
+ if (!cr.under_review && !cr.reviewed_by.trim()) {
+ throw new Error(
+ 'Malformed cross_reactivity: reviewed_by must be a non-empty string when under_review is false.'
+ );
+ }
+
+ if (!Array.isArray(cr.items)) {
+ throw new Error('Malformed cross_reactivity: items must be an array.');
+ }
+
+ if (cr.items.length === 0) {
+ throw new Error('Malformed cross_reactivity: items array is empty.');
+ }
+
+ const parsedItems: CrossReactivityItem[] = cr.items.map((item, index) => {
+ if (!item || typeof item !== 'object') {
+ throw new Error(`Malformed item at index ${index}: expected an object.`);
+ }
+
+ const { category, info, alternatives } = item as Record;
+
+ if (
+ typeof category !== 'string' ||
+ !category.trim() ||
+ typeof info !== 'string' ||
+ !info.trim() ||
+ typeof alternatives !== 'string' ||
+ !alternatives.trim()
+ ) {
+ throw new Error(
+ `Malformed item at index ${index}: category, info, and alternatives must be non-empty strings.`
+ );
+ }
+
+ return {
+ category,
+ info,
+ alternatives,
+ };
+ });
+
+ const governance: CrossReactivityGovernance = {
+ version: cr.version,
+ last_reviewed: cr.last_reviewed,
+ reviewed_by: cr.reviewed_by,
+ under_review: cr.under_review,
+ provenance: cr.provenance,
+ };
+
+ return {
+ governance,
+ items: parsedItems,
+ };
+}
+
+// Parse pinned snapshot at module load; fail loudly if missing or malformed
+const parsed = parseCrossReactivitySnapshot(rawSnapshot);
+
+export const CROSS_REACTIVITY_ITEMS: readonly CrossReactivityItem[] = parsed.items;
+export const CROSS_REACTIVITY_GOVERNANCE: Readonly = parsed.governance;
diff --git a/src/shared/data/protocols.snapshot.json b/src/shared/data/protocols.snapshot.json
index 469959a..1807879 100644
--- a/src/shared/data/protocols.snapshot.json
+++ b/src/shared/data/protocols.snapshot.json
@@ -1,7 +1,7 @@
{
- "schema_version": "1.0",
- "generated_at": "2026-08-20T11:45:36Z",
- "source_commit": "254b399",
+ "schema_version": "1.1",
+ "generated_at": "2026-08-20T20:20:12Z",
+ "source_commit": "d9bc1e3",
"drugs": [
{
"slug": "cefazolin",
@@ -255,5 +255,44 @@
}
]
}
- ]
+ ],
+ "cross_reactivity": {
+ "version": "1.0",
+ "last_reviewed": "",
+ "reviewed_by": "",
+ "under_review": true,
+ "provenance": "Transcribed unchanged from DREAM; awaits clinical sign-off.",
+ "items": [
+ {
+ "category": "Muscle Relaxants",
+ "info": "Quaternary ammonium compounds are thought to be responsible for most reactions. Cross-reactivity between agents is common due to structural similarities.",
+ "alternatives": "Consider non-depolarising agents with different structures. Cisatracurium may have lower immunogenicity."
+ },
+ {
+ "category": "Penicillins",
+ "info": "Cross-reactivity with cephalosporins depends on side chain similarity. First-generation cephalosporins have higher cross-reactivity (~2%).",
+ "alternatives": "Cephalosporins with dissimilar side chains, carbapenems (low cross-reactivity), or non-beta-lactams."
+ },
+ {
+ "category": "Cephalosporins",
+ "info": "Cross-reactivity is more related to R1 side chain similarity than the beta-lactam ring. Later generations have different side chains.",
+ "alternatives": "Cephalosporins with different side chains, carbapenems, aztreonam (minimal cross-reactivity)."
+ },
+ {
+ "category": "Local Anaesthetics",
+ "info": "True allergy is rare (<1%). Most reactions are vasovagal or due to adrenaline. Amide types rarely cross-react with each other.",
+ "alternatives": "Different amide local anaesthetic, or ester type if amide allergy confirmed."
+ },
+ {
+ "category": "Opioids",
+ "info": "Can cause direct mast cell degranulation (non-IgE mediated). Morphine and codeine are most histamine-releasing.",
+ "alternatives": "Fentanyl or remifentanil (less histamine release), or non-opioid analgesia."
+ },
+ {
+ "category": "Hypnotics",
+ "info": "Propofol reactions may be to the lipid emulsion or specific to propofol. Egg/soya allergy is not a contraindication.",
+ "alternatives": "Different induction agent (thiopentone, ketamine, etomidate)."
+ }
+ ]
+ }
}