module Test.Tui exposing ( TuiTest, Test, Step , start, startWithContext , test, describe, toTest, toNamedSnapshots , expect, snapshots, runSteps, applyStep , pressKey, pressKeyWith, pressKeyN, paste, resize , click, clickText, scrollDown, scrollUp, scrollDownN, scrollUpN , advanceTime , resolveEffect, resolveEffectWith , ensureView, ensureViewHas, ensureViewDoesNotHave , ensureModel, annotateAssertion , StyleCheck, bold, dim, italic, underline, fg, bg , ensureViewHasStyled, ensureViewDoesNotHaveStyled , expectRunning, expectExit, expectExitWith , BackendTaskSimulator , Snapshot, withModelToString ) {-| Test a `Tui.program` the same way a user uses it: start the app, simulate terminal events, and assert on the screen or model. Inspired by [`elm-program-test`](https://package.elm-lang.org/packages/avh4/elm-program-test/latest/). Typical flow: - Start with [`start`](#start) or [`startWithContext`](#startWithContext). - Simulate input with [`pressKey`](#pressKey), [`clickText`](#clickText), [`paste`](#paste), [`resize`](#resize), or [`advanceTime`](#advanceTime). - Resolve pending `BackendTask` effects with [`resolveEffect`](#resolveEffect) for the common case, or [`resolveEffectWith`](#resolveEffectWith) when you need custom HTTP/command simulation. - Finish with [`expectRunning`](#expectRunning), [`expectExit`](#expectExit), or [`expectExitWith`](#expectExitWith). The same named tests can be: - Run headlessly through [`toTest`](#toTest) with `elm-test` - Visualized through `elm-pages test`, which reads the same `TuiTest.Test` value and shows the recorded snapshots in the terminal stepper ```elm import BackendTask import Test import Test.BackendTask as BackendTaskTest import Test.Tui as TuiTest import Tui import Tui.Effect as Effect import Tui.Screen import Tui.Sub type Msg = Increment | Quit keyToMsg : Tui.Sub.KeyEvent -> Msg keyToMsg event = case event.key of Tui.Sub.Character 'j' -> Increment _ -> Quit app : Tui.ProgramConfig () Int Msg app = { data = BackendTask.succeed () , init = \() -> ( 0, Effect.none ) , update = \msg count -> case msg of Increment -> ( count + 1, Effect.none ) Quit -> ( count, Effect.exit ) , view = \_ count -> Tui.Screen.text ("Count: " ++ String.fromInt count) , subscriptions = \_ -> Tui.Sub.onKeyPress keyToMsg } run : Script run = Tui.program app |> Tui.toScript tuiTests : TuiTest.Test tuiTests = TuiTest.describe "Counter" [ TuiTest.test "increments with j" (TuiTest.start BackendTaskTest.init app) [ TuiTest.pressKey 'j' , TuiTest.ensureViewHas "Count: 1" , TuiTest.expectRunning ] ] suite : Test.Test suite = TuiTest.toTest tuiTests ``` @docs TuiTest, Test, Step ## Starting a Test Use [`start`](#start) for the default terminal size, and [`startWithContext`](#startWithContext) when you want custom dimensions or color profile. Both resolve `app.data` through [`Test.BackendTask`](Test-BackendTask). @docs start, startWithContext ## Building Test Suites Build a named test from a starting `TuiTest` and a list of [`Step`](#Step) values. Group tests with [`describe`](#describe). @docs test, describe, toTest, toNamedSnapshots ## Other Runners Use these when you don't want to wrap a single test in [`test`](#test) - for example, in plain `elm-test` `\() -> ...` bodies or when feeding the visual runner directly. [`runSteps`](#runSteps) is the lower-level primitive that threads a list of steps through a `TuiTest` without finalizing it. @docs expect, snapshots, runSteps, applyStep ## Simulating Events Simulate user interactions in the order they would happen. Each function returns a [`Step`](#Step); add them in order to the list passed to [`test`](#test) or [`expect`](#expect). Prefer the user-facing helpers first (`pressKey`, `clickText`, `paste`, `resize`). @docs pressKey, pressKeyWith, pressKeyN, paste, resize @docs click, clickText, scrollDown, scrollUp, scrollDownN, scrollUpN @docs advanceTime ## Resolving Effects When your `update` returns a `Tui.Effect` that performs a `BackendTask`, the test captures it as a pending effect instead of running it automatically. Use [`resolveEffect`](#resolveEffect) when the pending `BackendTask` can be resolved directly by `Test.BackendTask`. Use [`resolveEffectWith`](#resolveEffectWith) when you need to customize the simulation, like stubbing an HTTP response: TuiTest.resolveEffectWith (BackendTaskTest.simulateCommand "git" "M src/Main.elm") @docs resolveEffect, resolveEffectWith ## Screen Assertions Assert on the plain text content of the current screen. Failed assertions show the full screen output for easy debugging. Use [`ensureViewHas`](#ensureViewHas) and [`ensureViewDoesNotHave`](#ensureViewDoesNotHave) for the common case. Use [`ensureView`](#ensureView) when you want a custom assertion, and [`ensureModel`](#ensureModel) when the important state is not visible on screen. @docs ensureView, ensureViewHas, ensureViewDoesNotHave @docs ensureModel, annotateAssertion ## Styled Text Assertions Assert on text that appears with specific styling (bold, color, etc.). Adjacent spans with the same style are merged before matching, so fragmented rendering like `ERROR message` is treated as a single `"ERROR message"` red region. @docs StyleCheck, bold, dim, italic, underline, fg, bg @docs ensureViewHasStyled, ensureViewDoesNotHaveStyled ## Final Assertions Each scenario should end with a final-assertion `Step` that pins the expected termination state. If pending effects remain unresolved, these checks fail so you do not accidentally ignore `BackendTask`s. @docs expectRunning, expectExit, expectExitWith ## Internal Used by the generated `elm-pages test` stepper code. @docs BackendTaskSimulator ## Snapshots Every step records a snapshot automatically. `elm-pages test` reads those snapshots and lets you step through them visually in the terminal. Use [`snapshots`](#snapshots) for low-level inspection, and [`withModelToString`](#withModelToString) when you also want to record model state alongside each screen. @docs Snapshot, withModelToString -} import Ansi.Color import BackendTask exposing (BackendTask) import Dict exposing (Dict) import Expect exposing (Expectation) import FatalError exposing (FatalError) import Test as ElmTest import Test.BackendTask as BackendTaskTest import Test.BackendTask.Internal as BackendTaskTestInternal import Test.Runner import Time import Tui exposing (Context) import Tui.Effect as Effect exposing (Effect) import Tui.Effect.Internal as EffectInternal import Tui.Screen exposing (Screen) import Tui.Screen.Internal as ScreenInternal import Tui.Sub exposing (KeyEvent, Sub) import Tui.Sub.Internal as SubInternal {-| An in-progress TUI test. Created with [`start`](#start) (or [`startWithContext`](#startWithContext)) and passed to [`test`](#test) along with a list of [`Step`](#Step) values describing the user interactions and assertions. -} type TuiTest model msg = TuiTest (State model msg) | SetupError String {-| A single step in a TUI test pipeline - a key press, an effect resolution, an assertion, and so on. Steps are opaque values; build them with the helpers in this module ([`pressKey`](#pressKey), [`ensureViewHas`](#ensureViewHas), [`resolveEffect`](#resolveEffect), and friends). incrementSteps : List (TuiTest.Step Int Msg) incrementSteps = [ TuiTest.pressKey 'k' , TuiTest.ensureViewHas "Count: 1" , TuiTest.expectRunning ] -} type Step model msg = Step (TuiTest model msg -> TuiTest model msg) unwrapStep : Step model msg -> (TuiTest model msg -> TuiTest model msg) unwrapStep (Step fn) = fn {-| Run a list of [`Step`](#Step)s against a `TuiTest`, returning the new `TuiTest` (without finalizing). Use this to bake setup steps into the result of a custom `start` helper, or to compose helper bundles returned as `List Step`. End-of-test finalization is what [`test`](#test), [`expect`](#expect), and [`snapshots`](#snapshots) do for you. TuiTest.runSteps [ TuiTest.pressKey 'k' , TuiTest.ensureViewHas "Count: 1" ] counterTest -} runSteps : List (Step model msg) -> TuiTest model msg -> TuiTest model msg runSteps steps initial = List.foldl (\(Step fn) tt -> fn tt) initial steps {-| Apply a single [`Step`](#Step) to a `TuiTest`. Useful in meta tests that want to keep a chainable `|> step |> step` style with the new opaque `Step` type. End-user tests should prefer [`test`](#test) or [`expect`](#expect) over chaining directly. counterApp |> TuiTest.applyStep (TuiTest.pressKey 'k') |> TuiTest.applyStep TuiTest.expectRunning -} applyStep : Step model msg -> TuiTest model msg -> TuiTest model msg applyStep (Step fn) = fn type alias State model msg = { model : model , update : msg -> model -> ( model, Effect msg ) , view : Context -> model -> Screen , subscriptions : model -> Sub msg , context : Context , pendingEffects : List (BackendTask FatalError msg) , exited : Maybe Int , error : Maybe String , snapshots : List Snapshot , modelToString : Maybe (model -> String) , currentTime : Int , tickFireTimes : Dict Int Int } {-| A snapshot of the TUI state at a point in the test pipeline. Used by [`toSnapshots`](#toSnapshots) for the interactive test stepper. `screen` is the `Tui.Screen.Screen` value (preserving styling), not a plain string. Use `Tui.Screen.toString` to get plain text, or render it through the TUI pipeline for styled output. -} type alias Snapshot = { label : String , screen : Screen , hasPendingEffects : Bool , modelState : Maybe String , assertions : List String } {-| A named tree of TUI tests. Use [`test`](#test) for leaf tests and [`describe`](#describe) to group them, then pass the resulting value to [`toTest`](#toTest) for `elm-test` or expose it for `elm-pages test`. import Test import Test.Tui as TuiTest tuiTests : TuiTest.Test tuiTests = TuiTest.describe "Counter" [ TuiTest.test "increments" <| counterScenario |> TuiTest.pressKey 'j' |> TuiTest.expectRunning ] suite : Test.Test suite = TuiTest.toTest tuiTests -} type Test = SingleTest String Outcome | Describe String (List Test) {-| The finalized result of a single `TuiTest` scenario. Create an `Outcome` with [`expectRunning`](#expectRunning), [`expectExit`](#expectExit), or [`expectExitWith`](#expectExitWith). You can wrap it in a named test with [`test`](#test) or run it directly with [`done`](#done). import Expect import Test.Tui as TuiTest result : TuiTest.Outcome result = counterScenario |> TuiTest.pressKey 'q' |> TuiTest.expectExit check : Expect.Expectation check = TuiTest.done result -} type Outcome = Outcome { expectation : Expectation , snapshots : List Snapshot } {-| Start a TUI test with a default 80×24 terminal and `TrueColor` profile. This resolves `app.data` through [`Test.BackendTask`](Test-BackendTask) before the first snapshot. Pure startup data usually uses `BackendTask.succeed`. For file, env, time, or db-backed startup, seed the virtual environment through the `TestSetup`. If your app subscribes to `Tui.Sub.onResize`, the initial context is fired automatically. import BackendTask import Test.BackendTask as BackendTaskTest import Test.Tui as TuiTest import Tui import Tui.Effect as Effect import Tui.Screen as Screen import Tui.Sub type Msg = Quit counterTest : TuiTest.TuiTest Int Msg counterTest = TuiTest.start BackendTaskTest.init { data = BackendTask.succeed 0 , init = \count -> ( count, Effect.none ) , update = \_ count -> ( count, Effect.exit ) , view = \_ count -> Screen.text ("Count: " ++ String.fromInt count) , subscriptions = \_ -> Tui.Sub.onKeyPress (\_ -> Quit) } The config record is the same shape you pass to [`Tui.program`](Tui#program), so you can share a single app value between your production script and your tests. Use [`startWithContext`](#startWithContext) for a custom terminal size. -} start : BackendTaskTest.TestSetup -> Tui.ProgramConfig data model msg -> TuiTest model msg start setup app = startWithContext { width = 80, height = 24, colorProfile = Tui.TrueColor } setup app {-| Like [`start`](#start), but with a custom terminal context. Use this for responsive layouts, small terminals, or color-profile-dependent rendering. import BackendTask import Test.BackendTask as BackendTaskTest import Test.Tui as TuiTest import Tui import Tui.Effect as Effect import Tui.Screen as Screen import Tui.Sub type Msg = Resized { width : Int, height : Int } resizedTest : TuiTest.TuiTest { width : Int, height : Int } Msg resizedTest = TuiTest.startWithContext { width = 120, height = 40, colorProfile = Tui.TrueColor } BackendTaskTest.init { data = BackendTask.succeed { width = 0, height = 0 } , init = \model -> ( model, Effect.none ) , update = \msg _ -> case msg of Resized size -> ( size, Effect.none ) , view = \_ size -> Screen.text (String.fromInt size.width ++ "x" ++ String.fromInt size.height) , subscriptions = \_ -> Tui.Sub.onResize Resized } If your app subscribes to `Tui.Sub.onResize`, this initial context is also sent through that subscription once at startup. -} startWithContext : Context -> BackendTaskTest.TestSetup -> Tui.ProgramConfig data model msg -> TuiTest model msg startWithContext context setup app = case BackendTaskTest.fromBackendTaskWith setup app.data |> BackendTaskTestInternal.toResult of Ok resolvedData -> startResolvedWithContext context { data = resolvedData , init = app.init , update = app.update , view = app.view , subscriptions = app.subscriptions } Err errorMessage -> SetupError ("Failed to resolve app.data: " ++ errorMessage) startResolvedWithContext : Context -> { data : data , init : data -> ( model, Effect msg ) , update : msg -> model -> ( model, Effect msg ) , view : Context -> model -> Screen , subscriptions : model -> Sub msg } -> TuiTest model msg startResolvedWithContext context config = let ( initialModel, initialEffect ) = config.init config.data ( modelWithContext, contextEffect ) = SubInternal.routeEvents (config.subscriptions initialModel) (SubInternal.RawContext { width = context.width, height = context.height }) |> List.foldl (\msg ( m, accEffect ) -> let ( newModel, newEffect ) = config.update msg m in ( newModel, Effect.batch [ accEffect, newEffect ] ) ) ( initialModel, Effect.none ) combinedEffect : Effect msg combinedEffect = Effect.batch [ initialEffect, contextEffect ] pendingEffects : List (BackendTask FatalError msg) pendingEffects = extractBackendTasks combinedEffect in TuiTest { model = modelWithContext , update = config.update , view = config.view , subscriptions = config.subscriptions , context = context , pendingEffects = pendingEffects , exited = checkForExit combinedEffect , error = Nothing , snapshots = [ { label = "init" , screen = config.view context modelWithContext , hasPendingEffects = not (List.isEmpty pendingEffects) , modelState = Nothing , assertions = [] } ] , modelToString = Nothing , currentTime = 0 , tickFireTimes = Dict.empty } -- SIMULATING EVENTS {-| Simulate pressing a character key with no modifiers. [ TuiTest.pressKey 'j' ] -} pressKey : Char -> Step model msg pressKey char = pressKeyWith { key = Tui.Sub.Character char, modifiers = [] } {-| Simulate pressing a character key N times. -- Navigate down 7 items [ TuiTest.pressKeyN 7 'j' ] -} pressKeyN : Int -> Char -> Step model msg pressKeyN n char = Step (\initial -> List.foldl (\_ acc -> unwrapStep (pressKey char) acc) initial (List.range 1 n)) {-| Simulate pressing any key, including special keys and modifiers. [ TuiTest.pressKeyWith { key = Tui.Sub.Arrow Tui.Sub.Down , modifiers = [] } , TuiTest.pressKeyWith { key = Tui.Sub.Character 's' , modifiers = [ Tui.Sub.Ctrl ] } ] -} pressKeyWith : KeyEvent -> Step model msg pressKeyWith keyEvent = Step (pressKeyWithImpl keyEvent) pressKeyWithImpl : KeyEvent -> TuiTest model msg -> TuiTest model msg pressKeyWithImpl keyEvent tuiTest = case tuiTest of TuiTest state -> case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest state ( _, Just _ ) -> TuiTest { state | error = Just "Cannot press key: the TUI has already exited." } ( Nothing, Nothing ) -> let sub : Sub msg sub = state.subscriptions state.model in SubInternal.routeEvents sub (SubInternal.RawKeyPress keyEvent) |> List.foldl (applyMsg (keyEventLabel keyEvent)) (TuiTest state) SetupError _ -> tuiTest {-| Simulate a bracketed paste event. Delivers the text as a single `OnPaste` event, just like a real terminal with bracketed paste mode enabled. Use this instead of typing character-by-character when testing paste behavior. [ TuiTest.pressKey 'c' -- open commit dialog , TuiTest.paste "fix: null pointer" -- paste commit message , TuiTest.ensureViewHas "fix: null pointer" ] -} paste : String -> Step model msg paste pastedText = Step (pasteImpl pastedText) pasteImpl : String -> TuiTest model msg -> TuiTest model msg pasteImpl pastedText tuiTest = case tuiTest of TuiTest state -> case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest state ( _, Just _ ) -> TuiTest { state | error = Just "Cannot paste: the TUI has already exited." } ( Nothing, Nothing ) -> let sub : Sub msg sub = state.subscriptions state.model in SubInternal.routeEvents sub (SubInternal.RawPaste pastedText) |> List.foldl (applyMsg ("paste \"" ++ truncateLabel pastedText ++ "\"")) (TuiTest state) SetupError _ -> tuiTest truncateLabel : String -> String truncateLabel s = if String.length s > 30 then String.left 27 s ++ "..." else s {-| Simulate a terminal resize. The framework handles resize automatically: this updates the `Context` that `view` receives and routes the new size through any `Tui.Sub.onResize` subscriptions. -} resize : { width : Int, height : Int } -> Step model msg resize size = Step (resizeImpl size) resizeImpl : { width : Int, height : Int } -> TuiTest model msg -> TuiTest model msg resizeImpl size tuiTest = case tuiTest of TuiTest state -> case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest state ( _, Just _ ) -> TuiTest { state | error = Just "Cannot resize: the TUI has already exited." } ( Nothing, Nothing ) -> let newContext : Context newContext = { width = size.width, height = size.height, colorProfile = state.context.colorProfile } ( newModel, effect ) = SubInternal.routeEvents (state.subscriptions state.model) (SubInternal.RawContext { width = newContext.width, height = newContext.height }) |> List.foldl (\msg ( m, accEffect ) -> let ( m2, newEffect ) = state.update msg m in ( m2, Effect.batch [ accEffect, newEffect ] ) ) ( state.model, Effect.none ) newPendingEffects : List (BackendTask FatalError msg) newPendingEffects = state.pendingEffects ++ extractBackendTasks effect snapshot : Snapshot snapshot = { label = "resize " ++ String.fromInt size.width ++ "×" ++ String.fromInt size.height , screen = state.view newContext newModel , hasPendingEffects = not (List.isEmpty newPendingEffects) , modelState = Maybe.map (\f -> f newModel) state.modelToString , assertions = [] } in TuiTest { state | model = newModel , context = newContext , pendingEffects = newPendingEffects , exited = checkForExit effect , snapshots = state.snapshots ++ [ snapshot ] } SetupError _ -> tuiTest {-| Simulate a left mouse click at the given row and column (0-based). [ TuiTest.click { row = 3, col = 5 } ] -} click : { row : Int, col : Int } -> Step model msg click pos = Step (simulateMouseEvent ("click (" ++ String.fromInt pos.row ++ "," ++ String.fromInt pos.col ++ ")") (Tui.Sub.Click { row = pos.row, col = pos.col, button = Tui.Sub.LeftButton }) ) {-| Find a line containing the given text and simulate a click on it. Like elm-program-test's `clickButton`, finds elements by content instead of coordinates, making tests resilient to layout changes. [ TuiTest.clickText "def5678" ] Fails with a helpful message if the text is not found on screen. -} clickText : String -> Step model msg clickText needle = Step (clickTextImpl needle) clickTextImpl : String -> TuiTest model msg -> TuiTest model msg clickTextImpl needle tuiTest = case tuiTest of TuiTest state -> case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest state ( _, Just _ ) -> TuiTest { state | error = Just "Cannot click text: the TUI has already exited." } ( Nothing, Nothing ) -> let screenLines : List String screenLines = Tui.Screen.toString (state.view state.context state.model) |> String.split "\n" maybeMatch : Maybe { row : Int, col : Int } maybeMatch = screenLines |> List.indexedMap Tuple.pair |> List.filterMap (\( idx, line ) -> case String.indexes needle line of first :: _ -> Just { row = idx, col = first } [] -> Nothing ) |> List.head in case maybeMatch of Just match -> simulateMouseEvent ("clickText \"" ++ needle ++ "\"") (Tui.Sub.Click { row = match.row, col = match.col, button = Tui.Sub.LeftButton }) (TuiTest state) Nothing -> TuiTest { state | error = Just ("clickText: could not find \"" ++ needle ++ "\" on screen.\n\nThe screen contains:\n\n" ++ indentScreenText (Tui.Screen.toString (state.view state.context state.model)) ) } SetupError _ -> tuiTest {-| Simulate a scroll-down event at the given position. -} scrollDown : { row : Int, col : Int } -> Step model msg scrollDown pos = Step (simulateMouseEvent ("scrollDown (" ++ String.fromInt pos.row ++ "," ++ String.fromInt pos.col ++ ")") (Tui.Sub.ScrollDown { row = pos.row, col = pos.col, amount = 1 }) ) {-| Simulate a scroll-up event at the given position. -} scrollUp : { row : Int, col : Int } -> Step model msg scrollUp pos = Step (simulateMouseEvent ("scrollUp (" ++ String.fromInt pos.row ++ "," ++ String.fromInt pos.col ++ ")") (Tui.Sub.ScrollUp { row = pos.row, col = pos.col, amount = 1 }) ) {-| Simulate N scroll-down events at the given position. [ TuiTest.scrollDownN 10 { row = 3, col = 60 } ] -} scrollDownN : Int -> { row : Int, col : Int } -> Step model msg scrollDownN n pos = Step (\initial -> List.foldl (\_ acc -> unwrapStep (scrollDown pos) acc) initial (List.range 1 n)) {-| Simulate N scroll-up events at the given position. [ TuiTest.scrollUpN 5 { row = 3, col = 60 } ] -} scrollUpN : Int -> { row : Int, col : Int } -> Step model msg scrollUpN n pos = Step (\initial -> List.foldl (\_ acc -> unwrapStep (scrollUp pos) acc) initial (List.range 1 n)) simulateMouseEvent : String -> Tui.Sub.MouseEvent -> TuiTest model msg -> TuiTest model msg simulateMouseEvent label mouseEvent tuiTest = case tuiTest of TuiTest state -> case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest state ( _, Just _ ) -> TuiTest { state | error = Just ("Cannot " ++ label ++ ": the TUI has already exited.") } ( Nothing, Nothing ) -> let sub : Sub msg sub = state.subscriptions state.model in SubInternal.routeEvents sub (SubInternal.RawMouse mouseEvent) |> List.foldl (applyMsg label) (TuiTest state) SetupError _ -> tuiTest {-| Advance simulated time by the given number of milliseconds. Any [`Tui.Sub.everyMillis`](Tui-Sub#everyMillis) subscriptions fire for each interval boundary crossed, passing the simulated `Time.Posix` at the moment of fire to the user's message constructor. import Time TuiTest.test "animation advances one frame per 50ms tick" <| spinnerTest |> TuiTest.advanceTime 50 |> TuiTest.ensureViewHas "frame 1" |> TuiTest.advanceTime 50 |> TuiTest.ensureViewHas "frame 2" |> TuiTest.expectRunning Multi-interval subscriptions fire independently at their own rates. If multiple ticks fall in the same `advanceTime` call, they are delivered to `update` in chronological order; same-timestamp ticks from different intervals fire in subscription order. Catch-up semantics match the runtime: each interval fires at most once per `advanceTime` call with the actual simulated fire time, not the target time. The starting simulated clock is `1970-01-01T00:00:00Z` (posix 0). The first fire of `everyMillis n _` is at simulated posix `n`. -} advanceTime : Int -> Step model msg advanceTime deltaMs = Step (advanceTimeImpl deltaMs) advanceTimeImpl : Int -> TuiTest model msg -> TuiTest model msg advanceTimeImpl deltaMs tuiTest = case tuiTest of TuiTest state -> case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest state ( _, Just _ ) -> TuiTest { state | error = Just "Cannot advance time: the TUI has already exited." } ( Nothing, Nothing ) -> advanceTimeHelp (state.currentTime + deltaMs) (TuiTest state) SetupError _ -> tuiTest advanceTimeHelp : Int -> TuiTest model msg -> TuiTest model msg advanceTimeHelp targetTime tuiTest = case tuiTest of TuiTest state -> -- elm-review: known-unoptimized-recursion case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest { state | currentTime = targetTime } ( _, Just _ ) -> TuiTest { state | currentTime = targetTime } ( Nothing, Nothing ) -> let sub : Sub msg sub = state.subscriptions state.model intervals : List Int intervals = SubInternal.getTickIntervals sub nextFires : List ( Int, Int ) nextFires = intervals |> List.map (\interval -> let lastFire : Int lastFire = Dict.get interval state.tickFireTimes |> Maybe.withDefault 0 in ( interval, lastFire + interval ) ) |> List.filter (\( _, t ) -> t <= targetTime) |> List.sortBy Tuple.second in case nextFires of [] -> TuiTest { state | currentTime = targetTime } ( interval, fireTime ) :: _ -> let rawEvent : SubInternal.RawEvent rawEvent = SubInternal.RawTick { interval = interval , time = Time.millisToPosix fireTime } stateWithClock : State model msg stateWithClock = { state | currentTime = fireTime , tickFireTimes = Dict.insert interval fireTime state.tickFireTimes } msgs : List msg msgs = SubInternal.routeEvents sub rawEvent advancedTuiTest : TuiTest model msg advancedTuiTest = List.foldl (applyMsg label) (TuiTest stateWithClock) msgs label : String label = "advance " ++ String.fromInt fireTime ++ "ms" in advanceTimeHelp targetTime advancedTuiTest -- BACKENDTASK SIMULATION SetupError _ -> tuiTest {-| Resolve the next pending `BackendTask` effect with the default `Test.BackendTask` behavior. Use this for the common case where the pending effect can be resolved without extra setup, for example `BackendTask.succeed`, `map`, `andThen`, or other pure `BackendTask` flows. import BackendTask import Test.BackendTask as BackendTaskTest import Test.Tui as TuiTest import Tui import Tui.Effect as Effect import Tui.Screen as Screen import Tui.Sub type Msg = Fetch | Fetched String backendTaskTest : TuiTest.TuiTest String Msg backendTaskTest = TuiTest.start BackendTaskTest.init { data = BackendTask.succeed "idle" , init = \status -> ( status, Effect.none ) , update = \msg status -> case msg of Fetch -> ( status , BackendTask.succeed "done" |> Effect.perform Fetched ) Fetched newStatus -> ( newStatus, Effect.none ) , view = \_ status -> Screen.text status , subscriptions = \_ -> Tui.Sub.onKeyPress (\_ -> Fetch) } fetchTest : TuiTest.Test fetchTest = TuiTest.test "fetches when f is pressed" backendTaskTest [ TuiTest.pressKey 'f' , TuiTest.resolveEffect , TuiTest.ensureViewHas "done" , TuiTest.expectRunning ] -} resolveEffect : Step model msg resolveEffect = Step (resolveNextEffect BackendTaskTest.fromBackendTask) {-| Resolve the next pending `BackendTask` effect with a customized `Test.BackendTask` simulation pipeline. Use this when the pending effect needs extra simulation, like an HTTP response or shell command output. The callback receives the same `Test.BackendTask` pipeline you would get from `BackendTaskTest.fromBackendTask`, so in practice you usually just pipe one or more `BackendTaskTest.simulate...` helpers into it. import Json.Encode as Encode import Test.BackendTask as BackendTaskTest import Test.Tui as TuiTest [ TuiTest.pressKeyWith { key = Tui.Sub.Enter, modifiers = [] } , TuiTest.resolveEffectWith (BackendTaskTest.simulateHttpGet "https://api.github.com/repos/elm/core" (Encode.object [ ( "stargazers_count", Encode.int 7500 ) ]) ) , TuiTest.ensureViewHas "Stars: 7500" ] -} resolveEffectWith : (BackendTaskSimulator msg -> BackendTaskSimulator msg) -> Step model msg resolveEffectWith simulate = Step (resolveNextEffect (\bt -> bt |> BackendTaskTest.fromBackendTask |> simulate ) ) {-| Internal helper type used by [`resolveEffectWith`](#resolveEffectWith). Most tests do not need to refer to this directly; you can usually pass a `Test.BackendTask.simulate...` pipeline inline. import Json.Encode as Encode import Test.BackendTask as BackendTaskTest import Test.Tui as TuiTest simulateStars : TuiTest.BackendTaskSimulator msg -> TuiTest.BackendTaskSimulator msg simulateStars = BackendTaskTest.simulateHttpGet "https://api.github.com/repos/dillonkearns/elm-pages" (Encode.object [ ( "stargazers_count", Encode.int 1234 ) ]) -} type alias BackendTaskSimulator msg = BackendTaskTestInternal.BackendTaskTest msg -- SCREEN ASSERTIONS {-| Assert on the current screen content using a custom assertion function. The function receives the plain text content (no styling) of the rendered screen. [ TuiTest.ensureView (\text -> if String.contains "Error" text then Expect.fail "Should not show error" else Expect.pass ) ] -} ensureView : (String -> Expectation) -> Step model msg ensureView assertion = Step (ensureViewImpl assertion) ensureViewImpl : (String -> Expectation) -> TuiTest model msg -> TuiTest model msg ensureViewImpl assertion tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> TuiTest state Nothing -> let screenText : String screenText = Tui.Screen.toString (state.view state.context state.model) result : Expectation result = assertion screenText in case getFailureMessage result of Just msg -> TuiTest { state | error = Just ("ensureView failed:\n" ++ msg) } Nothing -> TuiTest (recordAssertion "ensureView ✓" state) SetupError _ -> tuiTest {-| Assert that the current screen contains the given text. [ TuiTest.ensureViewHas "Count: 0" ] -} ensureViewHas : String -> Step model msg ensureViewHas needle = Step (ensureViewHasImpl needle) ensureViewHasImpl : String -> TuiTest model msg -> TuiTest model msg ensureViewHasImpl needle tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> TuiTest state Nothing -> let screenText : String screenText = Tui.Screen.toString (state.view state.context state.model) in if String.contains needle screenText then TuiTest (recordAssertion ("ensureViewHas \"" ++ needle ++ "\" ✓") state) else TuiTest { state | error = Just ("ensureViewHas: expected screen to contain:\n\n \"" ++ needle ++ "\"\n\nbut the screen was:\n\n" ++ indentScreenText screenText ) } SetupError _ -> tuiTest {-| Assert that the current screen does NOT contain the given text. -} ensureViewDoesNotHave : String -> Step model msg ensureViewDoesNotHave needle = Step (ensureViewDoesNotHaveImpl needle) ensureViewDoesNotHaveImpl : String -> TuiTest model msg -> TuiTest model msg ensureViewDoesNotHaveImpl needle tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> TuiTest state Nothing -> let screenText : String screenText = Tui.Screen.toString (state.view state.context state.model) in if String.contains needle screenText then TuiTest { state | error = Just ("ensureViewDoesNotHave: expected screen NOT to contain:\n\n \"" ++ needle ++ "\"\n\nbut the screen was:\n\n" ++ indentScreenText screenText ) } else TuiTest (recordAssertion ("ensureViewDoesNotHave \"" ++ needle ++ "\" ✓") state) SetupError _ -> tuiTest {-| Assert on the model directly. Useful for verifying internal state that isn't visible in the rendered output, or for building higher-level test helpers that query opaque framework state (like `Layout.FrameworkModel`). TuiTest.test "counter is at 5" counterApp [ TuiTest.pressKeyN 5 'j' , TuiTest.ensureModel (\model -> Expect.equal 5 model.count) , TuiTest.expectRunning ] -} ensureModel : (model -> Expectation) -> Step model msg ensureModel assertion = Step (ensureModelImpl assertion) ensureModelImpl : (model -> Expectation) -> TuiTest model msg -> TuiTest model msg ensureModelImpl assertion tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> TuiTest state Nothing -> case getFailureMessage (assertion state.model) of Just msg -> TuiTest { state | error = Just ("ensureModel failed:\n" ++ msg) } Nothing -> TuiTest state SetupError _ -> tuiTest {-| Add an assertion label to the most recent snapshot. The stepper shows these beneath the action label, so custom helpers can describe what they checked without creating a new snapshot. Use this when building companion helpers on top of [`ensureModel`](#ensureModel): import Expect import Test.Tui as TuiTest ensureCount : Int -> List (TuiTest.Step Int msg) ensureCount expected = [ TuiTest.ensureModel (\actual -> Expect.equal expected actual) , TuiTest.annotateAssertion ("ensureCount " ++ String.fromInt expected ++ " ✓") ] -} annotateAssertion : String -> Step model msg annotateAssertion description = Step (annotateAssertionImpl description) annotateAssertionImpl : String -> TuiTest model msg -> TuiTest model msg annotateAssertionImpl description tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> TuiTest state Nothing -> TuiTest (recordAssertion description state) -- STYLED TEXT ASSERTIONS SetupError _ -> tuiTest {-| A check on a single style attribute. Combine multiple checks in a list to require all of them. For example, `[ bold, fg Ansi.Color.red ]` means "bold AND red." -} type StyleCheck = StyleCheck (ScreenInternal.FlatStyle -> Bool) {-| Match bold text. |> TuiTest.ensureViewHasStyled [ TuiTest.bold ] "selected item" -} bold : StyleCheck bold = StyleCheck .bold {-| Match dim text. -} dim : StyleCheck dim = StyleCheck .dim {-| Match italic text. -} italic : StyleCheck italic = StyleCheck .italic {-| Match underlined text. -} underline : StyleCheck underline = StyleCheck .underline {-| Match text with a specific foreground color. |> TuiTest.ensureViewHasStyled [ TuiTest.fg Ansi.Color.red ] "Error" -} fg : Ansi.Color.Color -> StyleCheck fg color = StyleCheck (\s -> s.foreground == Just color) {-| Match text with a specific background color. |> TuiTest.ensureViewHasStyled [ TuiTest.bg Ansi.Color.blue ] "Selected" -} bg : Ansi.Color.Color -> StyleCheck bg color = StyleCheck (\s -> s.background == Just color) {-| Assert that the screen contains the given text rendered with ALL of the specified style checks. Adjacent spans that satisfy the checks are merged before matching, so fragmented rendering is handled correctly. TuiTest.test "selected item is highlighted" myTest [ TuiTest.ensureViewHasStyled [ TuiTest.bold, TuiTest.fg Ansi.Color.yellow ] "selected" , TuiTest.expectRunning ] -} ensureViewHasStyled : List StyleCheck -> String -> Step model msg ensureViewHasStyled checks needle = Step (ensureViewHasStyledImpl checks needle) ensureViewHasStyledImpl : List StyleCheck -> String -> TuiTest model msg -> TuiTest model msg ensureViewHasStyledImpl checks needle tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> TuiTest state Nothing -> let screen : Screen screen = state.view state.context state.model in if containsStyledText checks needle screen then TuiTest (recordAssertion ("ensureViewHasStyled " ++ describeChecks checks ++ " \"" ++ needle ++ "\" ✓") state) else let screenText : String screenText = Tui.Screen.toString screen in TuiTest { state | error = Just ("ensureViewHasStyled: expected screen to contain:\n\n \"" ++ needle ++ "\"\n\nwith style " ++ describeChecks checks ++ "\n\nbut the screen was:\n\n" ++ indentScreenText screenText ) } SetupError _ -> tuiTest {-| Assert that the screen does NOT contain the given text with ALL of the specified style checks. TuiTest.test "error text is not bold" myTest [ TuiTest.ensureViewDoesNotHaveStyled [ TuiTest.bold ] "Error" , TuiTest.expectRunning ] -} ensureViewDoesNotHaveStyled : List StyleCheck -> String -> Step model msg ensureViewDoesNotHaveStyled checks needle = Step (ensureViewDoesNotHaveStyledImpl checks needle) ensureViewDoesNotHaveStyledImpl : List StyleCheck -> String -> TuiTest model msg -> TuiTest model msg ensureViewDoesNotHaveStyledImpl checks needle tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> TuiTest state Nothing -> let screen : Screen screen = state.view state.context state.model in if containsStyledText checks needle screen then let screenText : String screenText = Tui.Screen.toString screen in TuiTest { state | error = Just ("ensureViewDoesNotHaveStyled: expected screen NOT to contain:\n\n \"" ++ needle ++ "\"\n\nwith style " ++ describeChecks checks ++ "\n\nbut the screen was:\n\n" ++ indentScreenText screenText ) } else TuiTest (recordAssertion ("ensureViewDoesNotHaveStyled " ++ describeChecks checks ++ " \"" ++ needle ++ "\" ✓") state) SetupError _ -> tuiTest {-| Check if any line contains the needle as a substring within a contiguous region where all spans satisfy the style checks. Adjacent matching spans are merged before searching. -} containsStyledText : List StyleCheck -> String -> Screen -> Bool containsStyledText checks needle screen = let predicate : ScreenInternal.FlatStyle -> Bool predicate style = List.all (\(StyleCheck check) -> check style) checks spanLines : List (List ScreenInternal.Span) spanLines = ScreenInternal.flattenToSpanLines tuiStyleToFlatStyle screen in List.any (containsStyledInLine predicate needle) spanLines containsStyledInLine : (ScreenInternal.FlatStyle -> Bool) -> String -> List ScreenInternal.Span -> Bool containsStyledInLine predicate needle spans = containsStyledInLineHelp predicate needle "" spans containsStyledInLineHelp : (ScreenInternal.FlatStyle -> Bool) -> String -> String -> List ScreenInternal.Span -> Bool containsStyledInLineHelp predicate needle acc spans = case spans of [] -> String.contains needle acc span :: rest -> if predicate span.style then containsStyledInLineHelp predicate needle (acc ++ span.text) rest else if String.contains needle acc then True else containsStyledInLineHelp predicate needle "" rest describeChecks : List StyleCheck -> String describeChecks checks = let names : List String names = List.filterMap describeCheck checks in case names of [] -> "(any style)" _ -> "[" ++ String.join ", " names ++ "]" describeCheck : StyleCheck -> Maybe String describeCheck (StyleCheck check) = -- Check which attribute this check tests by probing with a styled FlatStyle let base : ScreenInternal.FlatStyle base = ScreenInternal.defaultFlatStyle in if check { base | bold = True } && not (check base) then Just "bold" else if check { base | dim = True } && not (check base) then Just "dim" else if check { base | italic = True } && not (check base) then Just "italic" else if check { base | underline = True } && not (check base) then Just "underline" else if check { base | foreground = Just Ansi.Color.white } && not (check base) then Just "fg color" else if check { base | background = Just Ansi.Color.white } && not (check base) then Just "bg color" else Nothing tuiStyleToFlatStyle : Tui.Screen.Style -> ScreenInternal.FlatStyle tuiStyleToFlatStyle = ScreenInternal.styleToFlatStyle -- TERMINAL ASSERTIONS {-| Assert that the TUI is still running (has not exited). Place this as the last step in your test list. Fails if there are unresolved pending `BackendTask` effects - use [`resolveEffect`](#resolveEffect) or [`resolveEffectWith`](#resolveEffectWith) earlier in the list to resolve them. -} expectRunning : Step model msg expectRunning = Step expectRunningImpl expectRunningImpl : TuiTest model msg -> TuiTest model msg expectRunningImpl tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> tuiTest Nothing -> case ( state.exited, state.pendingEffects ) of ( Nothing, [] ) -> TuiTest (recordAssertion "expectRunning ✓" state) ( Nothing, pending ) -> TuiTest { state | error = Just (pendingEffectsError (List.length pending)) } ( Just code, _ ) -> TuiTest { state | error = Just ("Expected TUI to be running, but it exited with code " ++ String.fromInt code) } SetupError _ -> tuiTest {-| Assert that the TUI exited with code 0. Place this as the last step in your test list. Fails if there are unresolved pending `BackendTask` effects. -} expectExit : Step model msg expectExit = Step expectExitImpl expectExitImpl : TuiTest model msg -> TuiTest model msg expectExitImpl tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> tuiTest Nothing -> case ( state.exited, state.pendingEffects ) of ( Just 0, [] ) -> TuiTest (recordAssertion "expectExit ✓" state) ( Just 0, pending ) -> TuiTest { state | error = Just (pendingEffectsError (List.length pending)) } ( Just code, _ ) -> TuiTest { state | error = Just ("Expected exit code 0, but got " ++ String.fromInt code) } ( Nothing, pending ) -> if List.isEmpty pending then TuiTest { state | error = Just "Expected TUI to exit, but it is still running" } else TuiTest { state | error = Just (pendingEffectsError (List.length pending)) } SetupError _ -> tuiTest {-| Assert that the TUI exited with a specific exit code. Place this as the last step in your test list. Fails if there are unresolved pending `BackendTask` effects. -} expectExitWith : Int -> Step model msg expectExitWith expectedCode = Step (expectExitWithImpl expectedCode) expectExitWithImpl : Int -> TuiTest model msg -> TuiTest model msg expectExitWithImpl expectedCode tuiTest = case tuiTest of TuiTest state -> case state.error of Just _ -> tuiTest Nothing -> case ( state.exited, state.pendingEffects ) of ( Just code, [] ) -> if code == expectedCode then TuiTest (recordAssertion ("expectExitWith " ++ String.fromInt expectedCode ++ " ✓") state) else TuiTest { state | error = Just ("Expected exit code " ++ String.fromInt expectedCode ++ ", but got " ++ String.fromInt code) } ( Just _, pending ) -> TuiTest { state | error = Just (pendingEffectsError (List.length pending)) } ( Nothing, pending ) -> if List.isEmpty pending then TuiTest { state | error = Just ("Expected TUI to exit with code " ++ String.fromInt expectedCode ++ ", but it is still running") } else TuiTest { state | error = Just (pendingEffectsError (List.length pending)) } SetupError _ -> tuiTest {-| Name a single test from a starting `TuiTest` and a list of [`Step`](#Step)s. The framework runs the steps and finalizes the test for you. import Test.Tui as TuiTest counterTests : TuiTest.Test counterTests = TuiTest.test "increments" counterScenario [ TuiTest.pressKey 'j' , TuiTest.expectRunning ] -} test : String -> TuiTest model msg -> List (Step model msg) -> Test test label initial steps = SingleTest label (toOutcome (runSteps steps initial)) {-| Run a list of [`Step`](#Step)s against a starting `TuiTest` and finalize the result as an `Expect.Expectation`. Use this when writing tests in plain `elm-test` style: import Expect import Test exposing (Test, test) import Test.Tui as TuiTest suite : Test suite = test "k increments" <| \() -> TuiTest.expect counterScenario [ TuiTest.pressKey 'k' , TuiTest.ensureViewHas "Count: 1" , TuiTest.expectRunning ] -} expect : TuiTest model msg -> List (Step model msg) -> Expectation expect initial steps = finalExpectation (runSteps steps initial) {-| Run a list of [`Step`](#Step)s against a starting `TuiTest` and return the recorded snapshots. Use this when feeding the TUI stepper without going through [`test`](#test). -} snapshots : TuiTest model msg -> List (Step model msg) -> List Snapshot snapshots initial steps = extractSnapshots (runSteps steps initial) {-| Group TUI tests under a shared heading. import Test.Tui as TuiTest counterTests : TuiTest.Test counterTests = TuiTest.describe "Counter" [ TuiTest.test "increments" counterScenario [ TuiTest.pressKey 'j' , TuiTest.expectRunning ] , TuiTest.test "quits" counterScenario [ TuiTest.pressKey 'q' , TuiTest.expectExit ] ] -} describe : String -> List Test -> Test describe label children = Describe label children {-| Convert a named TUI test tree into an `elm-test` `Test.Test`. `elm-pages test` works with `TuiTest.Test` directly so it can show the interactive stepper. Use `toTest` when you want to run the same named tests through plain `elm-test` without the `elm-pages` wrapper CLI. import Test import Test.Tui as TuiTest suite : Test.Test suite = TuiTest.toTest tuiTests -} toTest : Test -> ElmTest.Test toTest tuiTest = -- elm-review: known-unoptimized-recursion case tuiTest of SingleTest label (Outcome outcome) -> ElmTest.test label <| \() -> outcome.expectation Describe label children -> ElmTest.describe label (List.map toTest children) {-| Internal: extract the final `Expectation` from a finalized `TuiTest`. Used by [`expect`](#expect); not exposed. -} finalExpectation : TuiTest model msg -> Expectation finalExpectation tuiTest = case tuiTest of TuiTest state -> case state.error of Just msg -> Expect.fail msg Nothing -> Expect.pass SetupError setupMsg -> Expect.fail ("Setup failed: " ++ setupMsg) toOutcome : TuiTest model msg -> Outcome toOutcome tuiTest = Outcome { expectation = finalExpectation tuiTest , snapshots = extractSnapshots tuiTest } {-| Flatten a named TUI test tree into named snapshot sequences. This is used by the generated `elm-pages test` stepper code. The names include any enclosing [`describe`](#describe) labels so the selected test is easy to identify. import Test.Tui as TuiTest snapshotNames : List String snapshotNames = tuiTests |> TuiTest.toNamedSnapshots |> List.map Tuple.first -} toNamedSnapshots : Test -> List ( String, List Snapshot ) toNamedSnapshots tuiTest = toNamedSnapshotsHelp [] tuiTest toNamedSnapshotsHelp : List String -> Test -> List ( String, List Snapshot ) toNamedSnapshotsHelp ancestors tuiTest = -- elm-review: known-unoptimized-recursion case tuiTest of SingleTest label (Outcome outcome) -> [ ( String.join " / " (ancestors ++ [ label ]) , outcome.snapshots ) ] Describe label children -> children |> List.concatMap (toNamedSnapshotsHelp (ancestors ++ [ label ])) outcomeFromState : State model msg -> Expectation -> Outcome outcomeFromState state expectation = Outcome { expectation = expectation , snapshots = extractSnapshots (TuiTest state) } pendingEffectsError : Int -> String pendingEffectsError count = "There " ++ (if count == 1 then "is 1 pending BackendTask effect" else "are " ++ String.fromInt count ++ " pending BackendTask effects" ) ++ " that must be resolved before ending the test.\n\n" ++ "Use TuiTest.resolveEffect to run the next effect with the default Test.BackendTask simulation. This is the right choice even for auto-resolvable BackendTasks like BackendTask.succeed and virtual file, env, or db reads.\n\n" ++ "Use TuiTest.resolveEffectWith when the effect needs custom simulation (for example HTTP, commands, or custom effects)." -- HELPERS applyMsg : String -> msg -> TuiTest model msg -> TuiTest model msg applyMsg label msg tuiTest = case tuiTest of TuiTest state -> case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest state ( _, Just _ ) -> TuiTest state ( Nothing, Nothing ) -> let ( newModel, effect ) = state.update msg state.model newPendingEffects : List (BackendTask FatalError msg) newPendingEffects = extractBackendTasks effect snapshot : Snapshot snapshot = { label = label , screen = state.view state.context newModel , hasPendingEffects = not (List.isEmpty newPendingEffects) , modelState = Maybe.map (\f -> f newModel) state.modelToString , assertions = [] } in TuiTest { state | model = newModel , pendingEffects = newPendingEffects , exited = checkForExit effect , snapshots = state.snapshots ++ [ snapshot ] } SetupError _ -> tuiTest {-| Resolve the next pending BackendTask effect using a simulation function. The simulation function takes the raw BackendTask and returns a BackendTaskTest that has been configured with the appropriate simulation. -} resolveNextEffect : (BackendTask FatalError msg -> BackendTaskTestInternal.BackendTaskTest msg) -> TuiTest model msg -> TuiTest model msg resolveNextEffect simulate tuiTest = case tuiTest of TuiTest state -> case ( state.error, state.exited ) of ( Just _, _ ) -> TuiTest state ( _, Just _ ) -> TuiTest { state | error = Just "Cannot resolve effect: the TUI has already exited." } ( Nothing, Nothing ) -> case state.pendingEffects of [] -> TuiTest { state | error = Just "No pending BackendTask effect to resolve. Did you forget to trigger an action (e.g., press Enter) before simulating?" } bt :: rest -> let testResult : Result String msg testResult = simulate bt |> BackendTaskTestInternal.toResult in case testResult of Ok msg -> let ( newModel, newEffect ) = state.update msg state.model newPendingEffects : List (BackendTask FatalError msg) newPendingEffects = rest ++ extractBackendTasks newEffect snapshot : Snapshot snapshot = { label = "resolveEffect" , screen = state.view state.context newModel , hasPendingEffects = not (List.isEmpty newPendingEffects) , modelState = Maybe.map (\f -> f newModel) state.modelToString , assertions = [] } in TuiTest { state | model = newModel , pendingEffects = newPendingEffects , exited = checkForExit newEffect , snapshots = state.snapshots ++ [ snapshot ] } Err errMsg -> TuiTest { state | error = Just ("Effect resolution failed: " ++ errMsg) } SetupError _ -> tuiTest extractBackendTasks : Effect msg -> List (BackendTask FatalError msg) extractBackendTasks effect = extractBackendTasksHelp [ effect ] [] extractBackendTasksHelp : List (Effect msg) -> List (BackendTask FatalError msg) -> List (BackendTask FatalError msg) extractBackendTasksHelp remaining collected = case remaining of [] -> List.reverse collected next :: rest -> case next of EffectInternal.None -> extractBackendTasksHelp rest collected EffectInternal.Batch effects -> extractBackendTasksHelp (List.reverse effects ++ rest) collected EffectInternal.RunBackendTask backendTask -> extractBackendTasksHelp rest (backendTask :: collected) EffectInternal.Exit -> extractBackendTasksHelp rest collected EffectInternal.ExitWithCode _ -> extractBackendTasksHelp rest collected checkForExit : Effect msg -> Maybe Int checkForExit effect = checkForExitHelp [ effect ] checkForExitHelp : List (Effect msg) -> Maybe Int checkForExitHelp remaining = case remaining of [] -> Nothing next :: rest -> case next of EffectInternal.None -> checkForExitHelp rest EffectInternal.Batch effects -> checkForExitHelp (List.reverse effects ++ rest) EffectInternal.RunBackendTask _ -> checkForExitHelp rest EffectInternal.Exit -> Just 0 EffectInternal.ExitWithCode code -> Just code indentScreenText : String -> String indentScreenText screenText = screenText |> String.lines |> List.map (\line -> " " ++ line) |> String.join "\n" {-| Append an assertion description to the most recent snapshot. This doesn't create a new snapshot (the screen hasn't changed); it just annotates the last action with what was checked. -} recordAssertion : String -> State model msg -> State model msg recordAssertion description state = case List.reverse state.snapshots of last :: rest -> { state | snapshots = List.reverse ({ last | assertions = last.assertions ++ [ description ] } :: rest) } [] -> state getFailureMessage : Expectation -> Maybe String getFailureMessage expectation = case Test.Runner.getFailureReason expectation of Just reason -> Just reason.description Nothing -> Nothing {-| Enable model state inspection in snapshots. Pass `Debug.toString` (or any `model -> String` function) and each snapshot will include the pretty-printed model state. Since published packages cannot use `Debug.toString` directly, this must be called from your test code: TuiTest.snapshots counterTest [ TuiTest.withModelToString Debug.toString , TuiTest.pressKey 'k' ] |> List.map .modelState -- [ Just "{ count = 0 }", Just "{ count = 1 }" ] For nicer formatting, use `prettifyValue Debug.toString` from `dillonkearns/elm-snapshot` if you have it as a dependency. -} withModelToString : (model -> String) -> Step model msg withModelToString modelToString = Step (withModelToStringImpl modelToString) withModelToStringImpl : (model -> String) -> TuiTest model msg -> TuiTest model msg withModelToStringImpl modelToString tuiTest = case tuiTest of TuiTest state -> let updatedSnapshots : List Snapshot updatedSnapshots = state.snapshots |> List.map (\snapshot -> { snapshot | modelState = Just (modelToString state.model) } ) in TuiTest { state | modelToString = Just modelToString , snapshots = updatedSnapshots } SetupError _ -> tuiTest {-| Internal: extract the recorded snapshots from a `TuiTest`. Each step records a snapshot of the screen, the action label, and pending-effect state. If the pipeline encountered an error, a final snapshot with the error message is appended. -} extractSnapshots : TuiTest model msg -> List Snapshot extractSnapshots tuiTest = case tuiTest of TuiTest state -> case state.error of Just errorMsg -> let errorScreen : Screen errorScreen = Tui.Screen.text errorMsg in state.snapshots ++ [ { label = "ERROR" , screen = errorScreen , hasPendingEffects = False , modelState = Nothing , assertions = [] } ] Nothing -> state.snapshots SetupError _ -> [] keyEventLabel : KeyEvent -> String keyEventLabel event = let keyName : String keyName = case event.key of Tui.Sub.Character c -> "'" ++ String.fromChar c ++ "'" Tui.Sub.Enter -> "Enter" Tui.Sub.Escape -> "Escape" Tui.Sub.Tab -> "Tab" Tui.Sub.Backspace -> "Backspace" Tui.Sub.Delete -> "Delete" Tui.Sub.Arrow dir -> "Arrow " ++ (case dir of Tui.Sub.Up -> "Up" Tui.Sub.Down -> "Down" Tui.Sub.Left -> "Left" Tui.Sub.Right -> "Right" ) Tui.Sub.FunctionKey n -> "F" ++ String.fromInt n Tui.Sub.Home -> "Home" Tui.Sub.End -> "End" Tui.Sub.PageUp -> "PageUp" Tui.Sub.PageDown -> "PageDown" modPrefix : String modPrefix = event.modifiers |> List.map (\m -> case m of Tui.Sub.Ctrl -> "Ctrl+" Tui.Sub.Alt -> "Alt+" Tui.Sub.Shift -> "Shift+" ) |> String.concat in "pressKey " ++ modPrefix ++ keyName