arowM / tepa / Tepa.Scenario

Module for Scenario-Driven Development.

Core


type Scenario flags memory

Scenario describes how the application reacts to the user operations along the time line.

The Scenario you built can be converted to tests with toTest, and to documents with toMarkdown.

none : Scenario flags m

A Scenario that does nothing.

sequence : List (Scenario flags m) -> Scenario flags m

Return a new Scenario that evaluates given Scenarios sequentially.

toTest : { props : Tepa.ApplicationProps flags memory, sections : List (Section flags memory), origin : { secure : Basics.Bool, hostname : String, port_ : Maybe Basics.Int } } -> Test

Generate scenario tests.

toMarkdown : { title : String, sections : List (Section flags m), config : RenderConfig } -> Result InvalidMarkup String

Generate scenario document markdown text.


type InvalidMarkup
    = InvalidFromJust String
    | InvalidFromOk String
    | NoDependentSection String
    | DuplicatedSection String
    | ParameterNotFound String String

stringifyInvalidMarkup : InvalidMarkup -> String


type alias Section flags memory =
{ title : String
, content : List (Scenario flags memory)
, dependency : Dependency 
}

Titled sequence of Scenarios.

You may want to branch out in the middle of your scenario. In such case, you can declare common section, and refer to the title in dependency parameter:

import TimeZone


-- justinmimbs/timezone-data
myTest : Test
myTest =
    toTest
        { props = Debug.todo "props"
        , sections =
            [ commonScenario
            , caseA
            , caseB
            ]
        }

commonScenario : Section Flags Memory
commonScenario =
    { title = "Common scenario"
    , content =
        [ Debug.todo "Common scenarios"
        , Debug.todo "..."
        ]
    , dependency =
        EntryPoint
            (TimeZone.asia__tokyo ())
            (Time.millisToPosix 1672531200000)
    }

caseA : Section Flags Memory
caseA =
    { title = "Case A"
    , content =
        -- After common scenario,
        [ Debug.todo "User clicks button A"
        , Debug.todo "..."
        ]
    , dependency = RunAfter commonScenario.title
    }

caseB : Section Flags Memory
caseB =
    { title = "Case B"
    , content =
        -- After common scenario,
        [ Debug.todo "User clicks button B"
        , Debug.todo "..."
        ]
    , dependency = RunAfter commonScenario.title
    }


type Dependency
    = EntryPoint Tepa.Time.Zone Tepa.Time.Posix
    | RunAfter String

Dependency of a Section.

User


type User

An application user.

defineUser : { name : String } -> User

Define a user for your Scenario.

Session


type Session

Session is a unit that connects one application instance and its user. Basically, it corresponds to a tab in a browser.

So, for example, if you want to create a scenario where a user opens and operates two tabs, you need to define two separate sessions for the same user:

sakuraChan : User
sakuraChan =
    defineUser
        { name = "Sakura-chan"
        }

sakuraChanMainSession : Session
sakuraChanMainSession =
    defineSession
        { user = sakuraChan
        , name = "Main tab on the Sakura-chan's machine"
        }

sakuraChanSecondSession : Session
sakuraChanSecondSession =
    defineSession
        { user = sakuraChan
        , name = "Second tab on the Sakura-chan's machine"
        }

defineSession : { uniqueName : String, user : User } -> Session

Define a session for your Scenario.

Markup


type Markup

Represents markup for a scenario.

Suppose you have the following Markup, which uses arowM/elm-markdown-ast:

sample : Markup
sample =
    markup """
    `content` for the list item

    `detail` for the list item

    ```json
    {
      "code": "Next `detail` for the list item"
    }
    ```
    """

The sample represents the bellow markdown list item:

- `content` for the list item

    `detail` for the list item

    ```json
    {
      "code": "Next `detail` for the list item"
    }
    ```

You can use hide to skip the item from appearing up in the document, which allows you to generate documents for various targets:

type DocTarget
    = Developer
    | Manager
    | Customer

docLevelDev : DocTarget -> Markup -> Markup
docLevelDev target =
    hide <|
        case target of
            Developer ->
                False

            Manager ->
                True

            Customer ->
                True

myScenario : DocTarget -> Scenario Flags Command Memory Event
myScenario target =
    [ Debug.todo "After some operations..."
    , expectEvents sakuraChanMainSession
        (markup """
          Requests user profile to the server.
          """
            |> docLevelDev
        )
        (Debug.todo "Expectation Here")
    , Debug.todo "..."
    ]

markup : String -> Markup

Constructor for Markup.

Markup:

markup "No detail"

Rendered to:

- No detail

Markup:

markup """
This is **content**.
You can also provide detail informations.

```json
{
  "foo": 3
}
```
"""

