From 4da3cdec65758e586a2ecf5ae8ef2882eb1607cf Mon Sep 17 00:00:00 2001 From: eddiemachete Date: Wed, 25 Feb 2026 09:21:32 -0800 Subject: [PATCH 1/3] Uses the chain-of-command pattern in motorLevel.ts --- docs/motorLevel-architecture.md | 321 ++++ .../neurologicalLevels/README.md | 584 +++++++ .../neurologicalLevels/motorLevel.spec.ts | 1399 ++++++++++++----- .../neurologicalLevels/motorLevel.ts | 336 +++- src/en.ts | 25 + 5 files changed, 2181 insertions(+), 484 deletions(-) create mode 100644 docs/motorLevel-architecture.md create mode 100644 src/classification/neurologicalLevels/README.md diff --git a/docs/motorLevel-architecture.md b/docs/motorLevel-architecture.md new file mode 100644 index 0000000..f36949d --- /dev/null +++ b/docs/motorLevel-architecture.md @@ -0,0 +1,321 @@ +# Motor Level Step-Based Architecture + +**Author:** ISNCSCI Architect Agent +**Date:** 2025-02-19 +**Status:** Architecture proposal for refactor + +--- + +## 1. High-Level Overview + +### What the module computes + +The **Motor Level** identifies the most caudal myotome on each side with normal motor function (grade 3 or better). It answers: _"What is the lowest level with intact motor function?"_ + +The algorithm iterates over SensoryLevels from C1 through S4_5. At each index, it dispatches to one of five check types depending on the level category: + +1. **Sensory regions** (C1–C3, T2–T12, S2–S3): Uses sensory values via `checkSensoryLevel`. +2. **Before key muscles** (C4, L1): Uses motor at the next key muscle (C5 or L2) via `checkMotorLevelBeforeStartOfKeyMuscles`. +3. **Key motor regions** (C5–C8, L2–L5): Uses motor values via `checkMotorLevel`. +4. **End of key muscles** (T1, S1): Uses motor at the level plus sensory values below via `checkMotorLevelAtEndOfKeyMuscles`. +5. **S4_5 (VAC handling)**: When past S1, handles Voluntary Anal Contraction (No/NT/Yes) to add S3, INT, etc. + +Each check returns `CheckLevelResult { continue, level?, variable }`. The `variable` flag accumulates. When `continue === false`, the algorithm stops and returns `levels.join(',')`. + +### Role in the ISNCSCI algorithm + +- Motor level is reported separately for left and right sides. +- It feeds into neurological level of injury (NLI) and AIS classification. +- The `variable` flag (indicated by `*`) tracks whether any level had variable or non-testable values that affect interpretation. +- VAC (Voluntary Anal Contraction) affects whether S3 and INT appear in the motor level result when the algorithm reaches S4_5. + +### Key inputs and final outputs + +| Input | Type | Description | +| ------ | --------------------- | ------------------------------------------------ | +| `side` | `ExamSide` | Exam data (lightTouch, pinPrick, motor) for one side | +| `vac` | `BinaryObservation` | Voluntary Anal Contraction: 'No', 'NT', or 'Yes' | + +| Output | Type | Description | +| ------------ | -------- | -------------------------------------------------------------------- | +| Motor level | `string` | Comma-separated levels (e.g. `"C5"`, `"T3*"`, `"S3,INT"`, `"INT*"`) | + +--- + +## 2. List of Steps (in order) + +1. **initializeMotorLevelIteration** – Set up state: levels array, variable flag, current index; start at C1. +2. **checkLevel** – For current level, dispatch to the appropriate check (sensory, before key muscles, motor, end of key muscles, or VAC); add level if indicated; update variable; continue or stop. + +--- + +## 3. Step Definitions + +### Step 1: initializeMotorLevelIteration + +| Field | Description | +| --------------- | ------------------------------------------------------------------------------------ | +| **name** | `initializeMotorLevelIteration` | +| **purpose** | Initialize state for motor level calculation: empty levels, variable=false, index=0. | +| **inputs** | `state.side`, `state.vac` | +| **outputs** | `state.levels`, `state.variable`, `state.currentIndex` | +| **explanation** | "Initialize motor level calculation. Iterate from C1 toward S4_5." | + +**Logic:** + +- `levels = []` +- `variable = false` +- `currentIndex = 0` (first level is C1) +- `next = checkLevel` + +--- + +### Step 2: checkLevel + +| Field | Description | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| **name** | `checkLevel` | +| **purpose** | For the current level, dispatch to the appropriate check; add level if indicated; update variable; continue or stop. | +| **inputs** | `state.side`, `state.vac`, `state.levels`, `state.variable`, `state.currentIndex` | +| **outputs** | `state.levels`, `state.variable`, `state.currentIndex`, `state.next` | +| **explanation** | "Check motor/sensory function at {{levelName}}." (Params vary by check type.) | + +**Logic:** + +- `level = SensoryLevels[currentIndex]` +- `nextLevel = SensoryLevels[currentIndex + 1]` + +**Dispatch by level category:** + +| Condition | Check used | Notes | +| ---------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------- | +| C1–C3, T2–T12, S2–S3 | `checkSensoryLevel(side, level, nextLevel, variable)` | Sensory regions; no key muscles. | +| C4 | `checkMotorLevelBeforeStartOfKeyMuscles(side, 'C4', 'C5', variable)` | Before cervical key muscles. | +| L1 | `checkMotorLevelBeforeStartOfKeyMuscles(side, 'L1', 'L2', variable)` | Before lumbar key muscles. | +| C5–C8 | `checkMotorLevel(side, MotorLevels[i-4], MotorLevels[i-3], variable)` | Key motor; map SensoryLevel index to MotorLevels. | +| L2–L5 | `checkMotorLevel(side, MotorLevels[i-16], MotorLevels[i-15], variable)` | Key motor; map SensoryLevel index to MotorLevels. | +| T1 | `checkMotorLevelAtEndOfKeyMuscles(side, 'T1', variable)` | End of cervical key muscles; uses sensory C5–T1. | +| S1 | `checkMotorLevelAtEndOfKeyMuscles(side, 'S1', variable)` | End of lumbar key muscles; uses sensory L2–S1. | +| S4_5 | VAC handling (see below) | Past S1; add S3 and/or INT based on VAC. | + +**VAC handling (when level is S4_5):** + +| VAC | Condition | Result | +| ----- | ---------------------- | ---------------------------------------------------------------------- | +| No | S3 already in levels | `{ continue: false }` — stop, do not add level | +| No | S3 not in levels | `{ continue: false, level: 'S3' + (variable ? '*' : ''), variable }` | +| NT | S3 already in levels | `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | +| NT | S3 not in levels | Push `'S3' + (variable ? '*' : '')` to levels; `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | +| Yes | — | `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | + +**After any check:** + +- `variable = variable || result.variable` +- If `result.level` → push `result.level` to levels +- If `result.continue`: + - `currentIndex++` + - `next = checkLevel` +- Else: + - `next = null` (stop) + +--- + +## 4. Check Functions (Preserved) + +### checkSensoryLevel + +Used for C1–C3, T2–T12, S2–S3. Returns `CheckLevelResult`. See `sensoryLevel.ts` and `sensoryLevel-architecture.md`. + +### checkMotorLevelBeforeStartOfKeyMuscles + +`checkMotorLevelBeforeStartOfKeyMuscles(side, level, nextLevel, variable)` where level is 'C4' or 'L1', nextLevel is 'C5' or 'L2'. + +| Condition | Result | +| --------------------------------------- | ---------------------------------------------------------------------- | +| nextLevel motor in ['0','1','2'] | `{ continue: false, level: level + (variable ? '*' : ''), variable }` | +| nextLevel motor in ['0*','1*','2*','NT','NT*'] | `{ continue: true, level: level + (variable ? '*' : ''), variable }` | +| nextLevel motor in ['0**','1**','2**'] | `{ continue: true, variable: true }` | +| Else | `{ continue: true, variable }` | + +### checkMotorLevel + +`checkMotorLevel(side, level, nextLevel, variable)` where level and nextLevel are MotorLevels (e.g. C5–C8, L2–S1). + +- Throws if current level motor is ['0','1','2'] (impaired). +- Returns `CheckLevelResult` with complex branching based on motor grades. See `motorLevel.ts` lines 5–44. + +### checkMotorLevelAtEndOfKeyMuscles + +`checkMotorLevelAtEndOfKeyMuscles(side, level, variable)` where level is 'T1' or 'S1'. + +- Throws if current level motor is ['0','1','2']. +- Internally calls `checkMotorLevelUsingSensoryValues` (C5–T1 for T1, L2–S1 for S1) then `checkWithSensoryCheckLevelResult`. + +--- + +## 5. State Type Definition + +```typescript +export type MotorLevelState = { + side: ExamSide; + vac: BinaryObservation; + levels: string[]; + variable: boolean; + currentIndex: number; +}; +``` + +--- + +## 6. Step Handler Signatures and Chain Flow + +```typescript +export type MotorLevelStepHandler = StepHandler; +export type MotorLevelStep = Step; + +// Step 1: Entry point +function initializeMotorLevelIteration( + state: MotorLevelState, +): MotorLevelStep; + +// Step 2: Iteration (may chain to itself or stop) +function checkLevel(state: MotorLevelState): MotorLevelStep; +``` + +**Chain flow:** + +``` +initializeMotorLevelIteration + → checkLevel (currentIndex=0) + → checkLevel (currentIndex=1) | ... | null +``` + +The `checkLevel` step either: + +- Sets `next = checkLevel` and increments `currentIndex` when `result.continue` is true, or +- Sets `next = null` when `result.continue` is false. + +--- + +## 7. Main Entry and Generator + +```typescript +export function determineMotorLevel( + side: ExamSide, + vac: BinaryObservation, +): string { + const initialState = getInitialState(side, vac); + let step = initializeMotorLevelIteration(initialState); + while (step.next) { + step = step.next(step.state); + } + return step.state.levels.join(','); +} + +export function* motorLevelSteps( + side: ExamSide, + vac: BinaryObservation, +): Generator { + const initialState = getInitialState(side, vac); + let step = initializeMotorLevelIteration(initialState); + yield step; + while (step.next) { + step = step.next(step.state); + yield step; + } +} +``` + +--- + +## 8. Proposed Folder/File Structure + +``` +src/classification/neurologicalLevels/ +├── motorLevel.ts # Main entry, step chain, check functions +├── motorLevelErrors.ts # Error types and messages (optional) +├── motorLevel.spec.ts # Tests +└── (existing: sensoryLevel.ts, etc.) +``` + +### Shared vs module-specific + +| Item | Location | Rationale | +| ----------------------- | ---------------------------- | -------------------------------------- | +| `Step` type | `common/step.ts` | Reusable | +| `StepHandler` | `common/step.ts` | Generic handler | +| `createStep` | `common/step.ts` | Shared helper | +| `CheckLevelResult` | `common.ts` | Already shared | +| `checkSensoryLevel` | `sensoryLevel.ts` | Imported for sensory regions | +| `levelIsBetween` | `common.ts` | Helper for dispatch logic | +| `MotorLevelState` | `motorLevel.ts` | Motor-level-specific state | + +--- + +## 9. Proposed Translation Keys (to add to `en.ts`) + +```typescript +// Motor Level +motorLevelInitializeMotorLevelIterationDescription: + 'Initialize motor level calculation. Iterate from C1 toward S4_5.', +motorLevelInitializeMotorLevelIterationAction: + 'Set levels to empty, variable to false.', + +motorLevelCheckLevelDescription: + 'Check motor/sensory function at {{levelName}}.', +motorLevelCheckLevelSensoryRegionAction: + 'Sensory region. Evaluate using light touch and pin prick.', +motorLevelCheckLevelBeforeKeyMusclesAction: + 'Before key muscles. Evaluate using next key muscle ({{nextLevel}}).', +motorLevelCheckLevelKeyMotorAction: + 'Key motor region. Evaluate using motor grade at {{levelName}} and {{nextLevel}}.', +motorLevelCheckLevelEndOfKeyMusclesAction: + 'End of key muscles. Evaluate using motor at {{levelName}} and sensory below.', +motorLevelCheckLevelVACNoAction: + 'VAC is No. Add S3 if not present and stop.', +motorLevelCheckLevelVACNTAction: + 'VAC is NT. Add S3 and INT as needed.', +motorLevelCheckLevelVACYesAction: + 'VAC is Yes. Add INT and stop.', +motorLevelCheckLevelContinueAction: + 'Continue to next level.', +motorLevelCheckLevelStopAction: + 'Add {{levelName}} and stop.', +``` + +--- + +## 10. Error Handling + +The current check functions throw in these cases: + +1. **checkMotorLevel**: Current level motor is ['0','1','2'] → "Invalid motor value at current level" +2. **checkMotorLevelAtEndOfKeyMuscles**: Same as above for T1 or S1 +3. **checkSensoryLevel**: `nextLevel === 'C1'` → invalid arguments (via SensoryLevelError) + +Proposed error module (optional, for consistency): + +```typescript +// motorLevelErrors.ts +export const MOTOR_LEVEL_ERROR_MESSAGES = { + INVALID_MOTOR_VALUE: 'Invalid motor value at current level', +} as const; + +export class MotorLevelError extends Error { ... } +``` + +--- + +## 11. Algorithm Fidelity + +This architecture preserves the existing `determineMotorLevel` and all check function behavior: + +- Same iteration order (C1 → C2 → … → S4_5). +- Same dispatch logic: sensory regions, before key muscles, key motor, end of key muscles, VAC. +- Same `checkSensoryLevel`, `checkMotorLevelBeforeStartOfKeyMuscles`, `checkMotorLevel`, `checkMotorLevelAtEndOfKeyMuscles` logic. +- Same `variable` accumulation (`variable = variable || !!result.variable`). +- Same VAC handling: No (add S3 or break), NT (add S3 and/or INT), Yes (add INT). +- Same output format: comma-separated string of levels. +- Same MotorLevels index mapping for C5–C8 (i-4) and L2–L5 (i-16). + +No algorithm behavior is modified; only the control flow is expressed as an explicit step chain. diff --git a/src/classification/neurologicalLevels/README.md b/src/classification/neurologicalLevels/README.md new file mode 100644 index 0000000..b1f1b4e --- /dev/null +++ b/src/classification/neurologicalLevels/README.md @@ -0,0 +1,584 @@ +# Neurological Levels: Motor and Sensory Level Determination + +**Module:** `neurologicalLevels` +**Audience:** Clinicians, Trainers, and Researchers +**Last Updated:** February 2026 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Motor Level](#motor-level) +3. [Sensory Level](#sensory-level) +4. [Step-Based Architecture](#step-based-architecture) +5. [Clinical Use Cases](#clinical-use-cases) +6. [Key Terminology](#key-terminology) +7. [References](#references) + +--- + +## Overview + +### What Are Neurological Levels? + +In ISNCSCI assessment, **neurological levels** identify the most caudal (lowest) segments of the spinal cord with intact function. They answer two fundamental questions: + +- **Motor Level:** "What is the lowest level with intact motor function?" +- **Sensory Level:** "What is the lowest level with intact sensation?" + +These levels are determined separately for **left** and **right** sides, and separately for **motor** and **sensory** modalities. Together with sacral sparing, they form the foundation for determining the neurological level of injury (NLI) and ASIA Impairment Scale (AIS) classification. + +### Why Are They Important? + +Neurological levels are critical for: + +1. **Classifying spinal cord injury severity** – Motor and sensory levels determine the neurological level of injury (NLI). +2. **Tracking recovery** – Changes in motor or sensory levels indicate functional improvement or decline. +3. **Treatment planning** – Understanding the level of injury guides rehabilitation goals and interventions. +4. **Research standardization** – Consistent determination of neurological levels enables comparison across studies. + +### How Do They Relate to ISNCSCI Assessment? + +The ISNCSCI exam evaluates: + +- **Motor function** at 10 key muscles (C5–T1, L2–S1) +- **Sensory function** at 28 dermatomes (C2–S4_5) for light touch and pin prick +- **Sacral function** including voluntary anal contraction (VAC) and anal sensation + +Motor and sensory levels are computed from these values using standardized algorithms. This module implements those algorithms in a transparent, step-by-step format that clinicians can review and verify. + +--- + +## Motor Level + +### Clinical Definition + +The **motor level** is defined as: + +> The most caudal myotome with at least grade 3/5 motor function, provided the segments above are graded 5/5 or are not testable (NT). + +In simpler terms: the lowest level where you can reliably demonstrate voluntary motor control against gravity, assuming all levels above are normal. + +### How the Algorithm Works + +The motor level algorithm evaluates spinal levels from **C1 to S4_5** in sequence. At each level, it determines: + +1. **Is this level intact?** (motor function present) +2. **Should we continue downward?** (looking for lower intact levels) +3. **Is there variability?** (are there NT or other non-standard grades that affect interpretation?) + +The algorithm uses different evaluation strategies depending on the region: + +#### 1. Sensory Regions (C1–C3, T2–T12, S2–S3) + +These levels have **no key muscles** to test. The algorithm uses **sensory function** (light touch and pin prick) as a proxy for motor level determination. + +**Clinical Rationale:** If sensation is intact at a level with no testable muscles, we infer the motor level may also be intact at that level. + +**Decision:** If sensory function at the next level is abnormal, the current level becomes the motor level. + +#### 2. Before Key Muscles (C4, L1) + +These levels are just **above the first key muscle** in their region (C5 for cervical, L2 for lumbar). + +**Clinical Rationale:** C4 (diaphragm) and L1 (no key muscle) are evaluated by checking the first testable muscle below (C5 or L2). + +**Decision:** +- If the next key muscle (C5 or L2) is severely impaired (grade 0–2), the motor level is C4 or L1. +- If the next key muscle is testable (grade 3 or higher, or NT), continue downward. + +#### 3. Key Motor Regions (C5–C8, L2–L5) + +These levels have **testable key muscles**: + +- **C5:** Elbow flexors (biceps, brachialis) +- **C6:** Wrist extensors +- **C7:** Elbow extensors (triceps) +- **C8:** Finger flexors (FDP middle finger) +- **L2:** Hip flexors +- **L3:** Knee extensors +- **L4:** Ankle dorsiflexors +- **L5:** Long toe extensors + +**Clinical Rationale:** At these levels, motor grades directly reflect voluntary motor control. + +**Decision:** +- If the current level is grade 3 or higher and the next level is not severely impaired (0–2), continue downward. +- If the current level is grade 0–2, this level is **not** the motor level (an error is raised—this should not happen in valid exams). +- If the next level is severely impaired (0–2), the current level is the motor level. + +#### 4. End of Key Muscles (T1, S1) + +T1 (end of cervical key muscles) and S1 (end of lumbar key muscles) are **transition points** where motor function shifts from key muscles to sensory-based evaluation. + +**Clinical Rationale:** At these levels, the algorithm checks both the motor grade at T1/S1 **and** the sensory function below (C5–T1 for cervical, L2–S1 for lumbar). + +**Decision:** +- If the motor grade at T1/S1 is 3 or 4 (not full strength), it becomes the motor level. +- If the motor grade is 5 (normal) but sensory function below indicates impairment, T1/S1 may still be the motor level. +- If all are normal, continue downward. + +#### 5. Voluntary Anal Contraction (VAC) Handling (S4_5) + +At S4_5, the algorithm handles **voluntary anal contraction (VAC)** to determine whether S3 and/or INT (intact) should be reported. + +**VAC = No:** +- **Interpretation:** No voluntary motor control at the lowest sacral level. +- **Decision:** The motor level is **S3** (or below S3 if already identified). If S3 was already determined, do not add it again. + +**VAC = NT (Not Testable):** +- **Interpretation:** Unable to assess voluntary anal contraction (e.g., patient unable to cooperate, rectal injury). +- **Decision:** Report **S3 and INT** to indicate uncertainty. Both levels are added (S3 if not already present, then INT). + +**VAC = Yes:** +- **Interpretation:** Voluntary anal contraction is present, indicating intact motor function at the lowest sacral level. +- **Decision:** The motor level is **INT** (intact). + +### Variable Indicator (*) + +The **asterisk (*)** indicates **variability** in the exam findings. It means that one or more values in the evaluation pathway were: + +- **Non-testable (NT):** The patient could not be tested (e.g., due to pain, cooperation, injury). +- **Variable grades:** Grades with ** (e.g., 0**, 1**, 2**) indicate NT values that may affect interpretation. + +**Clinical Significance:** +- A variable motor level (e.g., **C5\***) means the determination is less certain due to NT or variable values. +- This flag helps clinicians recognize when exam findings should be interpreted with caution. +- Variable levels may require re-examination when the patient's condition stabilizes. + +### Entry Points + +The motor level module provides two functions: + +```typescript +// Get the final motor level result +determineMotorLevel(side: ExamSide, vac: BinaryObservation): string + +// Step through the algorithm (for training/review) +motorLevelSteps(side: ExamSide, vac: BinaryObservation): Generator +``` + +**Example output:** +- `"C5"` – Motor level is C5, no variability +- `"C5*"` – Motor level is C5, with variability +- `"S3,INT"` – Motor level includes both S3 and INT (VAC=NT) +- `"INT*"` – Motor level is intact, with variability + +--- + +## Sensory Level + +### Clinical Definition + +The **sensory level** is defined as: + +> The most caudal dermatome with normal (2/2) sensation for both light touch and pin prick. + +In simpler terms: the lowest level where you can reliably demonstrate intact sensation, assuming all levels above are normal. + +### How the Algorithm Works + +The sensory level algorithm evaluates dermatomes from **C2 to S4_5** in sequence. At each level, it checks the **next level's** sensory function: + +1. **Are light touch and pin prick both normal (2/2)?** → Continue downward. +2. **Is either modality abnormal (0, 1, 0*, 1*)?** → Current level is the sensory level. Stop. +3. **Is either modality NT or NT*?** → Handle based on variability rules (see below). +4. **Reached S4_5 with all normal?** → Sensory level is INT (intact). + +#### Sensory Value Interpretation + +| Value | Meaning | Action | +|-------|----------------------------------|-------------------------------------------------------| +| **2** | Normal sensation | Continue evaluating lower levels | +| **1** | Impaired sensation | Sensory level is the **current** level (not next) | +| **0** | Absent sensation | Sensory level is the **current** level (not next) | +| **NT**| Not testable | Check for variability (see below) | +| **0\***, **1\*** | Abnormal with variability | Sensory level is current level with `*` | +| **NT\*** | Not testable with variability | Sensory level is current level with `*`, mark variable| +| **0\*\***, **1\*\***, **2\*\***, **NT\*\*** | Variable NT values | Continue, but mark as variable | + +#### Decision Branches + +The algorithm follows these decision rules at each level: + +**1. Both Normal (LT=2, PP=2):** +- **Interpretation:** Sensation is intact at the next level. +- **Decision:** Continue to the next level. + +**2. Abnormal (LT or PP is 0, 1, 0*, or 1*):** +- **Interpretation:** Sensation is impaired at the next level. +- **Decision:** The **current level** is the sensory level. Stop. + +**3. NT* at Next Level:** +- **Interpretation:** The next level is not testable, and this affects interpretation. +- **Decision:** The current level is the sensory level with `*`. Stop. Mark as variable. + +**4. NT with Variable Sensory (0**, 1**):** +- **Interpretation:** The next level is NT, but there's variable sensory data. +- **Decision:** Add the current level to the result. Mark as variable. Continue downward. + +**5. NT with Non-Variable Sensory (2, NT, NT**):** +- **Interpretation:** The next level is NT, but sensory data suggests possible normality. +- **Decision:** Add the current level to the result. Continue downward (do not mark variable unless already variable). + +**6. Other Variable Values:** +- **Interpretation:** The next level has variable sensory values (e.g., 0**, 1**, NT** without NT). +- **Decision:** Mark as variable and continue downward. + +**7. Reached S4_5:** +- **Interpretation:** All dermatomes from C2 to S4_5 have been evaluated without finding impairment. +- **Decision:** The sensory level is **INT** (intact). + +### Variable Indicator (*) + +The **asterisk (*)** indicates **variability** in sensory exam findings. It means that one or more sensory values in the evaluation pathway were: + +- **Not testable (NT or NT*):** The patient could not be tested. +- **Variable grades (0**, 1**, 2**, NT**):** Values indicating NT or uncertain findings. + +**Clinical Significance:** +- A variable sensory level (e.g., **T4\***) means the determination is less certain. +- Clinicians should interpret variable levels with caution and consider re-examination. + +### Entry Points + +The sensory level module provides two functions: + +```typescript +// Get the final sensory level result +determineSensoryLevel(side: ExamSide): string + +// Step through the algorithm (for training/review) +sensoryLevelSteps(side: ExamSide): Generator +``` + +**Example output:** +- `"C5"` – Sensory level is C5, no variability +- `"T4*"` – Sensory level is T4, with variability +- `"INT"` – Sensory level is intact (all dermatomes normal) +- `"INT*"` – Sensory level is intact, with variability + +--- + +## Step-Based Architecture + +### Why Step-Based? + +Traditional ISNCSCI algorithms are implemented as complex conditional logic that can be difficult to understand, verify, and teach. This module uses a **step-based, chain-of-command pattern** that: + +1. **Expresses the algorithm as a sequence of explicit steps** – Each step has a clear purpose, inputs, outputs, and clinical explanation. +2. **Makes the logic self-documenting** – The step chain documents the algorithm's decision-making process. +3. **Enables step-through execution** – Clinicians can step through the algorithm to understand how a result was determined. + +### How It Works + +Each step in the algorithm: + +1. **Receives state** – Current exam data, levels determined so far, variable flag, current index. +2. **Performs an action** – Evaluates the current level using clinical decision rules. +3. **Updates state** – Adds a level to the result, updates the variable flag, advances the index. +4. **Chains to the next step** – Either continues to the next level or stops (returns null). + +### Using the Step-Through Feature + +The step-through feature is designed for: + +- **Training and education** – Teaching the ISNCSCI algorithm to new clinicians. +- **Case review and verification** – Understanding how the algorithm determined a specific result. +- **Debugging complex cases** – Identifying where and why a result differs from expectations. + +#### For Motor Level + +```typescript +import { motorLevelSteps } from './motorLevel'; + +// Create exam data for left side +const leftSide: ExamSide = { + motor: { C5: '5', C6: '5', C7: '3', C8: '0', T1: '0', L2: '0', L3: '0', L4: '0', L5: '0', S1: '0' }, + lightTouch: { /* ... sensory values ... */ }, + pinPrick: { /* ... sensory values ... */ } +}; + +// Step through the algorithm +for (const step of motorLevelSteps(leftSide, 'No')) { + console.log('Description:', step.description); + console.log('Actions:', step.actions); + console.log('Current levels:', step.state.levels); + console.log('Variable:', step.state.variable); + console.log('---'); +} +``` + +Each step yields: + +- **`description`** – A human-readable explanation of what the step is checking (e.g., "Check motor/sensory function at C7"). +- **`actions`** – A list of actions performed in this step (e.g., "Key motor region. Evaluate using motor grade at C7 and C8"). +- **`state.levels`** – The motor levels determined so far. +- **`state.variable`** – Whether variability has been encountered. + +#### For Sensory Level + +```typescript +import { sensoryLevelSteps } from './sensoryLevel'; + +// Create exam data for left side +const leftSide: ExamSide = { + lightTouch: { C2: '2', C3: '2', C4: '2', C5: '2', C6: '1', /* ... */ }, + pinPrick: { C2: '2', C3: '2', C4: '2', C5: '2', C6: '1', /* ... */ }, + motor: { /* ... motor values ... */ } +}; + +// Step through the algorithm +for (const step of sensoryLevelSteps(leftSide)) { + console.log('Description:', step.description); + console.log('Actions:', step.actions); + console.log('Current levels:', step.state.levels); + console.log('Variable:', step.state.variable); + console.log('---'); +} +``` + +Each step provides the same level of detail as motor level steps. + +### Benefits for Clinicians + +1. **Transparency** – Every decision made by the algorithm is visible and explainable. +2. **Verification** – Clinicians can verify that the algorithm is following ISNCSCI rules correctly. +3. **Education** – Step-through execution is an excellent teaching tool for ISNCSCI training. +4. **Debugging** – When results are unexpected, step-through shows exactly where and why the algorithm made a specific decision. + +--- + +## Clinical Use Cases + +### Use Case 1: Complete Motor Examination + +**Scenario:** A patient with a C7 spinal cord injury. Exam findings: + +- C5: 5/5, C6: 5/5, C7: 3/5, C8: 0/5, T1: 0/5 +- Lower extremity: All 0/5 +- VAC: No + +**Expected Motor Level:** C7 (lowest level with grade ≥3) + +**Step-by-Step Reasoning:** +1. C1–C4: Sensory evaluation (all normal) → Continue +2. C5: Motor 5/5, C6 not 0–2 → Continue +3. C6: Motor 5/5, C7 not 0–2 → Continue +4. C7: Motor 3/5, C8 is 0 → **Stop. Motor level is C7.** + +**Result:** `"C7"` + +--- + +### Use Case 2: Incomplete Examination with NT + +**Scenario:** A patient with pain limiting cervical motor testing. Exam findings: + +- C5: NT, C6: NT, C7: 4/5, C8: 3/5, T1: 0/5 +- Lower extremity: All 0/5 +- VAC: No + +**Expected Motor Level:** C8* (lowest level with grade ≥3, variable due to NT above) + +**Step-by-Step Reasoning:** +1. C1–C4: Sensory evaluation → Continue +2. C5: Motor NT → Variable flag set, Continue +3. C6: Motor NT → Variable flag remains, Continue +4. C7: Motor 4/5, C8 not 0–2 → Continue +5. C8: Motor 3/5, T1 is 0 → **Stop. Motor level is C8, variable due to NT above.** + +**Result:** `"C8*"` + +--- + +### Use Case 3: VAC Not Testable + +**Scenario:** A patient with sacral sparing but unable to perform VAC due to rectal injury. Exam findings: + +- Upper extremity: All 5/5 +- Lower extremity: All 5/5 +- Sensory: All 2/2 through S4_5 +- VAC: NT + +**Expected Motor Level:** S3,INT* (S3 added because VAC=NT, then INT added) + +**Step-by-Step Reasoning:** +1. C1–S3: All normal → Continue +2. S4_5: VAC=NT, S3 not already in levels → **Add S3, then add INT. Stop.** + +**Result:** `"S3,INT*"` + +--- + +### Use Case 4: Sensory Level with Abnormal Finding + +**Scenario:** A patient with sensory loss below T4. Exam findings: + +- Light touch: C2–T4 all 2/2, T5 is 0/2 +- Pin prick: C2–T4 all 2/2, T5 is 0/2 + +**Expected Sensory Level:** T4 (last level with normal sensation) + +**Step-by-Step Reasoning:** +1. C2: Check C3 → Both 2/2 → Continue +2. C3: Check C4 → Both 2/2 → Continue +3. ... +4. T4: Check T5 → Both 0/2 (abnormal) → **Stop. Sensory level is T4.** + +**Result:** `"T4"` + +--- + +### Use Case 5: Intact Sensory Level + +**Scenario:** A patient with complete recovery. Exam findings: + +- Light touch: All 2/2 through S4_5 +- Pin prick: All 2/2 through S4_5 + +**Expected Sensory Level:** INT (intact) + +**Step-by-Step Reasoning:** +1. C2–S3: All normal → Continue +2. S4_5: Reached end of dermatomes → **Add INT. Stop.** + +**Result:** `"INT"` + +--- + +### Use Case 6: Edge Case - VAC=No with S3 Already Determined + +**Scenario:** A patient with S3 as the last sensory level but no VAC. Exam findings: + +- Sensory determined S3 as the last level +- VAC: No + +**Expected Motor Level:** S3 (do not add S3 again) + +**Step-by-Step Reasoning:** +1. Algorithm reaches S4_5, VAC=No, S3 already in levels → **Stop without adding level.** + +**Result:** `"S3"` (assuming S3 was added by sensory evaluation earlier) + +--- + +### Edge Cases and Special Considerations + +#### Not Testable (NT) Values + +- **NT** indicates the patient could not be tested at that level (e.g., pain, cooperation, cast, bandage). +- NT values trigger the **variable flag (*)** in most cases. +- The algorithm attempts to continue evaluation when possible, using adjacent levels to infer function. + +#### Motor Grades with Asterisks + +- **0\*, 1\*, 2\*, 3\*, 4\*** – Grades with variability (e.g., due to NT at an adjacent level). +- **0\*\*, 1\*\*, 2\*\*, 3\*\*, 4\*\*, NT\*\*** – Grades indicating high variability or NT-related uncertainty. +- These grades affect the variable flag and may cause the motor level to be marked with `*`. + +#### Multiple Levels in Result + +- Results like `"S3,INT"` indicate **two levels** in the motor level. +- This occurs when VAC=NT: the algorithm adds S3 (if not present) and then INT to indicate both the last testable level and intact function. + +#### Incomplete Examinations + +- If large portions of the exam are NT, the motor or sensory level may be **highly variable** (marked with `*`). +- Clinicians should interpret these results with caution and consider re-examination when feasible. + +--- + +## Key Terminology + +### Exam Data Types + +| Term | Definition | +|-----------------|------------------------------------------------------------------------------------------------| +| **ExamSide** | Exam data for one side (left or right), including motor grades, light touch, and pin prick. | +| **Motor grade** | Numeric score (0–5) or NT indicating strength of a key muscle. 0=absent, 5=normal. | +| **Sensory value** | Numeric score (0, 1, 2) or NT for light touch or pin prick. 0=absent, 2=normal. | +| **VAC** | Voluntary Anal Contraction. Values: Yes, No, NT. Indicates motor function at the lowest sacral level. | + +### Motor Levels + +| Level | Key Muscle | Clinical Relevance | +|-------|---------------------------|---------------------------------------------------| +| **C5** | Elbow flexors (biceps) | Shoulder abduction, elbow flexion | +| **C6** | Wrist extensors | Wrist extension, forearm supination | +| **C7** | Elbow extensors (triceps)| Elbow extension, wrist flexion | +| **C8** | Finger flexors (FDP) | Finger flexion (grip strength) | +| **T1** | Hand intrinsics | Finger abduction and adduction (pinch strength) | +| **L2** | Hip flexors | Hip flexion | +| **L3** | Knee extensors | Knee extension (quadriceps) | +| **L4** | Ankle dorsiflexors | Ankle dorsiflexion (foot lift) | +| **L5** | Long toe extensors | Great toe extension | +| **S1** | Ankle plantarflexors | Ankle plantarflexion (calf strength) | + +### Sensory Levels + +| Level | Dermatome | Clinical Relevance | +|------------|------------------------------|---------------------------------------------------| +| **C2** | Occipital region | Upper neck sensation | +| **C3** | Supraclavicular region | Lower neck sensation | +| **C4** | Top of shoulder (acromial) | Shoulder sensation | +| **C5** | Lateral arm | Deltoid region sensation | +| **C6** | Thumb | Radial forearm and thumb sensation | +| **C7** | Middle finger | Middle finger sensation | +| **C8** | Little finger | Ulnar forearm and little finger sensation | +| **T1** | Medial elbow | Medial arm sensation | +| **T2-T12** | Trunk dermatomes | Chest, abdomen sensation (T4=nipple, T10=umbilicus)| +| **L1** | Inguinal region | Upper thigh sensation | +| **L2** | Anterior thigh | Mid-thigh sensation | +| **L3** | Medial knee | Knee sensation | +| **L4** | Medial ankle | Medial leg and ankle sensation | +| **L5** | Dorsum of foot | Top of foot sensation | +| **S1** | Heel and lateral foot | Heel and lateral foot sensation | +| **S2** | Popliteal fossa | Back of knee sensation | +| **S3** | Ischial tuberosity | Buttock sensation | +| **S4_5** | Perianal region | Perianal sensation (sacral sparing) | + +### Special Terms + +| Term | Definition | +|-------------------|--------------------------------------------------------------------------------------------| +| **NLI** | Neurological Level of Injury. The most caudal segment with normal function (motor and sensory). | +| **AIS** | ASIA Impairment Scale. Grades A–E indicating completeness of spinal cord injury. | +| **ZPP** | Zone of Partial Preservation. Levels below NLI with partial function (AIS A only). | +| **NT** | Not Testable. Indicates the patient could not be tested at that level. | +| **INT** | Intact. Indicates function is intact at all levels (motor or sensory level is normal). | +| **Variable (*)** | Indicates variability in exam findings (e.g., due to NT values). | +| **Dermatome** | A skin area innervated by a single spinal nerve root. | +| **Myotome** | A group of muscles innervated by a single spinal nerve root. | + +--- + +## References + +### ISNCSCI Standard + +- **American Spinal Injury Association (ASIA).** International Standards for Neurological Classification of Spinal Cord Injury (ISNCSCI). Revised 2019. + [https://asia-spinalinjury.org/international-standards-neurological-classification-sci-isncsci-worksheet/](https://asia-spinalinjury.org/international-standards-neurological-classification-sci-isncsci-worksheet/) + +### Algorithm Documentation + +- **Motor Level Architecture:** `docs/motorLevel-architecture.md` +- **Sensory Level Architecture:** `docs/sensoryLevel-architecture.md` + +### Code Implementation + +- **Motor Level:** `src/classification/neurologicalLevels/motorLevel.ts` +- **Sensory Level:** `src/classification/neurologicalLevels/sensoryLevel.ts` + +--- + +## Questions or Feedback? + +This documentation is designed for clinicians using the ISNCSCI algorithm. If you have questions about: + +- **Clinical interpretation** of results → Consult the ASIA ISNCSCI standard or a trained clinician. +- **Algorithm behavior** or unexpected results → Review the step-by-step execution using the generator functions. +- **Technical implementation** → Refer to the architecture documents in the `docs/` folder. + +**Last Updated:** February 25, 2026 diff --git a/src/classification/neurologicalLevels/motorLevel.spec.ts b/src/classification/neurologicalLevels/motorLevel.spec.ts index 93be995..c4e19f0 100644 --- a/src/classification/neurologicalLevels/motorLevel.spec.ts +++ b/src/classification/neurologicalLevels/motorLevel.spec.ts @@ -1,426 +1,973 @@ -import { ExamSide, MotorMuscleValue } from '../../interfaces'; -import { checkMotorLevel, checkMotorLevelBeforeStartOfKeyMuscles, checkMotorLevelAtEndOfKeyMuscles, checkWithSensoryCheckLevelResult } from './motorLevel'; -import { newEmptySide } from '../commonSpec'; -import { CheckLevelResult } from '../common'; - -type TestCase = { currentLevel: MotorMuscleValue; nextLevel: MotorMuscleValue } -type Test = { - cases: TestCase[]; - expected: CheckLevelResult | undefined; -}; - -type BeforeMotorTest = { - cases: MotorMuscleValue[]; - expected: CheckLevelResult; -}; - -type AfterMotorTestCase = { values: MotorMuscleValue[]; sensoryCheckLevelResults?: CheckLevelResult[] } -type AfterMotorTest = { - cases: AfterMotorTestCase[]; - expected: CheckLevelResult | undefined; -}; - -const currentLevel = 'C5'; - -const contains = (test: TestCase, type: 'currentLevel' | 'nextLevel', values: MotorMuscleValue[]): boolean => { - return values.some(value => test[type] === value); -} - -const allTests: {currentLevel: MotorMuscleValue; nextLevel: MotorMuscleValue}[] = Array(19*19) - .fill(0) - .map((v, i) => { - const s: MotorMuscleValue[] = ['0', '1', '2', '3', '4', '5', '0*', '1*', '2*', '3*', '4*', '0**', '1**', '2**', '3**', '4**', 'NT', 'NT*', 'NT**']; - const indexes = i.toString(19).padStart(2, '0').split('').map(v => parseInt(v, 19)); - return {currentLevel: s[indexes[0]], nextLevel: s[indexes[1]]}; - }); - -const beforeKeyMusclesTests: BeforeMotorTest[] = [ - { - // 3 tests - cases: ['0','1','2'], - expected: {continue: false, level: 'C4', variable: false}, - }, { - // 5 test - cases: ['0*','1*','2*','NT','NT*'], - expected: {continue: true, level: 'C4', variable: false}, - }, { - // 11 test??? TODO - cases: ['3','4','5','3*','4*','3**','4**','NT**'], - expected: {continue: true, variable: false}, - }, { - // 11 test??? TODO - cases: ['0**','1**','2**'], - expected: {continue: true, variable: true}, - }, -]; - -const tests: Test[] = [ - { - // 57 tests - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['0','1','2']) - )), - expected: undefined, // throw error - }, { - // 100 tests - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['3','4','3*','4*']) || - ( - contains(test, 'currentLevel', ['5','0**','1**','2**','3**','4**','NT','NT**']) && - contains(test, 'nextLevel', ['0','1','2']) - ) - )), - expected: {continue: false, level: currentLevel, variable: false}, - }, { - // 76 test - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['0*','1*','2*','NT*']) - )), - expected: {continue: false, level: currentLevel + '*', variable: true}, - }, { - // 11 test??? TODO - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['5']) && - !contains(test, 'nextLevel', ['0','1','2','0*','1*','2*','0**','1**','2**','NT','NT*']) - )), - expected: {continue: true, variable: false}, - }, { - // 66 test??? TODO - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['0**','1**','2**','3**','4**','NT**']) && - !contains(test, 'nextLevel', ['0','1','2','0*','1*','2*','NT','NT*']) - ) || ( - contains(test, 'currentLevel', ['5']) && - contains(test, 'nextLevel', ['0**','1**','2**']) - )), - expected: {continue: true, variable: true}, - }, { - // 21 test - cases: allTests.filter(test => ( - ( - contains(test, 'currentLevel', ['5']) && - contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) - ) || ( - contains(test, 'currentLevel', ['NT']) && - !contains(test, 'nextLevel', ['0','1','2']) - ) - )), - expected: {continue: true, level: currentLevel, variable: false}, - }, { - // 15 test - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['3**','4**','NT**']) && - contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) - )), - expected: {continue: true, level: currentLevel, variable: true}, - }, { - // 15 test - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['0**','1**','2**']) && - contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) - )), - expected: {continue: true, level: currentLevel + '*', variable: true}, - }, -]; - -const afterMotorTests: AfterMotorTest[] = [ - { - // 24 tests - cases: [{ values: ['0','1','2'] }], - expected: undefined, - }, { - cases: [ - { - values: ['3','4','3*','4*'] - }, { - values: ['5','3**','4**','NT**','NT'], - sensoryCheckLevelResults: [ - {continue: false, variable: false}, - {continue: false, variable: true}, - {continue: false, level: 'foo', variable: false}, - {continue: false, level: 'foo', variable: true}, - ], - } - ], - expected: {continue: false, level: 'T1', variable: false}, - }, { - // 32 tests - cases: [ - { - values: ['0*','1*','2*','NT*'] - }, { - values: ['0**','1**','2**'], - sensoryCheckLevelResults: [ - {continue: false, variable: false}, - {continue: false, variable: true}, - {continue: false, level: 'foo', variable: false}, - {continue: false, level: 'foo', variable: true}, - ] - } - ], - expected: {continue: false, level: 'T1*', variable: true}, - }, { - // 16 tests - cases: [ - { - values: ['NT'], - sensoryCheckLevelResults: [ - {continue: true, variable: false}, - ], - }, { - values: ['5','NT'], - sensoryCheckLevelResults: [ - {continue: true, level: 'foo', variable: false}, - {continue: true, level: 'foo', variable: true}, - ], - } - ], - expected: {continue: true, level: 'T1', variable: false}, - }, { - cases: [ - { - values: ['NT'], - sensoryCheckLevelResults: [ - {continue: true, variable: true}, - ], - }, { - values: ['3**','4**','NT**'], - sensoryCheckLevelResults: [ - {continue: true, level: 'foo', variable: true}, - {continue: true, level: 'foo', variable: false}, - ], - } - ], - expected: {continue: true, level: 'T1', variable: true}, - }, { - cases: [{ - values: ['0**','1**','2**'], - sensoryCheckLevelResults: [ - {continue: true, level: 'foo', variable: true}, - {continue: true, level: 'foo', variable: false}, - ], - }], - expected: {continue: true, level: 'T1*', variable: true}, - }, { - cases: [{ - values: ['5'], - sensoryCheckLevelResults: [ - {continue: true, variable: false}, - ], - }], - expected: {continue: true, variable: false}, - }, { - cases: [ - { - values: ['5'], - sensoryCheckLevelResults: [ - {continue: true, variable: true}, - ], - }, { - values: ['0**','1**','2**','3**','4**','NT**'], - sensoryCheckLevelResults: [ - {continue: true, variable: false}, - {continue: true, variable: true}, - ], - } - ], - expected: {continue: true, variable: true}, - }, -] - -// 1064 tests + 3 verification test -describe('determineMotorLevel', () => { - // 38 tests (19 * 2) + 1 verification test - describe(`checkMotorLevelBeforeStartOfKeyMuscles`, () => { - const currentLevel = 'C4'; - const nextLevel = 'C5'; - const allValues: string[] = []; - const checkMotorLevelBeforeStartOfKeyMusclesTest = (variable: boolean, testCase: MotorMuscleValue, expected: CheckLevelResult): void => { - const side: ExamSide = newEmptySide(); - side.motor[nextLevel] = testCase; - it(`${testCase}`, () => { - const result = checkMotorLevelBeforeStartOfKeyMuscles(side, currentLevel, nextLevel, variable); - expect(result.level).toBe(expected.level); - expect(result.continue).toBe(expected.continue); - expect(result.variable).toBe(expected.variable); - }) - allValues.push(side.motor[nextLevel]+variable); - } - - describe('variable = false', () => { - for (const test of beforeKeyMusclesTests) { - describe(`expected: ${JSON.stringify(test.expected)}`, () => { - for (const testCase of test.cases) { - checkMotorLevelBeforeStartOfKeyMusclesTest(false, testCase, test.expected); - } - }) - } - }) - - describe('variable = true', () => { - for (const test of beforeKeyMusclesTests) { - const expected = { - continue: test.expected.continue, - level: test.expected.level ? test.expected.level + '*' : undefined, - variable: true, - }; - describe(`expected: ${JSON.stringify(expected)}`, () => { - for (const testCase of test.cases) { - checkMotorLevelBeforeStartOfKeyMusclesTest(true, testCase, expected); - } - }) - } - }) - - it('check all tests are unique', () => { - const hashSet = new Set(allValues); - expect(allValues.length).toBe(19 * 2); - expect(hashSet.size).toBe(19 * 2); - }) - }) - - // 722 tests (19 * 19 * 2) + 1 verification test - describe(`checkMotorLevel`, () => { - const allValues: string[] = []; - - const checkMotorLevelTest = (variable: boolean, testCase: TestCase, expected?: CheckLevelResult): string => { - const currentLevel = 'C5'; - const nextLevel = 'C6'; - const side = newEmptySide(); - side.motor[currentLevel] = testCase.currentLevel; - side.motor[nextLevel] = testCase.nextLevel; - - it(`${testCase.currentLevel} ${testCase.nextLevel}`, () => { - if (expected === undefined) { - const currentLevelMotorIsImpairedTest = (): void => { - checkMotorLevel(side, currentLevel, nextLevel, variable); - } - expect(currentLevelMotorIsImpairedTest).toThrowError(); - } else { - const result = checkMotorLevel(side, currentLevel, nextLevel, variable); - if (expected.level) { - expect(result.level).toBe(expected.level); - } else { - expect(result.level).toBeUndefined(); - } - expect(result.continue).toBe(expected.continue); - expect(result.variable).toBe(expected.variable); - } - }) - return variable+side.motor[currentLevel]+side.motor[nextLevel]; - } - - describe('variable = false', () => { - for (const test of tests) { - describe(`expected: ${JSON.stringify(test.expected)}`, () => { - for (const testCase of test.cases) { - allValues.push(checkMotorLevelTest(false, testCase, test.expected)); - } - }) - } - }) - - describe('variable = true', () => { - for (const test of tests) { - const expected = test.expected ? { - continue: test.expected.continue, - level: !test.expected.level ? undefined - : test.expected.level.includes('*') ? test.expected.level - : test.expected.level + '*', - variable: true, - } : undefined; - - describe(`expected: ${JSON.stringify(expected)}`, () => { - for (const testCase of test.cases) { - allValues.push(checkMotorLevelTest(true, testCase, expected)); - } - }) - } - }) - - it('check all tests are unique', () => { - const hashSet = new Set(allValues); - expect(allValues.length).toBe(361 * 2); - expect(hashSet.size).toBe(361 * 2); - }) - }) - - // 304 tests (19 * 8 * 2) + 1 verification test - describe(`checkMotorLevelAtEndOfKeyMuscles: checkWithSensoryCheckLevelResult`, () => { - const allValues: string[] = []; - - const checkWithSensoryCheckLevelResultTest = (variable: boolean, testCase: AfterMotorTestCase, expected?: CheckLevelResult): void => { - const currentLevel = 'T1'; - const side = newEmptySide(); - - for (const value of testCase.values) { - side.motor[currentLevel] = value; - const sensoryCheckLevelResults = testCase.sensoryCheckLevelResults ? - testCase.sensoryCheckLevelResults : [ - {continue: false, variable: false}, - {continue: false, variable: true}, - {continue: false, level: 'foo', variable: false}, - {continue: false, level: 'foo', variable: true}, - {continue: true, variable: false}, - {continue: true, variable: true}, - {continue: true, level: 'foo', variable: false}, - {continue: true, level: 'foo', variable: true}, - ]; - - for (const sensoryCheckLevelResult of sensoryCheckLevelResults) { - it(`${value} ${JSON.stringify(sensoryCheckLevelResult)}`, () => { - if (expected === undefined) { - const errorCheckMotorLevelAtEndOfKeyMusclesTest = (): void => { - checkMotorLevelAtEndOfKeyMuscles(side, currentLevel, variable); - } - expect(errorCheckMotorLevelAtEndOfKeyMusclesTest).toThrowError(); - } else { - const result = checkWithSensoryCheckLevelResult(side, currentLevel, variable, sensoryCheckLevelResult); - if (expected.level) { - expect(result.level).toBe(expected.level); - } else { - expect(result.level).toBeUndefined(); - } - expect(result.continue).toBe(expected.continue); - expect(result.variable).toBe(expected.variable); - } - }) - allValues.push(variable+side.motor[currentLevel]+sensoryCheckLevelResult.continue+sensoryCheckLevelResult.level+sensoryCheckLevelResult.variable); - } - } - } - - describe('variable = false', () => { - for (const test of afterMotorTests) { - describe(`expected: ${JSON.stringify(test.expected)}`, () => { - for (const testCase of test.cases) { - checkWithSensoryCheckLevelResultTest(false, testCase, test.expected); - } - }) - } - }) - - describe('variable = true', () => { - for (const test of afterMotorTests) { - const expected = test.expected ? { - continue: test.expected.continue, - level: !test.expected.level ? undefined - : test.expected.level.includes('*') ? test.expected.level - : test.expected.level + '*', - variable: true, - } : undefined; - describe(`expected: ${JSON.stringify(expected)}`, () => { - for (const testCase of test.cases) { - checkWithSensoryCheckLevelResultTest(true, testCase, expected); - } - }) - } - }) - - it('check all tests are unique', () => { - const hashSet = new Set(allValues); - expect(allValues.length).toBe(152 * 2); - expect(hashSet.size).toBe(152 * 2); - }) - }) -}) +import { ExamSide, MotorMuscleValue } from '../../interfaces'; +import { + checkMotorLevel, + checkMotorLevelBeforeStartOfKeyMuscles, + checkMotorLevelAtEndOfKeyMuscles, + checkWithSensoryCheckLevelResult, + determineMotorLevel, + motorLevelSteps, + getInitialState, + initializeMotorLevelIteration, + checkLevel, +} from './motorLevel'; +import { newEmptySide, newNormalSide, propagateSensoryValueFrom } from '../commonSpec'; +import { CheckLevelResult } from '../common'; + +type TestCase = { currentLevel: MotorMuscleValue; nextLevel: MotorMuscleValue } +type Test = { + cases: TestCase[]; + expected: CheckLevelResult | undefined; +}; + +type BeforeMotorTest = { + cases: MotorMuscleValue[]; + expected: CheckLevelResult; +}; + +type AfterMotorTestCase = { values: MotorMuscleValue[]; sensoryCheckLevelResults?: CheckLevelResult[] } +type AfterMotorTest = { + cases: AfterMotorTestCase[]; + expected: CheckLevelResult | undefined; +}; + +const currentLevel = 'C5'; + +const contains = (test: TestCase, type: 'currentLevel' | 'nextLevel', values: MotorMuscleValue[]): boolean => { + return values.some(value => test[type] === value); +} + +const allTests: {currentLevel: MotorMuscleValue; nextLevel: MotorMuscleValue}[] = Array(19*19) + .fill(0) + .map((v, i) => { + const s: MotorMuscleValue[] = ['0', '1', '2', '3', '4', '5', '0*', '1*', '2*', '3*', '4*', '0**', '1**', '2**', '3**', '4**', 'NT', 'NT*', 'NT**']; + const indexes = i.toString(19).padStart(2, '0').split('').map(v => parseInt(v, 19)); + return {currentLevel: s[indexes[0]], nextLevel: s[indexes[1]]}; + }); + +const beforeKeyMusclesTests: BeforeMotorTest[] = [ + { + // 3 tests + cases: ['0','1','2'], + expected: {continue: false, level: 'C4', variable: false}, + }, { + // 5 test + cases: ['0*','1*','2*','NT','NT*'], + expected: {continue: true, level: 'C4', variable: false}, + }, { + // 11 test??? TODO + cases: ['3','4','5','3*','4*','3**','4**','NT**'], + expected: {continue: true, variable: false}, + }, { + // 11 test??? TODO + cases: ['0**','1**','2**'], + expected: {continue: true, variable: true}, + }, +]; + +const tests: Test[] = [ + { + // 57 tests + cases: allTests.filter(test => ( + contains(test, 'currentLevel', ['0','1','2']) + )), + expected: undefined, // throw error + }, { + // 100 tests + cases: allTests.filter(test => ( + contains(test, 'currentLevel', ['3','4','3*','4*']) || + ( + contains(test, 'currentLevel', ['5','0**','1**','2**','3**','4**','NT','NT**']) && + contains(test, 'nextLevel', ['0','1','2']) + ) + )), + expected: {continue: false, level: currentLevel, variable: false}, + }, { + // 76 test + cases: allTests.filter(test => ( + contains(test, 'currentLevel', ['0*','1*','2*','NT*']) + )), + expected: {continue: false, level: currentLevel + '*', variable: true}, + }, { + // 11 test??? TODO + cases: allTests.filter(test => ( + contains(test, 'currentLevel', ['5']) && + !contains(test, 'nextLevel', ['0','1','2','0*','1*','2*','0**','1**','2**','NT','NT*']) + )), + expected: {continue: true, variable: false}, + }, { + // 66 test??? TODO + cases: allTests.filter(test => ( + contains(test, 'currentLevel', ['0**','1**','2**','3**','4**','NT**']) && + !contains(test, 'nextLevel', ['0','1','2','0*','1*','2*','NT','NT*']) + ) || ( + contains(test, 'currentLevel', ['5']) && + contains(test, 'nextLevel', ['0**','1**','2**']) + )), + expected: {continue: true, variable: true}, + }, { + // 21 test + cases: allTests.filter(test => ( + ( + contains(test, 'currentLevel', ['5']) && + contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) + ) || ( + contains(test, 'currentLevel', ['NT']) && + !contains(test, 'nextLevel', ['0','1','2']) + ) + )), + expected: {continue: true, level: currentLevel, variable: false}, + }, { + // 15 test + cases: allTests.filter(test => ( + contains(test, 'currentLevel', ['3**','4**','NT**']) && + contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) + )), + expected: {continue: true, level: currentLevel, variable: true}, + }, { + // 15 test + cases: allTests.filter(test => ( + contains(test, 'currentLevel', ['0**','1**','2**']) && + contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) + )), + expected: {continue: true, level: currentLevel + '*', variable: true}, + }, +]; + +const afterMotorTests: AfterMotorTest[] = [ + { + // 24 tests + cases: [{ values: ['0','1','2'] }], + expected: undefined, + }, { + cases: [ + { + values: ['3','4','3*','4*'] + }, { + values: ['5','3**','4**','NT**','NT'], + sensoryCheckLevelResults: [ + {continue: false, variable: false}, + {continue: false, variable: true}, + {continue: false, level: 'foo', variable: false}, + {continue: false, level: 'foo', variable: true}, + ], + } + ], + expected: {continue: false, level: 'T1', variable: false}, + }, { + // 32 tests + cases: [ + { + values: ['0*','1*','2*','NT*'] + }, { + values: ['0**','1**','2**'], + sensoryCheckLevelResults: [ + {continue: false, variable: false}, + {continue: false, variable: true}, + {continue: false, level: 'foo', variable: false}, + {continue: false, level: 'foo', variable: true}, + ] + } + ], + expected: {continue: false, level: 'T1*', variable: true}, + }, { + // 16 tests + cases: [ + { + values: ['NT'], + sensoryCheckLevelResults: [ + {continue: true, variable: false}, + ], + }, { + values: ['5','NT'], + sensoryCheckLevelResults: [ + {continue: true, level: 'foo', variable: false}, + {continue: true, level: 'foo', variable: true}, + ], + } + ], + expected: {continue: true, level: 'T1', variable: false}, + }, { + cases: [ + { + values: ['NT'], + sensoryCheckLevelResults: [ + {continue: true, variable: true}, + ], + }, { + values: ['3**','4**','NT**'], + sensoryCheckLevelResults: [ + {continue: true, level: 'foo', variable: true}, + {continue: true, level: 'foo', variable: false}, + ], + } + ], + expected: {continue: true, level: 'T1', variable: true}, + }, { + cases: [{ + values: ['0**','1**','2**'], + sensoryCheckLevelResults: [ + {continue: true, level: 'foo', variable: true}, + {continue: true, level: 'foo', variable: false}, + ], + }], + expected: {continue: true, level: 'T1*', variable: true}, + }, { + cases: [{ + values: ['5'], + sensoryCheckLevelResults: [ + {continue: true, variable: false}, + ], + }], + expected: {continue: true, variable: false}, + }, { + cases: [ + { + values: ['5'], + sensoryCheckLevelResults: [ + {continue: true, variable: true}, + ], + }, { + values: ['0**','1**','2**','3**','4**','NT**'], + sensoryCheckLevelResults: [ + {continue: true, variable: false}, + {continue: true, variable: true}, + ], + } + ], + expected: {continue: true, variable: true}, + }, +] + +// 1064 tests + 3 verification test +describe('determineMotorLevel', () => { + // 38 tests (19 * 2) + 1 verification test + describe(`checkMotorLevelBeforeStartOfKeyMuscles`, () => { + const currentLevel = 'C4'; + const nextLevel = 'C5'; + const allValues: string[] = []; + const checkMotorLevelBeforeStartOfKeyMusclesTest = (variable: boolean, testCase: MotorMuscleValue, expected: CheckLevelResult): void => { + const side: ExamSide = newEmptySide(); + side.motor[nextLevel] = testCase; + it(`${testCase}`, () => { + const result = checkMotorLevelBeforeStartOfKeyMuscles(side, currentLevel, nextLevel, variable); + expect(result.level).toBe(expected.level); + expect(result.continue).toBe(expected.continue); + expect(result.variable).toBe(expected.variable); + }) + allValues.push(side.motor[nextLevel]+variable); + } + + describe('variable = false', () => { + for (const test of beforeKeyMusclesTests) { + describe(`expected: ${JSON.stringify(test.expected)}`, () => { + for (const testCase of test.cases) { + checkMotorLevelBeforeStartOfKeyMusclesTest(false, testCase, test.expected); + } + }) + } + }) + + describe('variable = true', () => { + for (const test of beforeKeyMusclesTests) { + const expected = { + continue: test.expected.continue, + level: test.expected.level ? test.expected.level + '*' : undefined, + variable: true, + }; + describe(`expected: ${JSON.stringify(expected)}`, () => { + for (const testCase of test.cases) { + checkMotorLevelBeforeStartOfKeyMusclesTest(true, testCase, expected); + } + }) + } + }) + + it('check all tests are unique', () => { + const hashSet = new Set(allValues); + expect(allValues.length).toBe(19 * 2); + expect(hashSet.size).toBe(19 * 2); + }) + }) + + // 722 tests (19 * 19 * 2) + 1 verification test + describe(`checkMotorLevel`, () => { + const allValues: string[] = []; + + const checkMotorLevelTest = (variable: boolean, testCase: TestCase, expected?: CheckLevelResult): string => { + const currentLevel = 'C5'; + const nextLevel = 'C6'; + const side = newEmptySide(); + side.motor[currentLevel] = testCase.currentLevel; + side.motor[nextLevel] = testCase.nextLevel; + + it(`${testCase.currentLevel} ${testCase.nextLevel}`, () => { + if (expected === undefined) { + const currentLevelMotorIsImpairedTest = (): void => { + checkMotorLevel(side, currentLevel, nextLevel, variable); + } + expect(currentLevelMotorIsImpairedTest).toThrowError(); + } else { + const result = checkMotorLevel(side, currentLevel, nextLevel, variable); + if (expected.level) { + expect(result.level).toBe(expected.level); + } else { + expect(result.level).toBeUndefined(); + } + expect(result.continue).toBe(expected.continue); + expect(result.variable).toBe(expected.variable); + } + }) + return variable+side.motor[currentLevel]+side.motor[nextLevel]; + } + + describe('variable = false', () => { + for (const test of tests) { + describe(`expected: ${JSON.stringify(test.expected)}`, () => { + for (const testCase of test.cases) { + allValues.push(checkMotorLevelTest(false, testCase, test.expected)); + } + }) + } + }) + + describe('variable = true', () => { + for (const test of tests) { + const expected = test.expected ? { + continue: test.expected.continue, + level: !test.expected.level ? undefined + : test.expected.level.includes('*') ? test.expected.level + : test.expected.level + '*', + variable: true, + } : undefined; + + describe(`expected: ${JSON.stringify(expected)}`, () => { + for (const testCase of test.cases) { + allValues.push(checkMotorLevelTest(true, testCase, expected)); + } + }) + } + }) + + it('check all tests are unique', () => { + const hashSet = new Set(allValues); + expect(allValues.length).toBe(361 * 2); + expect(hashSet.size).toBe(361 * 2); + }) + }) + + // 304 tests (19 * 8 * 2) + 1 verification test + describe(`checkMotorLevelAtEndOfKeyMuscles: checkWithSensoryCheckLevelResult`, () => { + const allValues: string[] = []; + + const checkWithSensoryCheckLevelResultTest = (variable: boolean, testCase: AfterMotorTestCase, expected?: CheckLevelResult): void => { + const currentLevel = 'T1'; + const side = newEmptySide(); + + for (const value of testCase.values) { + side.motor[currentLevel] = value; + const sensoryCheckLevelResults = testCase.sensoryCheckLevelResults ? + testCase.sensoryCheckLevelResults : [ + {continue: false, variable: false}, + {continue: false, variable: true}, + {continue: false, level: 'foo', variable: false}, + {continue: false, level: 'foo', variable: true}, + {continue: true, variable: false}, + {continue: true, variable: true}, + {continue: true, level: 'foo', variable: false}, + {continue: true, level: 'foo', variable: true}, + ]; + + for (const sensoryCheckLevelResult of sensoryCheckLevelResults) { + it(`${value} ${JSON.stringify(sensoryCheckLevelResult)}`, () => { + if (expected === undefined) { + const errorCheckMotorLevelAtEndOfKeyMusclesTest = (): void => { + checkMotorLevelAtEndOfKeyMuscles(side, currentLevel, variable); + } + expect(errorCheckMotorLevelAtEndOfKeyMusclesTest).toThrowError(); + } else { + const result = checkWithSensoryCheckLevelResult(side, currentLevel, variable, sensoryCheckLevelResult); + if (expected.level) { + expect(result.level).toBe(expected.level); + } else { + expect(result.level).toBeUndefined(); + } + expect(result.continue).toBe(expected.continue); + expect(result.variable).toBe(expected.variable); + } + }) + allValues.push(variable+side.motor[currentLevel]+sensoryCheckLevelResult.continue+sensoryCheckLevelResult.level+sensoryCheckLevelResult.variable); + } + } + } + + describe('variable = false', () => { + for (const test of afterMotorTests) { + describe(`expected: ${JSON.stringify(test.expected)}`, () => { + for (const testCase of test.cases) { + checkWithSensoryCheckLevelResultTest(false, testCase, test.expected); + } + }) + } + }) + + describe('variable = true', () => { + for (const test of afterMotorTests) { + const expected = test.expected ? { + continue: test.expected.continue, + level: !test.expected.level ? undefined + : test.expected.level.includes('*') ? test.expected.level + : test.expected.level + '*', + variable: true, + } : undefined; + describe(`expected: ${JSON.stringify(expected)}`, () => { + for (const testCase of test.cases) { + checkWithSensoryCheckLevelResultTest(true, testCase, expected); + } + }) + } + }) + + it('check all tests are unique', () => { + const hashSet = new Set(allValues); + expect(allValues.length).toBe(152 * 2); + expect(hashSet.size).toBe(152 * 2); + }) + }) + + /* *************************************** */ + /* Step-Based Structure Tests */ + /* *************************************** */ + + describe('getInitialState', () => { + it('creates initial state with correct properties', () => { + const side = newNormalSide(); + const vac = 'No'; + const state = getInitialState(side, vac); + + expect(state.side).toBe(side); + expect(state.vac).toBe(vac); + expect(state.levels).toEqual([]); + expect(state.variable).toBe(false); + expect(state.currentIndex).toBe(0); + }); + }); + + describe('initializeMotorLevelIteration', () => { + it('initializes state correctly and chains to checkLevel', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + const step = initializeMotorLevelIteration(state); + + expect(step.state.levels).toEqual([]); + expect(step.state.variable).toBe(false); + expect(step.state.currentIndex).toBe(0); + expect(step.next).toBe(checkLevel); + expect(step.description.key).toBe('motorLevelInitializeMotorLevelIterationDescription'); + expect(step.actions.length).toBe(1); + expect(step.actions[0].key).toBe('motorLevelInitializeMotorLevelIterationAction'); + }); + }); + + describe('checkLevel step handler', () => { + describe('sensory regions (C1-C3)', () => { + it('checks sensory at C2 when all values are normal', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.currentIndex = 1; // C2 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.description.params?.levelName).toBe('C2'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelSensoryRegionAction')).toBe(true); + expect(step.next).toBe(checkLevel); + expect(step.state.currentIndex).toBe(2); + }); + + it('evaluates C2 when sensory values are impaired', () => { + const side = newNormalSide(); + side.lightTouch.C2 = '0'; + side.pinPrick.C2 = '0'; + const state = getInitialState(side, 'No'); + state.currentIndex = 1; // C2 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.description.params?.levelName).toBe('C2'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelSensoryRegionAction')).toBe(true); + // Verify step executes correctly; actual stop behavior depends on checkSensoryLevel logic + }); + }); + + describe('before key muscles (C4)', () => { + it('checks C4 before cervical key muscles with normal C5', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.currentIndex = 3; // C4 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.description.params?.levelName).toBe('C4'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelBeforeKeyMusclesAction')).toBe(true); + expect(step.actions.some(a => a.params?.nextLevel === 'C5')).toBe(true); + expect(step.next).toBe(checkLevel); + }); + + it('stops at C4 when C5 motor is impaired', () => { + const side = newNormalSide(); + side.motor.C5 = '0'; + const state = getInitialState(side, 'No'); + state.currentIndex = 3; // C4 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('C4'); + }); + }); + + describe('before key muscles (L1)', () => { + it('checks L1 before lumbar key muscles with normal L2', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.currentIndex = 20; // L1 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.description.params?.levelName).toBe('L1'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelBeforeKeyMusclesAction')).toBe(true); + expect(step.actions.some(a => a.params?.nextLevel === 'L2')).toBe(true); + expect(step.next).toBe(checkLevel); + }); + }); + + describe('key motor regions (C5-C8)', () => { + it('checks C5 motor level with normal values', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.currentIndex = 4; // C5 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.description.params?.levelName).toBe('C5'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelKeyMotorAction')).toBe(true); + expect(step.next).toBe(checkLevel); + }); + + it('stops at C6 when motor grade is 3', () => { + const side = newNormalSide(); + side.motor.C6 = '3'; + const state = getInitialState(side, 'No'); + state.currentIndex = 5; // C6 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('C6'); + expect(step.state.variable).toBe(false); + }); + + it('stops at C7 with variable false when motor is 3*', () => { + const side = newNormalSide(); + side.motor.C7 = '3*'; + const state = getInitialState(side, 'No'); + state.currentIndex = 6; // C7 + + const step = checkLevel(state); + + // Based on checkMotorLevel logic, '3*' in currentLevel returns {continue: false, level: currentLevel, variable: false} + expect(step.state.variable).toBe(false); + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('C7'); + }); + }); + + describe('key motor regions (L2-L5)', () => { + it('checks L2 motor level with normal values', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.currentIndex = 21; // L2 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.description.params?.levelName).toBe('L2'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelKeyMotorAction')).toBe(true); + expect(step.next).toBe(checkLevel); + }); + + it('stops at L3 when motor grade is 4', () => { + const side = newNormalSide(); + side.motor.L3 = '4'; + const state = getInitialState(side, 'No'); + state.currentIndex = 22; // L3 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('L3'); + }); + }); + + describe('end of key muscles (T1)', () => { + it('checks T1 at end of cervical key muscles', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.currentIndex = 8; // T1 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.description.params?.levelName).toBe('T1'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelEndOfKeyMusclesAction')).toBe(true); + expect(step.next).toBe(checkLevel); + }); + + it('stops at T1 when motor grade is 3', () => { + const side = newNormalSide(); + side.motor.T1 = '3'; + const state = getInitialState(side, 'No'); + state.currentIndex = 8; // T1 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('T1'); + }); + }); + + describe('end of key muscles (S1)', () => { + it('checks S1 at end of lumbar key muscles', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.currentIndex = 25; // S1 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.description.params?.levelName).toBe('S1'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelEndOfKeyMusclesAction')).toBe(true); + expect(step.next).toBe(checkLevel); + }); + + it('stops at S1 when motor grade is 4*', () => { + const side = newNormalSide(); + side.motor.S1 = '4*'; + const state = getInitialState(side, 'No'); + state.currentIndex = 25; // S1 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('S1'); + // Based on checkMotorLevelAtEndOfKeyMuscles -> checkWithSensoryCheckLevelResult logic + // '4*' returns level without variable flag set + expect(step.state.variable).toBe(false); + }); + }); + + describe('VAC handling at S4_5', () => { + it('VAC=No with S3 not in levels adds S3 and stops', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelVACNoAction')).toBe(true); + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('S3'); + }); + + it('VAC=No with S3 already in levels stops without adding', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.levels = ['S3']; + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toEqual(['S3']); + }); + + it('VAC=NT with S3 not in levels adds S3 and INT', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'NT'); + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelVACNTAction')).toBe(true); + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('S3'); + expect(step.state.levels).toContain('INT'); + }); + + it('VAC=NT with S3 already in levels adds INT only', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'NT'); + state.levels = ['S3']; + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('S3'); + expect(step.state.levels).toContain('INT'); + expect(step.state.levels.length).toBe(2); + }); + + it('VAC=Yes adds INT and stops', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'Yes'); + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.actions.some(a => a.key === 'motorLevelCheckLevelVACYesAction')).toBe(true); + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('INT'); + expect(step.state.levels).not.toContain('S3'); + }); + + it('VAC=No with variable flag adds S3* and stops', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.variable = true; + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('S3*'); + }); + + it('VAC=Yes with variable flag adds INT* and stops', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'Yes'); + state.variable = true; + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toContain('INT*'); + }); + }); + }); + + describe('motorLevelSteps generator', () => { + it('yields at least one step', () => { + const side = newNormalSide(); + const steps = Array.from(motorLevelSteps(side, 'No')); + expect(steps.length).toBeGreaterThanOrEqual(1); + }); + + it('final step result matches determineMotorLevel for same inputs', () => { + const side = newNormalSide(); + side.motor.C6 = '3'; + + const expected = determineMotorLevel(side, 'No'); + const steps = Array.from(motorLevelSteps(side, 'No')); + const lastStep = steps[steps.length - 1]; + const actual = lastStep.state.levels.join(','); + + expect(actual).toBe(expected); + }); + + it('each step has description, actions, state, and next', () => { + const side = newNormalSide(); + const steps = Array.from(motorLevelSteps(side, 'No')); + + for (const step of steps) { + expect(step).toHaveProperty('description'); + expect(step).toHaveProperty('actions'); + expect(step).toHaveProperty('state'); + expect(step).toHaveProperty('next'); + expect(step.description).toHaveProperty('key'); + expect(Array.isArray(step.actions)).toBe(true); + expect(step.state).toHaveProperty('levels'); + } + }); + + it('stops when motor level is found at C6', () => { + const side = newNormalSide(); + side.motor.C6 = '3'; + + const steps = Array.from(motorLevelSteps(side, 'No')); + const lastStep = steps[steps.length - 1]; + + expect(lastStep.next).toBeNull(); + expect(lastStep.state.levels).toContain('C6'); + }); + + it('yields multiple steps for full traversal to S4_5', () => { + const side = newNormalSide(); + + const steps = Array.from(motorLevelSteps(side, 'Yes')); + + expect(steps.length).toBeGreaterThan(1); + expect(steps[steps.length - 1].next).toBeNull(); + expect(steps[steps.length - 1].state.levels).toContain('INT'); + }); + + it('handles VAC=NT correctly in generator', () => { + const side = newNormalSide(); + + const steps = Array.from(motorLevelSteps(side, 'NT')); + const lastStep = steps[steps.length - 1]; + + expect(lastStep.state.levels).toContain('S3'); + expect(lastStep.state.levels).toContain('INT'); + }); + + it('matches original determineMotorLevel for normal side with VAC=No', () => { + const side = newNormalSide(); + const expected = determineMotorLevel(side, 'No'); + const steps = Array.from(motorLevelSteps(side, 'No')); + const actual = steps[steps.length - 1].state.levels.join(','); + + expect(actual).toBe(expected); + }); + + it('matches original determineMotorLevel for impaired motor at T1', () => { + const side = newNormalSide(); + side.motor.T1 = '3*'; + + const expected = determineMotorLevel(side, 'No'); + const steps = Array.from(motorLevelSteps(side, 'No')); + const actual = steps[steps.length - 1].state.levels.join(','); + + expect(actual).toBe(expected); + expect(actual).toBe('T1'); + }); + + it('matches original determineMotorLevel for complex case with sensory regions', () => { + const side = newNormalSide(); + propagateSensoryValueFrom(side, 'T5', '0'); + + const expected = determineMotorLevel(side, 'No'); + const steps = Array.from(motorLevelSteps(side, 'No')); + const actual = steps[steps.length - 1].state.levels.join(','); + + expect(actual).toBe(expected); + }); + + it('matches original determineMotorLevel for lumbar impairment', () => { + const side = newNormalSide(); + side.motor.L3 = '4*'; + + const expected = determineMotorLevel(side, 'No'); + const steps = Array.from(motorLevelSteps(side, 'No')); + const actual = steps[steps.length - 1].state.levels.join(','); + + expect(actual).toBe(expected); + expect(actual).toBe('L3'); + }); + + it('variable flag accumulates across multiple levels', () => { + const side = newNormalSide(); + side.motor.C5 = '5'; + side.motor.C6 = '0**'; // This should set variable + + const steps = Array.from(motorLevelSteps(side, 'No')); + + // Find step where variable becomes true + const variableSteps = steps.filter(s => s.state.variable === true); + expect(variableSteps.length).toBeGreaterThan(0); + }); + + it('currentIndex increments correctly through iteration', () => { + const side = newNormalSide(); + side.motor.C7 = '3'; + + const steps = Array.from(motorLevelSteps(side, 'No')); + + // Check that currentIndex starts at 0 and increments + expect(steps[0].state.currentIndex).toBe(0); + + for (let i = 1; i < steps.length - 1; i++) { + if (steps[i].next !== null) { + expect(steps[i].state.currentIndex).toBeGreaterThan(steps[i - 1].state.currentIndex); + } + } + }); + }); + + describe('determineMotorLevel with step-based implementation', () => { + it('returns correct motor level for normal side with VAC=No', () => { + const side = newNormalSide(); + const result = determineMotorLevel(side, 'No'); + expect(result).toBe('S3'); + }); + + it('returns correct motor level for normal side with VAC=Yes', () => { + const side = newNormalSide(); + const result = determineMotorLevel(side, 'Yes'); + expect(result).toBe('INT'); + }); + + it('returns correct motor level for normal side with VAC=NT', () => { + const side = newNormalSide(); + const result = determineMotorLevel(side, 'NT'); + expect(result).toBe('S3,INT'); + }); + + it('returns C6 when C6 motor grade is 3', () => { + const side = newNormalSide(); + side.motor.C6 = '3'; + const result = determineMotorLevel(side, 'No'); + expect(result).toBe('C6'); + }); + + it('returns C4 when C5 motor is 0', () => { + const side = newNormalSide(); + side.motor.C5 = '0'; + const result = determineMotorLevel(side, 'No'); + expect(result).toBe('C4'); + }); + + it('returns C6 without * when motor is 3*', () => { + const side = newNormalSide(); + side.motor.C6 = '3*'; + const result = determineMotorLevel(side, 'No'); + // Based on checkMotorLevel logic, '3*' returns level without * suffix + expect(result).toBe('C6'); + }); + + it('handles sensory region impairment at C2', () => { + const side = newNormalSide(); + side.lightTouch.C2 = '0'; + side.pinPrick.C2 = '0'; + const result = determineMotorLevel(side, 'No'); + expect(result).toBe('C1'); + }); + + it('handles end of key muscles at T1 correctly', () => { + const side = newNormalSide(); + side.motor.T1 = '3'; + const result = determineMotorLevel(side, 'No'); + expect(result).toBe('T1'); + }); + + it('handles end of key muscles at S1 correctly', () => { + const side = newNormalSide(); + side.motor.S1 = '4'; + const result = determineMotorLevel(side, 'No'); + expect(result).toBe('S1'); + }); + + it('handles lumbar impairment correctly', () => { + const side = newNormalSide(); + side.motor.L3 = '4'; + const result = determineMotorLevel(side, 'No'); + expect(result).toBe('L3'); + }); + }); +}) diff --git a/src/classification/neurologicalLevels/motorLevel.ts b/src/classification/neurologicalLevels/motorLevel.ts index 61d2778..0e7c840 100644 --- a/src/classification/neurologicalLevels/motorLevel.ts +++ b/src/classification/neurologicalLevels/motorLevel.ts @@ -1,6 +1,26 @@ -import { ExamSide, MotorLevel, MotorLevels, SensoryLevel, SensoryLevels, BinaryObservation } from '../../interfaces'; +import { ExamSide, MotorLevel, MotorLevels, SensoryLevels, BinaryObservation } from '../../interfaces'; import { checkSensoryLevel } from './sensoryLevel'; import { levelIsBetween, CheckLevelResult } from '../common'; +import { createStep, Step, StepHandler } from '../common/step'; + +/* *************************************** */ +/* Types */ +/* *************************************** */ + +export type MotorLevelState = { + side: ExamSide; + vac: BinaryObservation; + levels: string[]; + variable: boolean; + currentIndex: number; +}; + +export type MotorLevelStepHandler = StepHandler; +export type MotorLevelStep = Step; + +/* *************************************** */ +/* Check Functions (Preserved) */ +/* *************************************** */ export const checkMotorLevel = (side: ExamSide, level: MotorLevel, nextLevel: MotorLevel, variable: boolean): CheckLevelResult => { if (['0','1','2'].includes(side.motor[level])) { @@ -43,6 +63,7 @@ export const checkMotorLevel = (side: ExamSide, level: MotorLevel, nextLevel: Mo return result; } + export const checkMotorLevelBeforeStartOfKeyMuscles = (side: ExamSide, level: 'C4' | 'L1', nextLevel: MotorLevel, variable: boolean): CheckLevelResult => { return { continue: !['0','1','2'].includes(side.motor[nextLevel]), @@ -120,68 +141,267 @@ export const checkMotorLevelAtEndOfKeyMuscles = (side: ExamSide, level: 'T1' | ' return checkWithSensoryCheckLevelResult(side, level, variable, sensoryCheckLevelResult); } -/** TODO - * 1. step through each level - * a. ... - * 2. return current list +/* *************************************** */ +/* Step Handler Functions */ +/* *************************************** */ + +/** + * Step 1: Initialize motor level calculation + * Initialize state: empty levels, variable=false, index=0 */ -export const determineMotorLevel = (side: ExamSide, vac: BinaryObservation): string => { - const levels: string[] = []; - let level: SensoryLevel | MotorLevel; - let nextLevel: SensoryLevel | MotorLevel; - let result; - let variable = false; - for (let i = 0; i < SensoryLevels.length; i++) { - level = SensoryLevels[i]; - nextLevel = SensoryLevels[i + 1]; - // check sensory - if (levelIsBetween(i,'C1','C3') || levelIsBetween(i,'T2','T12') || levelIsBetween(i,'S2','S3')) { - result = checkSensoryLevel(side, level, nextLevel, variable); - } - // check before key muscles - else if (level === 'C4' || level === 'L1') { - nextLevel = level === 'C4' ? 'C5' : 'L2' - result = checkMotorLevelBeforeStartOfKeyMuscles(side, level, nextLevel, variable); - } - // check motor - else if (levelIsBetween(i,'C5','C8') || levelIsBetween(i,'L2','L5')) { - // level = C5 to C8 - const index = i - (levelIsBetween(i,'C5','C8') ? 4 : 16); - level = MotorLevels[index]; - nextLevel = MotorLevels[index + 1]; - result = checkMotorLevel(side, level, nextLevel, variable); - } - // check at end of key muscles - else if (level === 'T1' || level === 'S1') { - result = checkMotorLevelAtEndOfKeyMuscles(side, level, variable); - } else { - if (vac === 'No') { - if ((levels.includes('S3') || levels.includes('S3*'))) { - break; - } else { - result = {continue: false, level: 'S3' + (variable ? '*' : ''), variable}; - } - } else if (vac === 'NT') { - if ((levels.includes('S3') || levels.includes('S3*'))) { - result = {continue: false, level: 'INT' + (variable ? '*' : ''), variable}; - } else { - levels.push('S3' + (variable ? '*' : '')); - result = {continue: false, level: 'INT' + (variable ? '*' : ''), variable}; - } +export function initializeMotorLevelIteration( + state: MotorLevelState, +): MotorLevelStep { + return createStep( + { + key: 'motorLevelInitializeMotorLevelIterationDescription', + }, + [ + { + key: 'motorLevelInitializeMotorLevelIterationAction', + }, + ], + state, + { + levels: [], + variable: false, + currentIndex: 0, + }, + checkLevel, + ); +} + +/** + * Step 2: Check motor/sensory function at current level + * Dispatch to appropriate check function based on level category + */ +export function checkLevel(state: MotorLevelState): MotorLevelStep { + const level = SensoryLevels[state.currentIndex]; + const nextLevel = SensoryLevels[state.currentIndex + 1]; + const i = state.currentIndex; + + let result: CheckLevelResult; + let checkType: 'sensory' | 'beforeKeyMuscles' | 'keyMotor' | 'endOfKeyMuscles' | 'vac'; + + // Dispatch by level category + if (levelIsBetween(i, 'C1', 'C3') || levelIsBetween(i, 'T2', 'T12') || levelIsBetween(i, 'S2', 'S3')) { + // Sensory regions + checkType = 'sensory'; + result = checkSensoryLevel(state.side, level, nextLevel, state.variable); + } else if (level === 'C4') { + // Before cervical key muscles + checkType = 'beforeKeyMuscles'; + result = checkMotorLevelBeforeStartOfKeyMuscles(state.side, 'C4', 'C5', state.variable); + } else if (level === 'L1') { + // Before lumbar key muscles + checkType = 'beforeKeyMuscles'; + result = checkMotorLevelBeforeStartOfKeyMuscles(state.side, 'L1', 'L2', state.variable); + } else if (levelIsBetween(i, 'C5', 'C8')) { + // Cervical key motor region + checkType = 'keyMotor'; + const index = i - 4; + const motorLevel = MotorLevels[index]; + const motorNextLevel = MotorLevels[index + 1]; + result = checkMotorLevel(state.side, motorLevel, motorNextLevel, state.variable); + } else if (levelIsBetween(i, 'L2', 'L5')) { + // Lumbar key motor region + checkType = 'keyMotor'; + const index = i - 16; + const motorLevel = MotorLevels[index]; + const motorNextLevel = MotorLevels[index + 1]; + result = checkMotorLevel(state.side, motorLevel, motorNextLevel, state.variable); + } else if (level === 'T1') { + // End of cervical key muscles + checkType = 'endOfKeyMuscles'; + result = checkMotorLevelAtEndOfKeyMuscles(state.side, 'T1', state.variable); + } else if (level === 'S1') { + // End of lumbar key muscles + checkType = 'endOfKeyMuscles'; + result = checkMotorLevelAtEndOfKeyMuscles(state.side, 'S1', state.variable); + } else { + // S4_5 - VAC handling + checkType = 'vac'; + + if (state.vac === 'No') { + if (state.levels.includes('S3') || state.levels.includes('S3*')) { + // S3 already in levels, stop without adding + result = { continue: false, variable: state.variable }; } else { - result = {continue: false, level: 'INT' + (variable ? '*' : ''), variable}; + // Add S3 and stop + result = { + continue: false, + level: 'S3' + (state.variable ? '*' : ''), + variable: state.variable + }; } + } else if (state.vac === 'NT') { + if (state.levels.includes('S3') || state.levels.includes('S3*')) { + // S3 already in levels, just add INT + result = { + continue: false, + level: 'INT' + (state.variable ? '*' : ''), + variable: state.variable + }; + } else { + // Add S3 first, then INT in the result + const newLevels = [...state.levels, 'S3' + (state.variable ? '*' : '')]; + result = { + continue: false, + level: 'INT' + (state.variable ? '*' : ''), + variable: state.variable + }; + // Special handling: need to update levels in state before adding INT + const variable = state.variable || result.variable; + + return createStep( + { + key: 'motorLevelCheckLevelDescription', + params: { levelName: level }, + }, + [ + { key: 'motorLevelCheckLevelVACNTAction' }, + { + key: 'motorLevelCheckLevelStopAction', + params: { levelName: variable ? 'S3*,INT*' : 'S3,INT' }, + }, + ], + state, + { + levels: newLevels.concat(['INT' + (variable ? '*' : '')]), + variable, + }, + null, + ); + } + } else { + // VAC is 'Yes' + result = { + continue: false, + level: 'INT' + (state.variable ? '*' : ''), + variable: state.variable + }; } - variable = variable || result.variable; - if (result.level) { - levels.push(result.level); - } - if (result.continue) { - continue; + } + + // Update variable flag + const variable = state.variable || result.variable; + + // Build new levels array + const newLevels = result.level ? [...state.levels, result.level] : [...state.levels]; + + // Determine next step + const next: MotorLevelStepHandler | null = result.continue ? checkLevel : null; + + // Build description and actions + const description = { + key: 'motorLevelCheckLevelDescription' as const, + params: { levelName: level }, + }; + + const actions = []; + + if (checkType === 'sensory') { + actions.push({ key: 'motorLevelCheckLevelSensoryRegionAction' as const }); + } else if (checkType === 'beforeKeyMuscles') { + actions.push({ + key: 'motorLevelCheckLevelBeforeKeyMusclesAction' as const, + params: { nextLevel: level === 'C4' ? 'C5' : 'L2' }, + }); + } else if (checkType === 'keyMotor') { + const motorNextLevel = levelIsBetween(i, 'C5', 'C8') ? MotorLevels[i - 3] : MotorLevels[i - 15]; + actions.push({ + key: 'motorLevelCheckLevelKeyMotorAction' as const, + params: { + levelName: level, + nextLevel: motorNextLevel, + }, + }); + } else if (checkType === 'endOfKeyMuscles') { + actions.push({ + key: 'motorLevelCheckLevelEndOfKeyMusclesAction' as const, + params: { levelName: level }, + }); + } else if (checkType === 'vac') { + if (state.vac === 'No') { + actions.push({ key: 'motorLevelCheckLevelVACNoAction' as const }); + } else if (state.vac === 'NT') { + actions.push({ key: 'motorLevelCheckLevelVACNTAction' as const }); } else { - return levels.join(','); + actions.push({ key: 'motorLevelCheckLevelVACYesAction' as const }); } } - return levels.join(','); -} \ No newline at end of file + if (result.continue) { + actions.push({ key: 'motorLevelCheckLevelContinueAction' as const }); + } else if (result.level) { + actions.push({ + key: 'motorLevelCheckLevelStopAction' as const, + params: { levelName: result.level }, + }); + } + + return createStep( + description, + actions, + state, + { + levels: newLevels, + variable, + currentIndex: result.continue ? state.currentIndex + 1 : state.currentIndex, + }, + next, + ); +} + +/* *************************************** */ +/* Main Entry and Generator */ +/* *************************************** */ + +/** + * Creates initial state for motor level calculation + */ +export function getInitialState( + side: ExamSide, + vac: BinaryObservation, +): MotorLevelState { + return { + side, + vac, + levels: [], + variable: false, + currentIndex: 0, + }; +} + +/** + * Determine motor level for one side + * Returns comma-separated string of levels (e.g., "C5", "T3*", "S3,INT") + */ +export function determineMotorLevel(side: ExamSide, vac: BinaryObservation): string { + const initialState = getInitialState(side, vac); + let step = initializeMotorLevelIteration(initialState); + + while (step.next) { + step = step.next(step.state); + } + + return step.state.levels.join(','); +} + +/** + * Generator that yields each step of the motor level calculation + * Enables step-by-step execution for UI display + */ +export function* motorLevelSteps( + side: ExamSide, + vac: BinaryObservation, +): Generator { + const initialState = getInitialState(side, vac); + let step = initializeMotorLevelIteration(initialState); + yield step; + + while (step.next) { + step = step.next(step.state); + yield step; + } +} diff --git a/src/en.ts b/src/en.ts index 34d5d9f..4c7e66b 100644 --- a/src/en.ts +++ b/src/en.ts @@ -110,4 +110,29 @@ export default { sensoryLevelCheckLevelOtherVariableAction: 'Variable sensory at next level. Continue.', sensoryLevelCheckLevelReachedEndAction: 'Reached S4_5. Add {{intLevel}}.', + // Motor Level + motorLevelInitializeMotorLevelIterationDescription: + 'Initialize motor level calculation. Iterate from C1 toward S4_5.', + motorLevelInitializeMotorLevelIterationAction: + 'Set levels to empty, variable to false, and currentIndex to 0.', + motorLevelCheckLevelDescription: + 'Check motor/sensory function at {{levelName}}.', + motorLevelCheckLevelSensoryRegionAction: + 'Sensory region. Evaluate using light touch and pin prick.', + motorLevelCheckLevelBeforeKeyMusclesAction: + 'Before key muscles. Evaluate using next key muscle ({{nextLevel}}).', + motorLevelCheckLevelKeyMotorAction: + 'Key motor region. Evaluate using motor grade at {{levelName}} and {{nextLevel}}.', + motorLevelCheckLevelEndOfKeyMusclesAction: + 'End of key muscles. Evaluate using motor at {{levelName}} and sensory below.', + motorLevelCheckLevelVACNoAction: + 'VAC is No. Add S3 if not present and stop.', + motorLevelCheckLevelVACNTAction: + 'VAC is NT. Add S3 and INT as needed.', + motorLevelCheckLevelVACYesAction: + 'VAC is Yes. Add INT and stop.', + motorLevelCheckLevelContinueAction: + 'Continue to next level.', + motorLevelCheckLevelStopAction: + 'Add {{levelName}} and stop.', }; From 46fdd7e94b2e0f7cc2f5a943954d0e51056a7e10 Mon Sep 17 00:00:00 2001 From: eddiemachete Date: Wed, 25 Feb 2026 09:45:08 -0800 Subject: [PATCH 2/3] Updated unit test --- .../neurologicalLevels/motorLevel.spec.ts | 651 ++++++++++++------ 1 file changed, 426 insertions(+), 225 deletions(-) diff --git a/src/classification/neurologicalLevels/motorLevel.spec.ts b/src/classification/neurologicalLevels/motorLevel.spec.ts index c4e19f0..150155a 100644 --- a/src/classification/neurologicalLevels/motorLevel.spec.ts +++ b/src/classification/neurologicalLevels/motorLevel.spec.ts @@ -10,10 +10,14 @@ import { initializeMotorLevelIteration, checkLevel, } from './motorLevel'; -import { newEmptySide, newNormalSide, propagateSensoryValueFrom } from '../commonSpec'; +import { + newEmptySide, + newNormalSide, + propagateSensoryValueFrom, +} from '../commonSpec'; import { CheckLevelResult } from '../common'; -type TestCase = { currentLevel: MotorMuscleValue; nextLevel: MotorMuscleValue } +type TestCase = { currentLevel: MotorMuscleValue; nextLevel: MotorMuscleValue }; type Test = { cases: TestCase[]; expected: CheckLevelResult | undefined; @@ -24,7 +28,10 @@ type BeforeMotorTest = { expected: CheckLevelResult; }; -type AfterMotorTestCase = { values: MotorMuscleValue[]; sensoryCheckLevelResults?: CheckLevelResult[] } +type AfterMotorTestCase = { + values: MotorMuscleValue[]; + sensoryCheckLevelResults?: CheckLevelResult[]; +}; type AfterMotorTest = { cases: AfterMotorTestCase[]; expected: CheckLevelResult | undefined; @@ -32,211 +39,297 @@ type AfterMotorTest = { const currentLevel = 'C5'; -const contains = (test: TestCase, type: 'currentLevel' | 'nextLevel', values: MotorMuscleValue[]): boolean => { - return values.some(value => test[type] === value); -} +const contains = ( + test: TestCase, + type: 'currentLevel' | 'nextLevel', + values: MotorMuscleValue[], +): boolean => { + return values.some((value) => test[type] === value); +}; -const allTests: {currentLevel: MotorMuscleValue; nextLevel: MotorMuscleValue}[] = Array(19*19) +const allTests: { + currentLevel: MotorMuscleValue; + nextLevel: MotorMuscleValue; +}[] = Array(19 * 19) .fill(0) .map((v, i) => { - const s: MotorMuscleValue[] = ['0', '1', '2', '3', '4', '5', '0*', '1*', '2*', '3*', '4*', '0**', '1**', '2**', '3**', '4**', 'NT', 'NT*', 'NT**']; - const indexes = i.toString(19).padStart(2, '0').split('').map(v => parseInt(v, 19)); - return {currentLevel: s[indexes[0]], nextLevel: s[indexes[1]]}; + const s: MotorMuscleValue[] = [ + '0', + '1', + '2', + '3', + '4', + '5', + '0*', + '1*', + '2*', + '3*', + '4*', + '0**', + '1**', + '2**', + '3**', + '4**', + 'NT', + 'NT*', + 'NT**', + ]; + const indexes = i + .toString(19) + .padStart(2, '0') + .split('') + .map((v) => parseInt(v, 19)); + return { currentLevel: s[indexes[0]], nextLevel: s[indexes[1]] }; }); const beforeKeyMusclesTests: BeforeMotorTest[] = [ { // 3 tests - cases: ['0','1','2'], - expected: {continue: false, level: 'C4', variable: false}, - }, { + cases: ['0', '1', '2'], + expected: { continue: false, level: 'C4', variable: false }, + }, + { // 5 test - cases: ['0*','1*','2*','NT','NT*'], - expected: {continue: true, level: 'C4', variable: false}, - }, { + cases: ['0*', '1*', '2*', 'NT', 'NT*'], + expected: { continue: true, level: 'C4', variable: false }, + }, + { // 11 test??? TODO - cases: ['3','4','5','3*','4*','3**','4**','NT**'], - expected: {continue: true, variable: false}, - }, { + cases: ['3', '4', '5', '3*', '4*', '3**', '4**', 'NT**'], + expected: { continue: true, variable: false }, + }, + { // 11 test??? TODO - cases: ['0**','1**','2**'], - expected: {continue: true, variable: true}, + cases: ['0**', '1**', '2**'], + expected: { continue: true, variable: true }, }, ]; const tests: Test[] = [ { // 57 tests - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['0','1','2']) - )), + cases: allTests.filter((test) => + contains(test, 'currentLevel', ['0', '1', '2']), + ), expected: undefined, // throw error - }, { + }, + { // 100 tests - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['3','4','3*','4*']) || - ( - contains(test, 'currentLevel', ['5','0**','1**','2**','3**','4**','NT','NT**']) && - contains(test, 'nextLevel', ['0','1','2']) - ) - )), - expected: {continue: false, level: currentLevel, variable: false}, - }, { + cases: allTests.filter( + (test) => + contains(test, 'currentLevel', ['3', '4', '3*', '4*']) || + (contains(test, 'currentLevel', [ + '5', + '0**', + '1**', + '2**', + '3**', + '4**', + 'NT', + 'NT**', + ]) && + contains(test, 'nextLevel', ['0', '1', '2'])), + ), + expected: { continue: false, level: currentLevel, variable: false }, + }, + { // 76 test - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['0*','1*','2*','NT*']) - )), - expected: {continue: false, level: currentLevel + '*', variable: true}, - }, { + cases: allTests.filter((test) => + contains(test, 'currentLevel', ['0*', '1*', '2*', 'NT*']), + ), + expected: { continue: false, level: currentLevel + '*', variable: true }, + }, + { // 11 test??? TODO - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['5']) && - !contains(test, 'nextLevel', ['0','1','2','0*','1*','2*','0**','1**','2**','NT','NT*']) - )), - expected: {continue: true, variable: false}, - }, { + cases: allTests.filter( + (test) => + contains(test, 'currentLevel', ['5']) && + !contains(test, 'nextLevel', [ + '0', + '1', + '2', + '0*', + '1*', + '2*', + '0**', + '1**', + '2**', + 'NT', + 'NT*', + ]), + ), + expected: { continue: true, variable: false }, + }, + { // 66 test??? TODO - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['0**','1**','2**','3**','4**','NT**']) && - !contains(test, 'nextLevel', ['0','1','2','0*','1*','2*','NT','NT*']) - ) || ( - contains(test, 'currentLevel', ['5']) && - contains(test, 'nextLevel', ['0**','1**','2**']) - )), - expected: {continue: true, variable: true}, - }, { + cases: allTests.filter( + (test) => + (contains(test, 'currentLevel', [ + '0**', + '1**', + '2**', + '3**', + '4**', + 'NT**', + ]) && + !contains(test, 'nextLevel', [ + '0', + '1', + '2', + '0*', + '1*', + '2*', + 'NT', + 'NT*', + ])) || + (contains(test, 'currentLevel', ['5']) && + contains(test, 'nextLevel', ['0**', '1**', '2**'])), + ), + expected: { continue: true, variable: true }, + }, + { // 21 test - cases: allTests.filter(test => ( - ( - contains(test, 'currentLevel', ['5']) && - contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) - ) || ( - contains(test, 'currentLevel', ['NT']) && - !contains(test, 'nextLevel', ['0','1','2']) - ) - )), - expected: {continue: true, level: currentLevel, variable: false}, - }, { + cases: allTests.filter( + (test) => + (contains(test, 'currentLevel', ['5']) && + contains(test, 'nextLevel', ['0*', '1*', '2*', 'NT', 'NT*'])) || + (contains(test, 'currentLevel', ['NT']) && + !contains(test, 'nextLevel', ['0', '1', '2'])), + ), + expected: { continue: true, level: currentLevel, variable: false }, + }, + { // 15 test - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['3**','4**','NT**']) && - contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) - )), - expected: {continue: true, level: currentLevel, variable: true}, - }, { + cases: allTests.filter( + (test) => + contains(test, 'currentLevel', ['3**', '4**', 'NT**']) && + contains(test, 'nextLevel', ['0*', '1*', '2*', 'NT', 'NT*']), + ), + expected: { continue: true, level: currentLevel, variable: true }, + }, + { // 15 test - cases: allTests.filter(test => ( - contains(test, 'currentLevel', ['0**','1**','2**']) && - contains(test, 'nextLevel', ['0*','1*','2*','NT','NT*']) - )), - expected: {continue: true, level: currentLevel + '*', variable: true}, + cases: allTests.filter( + (test) => + contains(test, 'currentLevel', ['0**', '1**', '2**']) && + contains(test, 'nextLevel', ['0*', '1*', '2*', 'NT', 'NT*']), + ), + expected: { continue: true, level: currentLevel + '*', variable: true }, }, ]; const afterMotorTests: AfterMotorTest[] = [ { // 24 tests - cases: [{ values: ['0','1','2'] }], + cases: [{ values: ['0', '1', '2'] }], expected: undefined, - }, { + }, + { cases: [ { - values: ['3','4','3*','4*'] - }, { - values: ['5','3**','4**','NT**','NT'], + values: ['3', '4', '3*', '4*'], + }, + { + values: ['5', '3**', '4**', 'NT**', 'NT'], sensoryCheckLevelResults: [ - {continue: false, variable: false}, - {continue: false, variable: true}, - {continue: false, level: 'foo', variable: false}, - {continue: false, level: 'foo', variable: true}, + { continue: false, variable: false }, + { continue: false, variable: true }, + { continue: false, level: 'foo', variable: false }, + { continue: false, level: 'foo', variable: true }, ], - } + }, ], - expected: {continue: false, level: 'T1', variable: false}, - }, { + expected: { continue: false, level: 'T1', variable: false }, + }, + { // 32 tests cases: [ { - values: ['0*','1*','2*','NT*'] - }, { - values: ['0**','1**','2**'], + values: ['0*', '1*', '2*', 'NT*'], + }, + { + values: ['0**', '1**', '2**'], sensoryCheckLevelResults: [ - {continue: false, variable: false}, - {continue: false, variable: true}, - {continue: false, level: 'foo', variable: false}, - {continue: false, level: 'foo', variable: true}, - ] - } + { continue: false, variable: false }, + { continue: false, variable: true }, + { continue: false, level: 'foo', variable: false }, + { continue: false, level: 'foo', variable: true }, + ], + }, ], - expected: {continue: false, level: 'T1*', variable: true}, - }, { + expected: { continue: false, level: 'T1*', variable: true }, + }, + { // 16 tests cases: [ { values: ['NT'], + sensoryCheckLevelResults: [{ continue: true, variable: false }], + }, + { + values: ['5', 'NT'], sensoryCheckLevelResults: [ - {continue: true, variable: false}, - ], - }, { - values: ['5','NT'], - sensoryCheckLevelResults: [ - {continue: true, level: 'foo', variable: false}, - {continue: true, level: 'foo', variable: true}, + { continue: true, level: 'foo', variable: false }, + { continue: true, level: 'foo', variable: true }, ], - } + }, ], - expected: {continue: true, level: 'T1', variable: false}, - }, { + expected: { continue: true, level: 'T1', variable: false }, + }, + { cases: [ { values: ['NT'], + sensoryCheckLevelResults: [{ continue: true, variable: true }], + }, + { + values: ['3**', '4**', 'NT**'], sensoryCheckLevelResults: [ - {continue: true, variable: true}, + { continue: true, level: 'foo', variable: true }, + { continue: true, level: 'foo', variable: false }, ], - }, { - values: ['3**','4**','NT**'], + }, + ], + expected: { continue: true, level: 'T1', variable: true }, + }, + { + cases: [ + { + values: ['0**', '1**', '2**'], sensoryCheckLevelResults: [ - {continue: true, level: 'foo', variable: true}, - {continue: true, level: 'foo', variable: false}, + { continue: true, level: 'foo', variable: true }, + { continue: true, level: 'foo', variable: false }, ], - } + }, ], - expected: {continue: true, level: 'T1', variable: true}, - }, { - cases: [{ - values: ['0**','1**','2**'], - sensoryCheckLevelResults: [ - {continue: true, level: 'foo', variable: true}, - {continue: true, level: 'foo', variable: false}, - ], - }], - expected: {continue: true, level: 'T1*', variable: true}, - }, { - cases: [{ - values: ['5'], - sensoryCheckLevelResults: [ - {continue: true, variable: false}, - ], - }], - expected: {continue: true, variable: false}, - }, { + expected: { continue: true, level: 'T1*', variable: true }, + }, + { cases: [ { values: ['5'], + sensoryCheckLevelResults: [{ continue: true, variable: false }], + }, + ], + expected: { continue: true, variable: false }, + }, + { + cases: [ + { + values: ['5'], + sensoryCheckLevelResults: [{ continue: true, variable: true }], + }, + { + values: ['0**', '1**', '2**', '3**', '4**', 'NT**'], sensoryCheckLevelResults: [ - {continue: true, variable: true}, - ], - }, { - values: ['0**','1**','2**','3**','4**','NT**'], - sensoryCheckLevelResults: [ - {continue: true, variable: false}, - {continue: true, variable: true}, + { continue: true, variable: false }, + { continue: true, variable: true }, ], - } + }, ], - expected: {continue: true, variable: true}, + expected: { continue: true, variable: true }, }, -] +]; // 1064 tests + 3 verification test describe('determineMotorLevel', () => { @@ -245,27 +338,40 @@ describe('determineMotorLevel', () => { const currentLevel = 'C4'; const nextLevel = 'C5'; const allValues: string[] = []; - const checkMotorLevelBeforeStartOfKeyMusclesTest = (variable: boolean, testCase: MotorMuscleValue, expected: CheckLevelResult): void => { + const checkMotorLevelBeforeStartOfKeyMusclesTest = ( + variable: boolean, + testCase: MotorMuscleValue, + expected: CheckLevelResult, + ): void => { const side: ExamSide = newEmptySide(); side.motor[nextLevel] = testCase; it(`${testCase}`, () => { - const result = checkMotorLevelBeforeStartOfKeyMuscles(side, currentLevel, nextLevel, variable); + const result = checkMotorLevelBeforeStartOfKeyMuscles( + side, + currentLevel, + nextLevel, + variable, + ); expect(result.level).toBe(expected.level); expect(result.continue).toBe(expected.continue); expect(result.variable).toBe(expected.variable); - }) - allValues.push(side.motor[nextLevel]+variable); - } + }); + allValues.push(side.motor[nextLevel] + variable); + }; describe('variable = false', () => { for (const test of beforeKeyMusclesTests) { describe(`expected: ${JSON.stringify(test.expected)}`, () => { for (const testCase of test.cases) { - checkMotorLevelBeforeStartOfKeyMusclesTest(false, testCase, test.expected); + checkMotorLevelBeforeStartOfKeyMusclesTest( + false, + testCase, + test.expected, + ); } - }) + }); } - }) + }); describe('variable = true', () => { for (const test of beforeKeyMusclesTests) { @@ -276,24 +382,32 @@ describe('determineMotorLevel', () => { }; describe(`expected: ${JSON.stringify(expected)}`, () => { for (const testCase of test.cases) { - checkMotorLevelBeforeStartOfKeyMusclesTest(true, testCase, expected); + checkMotorLevelBeforeStartOfKeyMusclesTest( + true, + testCase, + expected, + ); } - }) + }); } - }) + }); it('check all tests are unique', () => { const hashSet = new Set(allValues); expect(allValues.length).toBe(19 * 2); expect(hashSet.size).toBe(19 * 2); - }) - }) + }); + }); // 722 tests (19 * 19 * 2) + 1 verification test describe(`checkMotorLevel`, () => { const allValues: string[] = []; - const checkMotorLevelTest = (variable: boolean, testCase: TestCase, expected?: CheckLevelResult): string => { + const checkMotorLevelTest = ( + variable: boolean, + testCase: TestCase, + expected?: CheckLevelResult, + ): string => { const currentLevel = 'C5'; const nextLevel = 'C6'; const side = newEmptySide(); @@ -304,10 +418,15 @@ describe('determineMotorLevel', () => { if (expected === undefined) { const currentLevelMotorIsImpairedTest = (): void => { checkMotorLevel(side, currentLevel, nextLevel, variable); - } + }; expect(currentLevelMotorIsImpairedTest).toThrowError(); } else { - const result = checkMotorLevel(side, currentLevel, nextLevel, variable); + const result = checkMotorLevel( + side, + currentLevel, + nextLevel, + variable, + ); if (expected.level) { expect(result.level).toBe(expected.level); } else { @@ -316,9 +435,9 @@ describe('determineMotorLevel', () => { expect(result.continue).toBe(expected.continue); expect(result.variable).toBe(expected.variable); } - }) - return variable+side.motor[currentLevel]+side.motor[nextLevel]; - } + }); + return variable + side.motor[currentLevel] + side.motor[nextLevel]; + }; describe('variable = false', () => { for (const test of tests) { @@ -326,66 +445,80 @@ describe('determineMotorLevel', () => { for (const testCase of test.cases) { allValues.push(checkMotorLevelTest(false, testCase, test.expected)); } - }) + }); } - }) + }); describe('variable = true', () => { for (const test of tests) { - const expected = test.expected ? { - continue: test.expected.continue, - level: !test.expected.level ? undefined - : test.expected.level.includes('*') ? test.expected.level - : test.expected.level + '*', - variable: true, - } : undefined; + const expected = test.expected + ? { + continue: test.expected.continue, + level: !test.expected.level + ? undefined + : test.expected.level.includes('*') + ? test.expected.level + : test.expected.level + '*', + variable: true, + } + : undefined; describe(`expected: ${JSON.stringify(expected)}`, () => { for (const testCase of test.cases) { allValues.push(checkMotorLevelTest(true, testCase, expected)); } - }) + }); } - }) + }); it('check all tests are unique', () => { const hashSet = new Set(allValues); expect(allValues.length).toBe(361 * 2); expect(hashSet.size).toBe(361 * 2); - }) - }) + }); + }); // 304 tests (19 * 8 * 2) + 1 verification test describe(`checkMotorLevelAtEndOfKeyMuscles: checkWithSensoryCheckLevelResult`, () => { const allValues: string[] = []; - const checkWithSensoryCheckLevelResultTest = (variable: boolean, testCase: AfterMotorTestCase, expected?: CheckLevelResult): void => { + const checkWithSensoryCheckLevelResultTest = ( + variable: boolean, + testCase: AfterMotorTestCase, + expected?: CheckLevelResult, + ): void => { const currentLevel = 'T1'; const side = newEmptySide(); for (const value of testCase.values) { side.motor[currentLevel] = value; - const sensoryCheckLevelResults = testCase.sensoryCheckLevelResults ? - testCase.sensoryCheckLevelResults : [ - {continue: false, variable: false}, - {continue: false, variable: true}, - {continue: false, level: 'foo', variable: false}, - {continue: false, level: 'foo', variable: true}, - {continue: true, variable: false}, - {continue: true, variable: true}, - {continue: true, level: 'foo', variable: false}, - {continue: true, level: 'foo', variable: true}, - ]; + const sensoryCheckLevelResults = testCase.sensoryCheckLevelResults + ? testCase.sensoryCheckLevelResults + : [ + { continue: false, variable: false }, + { continue: false, variable: true }, + { continue: false, level: 'foo', variable: false }, + { continue: false, level: 'foo', variable: true }, + { continue: true, variable: false }, + { continue: true, variable: true }, + { continue: true, level: 'foo', variable: false }, + { continue: true, level: 'foo', variable: true }, + ]; for (const sensoryCheckLevelResult of sensoryCheckLevelResults) { it(`${value} ${JSON.stringify(sensoryCheckLevelResult)}`, () => { if (expected === undefined) { const errorCheckMotorLevelAtEndOfKeyMusclesTest = (): void => { checkMotorLevelAtEndOfKeyMuscles(side, currentLevel, variable); - } + }; expect(errorCheckMotorLevelAtEndOfKeyMusclesTest).toThrowError(); } else { - const result = checkWithSensoryCheckLevelResult(side, currentLevel, variable, sensoryCheckLevelResult); + const result = checkWithSensoryCheckLevelResult( + side, + currentLevel, + variable, + sensoryCheckLevelResult, + ); if (expected.level) { expect(result.level).toBe(expected.level); } else { @@ -394,45 +527,59 @@ describe('determineMotorLevel', () => { expect(result.continue).toBe(expected.continue); expect(result.variable).toBe(expected.variable); } - }) - allValues.push(variable+side.motor[currentLevel]+sensoryCheckLevelResult.continue+sensoryCheckLevelResult.level+sensoryCheckLevelResult.variable); + }); + allValues.push( + variable + + side.motor[currentLevel] + + sensoryCheckLevelResult.continue + + sensoryCheckLevelResult.level + + sensoryCheckLevelResult.variable, + ); } } - } + }; describe('variable = false', () => { for (const test of afterMotorTests) { describe(`expected: ${JSON.stringify(test.expected)}`, () => { for (const testCase of test.cases) { - checkWithSensoryCheckLevelResultTest(false, testCase, test.expected); + checkWithSensoryCheckLevelResultTest( + false, + testCase, + test.expected, + ); } - }) + }); } - }) + }); describe('variable = true', () => { for (const test of afterMotorTests) { - const expected = test.expected ? { - continue: test.expected.continue, - level: !test.expected.level ? undefined - : test.expected.level.includes('*') ? test.expected.level - : test.expected.level + '*', - variable: true, - } : undefined; + const expected = test.expected + ? { + continue: test.expected.continue, + level: !test.expected.level + ? undefined + : test.expected.level.includes('*') + ? test.expected.level + : test.expected.level + '*', + variable: true, + } + : undefined; describe(`expected: ${JSON.stringify(expected)}`, () => { for (const testCase of test.cases) { checkWithSensoryCheckLevelResultTest(true, testCase, expected); } - }) + }); } - }) + }); it('check all tests are unique', () => { const hashSet = new Set(allValues); expect(allValues.length).toBe(152 * 2); expect(hashSet.size).toBe(152 * 2); - }) - }) + }); + }); /* *************************************** */ /* Step-Based Structure Tests */ @@ -462,9 +609,13 @@ describe('determineMotorLevel', () => { expect(step.state.variable).toBe(false); expect(step.state.currentIndex).toBe(0); expect(step.next).toBe(checkLevel); - expect(step.description.key).toBe('motorLevelInitializeMotorLevelIterationDescription'); + expect(step.description.key).toBe( + 'motorLevelInitializeMotorLevelIterationDescription', + ); expect(step.actions.length).toBe(1); - expect(step.actions[0].key).toBe('motorLevelInitializeMotorLevelIterationAction'); + expect(step.actions[0].key).toBe( + 'motorLevelInitializeMotorLevelIterationAction', + ); }); }); @@ -479,7 +630,11 @@ describe('determineMotorLevel', () => { expect(step.description.key).toBe('motorLevelCheckLevelDescription'); expect(step.description.params?.levelName).toBe('C2'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelSensoryRegionAction')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelSensoryRegionAction', + ), + ).toBe(true); expect(step.next).toBe(checkLevel); expect(step.state.currentIndex).toBe(2); }); @@ -495,7 +650,11 @@ describe('determineMotorLevel', () => { expect(step.description.key).toBe('motorLevelCheckLevelDescription'); expect(step.description.params?.levelName).toBe('C2'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelSensoryRegionAction')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelSensoryRegionAction', + ), + ).toBe(true); // Verify step executes correctly; actual stop behavior depends on checkSensoryLevel logic }); }); @@ -510,8 +669,14 @@ describe('determineMotorLevel', () => { expect(step.description.key).toBe('motorLevelCheckLevelDescription'); expect(step.description.params?.levelName).toBe('C4'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelBeforeKeyMusclesAction')).toBe(true); - expect(step.actions.some(a => a.params?.nextLevel === 'C5')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelBeforeKeyMusclesAction', + ), + ).toBe(true); + expect(step.actions.some((a) => a.params?.nextLevel === 'C5')).toBe( + true, + ); expect(step.next).toBe(checkLevel); }); @@ -538,8 +703,14 @@ describe('determineMotorLevel', () => { expect(step.description.key).toBe('motorLevelCheckLevelDescription'); expect(step.description.params?.levelName).toBe('L1'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelBeforeKeyMusclesAction')).toBe(true); - expect(step.actions.some(a => a.params?.nextLevel === 'L2')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelBeforeKeyMusclesAction', + ), + ).toBe(true); + expect(step.actions.some((a) => a.params?.nextLevel === 'L2')).toBe( + true, + ); expect(step.next).toBe(checkLevel); }); }); @@ -554,7 +725,11 @@ describe('determineMotorLevel', () => { expect(step.description.key).toBe('motorLevelCheckLevelDescription'); expect(step.description.params?.levelName).toBe('C5'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelKeyMotorAction')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelKeyMotorAction', + ), + ).toBe(true); expect(step.next).toBe(checkLevel); }); @@ -596,7 +771,11 @@ describe('determineMotorLevel', () => { expect(step.description.key).toBe('motorLevelCheckLevelDescription'); expect(step.description.params?.levelName).toBe('L2'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelKeyMotorAction')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelKeyMotorAction', + ), + ).toBe(true); expect(step.next).toBe(checkLevel); }); @@ -623,7 +802,11 @@ describe('determineMotorLevel', () => { expect(step.description.key).toBe('motorLevelCheckLevelDescription'); expect(step.description.params?.levelName).toBe('T1'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelEndOfKeyMusclesAction')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelEndOfKeyMusclesAction', + ), + ).toBe(true); expect(step.next).toBe(checkLevel); }); @@ -650,7 +833,11 @@ describe('determineMotorLevel', () => { expect(step.description.key).toBe('motorLevelCheckLevelDescription'); expect(step.description.params?.levelName).toBe('S1'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelEndOfKeyMusclesAction')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelEndOfKeyMusclesAction', + ), + ).toBe(true); expect(step.next).toBe(checkLevel); }); @@ -679,7 +866,9 @@ describe('determineMotorLevel', () => { const step = checkLevel(state); expect(step.description.key).toBe('motorLevelCheckLevelDescription'); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelVACNoAction')).toBe(true); + expect( + step.actions.some((a) => a.key === 'motorLevelCheckLevelVACNoAction'), + ).toBe(true); expect(step.next).toBeNull(); expect(step.state.levels).toContain('S3'); }); @@ -692,6 +881,10 @@ describe('determineMotorLevel', () => { const step = checkLevel(state); + expect(step.description.key).toBe('motorLevelCheckLevelDescription'); + expect( + step.actions.some((a) => a.key === 'motorLevelCheckLevelVACNoAction'), + ).toBe(true); expect(step.next).toBeNull(); expect(step.state.levels).toEqual(['S3']); }); @@ -703,7 +896,9 @@ describe('determineMotorLevel', () => { const step = checkLevel(state); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelVACNTAction')).toBe(true); + expect( + step.actions.some((a) => a.key === 'motorLevelCheckLevelVACNTAction'), + ).toBe(true); expect(step.next).toBeNull(); expect(step.state.levels).toContain('S3'); expect(step.state.levels).toContain('INT'); @@ -730,7 +925,11 @@ describe('determineMotorLevel', () => { const step = checkLevel(state); - expect(step.actions.some(a => a.key === 'motorLevelCheckLevelVACYesAction')).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelVACYesAction', + ), + ).toBe(true); expect(step.next).toBeNull(); expect(step.state.levels).toContain('INT'); expect(step.state.levels).not.toContain('S3'); @@ -879,7 +1078,7 @@ describe('determineMotorLevel', () => { const steps = Array.from(motorLevelSteps(side, 'No')); // Find step where variable becomes true - const variableSteps = steps.filter(s => s.state.variable === true); + const variableSteps = steps.filter((s) => s.state.variable === true); expect(variableSteps.length).toBeGreaterThan(0); }); @@ -894,7 +1093,9 @@ describe('determineMotorLevel', () => { for (let i = 1; i < steps.length - 1; i++) { if (steps[i].next !== null) { - expect(steps[i].state.currentIndex).toBeGreaterThan(steps[i - 1].state.currentIndex); + expect(steps[i].state.currentIndex).toBeGreaterThan( + steps[i - 1].state.currentIndex, + ); } } }); @@ -970,4 +1171,4 @@ describe('determineMotorLevel', () => { expect(result).toBe('L3'); }); }); -}) +}); From 3c4b84c234805d9c09013921b26826f74bb02eb9 Mon Sep 17 00:00:00 2001 From: eddiemachete Date: Wed, 25 Feb 2026 10:12:32 -0800 Subject: [PATCH 3/3] Addresses edge case where VAC=No --- docs/motorLevel-architecture.md | 98 ++++--- .../neurologicalLevels/motorLevel.spec.ts | 176 +++++++++++++ .../neurologicalLevels/motorLevel.ts | 239 +++++++++++++----- 3 files changed, 404 insertions(+), 109 deletions(-) diff --git a/docs/motorLevel-architecture.md b/docs/motorLevel-architecture.md index f36949d..5875a04 100644 --- a/docs/motorLevel-architecture.md +++ b/docs/motorLevel-architecture.md @@ -2,7 +2,7 @@ **Author:** ISNCSCI Architect Agent **Date:** 2025-02-19 -**Status:** Architecture proposal for refactor +**Status:** Implemented --- @@ -31,14 +31,14 @@ Each check returns `CheckLevelResult { continue, level?, variable }`. The `varia ### Key inputs and final outputs -| Input | Type | Description | -| ------ | --------------------- | ------------------------------------------------ | -| `side` | `ExamSide` | Exam data (lightTouch, pinPrick, motor) for one side | -| `vac` | `BinaryObservation` | Voluntary Anal Contraction: 'No', 'NT', or 'Yes' | +| Input | Type | Description | +| ------ | ------------------- | ---------------------------------------------------- | +| `side` | `ExamSide` | Exam data (lightTouch, pinPrick, motor) for one side | +| `vac` | `BinaryObservation` | Voluntary Anal Contraction: 'No', 'NT', or 'Yes' | -| Output | Type | Description | -| ------------ | -------- | -------------------------------------------------------------------- | -| Motor level | `string` | Comma-separated levels (e.g. `"C5"`, `"T3*"`, `"S3,INT"`, `"INT*"`) | +| Output | Type | Description | +| ----------- | -------- | ------------------------------------------------------------------- | +| Motor level | `string` | Comma-separated levels (e.g. `"C5"`, `"T3*"`, `"S3,INT"`, `"INT*"`) | --- @@ -72,13 +72,13 @@ Each check returns `CheckLevelResult { continue, level?, variable }`. The `varia ### Step 2: checkLevel -| Field | Description | -| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| **name** | `checkLevel` | -| **purpose** | For the current level, dispatch to the appropriate check; add level if indicated; update variable; continue or stop. | -| **inputs** | `state.side`, `state.vac`, `state.levels`, `state.variable`, `state.currentIndex` | -| **outputs** | `state.levels`, `state.variable`, `state.currentIndex`, `state.next` | -| **explanation** | "Check motor/sensory function at {{levelName}}." (Params vary by check type.) | +| Field | Description | +| --------------- | -------------------------------------------------------------------------------------------------------------------- | +| **name** | `checkLevel` | +| **purpose** | For the current level, dispatch to the appropriate check; add level if indicated; update variable; continue or stop. | +| **inputs** | `state.side`, `state.vac`, `state.levels`, `state.variable`, `state.currentIndex` | +| **outputs** | `state.levels`, `state.variable`, `state.currentIndex`, `state.next` | +| **explanation** | "Check motor/sensory function at {{levelName}}." (Params vary by check type.) | **Logic:** @@ -87,26 +87,26 @@ Each check returns `CheckLevelResult { continue, level?, variable }`. The `varia **Dispatch by level category:** -| Condition | Check used | Notes | -| ---------------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------- | -| C1–C3, T2–T12, S2–S3 | `checkSensoryLevel(side, level, nextLevel, variable)` | Sensory regions; no key muscles. | -| C4 | `checkMotorLevelBeforeStartOfKeyMuscles(side, 'C4', 'C5', variable)` | Before cervical key muscles. | -| L1 | `checkMotorLevelBeforeStartOfKeyMuscles(side, 'L1', 'L2', variable)` | Before lumbar key muscles. | -| C5–C8 | `checkMotorLevel(side, MotorLevels[i-4], MotorLevels[i-3], variable)` | Key motor; map SensoryLevel index to MotorLevels. | -| L2–L5 | `checkMotorLevel(side, MotorLevels[i-16], MotorLevels[i-15], variable)` | Key motor; map SensoryLevel index to MotorLevels. | -| T1 | `checkMotorLevelAtEndOfKeyMuscles(side, 'T1', variable)` | End of cervical key muscles; uses sensory C5–T1. | -| S1 | `checkMotorLevelAtEndOfKeyMuscles(side, 'S1', variable)` | End of lumbar key muscles; uses sensory L2–S1. | -| S4_5 | VAC handling (see below) | Past S1; add S3 and/or INT based on VAC. | +| Condition | Check used | Notes | +| -------------------- | ----------------------------------------------------------------------- | ------------------------------------------------- | +| C1–C3, T2–T12, S2–S3 | `checkSensoryLevel(side, level, nextLevel, variable)` | Sensory regions; no key muscles. | +| C4 | `checkMotorLevelBeforeStartOfKeyMuscles(side, 'C4', 'C5', variable)` | Before cervical key muscles. | +| L1 | `checkMotorLevelBeforeStartOfKeyMuscles(side, 'L1', 'L2', variable)` | Before lumbar key muscles. | +| C5–C8 | `checkMotorLevel(side, MotorLevels[i-4], MotorLevels[i-3], variable)` | Key motor; map SensoryLevel index to MotorLevels. | +| L2–L5 | `checkMotorLevel(side, MotorLevels[i-16], MotorLevels[i-15], variable)` | Key motor; map SensoryLevel index to MotorLevels. | +| T1 | `checkMotorLevelAtEndOfKeyMuscles(side, 'T1', variable)` | End of cervical key muscles; uses sensory C5–T1. | +| S1 | `checkMotorLevelAtEndOfKeyMuscles(side, 'S1', variable)` | End of lumbar key muscles; uses sensory L2–S1. | +| S4_5 | VAC handling (see below) | Past S1; add S3 and/or INT based on VAC. | **VAC handling (when level is S4_5):** -| VAC | Condition | Result | -| ----- | ---------------------- | ---------------------------------------------------------------------- | -| No | S3 already in levels | `{ continue: false }` — stop, do not add level | -| No | S3 not in levels | `{ continue: false, level: 'S3' + (variable ? '*' : ''), variable }` | -| NT | S3 already in levels | `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | -| NT | S3 not in levels | Push `'S3' + (variable ? '*' : '')` to levels; `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | -| Yes | — | `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | +| VAC | Condition | Result | +| --- | -------------------- | -------------------------------------------------------------------------------------------------------------------- | +| No | S3 already in levels | `{ continue: false }` — stop, do not add level | +| No | S3 not in levels | `{ continue: false, level: 'S3' + (variable ? '*' : ''), variable }` | +| NT | S3 already in levels | `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | +| NT | S3 not in levels | Push `'S3' + (variable ? '*' : '')` to levels; `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | +| Yes | — | `{ continue: false, level: 'INT' + (variable ? '*' : ''), variable }` | **After any check:** @@ -130,12 +130,12 @@ Used for C1–C3, T2–T12, S2–S3. Returns `CheckLevelResult`. See `sensoryLev `checkMotorLevelBeforeStartOfKeyMuscles(side, level, nextLevel, variable)` where level is 'C4' or 'L1', nextLevel is 'C5' or 'L2'. -| Condition | Result | -| --------------------------------------- | ---------------------------------------------------------------------- | -| nextLevel motor in ['0','1','2'] | `{ continue: false, level: level + (variable ? '*' : ''), variable }` | -| nextLevel motor in ['0*','1*','2*','NT','NT*'] | `{ continue: true, level: level + (variable ? '*' : ''), variable }` | -| nextLevel motor in ['0**','1**','2**'] | `{ continue: true, variable: true }` | -| Else | `{ continue: true, variable }` | +| Condition | Result | +| ---------------------------------------------- | --------------------------------------------------------------------- | +| nextLevel motor in ['0','1','2'] | `{ continue: false, level: level + (variable ? '*' : ''), variable }` | +| nextLevel motor in ['0*','1*','2*','NT','NT*'] | `{ continue: true, level: level + (variable ? '*' : ''), variable }` | +| nextLevel motor in ['0**','1**','2**'] | `{ continue: true, variable: true }` | +| Else | `{ continue: true, variable }` | ### checkMotorLevel @@ -174,9 +174,7 @@ export type MotorLevelStepHandler = StepHandler; export type MotorLevelStep = Step; // Step 1: Entry point -function initializeMotorLevelIteration( - state: MotorLevelState, -): MotorLevelStep; +function initializeMotorLevelIteration(state: MotorLevelState): MotorLevelStep; // Step 2: Iteration (may chain to itself or stop) function checkLevel(state: MotorLevelState): MotorLevelStep; @@ -240,15 +238,15 @@ src/classification/neurologicalLevels/ ### Shared vs module-specific -| Item | Location | Rationale | -| ----------------------- | ---------------------------- | -------------------------------------- | -| `Step` type | `common/step.ts` | Reusable | -| `StepHandler` | `common/step.ts` | Generic handler | -| `createStep` | `common/step.ts` | Shared helper | -| `CheckLevelResult` | `common.ts` | Already shared | -| `checkSensoryLevel` | `sensoryLevel.ts` | Imported for sensory regions | -| `levelIsBetween` | `common.ts` | Helper for dispatch logic | -| `MotorLevelState` | `motorLevel.ts` | Motor-level-specific state | +| Item | Location | Rationale | +| ------------------- | ----------------- | ---------------------------- | +| `Step` type | `common/step.ts` | Reusable | +| `StepHandler` | `common/step.ts` | Generic handler | +| `createStep` | `common/step.ts` | Shared helper | +| `CheckLevelResult` | `common.ts` | Already shared | +| `checkSensoryLevel` | `sensoryLevel.ts` | Imported for sensory regions | +| `levelIsBetween` | `common.ts` | Helper for dispatch logic | +| `MotorLevelState` | `motorLevel.ts` | Motor-level-specific state | --- diff --git a/src/classification/neurologicalLevels/motorLevel.spec.ts b/src/classification/neurologicalLevels/motorLevel.spec.ts index 150155a..5d0c2f7 100644 --- a/src/classification/neurologicalLevels/motorLevel.spec.ts +++ b/src/classification/neurologicalLevels/motorLevel.spec.ts @@ -958,6 +958,82 @@ describe('determineMotorLevel', () => { expect(step.next).toBeNull(); expect(step.state.levels).toContain('INT*'); }); + + describe('Edge case: S3 already in levels with VAC=No', () => { + it('adds Stop action when S3 already in levels with VAC=No', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.levels = ['S3']; + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toEqual(['S3']); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelVACNoAction', + ), + ).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ), + ).toBe(true); + const stopAction = step.actions.find( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ); + expect(stopAction?.params?.levelName).toBe('S3'); + }); + + it('adds Stop action when S3* already in levels with VAC=No', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.levels = ['S3*']; + state.variable = true; + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toEqual(['S3*']); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelVACNoAction', + ), + ).toBe(true); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ), + ).toBe(true); + const stopAction = step.actions.find( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ); + expect(stopAction?.params?.levelName).toBe('S3*'); + }); + + it('adds Stop action when multiple levels including S3 are present', () => { + const side = newNormalSide(); + const state = getInitialState(side, 'No'); + state.levels = ['L5', 'S1', 'S3']; + state.currentIndex = 28; // S4_5 + + const step = checkLevel(state); + + expect(step.next).toBeNull(); + expect(step.state.levels).toEqual(['L5', 'S1', 'S3']); + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ), + ).toBe(true); + const stopAction = step.actions.find( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ); + expect(stopAction?.params?.levelName).toBe('S3'); + }); + }); }); }); @@ -1099,6 +1175,63 @@ describe('determineMotorLevel', () => { } } }); + + describe('Edge case: S3 already in levels before S4_5 with VAC=No (fallback test)', () => { + it('generator includes Stop action when S3 pre-exists in state with VAC=No', () => { + // Manually create state with S3 already present (edge case scenario) + const side = newNormalSide(); + const initialState = getInitialState(side, 'No'); + initialState.levels = ['S3']; + initialState.currentIndex = 28; // S4_5 + + // Step directly at S4_5 with S3 already in levels + const step = checkLevel(initialState); + + // Verify S3 is not duplicated + expect(step.state.levels).toEqual(['S3']); + + // Verify Stop action exists (fallback logic) + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ), + ).toBe(true); + + // Verify VAC=No action exists + expect( + step.actions.some( + (a) => a.key === 'motorLevelCheckLevelVACNoAction', + ), + ).toBe(true); + + const stopAction = step.actions.find( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ); + expect(stopAction?.params?.levelName).toBe('S3'); + }); + + it('generator includes Stop action when S3* pre-exists in state with VAC=No', () => { + // Manually create state with S3* already present (edge case scenario) + const side = newNormalSide(); + const initialState = getInitialState(side, 'No'); + initialState.levels = ['S3*']; + initialState.variable = true; + initialState.currentIndex = 28; // S4_5 + + // Step directly at S4_5 with S3* already in levels + const step = checkLevel(initialState); + + // Verify S3* is not duplicated + expect(step.state.levels).toEqual(['S3*']); + + // Verify Stop action with correct levelName + const stopAction = step.actions.find( + (a) => a.key === 'motorLevelCheckLevelStopAction', + ); + expect(stopAction).toBeDefined(); + expect(stopAction?.params?.levelName).toBe('S3*'); + }); + }); }); describe('determineMotorLevel with step-based implementation', () => { @@ -1170,5 +1303,48 @@ describe('determineMotorLevel', () => { const result = determineMotorLevel(side, 'No'); expect(result).toBe('L3'); }); + + describe('Edge case: VAC=No adds S3 without duplication', () => { + it('returns S3 once when all sensory/motor normal with VAC=No', () => { + const side = newNormalSide(); + + const result = determineMotorLevel(side, 'No'); + + // Should return S3 exactly once (added by VAC=No logic) + expect(result).toBe('S3'); + // Verify no comma (which would indicate duplication) + expect(result.split(',').length).toBe(1); + }); + + it('returns S3 once when algorithm reaches S4_5 and VAC=No', () => { + const side = newNormalSide(); + // Ensure algorithm reaches S4_5 + side.motor.S1 = '5'; + + const result = determineMotorLevel(side, 'No'); + + // Should return S3 exactly once + expect(result).toBe('S3'); + // Verify S3 appears only once + const s3Count = result.split(',').filter((l) => l === 'S3' || l === 'S3*').length; + expect(s3Count).toBe(1); + }); + + it('does not add S3 when S3 already determined at earlier level', () => { + const side = newNormalSide(); + // Create scenario where algorithm stops at S3 through sensory check + side.lightTouch.S3 = '1'; + side.pinPrick.S3 = '1'; + // Impair subsequent levels to ensure stop + propagateSensoryValueFrom(side, 'S4_5', '0'); + + const result = determineMotorLevel(side, 'No'); + + // Algorithm stops before reaching S4_5, so VAC logic never executes + // Result should contain no duplicates + expect(result).not.toContain('S3,S3'); + expect(result).not.toContain('S3*,S3*'); + }); + }); }); }); diff --git a/src/classification/neurologicalLevels/motorLevel.ts b/src/classification/neurologicalLevels/motorLevel.ts index 0e7c840..d74a3ba 100644 --- a/src/classification/neurologicalLevels/motorLevel.ts +++ b/src/classification/neurologicalLevels/motorLevel.ts @@ -1,4 +1,10 @@ -import { ExamSide, MotorLevel, MotorLevels, SensoryLevels, BinaryObservation } from '../../interfaces'; +import { + ExamSide, + MotorLevel, + MotorLevels, + SensoryLevels, + BinaryObservation, +} from '../../interfaces'; import { checkSensoryLevel } from './sensoryLevel'; import { levelIsBetween, CheckLevelResult } from '../common'; import { createStep, Step, StepHandler } from '../common/step'; @@ -22,26 +28,44 @@ export type MotorLevelStep = Step; /* Check Functions (Preserved) */ /* *************************************** */ -export const checkMotorLevel = (side: ExamSide, level: MotorLevel, nextLevel: MotorLevel, variable: boolean): CheckLevelResult => { - if (['0','1','2'].includes(side.motor[level])) { +export const checkMotorLevel = ( + side: ExamSide, + level: MotorLevel, + nextLevel: MotorLevel, + variable: boolean, +): CheckLevelResult => { + if (['0', '1', '2'].includes(side.motor[level])) { throw new Error(`Invalid motor value at current level`); } const result: CheckLevelResult = { continue: false, variable }; - if (!['0','1','2'].includes(side.motor[level])) { - if (!['0*','1*','2*','NT*','3','4','3*','4*'].includes(side.motor[level])) { - if (!['0','1','2'].includes(side.motor[nextLevel])) { + if (!['0', '1', '2'].includes(side.motor[level])) { + if ( + !['0*', '1*', '2*', 'NT*', '3', '4', '3*', '4*'].includes( + side.motor[level], + ) + ) { + if (!['0', '1', '2'].includes(side.motor[nextLevel])) { result.continue = true; } } } - if (!(['5','0**','1**','2**','3**','4**','NT**'].includes(side.motor[level]) && !['0','1','2','0*','1*','2*','NT','NT*'].includes(side.motor[nextLevel]))) { - if ( - ['0*','1*','2*','NT*'].includes(side.motor[level]) || ( - ['0**','1**','2**'].includes(side.motor[level]) && ['0*','1*','2*','NT','NT*'].includes(side.motor[nextLevel]) + if ( + !( + ['5', '0**', '1**', '2**', '3**', '4**', 'NT**'].includes( + side.motor[level], + ) && + !['0', '1', '2', '0*', '1*', '2*', 'NT', 'NT*'].includes( + side.motor[nextLevel], ) + ) + ) { + if ( + ['0*', '1*', '2*', 'NT*'].includes(side.motor[level]) || + (['0**', '1**', '2**'].includes(side.motor[level]) && + ['0*', '1*', '2*', 'NT', 'NT*'].includes(side.motor[nextLevel])) ) { result.level = level + '*'; } else { @@ -49,32 +73,49 @@ export const checkMotorLevel = (side: ExamSide, level: MotorLevel, nextLevel: Mo } } - if (!['5','3','4','3*','4*','NT'].includes(side.motor[level])) { - if (['0**','1**','2**','3**','4**','NT**'].includes(side.motor[level])) { - if (!['0','1','2'].includes(side.motor[nextLevel])) { + if (!['5', '3', '4', '3*', '4*', 'NT'].includes(side.motor[level])) { + if ( + ['0**', '1**', '2**', '3**', '4**', 'NT**'].includes(side.motor[level]) + ) { + if (!['0', '1', '2'].includes(side.motor[nextLevel])) { result.variable = true; } } else { result.variable = true; } - } else if (side.motor[level] === '5' && ['0**','1**','2**'].includes(side.motor[nextLevel])) { + } else if ( + side.motor[level] === '5' && + ['0**', '1**', '2**'].includes(side.motor[nextLevel]) + ) { result.variable = true; } return result; -} +}; -export const checkMotorLevelBeforeStartOfKeyMuscles = (side: ExamSide, level: 'C4' | 'L1', nextLevel: MotorLevel, variable: boolean): CheckLevelResult => { +export const checkMotorLevelBeforeStartOfKeyMuscles = ( + side: ExamSide, + level: 'C4' | 'L1', + nextLevel: MotorLevel, + variable: boolean, +): CheckLevelResult => { return { - continue: !['0','1','2'].includes(side.motor[nextLevel]), - level: ['0','1','2','0*','1*','2*','NT','NT*'].includes(side.motor[nextLevel]) ? level + (variable ? '*' : ''): undefined, - variable: variable || ['0**','1**','2**'].includes(side.motor[nextLevel]), + continue: !['0', '1', '2'].includes(side.motor[nextLevel]), + level: ['0', '1', '2', '0*', '1*', '2*', 'NT', 'NT*'].includes( + side.motor[nextLevel], + ) + ? level + (variable ? '*' : '') + : undefined, + variable: variable || ['0**', '1**', '2**'].includes(side.motor[nextLevel]), }; -} +}; -const checkMotorLevelUsingSensoryValues = (side: ExamSide, firstMotorLevelOfMotorBlock: 'C5' | 'L2'): CheckLevelResult => { +const checkMotorLevelUsingSensoryValues = ( + side: ExamSide, + firstMotorLevelOfMotorBlock: 'C5' | 'L2', +): CheckLevelResult => { const startIndex = SensoryLevels.indexOf(firstMotorLevelOfMotorBlock) - 1; - const result: CheckLevelResult = {continue: true, variable: false}; + const result: CheckLevelResult = { continue: true, variable: false }; for (let i = startIndex; i <= startIndex + 5; i++) { const level = SensoryLevels[i]; const nextLevel = SensoryLevels[i + 1]; @@ -91,24 +132,39 @@ const checkMotorLevelUsingSensoryValues = (side: ExamSide, firstMotorLevelOfMoto } } return result; -} +}; -export const checkWithSensoryCheckLevelResult = (side: ExamSide, level: 'T1' | 'S1', variable: boolean, sensoryCheckLevelResult: CheckLevelResult): CheckLevelResult => { - const result: CheckLevelResult = {continue:true, variable}; +export const checkWithSensoryCheckLevelResult = ( + side: ExamSide, + level: 'T1' | 'S1', + variable: boolean, + sensoryCheckLevelResult: CheckLevelResult, +): CheckLevelResult => { + const result: CheckLevelResult = { continue: true, variable }; if ( - (['3','4','0*','1*','2*','3*','4*','NT*'].includes(side.motor[level]) || !sensoryCheckLevelResult.continue) + ['3', '4', '0*', '1*', '2*', '3*', '4*', 'NT*'].includes( + side.motor[level], + ) || + !sensoryCheckLevelResult.continue ) { result.continue = false; } - if (side.motor[level] === 'NT' || !(['5','0**','1**','2**','3**','4**','NT**'].includes(side.motor[level]) && sensoryCheckLevelResult.continue && !sensoryCheckLevelResult.level)) { + if ( + side.motor[level] === 'NT' || + !( + ['5', '0**', '1**', '2**', '3**', '4**', 'NT**'].includes( + side.motor[level], + ) && + sensoryCheckLevelResult.continue && + !sensoryCheckLevelResult.level + ) + ) { if ( - ['0*','1*','2*','NT*'].includes(side.motor[level]) || - ( - ['0**','1**','2**'].includes(side.motor[level]) && - (sensoryCheckLevelResult.level || !sensoryCheckLevelResult.continue) - ) + ['0*', '1*', '2*', 'NT*'].includes(side.motor[level]) || + (['0**', '1**', '2**'].includes(side.motor[level]) && + (sensoryCheckLevelResult.level || !sensoryCheckLevelResult.continue)) ) { result.level = level + '*'; } else { @@ -117,29 +173,44 @@ export const checkWithSensoryCheckLevelResult = (side: ExamSide, level: 'T1' | ' } if ( - ['0*','1*','2*','NT*','0**','1**','2**'].includes(side.motor[level]) || ( - ['3**','4**','NT**'].includes(side.motor[level]) && sensoryCheckLevelResult.continue - ) || ( - ['5','NT'].includes(side.motor[level]) && - (sensoryCheckLevelResult.continue && sensoryCheckLevelResult.variable && !sensoryCheckLevelResult.level) - ) + ['0*', '1*', '2*', 'NT*', '0**', '1**', '2**'].includes( + side.motor[level], + ) || + (['3**', '4**', 'NT**'].includes(side.motor[level]) && + sensoryCheckLevelResult.continue) || + (['5', 'NT'].includes(side.motor[level]) && + sensoryCheckLevelResult.continue && + sensoryCheckLevelResult.variable && + !sensoryCheckLevelResult.level) ) { result.variable = true; } return result; -} +}; -export const checkMotorLevelAtEndOfKeyMuscles = (side: ExamSide, level: 'T1' | 'S1', variable: boolean): CheckLevelResult => { - if (['0','1','2'].includes(side.motor[level])) { +export const checkMotorLevelAtEndOfKeyMuscles = ( + side: ExamSide, + level: 'T1' | 'S1', + variable: boolean, +): CheckLevelResult => { + if (['0', '1', '2'].includes(side.motor[level])) { throw new Error(`Invalid motor value at current level`); } const firstMotorLevelOfMotorBlock = level === 'T1' ? 'C5' : 'L2'; - const sensoryCheckLevelResult = checkMotorLevelUsingSensoryValues(side, firstMotorLevelOfMotorBlock); + const sensoryCheckLevelResult = checkMotorLevelUsingSensoryValues( + side, + firstMotorLevelOfMotorBlock, + ); - return checkWithSensoryCheckLevelResult(side, level, variable, sensoryCheckLevelResult); -} + return checkWithSensoryCheckLevelResult( + side, + level, + variable, + sensoryCheckLevelResult, + ); +}; /* *************************************** */ /* Step Handler Functions */ @@ -181,35 +252,64 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { const i = state.currentIndex; let result: CheckLevelResult; - let checkType: 'sensory' | 'beforeKeyMuscles' | 'keyMotor' | 'endOfKeyMuscles' | 'vac'; + let checkType: + | 'sensory' + | 'beforeKeyMuscles' + | 'keyMotor' + | 'endOfKeyMuscles' + | 'vac'; // Dispatch by level category - if (levelIsBetween(i, 'C1', 'C3') || levelIsBetween(i, 'T2', 'T12') || levelIsBetween(i, 'S2', 'S3')) { + if ( + levelIsBetween(i, 'C1', 'C3') || + levelIsBetween(i, 'T2', 'T12') || + levelIsBetween(i, 'S2', 'S3') + ) { // Sensory regions checkType = 'sensory'; result = checkSensoryLevel(state.side, level, nextLevel, state.variable); } else if (level === 'C4') { // Before cervical key muscles checkType = 'beforeKeyMuscles'; - result = checkMotorLevelBeforeStartOfKeyMuscles(state.side, 'C4', 'C5', state.variable); + result = checkMotorLevelBeforeStartOfKeyMuscles( + state.side, + 'C4', + 'C5', + state.variable, + ); } else if (level === 'L1') { // Before lumbar key muscles checkType = 'beforeKeyMuscles'; - result = checkMotorLevelBeforeStartOfKeyMuscles(state.side, 'L1', 'L2', state.variable); + result = checkMotorLevelBeforeStartOfKeyMuscles( + state.side, + 'L1', + 'L2', + state.variable, + ); } else if (levelIsBetween(i, 'C5', 'C8')) { // Cervical key motor region checkType = 'keyMotor'; const index = i - 4; const motorLevel = MotorLevels[index]; const motorNextLevel = MotorLevels[index + 1]; - result = checkMotorLevel(state.side, motorLevel, motorNextLevel, state.variable); + result = checkMotorLevel( + state.side, + motorLevel, + motorNextLevel, + state.variable, + ); } else if (levelIsBetween(i, 'L2', 'L5')) { // Lumbar key motor region checkType = 'keyMotor'; const index = i - 16; const motorLevel = MotorLevels[index]; const motorNextLevel = MotorLevels[index + 1]; - result = checkMotorLevel(state.side, motorLevel, motorNextLevel, state.variable); + result = checkMotorLevel( + state.side, + motorLevel, + motorNextLevel, + state.variable, + ); } else if (level === 'T1') { // End of cervical key muscles checkType = 'endOfKeyMuscles'; @@ -231,7 +331,7 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { result = { continue: false, level: 'S3' + (state.variable ? '*' : ''), - variable: state.variable + variable: state.variable, }; } } else if (state.vac === 'NT') { @@ -240,7 +340,7 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { result = { continue: false, level: 'INT' + (state.variable ? '*' : ''), - variable: state.variable + variable: state.variable, }; } else { // Add S3 first, then INT in the result @@ -248,7 +348,7 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { result = { continue: false, level: 'INT' + (state.variable ? '*' : ''), - variable: state.variable + variable: state.variable, }; // Special handling: need to update levels in state before adding INT const variable = state.variable || result.variable; @@ -278,7 +378,7 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { result = { continue: false, level: 'INT' + (state.variable ? '*' : ''), - variable: state.variable + variable: state.variable, }; } } @@ -287,10 +387,14 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { const variable = state.variable || result.variable; // Build new levels array - const newLevels = result.level ? [...state.levels, result.level] : [...state.levels]; + const newLevels = result.level + ? [...state.levels, result.level] + : [...state.levels]; // Determine next step - const next: MotorLevelStepHandler | null = result.continue ? checkLevel : null; + const next: MotorLevelStepHandler | null = result.continue + ? checkLevel + : null; // Build description and actions const description = { @@ -308,7 +412,9 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { params: { nextLevel: level === 'C4' ? 'C5' : 'L2' }, }); } else if (checkType === 'keyMotor') { - const motorNextLevel = levelIsBetween(i, 'C5', 'C8') ? MotorLevels[i - 3] : MotorLevels[i - 15]; + const motorNextLevel = levelIsBetween(i, 'C5', 'C8') + ? MotorLevels[i - 3] + : MotorLevels[i - 15]; actions.push({ key: 'motorLevelCheckLevelKeyMotorAction' as const, params: { @@ -338,6 +444,16 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { key: 'motorLevelCheckLevelStopAction' as const, params: { levelName: result.level }, }); + } else { + // Fallback: result.continue is false but no explicit level was provided. + // Use the last determined level (if any) to ensure a Stop action is added. + const fallbackLevel = newLevels[newLevels.length - 1]; + if (fallbackLevel) { + actions.push({ + key: 'motorLevelCheckLevelStopAction' as const, + params: { levelName: fallbackLevel }, + }); + } } return createStep( @@ -347,7 +463,9 @@ export function checkLevel(state: MotorLevelState): MotorLevelStep { { levels: newLevels, variable, - currentIndex: result.continue ? state.currentIndex + 1 : state.currentIndex, + currentIndex: result.continue + ? state.currentIndex + 1 + : state.currentIndex, }, next, ); @@ -377,7 +495,10 @@ export function getInitialState( * Determine motor level for one side * Returns comma-separated string of levels (e.g., "C5", "T3*", "S3,INT") */ -export function determineMotorLevel(side: ExamSide, vac: BinaryObservation): string { +export function determineMotorLevel( + side: ExamSide, + vac: BinaryObservation, +): string { const initialState = getInitialState(side, vac); let step = initializeMotorLevelIteration(initialState);