test/Main.hs

Machine newMachine sendMEOS ms Check ensure andThen waitUntil beamStopped finishTreatment describe tylerRace tylerRaceOverdosesThenSuspends softResetWorksDuringPause editDuringFirstMagnetIsCaught noBeginNoBeam beginShortlyAfterEntryTreats setButtonAt yakimaSetAtRolloverOverdoses yakimaSetAtOtherTimesIsSafe beamOnKeyWaitsAndPrescriptionIsHonoured resetWhileBeamReady reenteringDuringResetIsKept badInputFromUIIsIgnored scenarios main

1module Main (main) where23-- Scenario tests. Each one drives its own machine through the same entry points the UIs use,4-- and they all run at the same time (the slowest takes about 40 s).5-- They check that the historical bugs DO happen when provoked the historical way, and that the6-- paths the original software got right stay safe.78import Control.Concurrent (forkIO, threadDelay)9import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)10import Control.Exception (SomeException, try)11import Control.Monad (forM, unless, when)12import Foreign.C.String (peekCString)13import Foreign.Marshal.Alloc (free)14import Therac2515import System.Exit (exitFailure)1617data Machine = Machine18  { call :: Int -> Int -> Int -> Int -> IO (),19    ask :: Int -> IO String20  }2122newMachine :: IO Machine23newMachine = do24  wc <- startMachine25  pure26    Machine27      { call = externalCallWrap wc,28        ask = \n -> do29          p <- requestStateInfo wc n30          s <- peekCString p31          free p32          pure s33      }3435xRay, electron :: (Int, Int, Int)36xRay = (1, 1, 25000)37electron = (2, 2, 20000)3839sendMEOS :: Machine -> (Int, Int, Int) -> IO ()40sendMEOS m (b, c, e) = call m 1 b c e4142begin, proceed, reset, setButton, fieldLight, beamOnKey, beamOn :: Machine -> IO ()43begin m = call m 2 0 0 044beamOnKey m = call m 10 0 0 045beamOn m = call m 9 0 0 046proceed m = call m 5 0 0 047reset m = call m 4 0 0 048setButton m = call m 7 0 0 049fieldLight m = call m 8 0 0 05051outcome, tphase, turntable, beam :: Machine -> IO String52outcome m = ask m 153tphase m = ask m 354beam m = ask m 555turntable m = ask m 95657class3, displayed, patientDose :: Machine -> IO Int58class3 m = read <$> ask m 859displayed m = read <$> ask m 1060patientDose m = read <$> ask m 116162ms :: Int -> IO ()63ms n = threadDelay (n * 1000)6465type Check = IO (Either String ())6667ensure :: Bool -> String -> Check68ensure ok msg = pure $ if ok then Right () else Left msg6970andThen :: Check -> Check -> Check71andThen a b = a >>= either (pure . Left) (const b)7273-- poll until the condition holds, or fail after the timeout (ms)74waitUntil :: Int -> String -> IO Bool -> Check75waitUntil timeout what cond = go timeout76  where77    go t = do78      ok <- cond79      if ok80        then pure (Right ())81        else82          if t <= 083            then pure (Left ("timed out waiting for " ++ what))84            else ms 20 >> go (t - 20)8586beamStopped :: Machine -> IO Bool87beamStopped m = (`elem` ["TP_PauseTreatment", "TP_TerminateTreatment"]) <$> tphase m8889-- press P through any nuisance pauses until the treatment is over90finishTreatment :: Machine -> Check91finishTreatment m = go (10 :: Int)92  where93    go 0 = pure (Left "treatment never finished")94    go n = do95      r <- waitUntil 15000 "the beam to stop" (beamStopped m)96      case r of97        Left e -> pure (Left e)98        Right () -> do99          p <- tphase m100          if p == "TP_TerminateTreatment"101            then pure (Right ())102            else proceed m >> ms 300 >> go (n - 1)103104describe :: Machine -> IO String105describe m = do106  o <- outcome m107  p <- tphase m108  b <- beam m109  t <- turntable m110  d <- patientDose m111  pure (" [outcome=" ++ show o ++ " phase=" ++ p ++ " beam=" ++ b ++ " turntable=" ++ t ++ " patientDose=" ++ show d ++ "]")112113-- Tyler: "made an entry indicating the mode/energy, went to the command line, then moved the114-- cursor up to change the mode/energy, and returned to the command line all within 8 seconds"115tylerRace :: Machine -> IO ()116tylerRace m = do117  sendMEOS m xRay118  begin m119  ms 3500 -- past the first Ptime, so the edit goes unnoticed120  sendMEOS m electron121  begin m122123tylerRaceOverdosesThenSuspends :: Check124tylerRaceOverdosesThenSuspends = do125  m <- newMachine126  tylerRace m127  first <-128    waitUntil 15000 "the beam to stop" (beamStopped m)129      `andThen` (outcome m >>= \o -> ensure (o == "MALFUNCTION 54") ("expected MALFUNCTION 54, got " ++ show o))130      `andThen` (displayed m >>= \d -> ensure (d == 6) ("dose monitor should read 6 MU, got " ++ show d))131      `andThen` (patientDose m >>= \d -> ensure (d >= 16500) ("expected a Tyler-sized overdose, got " ++ show d))132      `andThen` (beam m >>= \b -> ensure (b == "BeamTypeXRay") ("hardware should still be set up for X-rays, got " ++ b))133      `andThen` (turntable m >>= \t -> ensure (t == "CollimatorPositionElectronBeam") ("turntable should have followed the edit, got " ++ t))134  case first of135    Left e -> pure (Left e)136    Right () -> do137      -- P four more times: every one is another overdose, and the 5th pause suspends138      let pressP = proceed m >> ms 400139      mapM_ (const pressP) [1 .. 4 :: Int]140      (tphase m >>= \p -> ensure (p == "TP_TerminateTreatment") ("expected treatment suspend after 5 pauses, got " ++ p))141        `andThen` (outcome m >>= \o -> ensure (o == "MALFUNCTION 54") ("suspend should keep the message, got " ++ show o))142        `andThen` (patientDose m >>= \d -> ensure (d >= 5 * 16500) ("expected five overdoses, got " ++ show d))143        `andThen` (reset m >> waitUntil 2000 "reset" ((== "TP_Datent") <$> tphase m))144145softResetWorksDuringPause :: Check146softResetWorksDuringPause = do147  m <- newMachine148  tylerRace m149  waitUntil 15000 "the pause" ((== "TP_PauseTreatment") <$> tphase m)150    `andThen` (reset m >> waitUntil 2000 "reset out of the pause" ((== "TP_Datent") <$> tphase m))151    `andThen` (patientDose m >>= \d -> ensure (d == 0) "reset should start a new patient record")152153-- "Ptime ... If there are edits, then Ptime clears the bending magnet variable and exits to154-- Magnet, which then exits to Datent": an edit during the FIRST magnet is caught155editDuringFirstMagnetIsCaught :: Check156editDuringFirstMagnetIsCaught = do157  m <- newMachine158  sendMEOS m xRay159  begin m160  ms 500161  sendMEOS m electron162  begin m163  r <- finishTreatment m164  d <- describe m165  pure r166    `andThen` (beam m >>= \b -> ensure (b == "BeamTypeElectron") ("Datent should have redone the setup for electrons" ++ d))167    `andThen` (patientDose m >>= \p -> ensure (p <= 200) ("no overdose expected" ++ d))168169noBeginNoBeam :: Check170noBeginNoBeam = do171  m <- newMachine172  sendMEOS m xRay173  ms 10000174  (tphase m >>= \p -> ensure (p == "TP_Datent") ("without Begin the machine must stay in data entry, got " ++ p))175    `andThen` (patientDose m >>= \d -> ensure (d == 0) "no beam without Begin")176    `andThen` (beam m >>= \b -> ensure (b == "BeamTypeXRay") "Datent still sets the hardware up while waiting")177178-- used to leave the machine stuck in DATA ENTRY (the magnets set the flag, Begin toggled it off)179beginShortlyAfterEntryTreats :: Check180beginShortlyAfterEntryTreats = do181  m <- newMachine182  sendMEOS m xRay183  ms 300184  begin m185  r <- finishTreatment m186  d <- describe m187  pure r188    `andThen` (patientDose m >>= \p -> ensure (p <= 200) ("no overdose expected" ++ d))189    `andThen` (beam m >>= \b -> ensure (b == "BeamTypeXRay") ("expected an X-ray setup" ++ d))190191-- press set when the Class3 counter is in the given range, with the field light on192setButtonAt :: Machine -> (Int, Int) -> Check193setButtonAt m (lo, hi) = do194  sendMEOS m xRay195  fieldLight m196  begin m197  waitUntil 15000 "set-up test with the field light on" ((== "TP_SetupTest") <$> tphase m)198    `andThen` waitUntil 5000 "the turntable to reach the field light" ((== "CollimatorPositionFieldLight") <$> turntable m)199    `andThen` (ask m 12 >>= \p -> ensure (p == "PRESS SET BUTTON") ("expected the set prompt, got " ++ show p))200    `andThen` waitUntil 40000 "Class3 to come round" ((\c -> c >= lo && c <= hi) <$> class3 m)201    `andThen` (setButton m >> pure (Right ()))202203-- Yakima: "The overexposure occurred when the operator hit the 'set' button at the precise moment204-- that Class3 rolled over to zero ... the upper collimator was still in field-light position."205yakimaSetAtRolloverOverdoses :: Check206yakimaSetAtRolloverOverdoses = do207  m <- newMachine208  setButtonAt m (244, 250)209    `andThen` waitUntil 5000 "the beam to stop" (beamStopped m)210    `andThen` (describe m >>= \d -> outcome m >>= \o -> ensure (o == "FLATNESS") ("expected FLATNESS" ++ d))211    `andThen` (displayed m >>= \d -> ensure (d == 0) "no ion chamber in the field-light position, so no dose shown")212    `andThen` (patientDose m >>= \d -> ensure (d >= 4000) ("expected a Yakima-sized overdose, got " ++ show d))213    -- "The machine paused again, this time displaying 'flatness'"214    `andThen` (proceed m >> ms 400 >> pure (Right ()))215    `andThen` (describe m >>= \d -> outcome m >>= \o -> ensure (o == "FLATNESS") ("P should repeat it" ++ d))216    `andThen` (patientDose m >>= \d -> ensure (d >= 8000) ("expected two overdoses, got " ++ show d))217218yakimaSetAtOtherTimesIsSafe :: Check219yakimaSetAtOtherTimesIsSafe = do220  m <- newMachine221  r <- setButtonAt m (100, 180) `andThen` finishTreatment m222  d <- describe m223  pure r224    `andThen` (patientDose m >>= \p -> ensure (p <= 200) ("no overdose expected" ++ d))225    `andThen` (turntable m >>= \t -> ensure (t == "CollimatorPositionXRay") ("turntable should be back in the X-ray position" ++ d))226227-- A UI with a "B" command: BEAM READY waits for it, and a normal treatment delivers what was228-- prescribed ("the operator had requested 202 monitor units")229beamOnKeyWaitsAndPrescriptionIsHonoured :: Check230beamOnKeyWaitsAndPrescriptionIsHonoured = do231  m <- newMachine232  beamOnKey m233  call m 11 0 0 202234  sendMEOS m xRay235  begin m236  r <-237    waitUntil 15000 "BEAM READY" ((== "TP_SetupDone") <$> tphase m)238      `andThen` (ms 1500 >> tphase m >>= \p -> ensure (p == "TP_SetupDone") ("must wait for B, got " ++ p))239      `andThen` (patientDose m >>= \d -> ensure (d == 0) "no beam before B")240      `andThen` (beamOn m >> finishTreatment m)241  d <- describe m242  pure r243    `andThen` (outcome m >>= \o -> ensure (o == "TREATMENT OK") ("expected TREATMENT OK" ++ d))244    `andThen` (displayed m >>= \x -> ensure (x == 202) ("dose monitor should show the prescribed 202 MU, got " ++ show x))245    `andThen` (patientDose m >>= \x -> ensure (x == 202) ("expected 202 delivered" ++ d))246247resetWhileBeamReady :: Check248resetWhileBeamReady = do249  m <- newMachine250  beamOnKey m251  sendMEOS m xRay252  begin m253  waitUntil 15000 "BEAM READY" ((== "TP_SetupDone") <$> tphase m)254    `andThen` (reset m >> waitUntil 2000 "reset" ((== "TP_Datent") <$> tphase m))255    `andThen` (beamOn m >> ms 500 >> patientDose m >>= \x -> ensure (x == 0) "B after a reset must not fire")256257-- R while the magnets are being set is only acted on once they are done. A prescription entered258-- again in the meantime used to be wiped when the reset finally happened, leaving the machine in259-- data entry for good.260reenteringDuringResetIsKept :: Check261reenteringDuringResetIsKept = do262  m <- newMachine263  sendMEOS m xRay264  begin m265  ms 1000266  reset m267  ms 500268  sendMEOS m xRay269  begin m270  r <-271    waitUntil 25000 "the re-entered prescription to be treated" ((== "TP_TerminateTreatment") <$> tphase m)272      `andThen` (outcome m >>= \o -> ensure (o /= "") "no treatment")273  d <- describe m274  pure (either (\e -> Left (e ++ d)) Right r)275276-- used to kill the keyboard handler, or poison the state and crash the host on the next request277badInputFromUIIsIgnored :: Check278badInputFromUIIsIgnored = do279  m <- newMachine280  call m 0 1 1 25000281  call m 99 1 1 25000282  sendMEOS m (0, 1, 25000)283  sendMEOS m (1, 0, 25000)284  sendMEOS m (7, 7, 25000)285  ms 500286  unknown <- ask m 99287  dump <- ask m 7288  sendMEOS m xRay289  begin m290  ensure (unknown == "") "unknown request should give an empty string"291    `andThen` ensure (take 11 dump == "TheracState") ("full state dump should work, got " ++ show dump)292    `andThen` waitUntil 15000 "the valid calls to still be handled" ((/= "TP_Datent") <$> tphase m)293294scenarios :: [(String, Check)]295scenarios =296  [ ("Tyler race gives MALFUNCTION 54 and an overdose, P repeats it, 5th pause suspends", tylerRaceOverdosesThenSuspends),297    ("soft reset works during a pause", softResetWorksDuringPause),298    ("an edit during the first magnet is caught", editDuringFirstMagnetIsCaught),299    ("no Begin, no beam", noBeginNoBeam),300    ("Begin shortly after entering the prescription treats normally", beginShortlyAfterEntryTreats),301    ("Yakima: set just before Class3 rolls over gives an overdose, P repeats it", yakimaSetAtRolloverOverdoses),302    ("Yakima: set at any other time is safe", yakimaSetAtOtherTimesIsSafe),303    ("bad input from the UI is ignored", badInputFromUIIsIgnored),304    ("with a B command, BEAM READY waits for B and the prescribed MU are delivered", beamOnKeyWaitsAndPrescriptionIsHonoured),305    ("reset works while the console says BEAM READY", resetWhileBeamReady),306    ("a prescription re-entered while a reset is pending is kept", reenteringDuringResetIsKept)307  ]308309main :: IO ()310main = do311  pending <- forM scenarios $ \(name, check) -> do312    done <- newEmptyMVar313    _ <- forkIO $ do314      r <- try check315      putMVar done $ case r of316        Left (e :: SomeException) -> Left ("exception: " ++ show e)317        Right x -> x318    pure (name, done)319  results <- forM pending $ \(name, done) -> do320    r <- takeMVar done321    putStrLn $ either (\e -> "FAIL " ++ name ++ ": " ++ e) (const ("ok   " ++ name)) r322    pure r323  let failures = length [() | Left _ <- results]324  when (failures > 0) $ putStrLn (show failures ++ " scenario(s) failed")325  unless (failures == 0) exitFailure