Rendered:

- This is **content**.

    You can also provide detail informations.

    ```json
    {
      "foo": 3
    }
    ```

Markup:

markup """
You can interpolate value by `\\{{key}}` syntax.
Here is the sample: {{message}}
By appending `|raw`, you can avoid sanitizing: {{message|raw}}

The `\\{{key|json}}` syntax can embed the JSON structure from a string generated by `Debug.toString`.
Sample:

{{sampleStructure|json}}
"""
|> setParam "message" "Hello!"
|> setParam "sampleStructure"
    ( Debug.toString <|
        { "foo": 3
        , "bar": "baz"
        }
    )

Rendered:

- You can interpolate value by `{{key}}` syntax.

    Here is the sample: Hello\!
    By appending `|raw`, you can avoid sanitizing: Hello!

    The `{{key|json}}` syntax can embed the JSON structure from a string generated by `Debug.toString`.
    Sample:

    ```json
    {
        "foo": 3,
        "bar": "baz"
    }
    ```

hide : Basics.Bool -> Markup -> Markup

Hide the Markup from rendered scenario document.

setParam : String -> String -> Markup -> Markup

Specify values for embedded variables.

modifyContent : (String -> String) -> Markup -> Markup

Modify Markup content part.

Markup:

markup """
This is **content**.
You can also provide detail informations.

```json
{
  "foo": 3
}
```
"""
    |> modifyContent (String.prepend "(notation) ")

Rendered to:

- (notation) This is **content**.

    You can also provide detail informations.

    ```json
    {
      "foo": 3
    }
    ```

Primitives

Comments

userComment : User -> String -> Scenario flags m

User comment.

myScenario =
    [ userComment sakuraChan
        "Hi. I'm Sakura-chan, the cutest goat girl in the world."
    , userComment sakuraChan
        "Today I'll try a goat management service."
    , Debug.todo "..."
    ]

This Scenario only affects document generation, and is ignored for scenario test generation.

You can start with userComment and systemComment to build the skeleton of your scenario, and gradually replace userComment with Event Simulator and systemComment with Expectation.

systemComment : Session -> String -> Scenario flags m

System comment.

This Scenario only affects document generation, and is ignored for scenario test generation.

comment : Markup -> Scenario flags m

Lower level function to add detailed comments.

Stubs

todo : Session -> Markup -> Scenario flags m

Generates documentation, but the test always fails.

You can create a scenario first with todo and later replace that todo with an actual test, which is the scenario driven development.

userTodo : Session -> Markup -> Scenario flags m

Similar todo, but for user actions.

It prepends the username to the markup.

Expectations

expectMemory : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), expectation : m1 -> Expectation } -> Scenario flags m

Describe your expectations for the application memory state at the point.

Suppose your application has a counter:

import Expect

myScenario =
    [ Debug.todo "After some operations..."
    , expectMemory sakuraChanMainSession
        (markup "The counter must be less than four.")
        { layer =
            pageHomeLayer
        , expectation =
            \pageHomeMemory ->
                pageHomeMemory.counter
                    |> Expect.lessThan 4
        }
    , Debug.todo "..."
    ]

You use Expect module to describe your expectation.

During the initialization phase of the application, or if no Layers are found for the query, it fails the test.

expectAppView : Session -> Markup -> { expectation : Tepa.Document -> Expectation } -> Scenario flags m

Describe your expectations for the application's view at the point.

Suppose your application has a popup:

import Html.Attribute exposing (attribute)
import Test.Html.Query as Query
import Test.Html.Selector as Selector

myScenario =
    [ Debug.todo "After some operations..."
    , expectAppView sakuraChanMainSession
        (markup "Show popup message.")
        { expectation =
            \{ body } ->
                Query.fromHtml (Html.div [] body)
                    |> Query.find [ Selector.id "popup" ]
                    |> Query.has
                        [ Selector.attribute
                            (attribute "aria-hidden" "false")
                        ]
        }
    , Debug.todo "..."
    ]

You use Expect module to describe your expectation.

Note that the expectation field takes whole page view even if you use it in onLayer function.

onLayer popup
    [ expectAppView sakuraChanMainSession
        (markup "expectation about the whole application view")
        { expectation =
            \html ->
                Debug.todo
                    "the argument is not the partial view for the Layer, but for the whole page."
        }
    , Debug.todo "..."
    ]

expectValues : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), expectation : Dict String String -> Expectation } -> Scenario flags m

Describe your expectations for the user input values in the specified Layer at the point.

You use Expect module to describe your expectation.

During the initialization phase of the application, or if no Layers are found for the query, it fails the test.

