diff --git a/scripts/generate-drug-masterlist.mjs b/scripts/generate-drug-masterlist.mjs index a655c87..8498b7b 100644 --- a/scripts/generate-drug-masterlist.mjs +++ b/scripts/generate-drug-masterlist.mjs @@ -27,7 +27,7 @@ export function transformSnapshotToProtocols(snapshot) { const protocols = []; for (const drug of snapshot.drugs || []) { - const drugName = drug.title; + const drugName = drug.dream?.drug_name || drug.title; const category = drug.dream?.category || ''; const sourceSlug = drug.slug; const lastReviewed = drug.last_reviewed; @@ -61,11 +61,13 @@ export function transformSnapshotToProtocols(snapshot) { idtSteps: (protocol.idt || []).map((step) => ({ ratio: step.dilution || '', concentration: step.concentration || '', + ...(step.preparation ? { preparation: step.preparation } : {}), })), challengeSteps: [], protocolLabel: protocol.label, sourceSlug, underReview, + ...(protocol.review_note ? { reviewNote: protocol.review_note } : {}), lastReviewed, ...(needsPharmacyVerification ? { needsPharmacyVerification: true } : {}), }); @@ -89,6 +91,7 @@ export function transformSnapshotToProtocols(snapshot) { protocolLabel: `${protocol.label} Challenge`, sourceSlug, underReview, + ...(protocol.review_note ? { reviewNote: protocol.review_note } : {}), lastReviewed, ...(needsPharmacyVerification ? { needsPharmacyVerification: true } : {}), }); @@ -112,6 +115,7 @@ export function transformSnapshotToProtocols(snapshot) { protocolLabel: protocol.label, sourceSlug, underReview, + ...(protocol.review_note ? { reviewNote: protocol.review_note } : {}), lastReviewed, ...(needsPharmacyVerification ? { needsPharmacyVerification: true } : {}), }); @@ -128,11 +132,13 @@ export function transformSnapshotToProtocols(snapshot) { idtSteps: (protocol.idt || []).map((step) => ({ ratio: step.dilution || '', concentration: step.concentration || '', + ...(step.preparation ? { preparation: step.preparation } : {}), })), challengeSteps: [], protocolLabel: protocol.label, sourceSlug, underReview, + ...(protocol.review_note ? { reviewNote: protocol.review_note } : {}), lastReviewed, ...(needsPharmacyVerification ? { needsPharmacyVerification: true } : {}), }); @@ -155,7 +161,7 @@ export function generateTypeScript(protocols) { "import type { DrugProtocol, IDTStep, ChallengeStep } from '@features/testing/types';", '', '// Compact helpers for readability', - 'const s = (ratio: string, concentration: string): IDTStep => ({ ratio, concentration });', + 'const s = (ratio: string, concentration: string, preparation?: string): IDTStep => (preparation ? { ratio, concentration, preparation } : { ratio, concentration });', 'const c = (step: number, dose: string, volume: string, cumulative: string): ChallengeStep => ({ step, dose, volume, cumulative });', '', 'export const GENERATED_PROTOCOLS: DrugProtocol[] = [', @@ -178,7 +184,12 @@ export function generateTypeScript(protocols) { lines.push(' idtSteps: [],'); } else { const idtFormatted = p.idtSteps - .map((step) => `s(${formatStringLiteral(step.ratio)}, ${formatStringLiteral(step.concentration)})`) + .map((step) => { + if (step.preparation) { + return `s(${formatStringLiteral(step.ratio)}, ${formatStringLiteral(step.concentration)}, ${formatStringLiteral(step.preparation)})`; + } + return `s(${formatStringLiteral(step.ratio)}, ${formatStringLiteral(step.concentration)})`; + }) .join(', '); lines.push(` idtSteps: [${idtFormatted}],`); } @@ -199,6 +210,9 @@ export function generateTypeScript(protocols) { if (p.underReview !== undefined) { lines.push(` underReview: ${p.underReview},`); } + if (p.reviewNote) { + lines.push(` reviewNote: ${formatStringLiteral(p.reviewNote)},`); + } if (p.lastReviewed) { lines.push(` lastReviewed: ${formatStringLiteral(p.lastReviewed)},`); } diff --git a/scripts/sync-protocols.mjs b/scripts/sync-protocols.mjs index b6886ce..4715b9b 100644 --- a/scripts/sync-protocols.mjs +++ b/scripts/sync-protocols.mjs @@ -60,7 +60,7 @@ export function computeDoseLevelDiff(oldSnapshot, newSnapshot) { } const pChanges = []; - const scalarFields = ['label', 'test_type', 'presentation', 'diluent', 'under_review', 'needs_pharmacy_verification']; + const scalarFields = ['label', 'test_type', 'presentation', 'diluent', 'under_review', 'review_note', 'needs_pharmacy_verification']; for (const field of scalarFields) { if (oldP[field] !== newP[field]) { pChanges.push(`${field}: ${JSON.stringify(oldP[field])} -> ${JSON.stringify(newP[field])}`); diff --git a/scripts/verify-order.mjs b/scripts/verify-order.mjs index fad7b61..d33ec18 100644 --- a/scripts/verify-order.mjs +++ b/scripts/verify-order.mjs @@ -1,50 +1,236 @@ #!/usr/bin/env node +/** + * scripts/verify-order.mjs + * + * Positional order verifier with an independent cryptographic guard for the + * frozen pre-snapshot DREAM protocol baseline. + * + * BACKGROUND & CLINICAL SAFETY: + * DREAM stores clinical testing plans referencing protocols by their 0-based + * array index (`protocolIndex`). Any unexpected positional shift silently + * redirects historical saved plans to unintended drugs, test modalities, or + * concentration steps. + * + * To ensure safe backwards compatibility: + * 1. The original pre-snapshot DREAM protocol list (the first 116 records, + * indices 0..115) is permanently FROZEN. + * 2. Any new protocols (e.g. index 116: Flucloxacillin skin/IV) must be + * appended strictly at the end (append-only semantics). + * 3. This verifier maintains a committed canonical SHA-256 hash baseline for + * the first 116 records, preventing accidental reordering, deletion, or + * tampering in expectedProtocolOrder.json from going undetected. + */ + import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; +import { createHash } from 'node:crypto'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const FIXTURE_PATH = join(ROOT, 'src', 'shared', 'data', 'expectedProtocolOrder.json'); - -// 1. Read and validate expectedProtocolOrder.json fixture -let fixture; -try { - const fixtureContent = readFileSync(FIXTURE_PATH, 'utf8'); - fixture = JSON.parse(fixtureContent); -} catch (err) { - console.error(`BROKEN CHECK: Failed to read or parse fixture at ${FIXTURE_PATH}: ${err.message}`); - process.exit(2); +const DEFAULT_FIXTURE_PATH = join(ROOT, 'src', 'shared', 'data', 'expectedProtocolOrder.json'); + +/** + * The frozen count of protocols that existed in DREAM prior to the SCRATCH + * snapshot cutover. Indices 0..115 are immutable to protect saved clinical plans. + */ +export const FROZEN_PRE_SNAPSHOT_COUNT = 116; + +/** + * The minimum required baseline count for DREAM protocols, including the + * 116 frozen pre-snapshot records plus the committed appended Flucloxacillin + * record at index 116. Future records may only be added as an appended suffix (>= 117). + */ +export const MINIMUM_BASELINE_COUNT = 117; + + +/** + * Committed canonical SHA-256 hash of the 116 frozen pre-snapshot protocol tuples. + * + * Canonical representation: + * JSON array of `{ drugName: string, testType: string, protocolLabel: string }` + * for indices 0 through 115. + */ +export const FROZEN_PRE_SNAPSHOT_PREFIX_SHA256 = + 'e1441bb00c906da6bdd7138b2206f5e6a5735f54805bfa2d9cecd5d00c007096'; + +/** + * Key boundary checkpoints in the masterlist to give immediate, actionable + * diagnostics if order drifts. + */ +export const FROZEN_PREFIX_CHECKPOINTS = Object.freeze({ + HEAD_0: { + index: 0, + tuple: { drugName: 'Cis-atracurium', testType: 'skin', protocolLabel: 'IV' }, + description: 'First record in frozen pre-snapshot prefix', + }, + TAIL_115: { + index: 115, + tuple: { drugName: 'Voltaren (Diclofenac)', testType: 'challenge', protocolLabel: 'Graded Challenge' }, + description: 'Last record in frozen pre-snapshot prefix (index 115)', + }, + APPENDED_116: { + index: 116, + tuple: { drugName: 'Flucloxacillin', testType: 'skin', protocolLabel: 'IV' }, + description: 'First deliberate appended source record (index 116)', + }, +}); + +/** + * Canonicalizes a protocol tuple to a clean object with deterministic keys. + */ +export function canonicalizeTuple(item) { + if (!item || typeof item !== 'object') { + throw new Error(`Invalid protocol tuple: ${JSON.stringify(item)}`); + } + return { + drugName: String(item.drugName ?? ''), + testType: String(item.testType ?? ''), + protocolLabel: String(item.protocolLabel ?? ''), + }; } -const EXPECTED_PROTOCOL_COUNT = 116; -if ( - !fixture || - typeof fixture !== 'object' || - !Array.isArray(fixture.order) || - !Number.isInteger(fixture.count) || - fixture.count !== EXPECTED_PROTOCOL_COUNT || - fixture.order.length !== EXPECTED_PROTOCOL_COUNT -) { - console.error( - `BROKEN CHECK: fixture is malformed or count mismatch (count: ${fixture?.count}, order.length: ${fixture?.order?.length}).\n` + - `Expected an object with 'order' array and matching integer 'count' equal to ${EXPECTED_PROTOCOL_COUNT}.` - ); - process.exit(2); +/** + * Deterministic JSON stringification of a tuple array. + */ +export function canonicalizeTuples(tuples) { + if (!Array.isArray(tuples)) { + throw new TypeError('Expected an array of tuples to canonicalize'); + } + return JSON.stringify(tuples.map(canonicalizeTuple)); } -for (let i = 0; i < fixture.order.length; i++) { - const item = fixture.order[i]; - if (!item || typeof item.drugName !== 'string' || typeof item.testType !== 'string' || typeof item.protocolLabel !== 'string') { - console.error(`BROKEN CHECK: fixture entry at index ${i} is malformed: ${JSON.stringify(item)}`); - process.exit(2); +/** + * Computes the SHA-256 hex digest for an array of protocol tuples. + * If `count` is specified, hashes only the first `count` items. + */ +export function computeTuplesHash(tuples, count = FROZEN_PRE_SNAPSHOT_COUNT) { + if (!Array.isArray(tuples)) { + throw new TypeError('Expected an array of tuples'); } + const slice = typeof count === 'number' ? tuples.slice(0, count) : tuples; + const canonicalString = canonicalizeTuples(slice); + return createHash('sha256').update(canonicalString, 'utf8').digest('hex'); } -const expectedTuples = fixture.order; +/** + * Validates the schema and structure of expectedProtocolOrder.json. + */ +export function validateFixtureSchema(fixture) { + if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) { + throw new Error("Fixture must be a non-null JSON object containing 'count' and 'order'."); + } + + if (!Number.isInteger(fixture.count) || fixture.count < 0) { + throw new Error(`Fixture 'count' must be a non-negative integer (received: ${fixture.count}).`); + } + + if (!Array.isArray(fixture.order)) { + throw new Error("Fixture must include an 'order' array."); + } + + if (fixture.count !== fixture.order.length) { + throw new Error( + `Fixture count mismatch: declared count is ${fixture.count} but order array length is ${fixture.order.length}.` + ); + } + + for (let i = 0; i < fixture.order.length; i++) { + const item = fixture.order[i]; + if ( + !item || + typeof item.drugName !== 'string' || + item.drugName.trim().length === 0 || + typeof item.testType !== 'string' || + item.testType.trim().length === 0 || + typeof item.protocolLabel !== 'string' + ) { + throw new Error( + `Fixture entry at index ${i} is malformed: ${JSON.stringify(item)}. ` + + `Expected { drugName: string, testType: string, protocolLabel: string }.` + ); + } + } + + return true; +} -function extractTuples(fileContent) { +/** + * Validates the frozen pre-snapshot prefix against the independent canonical hash. + */ +export function validateFrozenPrefix(tuples, sourceLabel = 'fixture') { + if (!Array.isArray(tuples)) { + return { + valid: false, + error: `${sourceLabel}: tuples must be an array.`, + }; + } + + if (tuples.length < FROZEN_PRE_SNAPSHOT_COUNT) { + return { + valid: false, + error: + `CRITICAL SAFETY FAILURE (${sourceLabel}): contains only ${tuples.length} records, ` + + `which is fewer than the frozen pre-snapshot baseline of ${FROZEN_PRE_SNAPSHOT_COUNT} records.\n` + + `Truncating frozen records breaks backwards compatibility with stored clinical plans!`, + }; + } + + const actualHash = computeTuplesHash(tuples, FROZEN_PRE_SNAPSHOT_COUNT); + + if (actualHash !== FROZEN_PRE_SNAPSHOT_PREFIX_SHA256) { + // Collect boundary diagnostic hints + const head = tuples[0]; + const tail = tuples[115]; + const headExpected = FROZEN_PREFIX_CHECKPOINTS.HEAD_0.tuple; + const tailExpected = FROZEN_PREFIX_CHECKPOINTS.TAIL_115.tuple; + + const headMatches = + head?.drugName === headExpected.drugName && + head?.testType === headExpected.testType && + head?.protocolLabel === headExpected.protocolLabel; + + const tailMatches = + tail?.drugName === tailExpected.drugName && + tail?.testType === tailExpected.testType && + tail?.protocolLabel === tailExpected.protocolLabel; + + let diagnostic = ''; + if (!headMatches) { + diagnostic += `\n - Head checkpoint (index 0) mismatch:\n Expected: ${JSON.stringify(headExpected)}\n Received: ${JSON.stringify(head)}`; + } + if (!tailMatches) { + diagnostic += `\n - Tail checkpoint (index 115) mismatch:\n Expected: ${JSON.stringify(tailExpected)}\n Received: ${JSON.stringify(tail)}`; + } + + return { + valid: false, + actualHash, + expectedHash: FROZEN_PRE_SNAPSHOT_PREFIX_SHA256, + error: + `CRITICAL SAFETY FAILURE: The frozen 116-record pre-snapshot prefix in ${sourceLabel} ` + + `has been modified, reordered, or tampered with!\n` + + ` Expected SHA-256: ${FROZEN_PRE_SNAPSHOT_PREFIX_SHA256}\n` + + ` Actual SHA-256: ${actualHash}\n` + + ` Clinical safety notice: DREAM saved plans reference protocol array indices 0..115 directly. ` + + `Any reordering, insertion, or modification in this frozen prefix breaks stored patient records!` + + diagnostic, + }; + } + + return { + valid: true, + actualHash, + expectedHash: FROZEN_PRE_SNAPSHOT_PREFIX_SHA256, + }; +} + +/** + * Extracts protocol tuples from generated and dream-only TypeScript data files. + */ +export function extractTuples(fileContent) { const tuples = []; - const recordRegex = /\{\s*(?:id:\s*['"][^'"]*['"],\s*)?drugName:\s*['"]([^'"]+)['"][\s\S]*?testType:\s*['"]([^'"]+)['"][\s\S]*?protocolLabel:\s*['"]([^'"]*)['"]/g; + const recordRegex = + /\{\s*(?:id:\s*['"][^'"]*['"],\s*)?drugName:\s*['"]([^'"]+)['"][\s\S]*?testType:\s*['"]([^'"]+)['"][\s\S]*?protocolLabel:\s*['"]([^'"]*)['"]/g; let m; while ((m = recordRegex.exec(fileContent)) !== null) { tuples.push({ @@ -56,64 +242,269 @@ function extractTuples(fileContent) { return tuples; } -// 2. Read generated and dream-only files to reconstruct the merged DRUG_MASTERLIST order -const generatedContent = readFileSync(join(ROOT, 'src', 'shared', 'data', 'drugMasterlist.generated.ts'), 'utf8'); -const dreamOnlyContent = readFileSync(join(ROOT, 'src', 'shared', 'data', 'dreamOnlyProtocols.ts'), 'utf8'); +/** + * Reconstructs DRUG_MASTERLIST tuples from drugMasterlist.ts by resolving + * findGenerated and findDreamOnly calls against the source arrays. + */ +export function extractMasterlistTuples(masterlistContent, generatedTuples, dreamOnlyTuples) { + function findGenTuple(drugName, testType, label) { + const match = generatedTuples.find( + (t) => t.drugName === drugName && t.testType === testType && (!label || t.protocolLabel === label) + ); + if (!match) throw new Error(`Missing generated tuple: ${drugName} (${testType}${label ? ` - ${label}` : ''})`); + return match; + } + + function findDreamTuple(drugName, testType, label) { + const match = dreamOnlyTuples.find( + (t) => t.drugName === drugName && t.testType === testType && (!label || t.protocolLabel === label) + ); + if (!match) throw new Error(`Missing DREAM-only tuple: ${drugName} (${testType}${label ? ` - ${label}` : ''})`); + return match; + } + + const tuples = []; + const regex = /find(Generated|DreamOnly)\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]*)['"])?\s*\)/g; + let m; + while ((m = regex.exec(masterlistContent)) !== null) { + const isGen = m[1] === 'Generated'; + const drugName = m[2]; + const testType = m[3]; + const protocolLabel = m[4] || ''; + if (isGen) { + tuples.push(findGenTuple(drugName, testType, protocolLabel)); + } else { + tuples.push(findDreamTuple(drugName, testType, protocolLabel)); + } + } + return tuples; +} + +/** + * Full verification pipeline: + * 1. Validates fixture schema. + * 2. Independently validates fixture's frozen pre-snapshot prefix against canonical hash. + * 3. Reconstructs actual merged masterlist from source files. + * 4. Independently validates actual masterlist's frozen pre-snapshot prefix against canonical hash. + * 5. Compares expected vs actual across all positions. + * 6. Validates deliberate appended source record(s). + */ +export function verifyOrder({ + rootDir = ROOT, + fixturePath = DEFAULT_FIXTURE_PATH, +} = {}) { + const errors = []; + const warnings = []; + const mismatches = []; + + // 1. Read and validate fixture + let fixtureContent; + try { + fixtureContent = readFileSync(fixturePath, 'utf8'); + } catch (err) { + return { + success: false, + criticalError: `Failed to read fixture at ${fixturePath}: ${err.message}`, + errors: [`Failed to read fixture at ${fixturePath}: ${err.message}`], + isFixtureError: true, + }; + } + + let fixture; + try { + fixture = JSON.parse(fixtureContent); + } catch (err) { + return { + success: false, + criticalError: `Failed to parse fixture JSON at ${fixturePath}: ${err.message}`, + errors: [`Failed to parse fixture JSON at ${fixturePath}: ${err.message}`], + isFixtureError: true, + }; + } + + try { + validateFixtureSchema(fixture); + } catch (err) { + return { + success: false, + criticalError: `Fixture schema validation failed: ${err.message}`, + errors: [err.message], + isFixtureError: true, + }; + } + + const expectedTuples = fixture.order; + + // 2. Independently verify fixture's frozen prefix (0..115) against committed canonical hash + const fixturePrefixCheck = validateFrozenPrefix(expectedTuples, 'expectedProtocolOrder.json fixture'); + if (!fixturePrefixCheck.valid) { + errors.push(fixturePrefixCheck.error); + } + + // 3. Read source files and reconstruct actual tuples + let generatedContent, dreamOnlyContent, masterlistContent; + try { + generatedContent = readFileSync(join(rootDir, 'src', 'shared', 'data', 'drugMasterlist.generated.ts'), 'utf8'); + dreamOnlyContent = readFileSync(join(rootDir, 'src', 'shared', 'data', 'dreamOnlyProtocols.ts'), 'utf8'); + masterlistContent = readFileSync(join(rootDir, 'src', 'shared', 'data', 'drugMasterlist.ts'), 'utf8'); + } catch (err) { + return { + success: false, + criticalError: `Failed to read source data files: ${err.message}`, + errors: [`Failed to read source data files: ${err.message}`], + isFixtureError: false, + }; + } + + const generatedTuples = extractTuples(generatedContent); + const dreamOnlyTuples = extractTuples(dreamOnlyContent); + let actualTuples = []; + try { + actualTuples = extractMasterlistTuples(masterlistContent, generatedTuples, dreamOnlyTuples); + } catch (err) { + errors.push(`Failed to reconstruct masterlist tuples: ${err.message}`); + } -const generatedTuples = extractTuples(generatedContent); -const dreamOnlyTuples = extractTuples(dreamOnlyContent); + // 4. Independently verify masterlist's frozen prefix (0..115) against committed canonical hash + const masterlistPrefixCheck = validateFrozenPrefix(actualTuples, 'merged drugMasterlist.ts'); + if (!masterlistPrefixCheck.valid) { + errors.push(masterlistPrefixCheck.error); + } -function findGenTuple(drugName, testType, label) { - const match = generatedTuples.find( - (t) => t.drugName === drugName && t.testType === testType && (!label || t.protocolLabel === label) - ); - if (!match) throw new Error(`Missing generated tuple: ${drugName} (${testType})`); - return match; + // 5. Check positional matches across all records + const totalPositions = Math.max(expectedTuples.length, actualTuples.length); + for (let i = 0; i < totalPositions; i++) { + const exp = expectedTuples[i]; + const act = actualTuples[i]; + + if ( + !exp || + !act || + exp.drugName !== act.drugName || + exp.testType !== act.testType || + exp.protocolLabel !== act.protocolLabel + ) { + mismatches.push({ index: i, expected: exp, actual: act }); + } + } + + // 6. Verify deliberate appended record(s) and minimum baseline count + const expectedApp116 = FROZEN_PREFIX_CHECKPOINTS.APPENDED_116.tuple; + + if (expectedTuples.length < MINIMUM_BASELINE_COUNT) { + errors.push( + `CRITICAL SAFETY FAILURE: expectedProtocolOrder.json contains only ${expectedTuples.length} records, ` + + `which is fewer than the required baseline count of ${MINIMUM_BASELINE_COUNT} records (missing committed index 116 checkpoint).` + ); + } else { + const fixtureApp116 = expectedTuples[116]; + if ( + !fixtureApp116 || + fixtureApp116.drugName !== expectedApp116.drugName || + fixtureApp116.testType !== expectedApp116.testType || + fixtureApp116.protocolLabel !== expectedApp116.protocolLabel + ) { + errors.push( + `Fixture appended record at index 116 mismatch: expected ${JSON.stringify(expectedApp116)}, received ${JSON.stringify(fixtureApp116)}` + ); + } + } + + if (actualTuples.length < MINIMUM_BASELINE_COUNT) { + errors.push( + `CRITICAL SAFETY FAILURE: Reconstructed masterlist contains only ${actualTuples.length} records, ` + + `which is fewer than the required baseline count of ${MINIMUM_BASELINE_COUNT} records (missing committed index 116 checkpoint).` + ); + } else { + const actualApp116 = actualTuples[116]; + if ( + !actualApp116 || + actualApp116.drugName !== expectedApp116.drugName || + actualApp116.testType !== expectedApp116.testType || + actualApp116.protocolLabel !== expectedApp116.protocolLabel + ) { + errors.push( + `Masterlist appended record at index 116 mismatch: expected ${JSON.stringify(expectedApp116)}, received ${JSON.stringify(actualApp116)}` + ); + } + } + + const success = errors.length === 0 && mismatches.length === 0; + + return { + success, + fixtureCount: expectedTuples.length, + actualCount: actualTuples.length, + frozenPrefixCount: FROZEN_PRE_SNAPSHOT_COUNT, + frozenPrefixHash: FROZEN_PRE_SNAPSHOT_PREFIX_SHA256, + fixturePrefixValid: fixturePrefixCheck.valid, + masterlistPrefixValid: masterlistPrefixCheck.valid, + mismatches, + errors, + warnings, + }; } -// Reconstruct merged array order exactly as drugMasterlist.ts does -const actualTuples = [ - findGenTuple('Cis-atracurium', 'skin'), - findGenTuple('Rocuronium', 'skin'), - findGenTuple('Pancuronium', 'skin'), - findGenTuple('Vecuronium', 'skin'), - findGenTuple('Suxamethonium', 'skin'), - ...dreamOnlyTuples.slice(0, 17), - findGenTuple('Cefazolin', 'skin'), - ...dreamOnlyTuples.slice(17, 100), - findGenTuple('Cefazolin', 'challenge'), - ...dreamOnlyTuples.slice(100), -]; - -console.log('================================================================================'); -console.log('DRUG MASTERLIST POSITIONAL ORDER VERIFICATION'); -console.log('================================================================================'); -console.log(`EXPECTED (fixture) record count: ${expectedTuples.length}`); -console.log(`ACTUAL (merged) record count: ${actualTuples.length}`); - -let mismatches = 0; -const total = Math.max(expectedTuples.length, actualTuples.length); - -for (let i = 0; i < total; i++) { - const exp = expectedTuples[i]; - const act = actualTuples[i]; - - if (!exp || !act || exp.drugName !== act.drugName || exp.testType !== act.testType || exp.protocolLabel !== act.protocolLabel) { - mismatches++; - console.error(`MISMATCH at index ${i}:`); - console.error(` EXPECTED: ${JSON.stringify(exp)}`); - console.error(` ACTUAL: ${JSON.stringify(act)}`); +export function main() { + console.log('================================================================================'); + console.log('DRUG MASTERLIST POSITIONAL ORDER & INDEPENDENT FROZEN PREFIX VERIFICATION'); + console.log('================================================================================'); + console.log(`FROZEN PREFIX BASELINE: ${FROZEN_PRE_SNAPSHOT_COUNT} records (SHA-256: ${FROZEN_PRE_SNAPSHOT_PREFIX_SHA256})`); + console.log(`MINIMUM BASELINE COUNT: ${MINIMUM_BASELINE_COUNT} records`); + console.log(`FIXTURE PATH: ${DEFAULT_FIXTURE_PATH}`); + + const result = verifyOrder(); + + if (result.criticalError) { + console.error(`\nBROKEN CHECK: ${result.criticalError}`); + process.exit(result.isFixtureError ? 2 : 1); + } + + console.log(`EXPECTED (fixture) count: ${result.fixtureCount}`); + console.log(`ACTUAL (merged) count: ${result.actualCount}`); + console.log('--------------------------------------------------------------------------------'); + console.log(`[GUARD 1] Fixture schema integrity: PASS`); + console.log(`[GUARD 2] Fixture frozen prefix (0..115) hash: ${result.fixturePrefixValid ? 'PASS' : 'FAIL'}`); + console.log(`[GUARD 3] Masterlist frozen prefix (0..115) hash: ${result.masterlistPrefixValid ? 'PASS' : 'FAIL'}`); + + const appendedPassed = + result.errors.every((e) => !e.toLowerCase().includes('116') && !e.toLowerCase().includes('117')) && + result.fixtureCount >= MINIMUM_BASELINE_COUNT && + result.actualCount >= MINIMUM_BASELINE_COUNT; + console.log(`[GUARD 4] Deliberate appended record (index 116): ${appendedPassed ? 'PASS (Flucloxacillin skin/IV)' : 'FAIL'}`); + + console.log(`[GUARD 5] Positional 1:1 parity (${result.fixtureCount} positions): ${result.mismatches.length === 0 ? 'PASS (0 mismatches)' : `FAIL (${result.mismatches.length} mismatches)`}`); + console.log('--------------------------------------------------------------------------------'); + + + if (result.errors.length > 0) { + console.error('ERROR DETAILS:'); + for (const err of result.errors) { + console.error(`- ${err}`); + } + } + + if (result.mismatches.length > 0) { + console.error(`\nPOSITIONAL MISMATCHES (${result.mismatches.length}):`); + for (const m of result.mismatches) { + console.error(`MISMATCH at index ${m.index}:`); + console.error(` EXPECTED: ${JSON.stringify(m.expected)}`); + console.error(` ACTUAL: ${JSON.stringify(m.actual)}`); + } + } + + if (result.success) { + console.log(`Positional mismatches: 0 out of ${result.fixtureCount} positions.`); + console.log('STATUS: PASS (0 mismatches). Clinical saved plan positions and frozen prefix are 100% verified.'); + console.log('================================================================================\n'); + process.exit(0); + } else { + console.error('\nSTATUS: FAIL. Positional ordering or frozen baseline guard failed!'); + console.log('================================================================================\n'); + process.exit(1); } } -console.log('--------------------------------------------------------------------------------'); -console.log(`Positional mismatches: ${mismatches} out of ${total} positions.`); -if (mismatches === 0) { - console.log('STATUS: PASS (0 mismatches). Clinical saved plan positions are 100% preserved.'); - console.log('================================================================================\n'); - process.exit(0); -} else { - console.error('STATUS: FAIL. Positional ordering was altered!'); - console.log('================================================================================\n'); - process.exit(1); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); } diff --git a/src/features/testing/components/ProtocolDoseTable.test.tsx b/src/features/testing/components/ProtocolDoseTable.test.tsx new file mode 100644 index 0000000..3ffc4d7 --- /dev/null +++ b/src/features/testing/components/ProtocolDoseTable.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ProtocolDoseTable } from './ProtocolDoseTable'; +import { DrugProtocol } from '@features/testing/types'; + +describe('ProtocolDoseTable', () => { + it('renders a generated protocol with exact IDT preparation, source deep-link, and under-review note', () => { + const protocol: DrugProtocol = { + id: 'iv', + drugName: 'Pantoprazole', + category: 'Proton Pump Inhibitors', + testType: 'skin', + presentation: '40 mg powder for injection', + sptNeatConcentration: 'Neat (4 mg/mL)', + diluent: '0.9% sodium chloride (reconstitute with 10 mL NS)', + idtSteps: [ + { ratio: '1:1,000', concentration: '0.004 mg/mL', preparation: '0.1 mL of 0.04 mg/mL + 0.9 mL NS' }, + { ratio: '1:100', concentration: '0.04 mg/mL', preparation: '0.1 mL of 0.4 mg/mL + 0.9 mL NS' }, + { ratio: '1:10', concentration: '0.4 mg/mL', preparation: '0.1 mL neat + 0.9 mL NS' }, + ], + challengeSteps: [], + protocolLabel: 'IV', + sourceSlug: 'pantoprazole', + underReview: true, + reviewNote: 'The Spreadsheet 2 spreadsheet labels the SPT concentration as "Neat (40 mg/mL)". This is a spreadsheet labelling error — the correct reconstituted concentration is 4 mg/mL (40 mg powder + 10 mL NS).', + lastReviewed: '2026-03-28', + }; + + render(); + + expect(screen.getByText('Pantoprazole')).toBeInTheDocument(); + expect(screen.getByText('IV')).toBeInTheDocument(); + expect(screen.getByText('40 mg powder for injection')).toBeInTheDocument(); + expect(screen.getByText('Neat (4 mg/mL)')).toBeInTheDocument(); + expect(screen.getByText('0.9% sodium chloride (reconstitute with 10 mL NS)')).toBeInTheDocument(); + + // Source link + const sourceLink = screen.getByRole('link', { name: /View Pantoprazole on SCRATCH|SCRATCH Protocol/i }); + expect(sourceLink).toHaveAttribute('href', 'https://scratch.yuson.au/drugs/pantoprazole/'); + expect(sourceLink).toHaveAttribute('target', '_blank'); + expect(sourceLink).toHaveAttribute('rel', 'noopener noreferrer'); + + // Under-review badge and exact review note + expect(screen.getByText(/Under review/i)).toBeInTheDocument(); + expect(screen.getByText('The Spreadsheet 2 spreadsheet labels the SPT concentration as "Neat (40 mg/mL)". This is a spreadsheet labelling error — the correct reconstituted concentration is 4 mg/mL (40 mg powder + 10 mL NS).')).toBeInTheDocument(); + + // IDT steps in exact order with preparation strings + const ratios = screen.getAllByRole('cell').map(c => c.textContent); + expect(ratios).toContain('1:1,000'); + expect(ratios).toContain('0.004 mg/mL'); + expect(ratios).toContain('0.1 mL of 0.04 mg/mL + 0.9 mL NS'); + expect(ratios).toContain('1:100'); + expect(ratios).toContain('0.04 mg/mL'); + expect(ratios).toContain('0.1 mL of 0.4 mg/mL + 0.9 mL NS'); + expect(ratios).toContain('1:10'); + expect(ratios).toContain('0.4 mg/mL'); + expect(ratios).toContain('0.1 mL neat + 0.9 mL NS'); + }); + + it('renders pharmacy verification warning when flagged', () => { + const protocol: DrugProtocol = { + id: 'iv', + drugName: 'Cephalexin', + category: 'Cephalosporins', + testType: 'skin', + presentation: '500 mg powder for injection', + sptNeatConcentration: 'Neat (50 mg/mL)', + diluent: '0.9% sodium chloride (reconstitute with 10 mL WFI)', + idtSteps: [ + { ratio: '1:100', concentration: '0.5 mg/mL' }, + { ratio: '1:10', concentration: '5 mg/mL' }, + ], + challengeSteps: [], + protocolLabel: 'IV', + needsPharmacyVerification: true, + }; + + render(); + + expect(screen.getByText(/Confirm preparation with pharmacy/i)).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders a DREAM-only protocol without a fake source link', () => { + const protocol: DrugProtocol = { + id: 'ppl', + drugName: 'Penicillin Major', + category: 'Penicillins', + testType: 'skin', + presentation: 'Ampoule + 1 mL diluent', + sptNeatConcentration: 'Neat (8.6 x 10^-5 M)', + diluent: 'Phosphate-buffered saline (1 mL supplied diluent — not plain saline)', + idtSteps: [ + { ratio: '1:100', concentration: '8.6 x 10^-7 M' }, + ], + challengeSteps: [], + protocolLabel: 'PPL', + }; + + render(); + + expect(screen.getByText('Penicillin Major')).toBeInTheDocument(); + expect(screen.getByText('PPL')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByText(/scratch\.yuson\.au/i)).not.toBeInTheDocument(); + }); + + it('renders both under-review and pharmacy-verification warnings when both flags apply', () => { + const protocol: DrugProtocol = { + id: 'test', + drugName: 'Experimental Agent', + category: 'Others', + testType: 'experimental', + presentation: '10 mg/mL vial', + sptNeatConcentration: 'Neat (10 mg/mL)', + diluent: '0.9% sodium chloride', + idtSteps: [], + challengeSteps: [], + protocolLabel: 'Test', + underReview: true, + reviewNote: 'Safety review in progress for concentration.', + needsPharmacyVerification: true, + }; + + render(); + + expect(screen.getByText(/Under review/i)).toBeInTheDocument(); + expect(screen.getByText('Safety review in progress for concentration.')).toBeInTheDocument(); + expect(screen.getByText(/Confirm preparation with pharmacy/i)).toBeInTheDocument(); + }); +}); diff --git a/src/features/testing/components/ProtocolDoseTable.tsx b/src/features/testing/components/ProtocolDoseTable.tsx new file mode 100644 index 0000000..dd46fac --- /dev/null +++ b/src/features/testing/components/ProtocolDoseTable.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { DrugProtocol } from '@features/testing/types'; +import { Badge } from '@/components/ui'; +import { ExternalLink, AlertTriangle } from 'lucide-react'; + +interface ProtocolDoseTableProps { + protocol: DrugProtocol; + className?: string; +} + +export const ProtocolDoseTable: React.FC = ({ protocol, className = '' }) => { + return ( +
+ {/* Header: Drug Name, Protocol Label, Test Type, Source Deep Link */} +
+
+ {protocol.drugName} + {protocol.protocolLabel && ( + + {protocol.protocolLabel} + + )} + {protocol.testType && protocol.testType !== 'skin' && ( + + {protocol.testType} + + )} +
+ {protocol.sourceSlug ? ( + + SCRATCH Protocol + + ) : null} +
+ + {/* Safety Badges & Warnings: Under Review & Pharmacy Verification */} + {(protocol.underReview || protocol.needsPharmacyVerification) && ( +
+ {protocol.underReview && ( +
+
+
+ {protocol.reviewNote && ( +

+ {protocol.reviewNote} +

+ )} +
+ )} + {protocol.needsPharmacyVerification && ( +
+
+ )} +
+ )} + + {/* Presentation, SPT concentration, Diluent */} +
+
+ Presentation + {protocol.presentation || '—'} +
+
+ SPT Neat Concentration + {protocol.sptNeatConcentration || '—'} +
+
+ Diluent + {protocol.diluent || '—'} +
+
+ + {/* IDT Dilution Table */} + {protocol.idtSteps && protocol.idtSteps.length > 0 ? ( +
+ + IDT Dilution Steps + +
+ + + + + + + + + + {protocol.idtSteps.map((step, idx) => ( + + + + + + ))} + +
RatioConcentrationPreparation
{step.ratio}{step.concentration}{step.preparation || '—'}
+
+
+ ) : null} +
+ ); +}; diff --git a/src/features/testing/components/TestingPlanGenerator.test.tsx b/src/features/testing/components/TestingPlanGenerator.test.tsx index c8da886..391e421 100644 --- a/src/features/testing/components/TestingPlanGenerator.test.tsx +++ b/src/features/testing/components/TestingPlanGenerator.test.tsx @@ -8,6 +8,7 @@ const drugCategories = { 'Muscle Relaxants': ['Cis-atracurium'], Penicillins: ['Cephalexin'], Hypnotics: ['Ketamine'], + 'Proton Pump Inhibitors': ['Pantoprazole'], Others: ['Chlorhexidine', 'Latex'], }; @@ -76,9 +77,8 @@ describe('TestingPlanGenerator', () => { fireEvent.click(screen.getByRole('button', { name: 'Cephalexin' })); - const warning = screen.getByText('⚠ Confirm preparation with pharmacy'); - expect(warning).toBeInTheDocument(); - expect(warning).toHaveClass('border-status-warning', 'text-status-warning'); + const warning = screen.getAllByText(/Confirm preparation with pharmacy/i); + expect(warning.length).toBeGreaterThan(0); expect(within(screen.getByRole('button', { name: /Cephalexin/i })).getByText(/Confirm preparation with pharmacy/)).toBeInTheDocument(); expect(within(screen.getByRole('button', { name: /Chlorhexidine/i })).queryByText(/Confirm preparation with pharmacy/)).not.toBeInTheDocument(); }); @@ -145,8 +145,6 @@ describe('TestingPlanGenerator', () => { }); it('preselects Cis-atracurium when reaction history uses the unhyphenated REDCap spelling', () => { - // REDCap's reaction form stores "Cisatracurium" (no hyphen) while the - // masterlist canonical name is "Cis-atracurium" — the matcher must bridge them. const patientWithReactionDrug = createMockPatient({ id: 'PLAN-CISATRA', history: { @@ -203,4 +201,119 @@ describe('TestingPlanGenerator', () => { expect(screen.getByText('(not listed)')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Remove custom drug Sodium citrate flush' })).toBeInTheDocument(); }); + + it('renders inline protocol dose tables with exact clinical strings and source links for selected listed drugs', () => { + renderGenerator(); + + // Chlorhexidine is preselected by default + const tableSection = screen.getByTestId('selected-protocol-details'); + expect(tableSection).toBeInTheDocument(); + + expect(within(tableSection).getByText('Chlorhexidine')).toBeInTheDocument(); + expect(within(tableSection).getByText('0.02% solution (0.2 mg/mL) or 0.1% solution (1 mg/mL)')).toBeInTheDocument(); + expect(within(tableSection).getByText('Neat (0.2 mg/mL)')).toBeInTheDocument(); + expect(within(tableSection).getByText('0.9% sodium chloride')).toBeInTheDocument(); + expect(within(tableSection).getByText('0.1 mL of 0.02 mg/mL + 0.9 mL NS')).toBeInTheDocument(); + + const scratchLink = within(tableSection).getByRole('link', { name: /View Chlorhexidine on SCRATCH/i }); + expect(scratchLink).toHaveAttribute('href', 'https://scratch.yuson.au/drugs/chlorhexidine/'); + }); + + it('renders under-review badge, reviewNote, and source link when selecting a generated drug under review', () => { + renderGenerator(); + + fireEvent.click(screen.getByRole('button', { name: 'Pantoprazole' })); + + const tableSection = screen.getByTestId('selected-protocol-details'); + expect(within(tableSection).getByText('Pantoprazole')).toBeInTheDocument(); + expect(within(tableSection).getByText(/Under review/i)).toBeInTheDocument(); + expect(within(tableSection).getByText('The Spreadsheet 2 spreadsheet labels the SPT concentration as "Neat (40 mg/mL)". This is a spreadsheet labelling error — the correct reconstituted concentration is 4 mg/mL (40 mg powder + 10 mL NS).')).toBeInTheDocument(); + + const scratchLink = within(tableSection).getByRole('link', { name: /View Pantoprazole on SCRATCH/i }); + expect(scratchLink).toHaveAttribute('href', 'https://scratch.yuson.au/drugs/pantoprazole/'); + + // IDT steps exact strings + expect(within(tableSection).getByText('0.1 mL of 0.04 mg/mL + 0.9 mL NS')).toBeInTheDocument(); + expect(within(tableSection).getByText('0.1 mL of 0.4 mg/mL + 0.9 mL NS')).toBeInTheDocument(); + expect(within(tableSection).getByText('0.1 mL neat + 0.9 mL NS')).toBeInTheDocument(); + }); + + it('updates the active inline dose table when switching protocol choices on a multi-protocol drug', async () => { + renderGenerator(); + + fireEvent.click(screen.getByRole('button', { name: 'Ketamine' })); + + const tableSection = screen.getByTestId('selected-protocol-details'); + expect(within(tableSection).getByText('Ketamine')).toBeInTheDocument(); + expect(within(tableSection).getByText('1:1,000 start')).toBeInTheDocument(); + expect(within(tableSection).getByText('1:1,000')).toBeInTheDocument(); + + // Switch protocol to 1:100 start + fireEvent.click(screen.getByRole('combobox', { name: 'Ketamine' })); + fireEvent.click(await screen.findByRole('option', { name: /1:100 start/i })); + + expect(within(tableSection).getByText('1:100 start')).toBeInTheDocument(); + expect(within(tableSection).getByText('1:100')).toBeInTheDocument(); + + // Ketamine is DREAM-only, so no SCRATCH link should exist for it + expect(within(tableSection).queryByRole('link', { name: /View Ketamine on SCRATCH/i })).not.toBeInTheDocument(); + }); + + it('fails closed and shows accessible review alert when restored draft contains invalid protocol index', async () => { + localStorage.setItem(TESTING_PLAN_BUILDER_DRAFTS_KEY, JSON.stringify({ + savedAt: Date.now(), + value: { + [patient.id]: { + selectedDrugs: ['Ketamine'], + selectedProtocols: { Ketamine: 99 }, // Out of range + customDrugs: [], + notes: '', + urgent: false, + reactionDate: '2024-02-10', + documentsToChase: { + tryptases: false, + anaestheticChart: false, + other: false, + otherText: '', + }, + }, + }, + })); + + const { onPreview } = renderGenerator(); + + await waitFor(() => { + expect(screen.getByTestId('protocol-review-required-Ketamine')).toBeInTheDocument(); + }); + + const alert = screen.getByTestId('protocol-review-required-Ketamine'); + expect(alert).toHaveTextContent('⚠ Ketamine — Protocol selection requires review'); + + // Verify guessed dose table is NOT rendered + expect(screen.queryByTestId('protocol-dose-table-Ketamine')).not.toBeInTheDocument(); + + // Verify dropdown shows review required indicator + expect(screen.getByText('⚠ Review required')).toBeInTheDocument(); + + // Verify persistent alert near Preview & Print button is visible + const buttonAlert = screen.getByTestId('protocol-selection-review-alert'); + expect(buttonAlert).toBeInTheDocument(); + expect(buttonAlert).toHaveTextContent('Protocol selection requires review. A valid protocol option must be selected before previewing or printing.'); + + // Verify onPreview is NOT called when clicking Preview & Print with an invalid restored protocol index + fireEvent.click(screen.getByRole('button', { name: /Preview & Print Request Form/i })); + expect(onPreview).not.toHaveBeenCalled(); + + // Now select a valid protocol option from dropdown + fireEvent.click(screen.getByRole('combobox', { name: 'Ketamine' })); + fireEvent.click(await screen.findByRole('option', { name: /1:100 start/i })); + + // Verify review alert near button is cleared and onPreview succeeds + expect(screen.queryByTestId('protocol-selection-review-alert')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Preview & Print Request Form/i })); + expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ + selectedDrugs: ['Ketamine'], + selectedProtocols: expect.objectContaining({ Ketamine: 1 }), + })); + }); }); diff --git a/src/features/testing/components/TestingPlanGenerator.tsx b/src/features/testing/components/TestingPlanGenerator.tsx index e9ce3f3..795a484 100644 --- a/src/features/testing/components/TestingPlanGenerator.tsx +++ b/src/features/testing/components/TestingPlanGenerator.tsx @@ -2,11 +2,15 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; import { Card, CardContent, Button, Label, Switch, Checkbox, Input, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Patient, TestingPlanData, CustomDrugEntry, DocumentsToChase } from '@shared/types'; +import { DrugProtocol } from '@features/testing/types'; import { Printer, Check, X, ClipboardList, ChevronDown, Plus, History, Pin, Search } from 'lucide-react'; import { CATEGORY_THEMES, DEFAULT_THEME, DEFAULT_SELECTED_DRUGS } from '@shared/utils/constants'; import { getSkinProtocolsForDrug } from '@shared/data/drugMasterlist'; +import { resolveSelectedProtocol, type ProtocolResolution } from '@shared/utils/protocolResolver'; import { getIfFresh, setWithTTL, TESTING_PLAN_BUILDER_DRAFTS_KEY } from '@shared/utils/ttlStorage'; import { DraftSaveIndicator } from './DraftSaveIndicator'; +import { ProtocolDoseTable } from './ProtocolDoseTable'; + interface TestingPlanGeneratorProps { patient: Patient; @@ -302,22 +306,55 @@ const TestingPlanGenerator: React.FC = ({ patient, dr .filter(({ protocols }) => protocols.length > 1) ), [selectedDrugs]); + const selectedListedDrugResolutions = useMemo(() => { + return selectedDrugs + .filter(drug => !customDrugs.some(c => c.name === drug)) + .map(drug => { + const protocols = getSkinProtocolsForDrug(drug); + const resolution = resolveSelectedProtocol(protocols, selectedProtocols[drug]); + return { drug, protocols, resolution }; + }); + }, [selectedDrugs, customDrugs, selectedProtocols]); + + const hasUnresolvedProtocols = useMemo(() => { + return selectedListedDrugResolutions.some( + item => item.resolution.status === 'invalid' || item.resolution.status === 'empty' + ); + }, [selectedListedDrugResolutions]); + + const selectedListedDrugs = useMemo(() => { + return selectedListedDrugResolutions.filter( + (item): item is { drug: string; protocols: DrugProtocol[]; resolution: ProtocolResolution } => item.protocols.length > 0 + ); + }, [selectedListedDrugResolutions]); + const handlePreview = () => { - const selectedProtocolPayload = Object.fromEntries( - selectedDrugs.map(drug => [drug, selectedProtocols[drug] ?? 0]) + const isBlocked = selectedListedDrugResolutions.some( + item => item.resolution.status === 'invalid' || item.resolution.status === 'empty' ); + if (isBlocked) { + return; + } + + const selectedProtocolPayload: Record = {}; + selectedDrugs.forEach(drug => { + if (selectedProtocols[drug] !== undefined) { + selectedProtocolPayload[drug] = selectedProtocols[drug]; + } + }); onPreview({ - selectedDrugs, - selectedProtocols: selectedProtocolPayload, - customDrugs, - notes, - urgent, - reactionDate, - documentsToChase, + selectedDrugs, + selectedProtocols: selectedProtocolPayload, + customDrugs, + notes, + urgent, + reactionDate, + documentsToChase, }); }; + const customTheme = CATEGORY_THEMES['Others'] || DEFAULT_THEME; const hasCustomActive = customDrugs.some(e => selectedDrugs.includes(e.name)); const selectedSummary = `${selectedDrugs.length} drug${selectedDrugs.length === 1 ? '' : 's'} selected`; @@ -495,8 +532,8 @@ const TestingPlanGenerator: React.FC = ({ patient, dr const fromHistory = historyDrugs.includes(drug); const isDefault = DEFAULT_SELECTED_DRUGS.includes(drug); const protocols = getSkinProtocolsForDrug(drug); - const activeProtocolIndex = Math.min(selectedProtocols[drug] ?? 0, Math.max(protocols.length - 1, 0)); - const needsPharmacyVerification = protocols[activeProtocolIndex]?.needsPharmacyVerification === true; + const resolution = resolveSelectedProtocol(protocols, selectedProtocols[drug]); + const needsPharmacyVerification = resolution.status === 'valid' && resolution.protocol.needsPharmacyVerification === true; return (