expectCurrentTime : Markup -> { expectation : Tepa.Time.Posix -> Expectation } -> Scenario flags m

Describe your expectations for the emulated current time of the application.

Suppose you want to check current time after sleep.

import Expect
import Tepa.Scenario as Scenario
import Time
import TimeZone -- justinmimbs/timezone-data


sample : Section Flags Memory
sample =
    { title = "Sample Scenario"
    , dependency =
        Scenario.EntryPoint
            (TimeZone.asia__tokyo ())
            (Time.millisToPosix 1672531200000)
    , content =
        [ userComment sakuraChan "I'm trying to access the Goat SNS."
        , Scenario.sleep (Scenario.markup "Passing one minutes.")
        , userComment sakuraChan "Oops, I've slept a little."
        , let
            curr = Time.millisToPosix <| 1672531200000 + 1 * 60 * 1000
          in
          Scenario.expectCurrentTime
            (Scenario.markup <| "Current time is: " ++ formatPosix curr ++ ".")
            { expectation =
                Expect.equal curr
            }
        ]

You use Expect module to describe your expectation.

expectHttpRequest : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), expectation : List HttpRequest -> Expectation } -> Scenario flags m

Describe your expectations for the unresolved HTTP requests at the time.

You use Expect module to describe your expectation.

During the initialization phase of the application, the layer parameter is ignored, so it is common to set \_ -> Nothing in such case. After initialization, if no Layers are found for the query, it fails the test.

expectPortRequest : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), expectation : List PortRequest -> Expectation } -> Scenario flags m

Describe your expectations for the unresolved Port requests at the time.

You use Expect module to describe your expectation.

During the initialization phase of the application, the layer parameter is ignored, so it is common to set \_ -> Nothing in such case. After initialization, if no Layers are found for the query, it fails the test.

expectRandomRequest : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), spec : Tepa.Random.Spec a } -> Scenario flags m

Describe your expectations for the unresolved Random requests at the time.

You pass Tepa.Random.Spec to specify your expected request.

During the initialization phase of the application, the layer parameter is ignored, so it is common to set \_ -> Nothing in such case. After initialization, if no Layers are found for the query, it fails the test.

Helper functions to specify Layer

appLayer : Tepa.Layer m -> Maybe (Tepa.Layer m)

Specifies the application root layer, which is just an alias for Just.

childLayer : (m1 -> Maybe (Tepa.Layer m2)) -> (Tepa.Layer m -> Maybe (Tepa.Layer m1)) -> Tepa.Layer m -> Maybe (Tepa.Layer m2)

Helper function to specify child layer.

type alias Parent =
    { child : Maybe (Layer Child)
    }

myParentLayer : m -> Maybe (Layer Parent)
myParentLayer =
    Debug.todo "parent Layer"

myChildLayer : m -> Maybe (Layer Child)
myChildLayer =
    myParentLayer
        |> childLayer .child

mapLayer : (m1 -> m2) -> (Tepa.Layer m -> Maybe (Tepa.Layer m1)) -> Tepa.Layer m -> Maybe (Tepa.Layer m2)

Event Simulators

loadApp : Session -> Markup -> { path : AppUrl, flags : Json.Encode.Value } -> Scenario flags m

Load the app. You can also reload the app by calling loadApp.

import AppUrl exposing (AppUrl)
import Dict
import Json.Encode as JE

myScenario =
    [ userComment sakuraChan
        "Hi. I'm Sakura-chan, the cutest goat girl in the world."
    , userComment sakuraChan
        "I'll open the home page..."
    , loadApp sakuraChanMainSession
        (markup "Load the home page.")
        { path =
            { path = []
            , query = Dict.empty
            , fragment = Nothing
            }
        , flags =
            JE.object []
        }
    , systemComment sakuraChanMainSession
        "Show home page."
    , userComment sakuraChan
        "Oops, I accidentally hit the F5 button..."
    , loadApp sakuraChanMainSession
        (markup "Reload the page.")
        { path =
            { path = []
            , query = Dict.empty
            , fragment = Nothing
            }
        , flags =
            JE.object []
        }
    , Debug.todo "..."
    ]

closeApp : Session -> Markup -> Scenario flags m

Close the app.

userOperation : Session -> Markup -> { query : Test.Html.Query.Single Tepa.Msg -> Test.Html.Query.Single Tepa.Msg, operation : ( String, Json.Encode.Value ) } -> Scenario flags m

About options:

Note that current version does not trigger page transition on clicking anchor elements. Alternatively, you can check the anchor has expected href value with expectAppView, and pushPath to the path.

sleep : Markup -> Basics.Int -> Scenario flags m

Wait for given micro seconds.

It only affects Promises defined in Tepa.Time, so you should not use Time module and Process.sleep with TEPA._

Http response Simulators

httpResponse : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), response : HttpRequest -> Maybe ( Http.Metadata, String ) } -> Scenario flags m

Simulate response to the Tepa.Http.request and Tepa.Http.bytesRequest.

During the initialization phase of the application, the layer parameter is ignored, so it is common to set \_ -> Nothing in such case. After initialization, if no Layers are found for the query, it does nothing and just passes the test.

httpBytesResponse : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), response : HttpRequest -> Maybe ( Http.Metadata, Bytes ) } -> Scenario flags m

Similar to httpResponse, but responds with Bytes.


type alias HttpRequest =
{ method : String
, headers : Dict String String
, url : String
, requestBody : HttpRequestBody 
}

Http request that your web API server will receive:

Note: It is possible for a request to have the same header multiple times. In that case, all the values end up in a single entry in the headers dictionary. The values are separated by commas.


type HttpRequestBody
    = EmptyHttpRequestBody
    | StringHttpRequestBody String String
    | JsonHttpRequestBody Json.Encode.Value
    | FileHttpRequestBody File
    | BytesHttpRequestBody String Bytes

Port response Simulators

portResponse : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), response : PortRequest -> Maybe Json.Encode.Value } -> Scenario flags m

Simulate response to the Tepa.portRequest or Tepa.listenPortStream.

Suppose your application requests to access localStorage via port request named "Port to get page.account.bio":

import Json.Encode as JE

myScenario =
    [ Debug.todo "After request to the port..."
    , portResponse sakuraChanMainSession
        (markup "Received response.")
        { layer = "Port to get page.account.bio"
        , response =
            \_ ->
                JE.string "I'm Sakura-chan."
        }
    , Debug.todo "..."
    ]

During the initialization phase of the application, the layer parameter is ignored, so it is common to set \_ -> Nothing in such case. After initialization, if no Layers are found for the query, it does nothing and just passes the test.


type alias PortRequest =
{ name : String
, requestBody : Json.Encode.Value 
}

Port request:

Random response Simulators

randomResponse : Session -> Markup -> { layer : Tepa.Layer m -> Maybe (Tepa.Layer m1), spec : Tepa.Random.Spec a, response : a } -> Scenario flags m

Simulate response to the Tepa.Random.request.

Suppose your application requests random integer:

import Tepa.Random as Random
import Tepa.Scenario as Scenario

oneToTen : Random.Spec Int
oneToTen =
    Random.int 1 10

respondToOneToTenInt : Scenario flags m
respondToOneToTenInt =
    Scenario.randomResponse
        mySession
        (Scenario.markup "Respond `1` to the first unresolved `oneToTen` request.")
        { layer = myLayer
        , spec = oneToTen
        , value = 1
        }

-- If there is no unresolved `oneToTen` request at the time, the test fails.

During the initialization phase of the application, the layer parameter is ignored, so it is common to set \_ -> Nothing in such case. After initialization, if no Layers are found for the query, it does nothing and just passes the test.

Browser Simulators

forward : Session -> Markup -> Scenario flags m

Simulate borwser forward event.

If there are no pages to forward, the test fails.

back : Session -> Markup -> Scenario flags m

Simulate borwser back event.

If there are no pages to back, the test fails.

pushPath : Session -> AppUrl -> Markup -> Scenario flags m

Simulate page transition.

Conditions

fromJust : String -> Maybe a -> (a -> List (Scenario flags m)) -> Scenario flags m

Extract Just value.

If the given value is Nothing, document generation and tests fails.

import Url

myScenario =
    [ Debug.todo "After some operations..."
    , fromJust "Make URL"
        (Url.fromString "https://example.com/foo/")
      <|
        \url ->
            [ Debug.todo "Scenarios that use `url`"
            ]
    , Debug.todo "..."
    ]

fromOk : String -> Result err a -> (a -> List (Scenario flags m)) -> Scenario flags m

Similar to fromJust, but extract Ok valur from Result.

RenderConfig


type alias RenderConfig =
{ entryPointFirstListItem : Tepa.Time.Zone -> Tepa.Time.Posix -> Markup
, dependentScenarioFirstListItem : { href : String
, name : String } -> Markup
, processSessionScenario : { uniqueSessionName : String } -> Markup -> Markup
, processSystemScenario : { uniqueSessionName : String } -> Markup -> Markup
, processUserScenario : { uniqueSessionName : String
, userName : String } -> Markup -> Markup 
}

Configuration for rendering scenario.

en_US : RenderConfig

Standard configuration for en_US.

ja_JP : RenderConfig

Standard configuration for ja_JP.