Copy vendored elm-program-test as starting point for custom elm-pages state.

This commit is contained in:
Dillon Kearns 2022-03-16 14:00:14 -07:00
parent 0b7e6053c9
commit f541e5576b
22 changed files with 4644 additions and 1 deletions

View File

@ -0,0 +1,49 @@
module MultiDict exposing (MultiDict, empty, get, insert, keys, remove, set)
import Dict exposing (Dict)
import List.Extra
import List.Nonempty as NonEmpty
type alias NonEmpty a =
NonEmpty.Nonempty a
type MultiDict k v
= MultiDict (Dict k (NonEmpty v))
empty : MultiDict k v
empty =
MultiDict Dict.empty
insert : comparable -> v -> MultiDict comparable v -> MultiDict comparable v
insert key value (MultiDict dict) =
MultiDict
(Dict.update key (Maybe.map (NonEmpty.cons value) >> Maybe.withDefault (NonEmpty.fromElement value) >> Just) dict)
get : comparable -> MultiDict comparable v -> List v
get key (MultiDict dict) =
Dict.get key dict
|> Maybe.map NonEmpty.toList
|> Maybe.withDefault []
keys : MultiDict k v -> List k
keys (MultiDict dict) =
Dict.toList dict
|> List.concatMap (\( k, vs ) -> List.repeat (NonEmpty.length vs) k)
remove : comparable -> v -> MultiDict comparable v -> MultiDict comparable v
remove key value (MultiDict dict) =
MultiDict
(Dict.update key (Maybe.andThen (NonEmpty.toList >> List.Extra.remove value >> NonEmpty.fromList)) dict)
set : comparable -> List v -> MultiDict comparable v -> MultiDict comparable v
set key values (MultiDict dict) =
MultiDict
(Dict.update key (\_ -> NonEmpty.fromList values) dict)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
module PairingHeap exposing
( PairingHeap, empty
, insert, merge, findMin, deleteMin
, fromList, toSortedList
)
{-| This is a simple pairing heap implementation written in Elm usable as a priority queue. This code is
based heavily on the pseudocode available at [the Wikipedia page](https://en.wikipedia.org/wiki/Pairing_heap).
# Type and Constructor
@docs PairingHeap, empty
# Operations
@docs insert, merge, findMin, deleteMin
# Convenience functions
@docs fromList, toSortedList
-}
{-| A `PairingHeap` has comparable keys and values of an arbitrary type.
-}
type PairingHeap comparable a
= Empty
| Heap comparable a (List (PairingHeap comparable a))
{-| Create an empty PairingHeap.
-}
empty : PairingHeap comparable a
empty =
Empty
{-| Find the minimum value in a heap returning Nothing if the heap is empty.
Complexity: O(1)
findMin (fromList [ ( 10, () ), ( 3, () ), ( 8, () ) ]) == Just 3
-}
findMin : PairingHeap comparable a -> Maybe ( comparable, a )
findMin x =
case x of
Empty ->
Nothing
Heap k v _ ->
Just ( k, v )
{-| Merges two `PairingHeap`s together into one new heap containing all of the key-value pairs from both inputs.
Complexity: O(1)
-}
merge : PairingHeap comparable a -> PairingHeap comparable a -> PairingHeap comparable a
merge heap1 heap2 =
case ( heap1, heap2 ) of
( Empty, _ ) ->
heap2
( _, Empty ) ->
heap1
( Heap k1 v1 hs1, Heap k2 v2 hs2 ) ->
if k1 < k2 then
Heap k1 v1 (heap2 :: hs1)
else
Heap k2 v2 (heap1 :: hs2)
{-| Inserts a new element into a `PairingHeap`.
Complexity: O(1)
-}
insert : comparable -> a -> PairingHeap comparable a -> PairingHeap comparable a
insert k v heap =
merge (Heap k v []) heap
{-| Removes the minimum element from a `PairingHeap` returning a new heap without that element.
This will return an empty heap if given an empty heap as input.
Complexity: O(log n)
-}
deleteMin : PairingHeap comparable a -> PairingHeap comparable a
deleteMin heap =
case heap of
Empty ->
Empty
Heap k v heaps ->
mergePairs heaps
{-| This is an internal function used by deleteMin.
-}
mergePairs : List (PairingHeap comparable a) -> PairingHeap comparable a
mergePairs heaps =
case heaps of
[] ->
Empty
x :: [] ->
x
x :: (y :: xs) ->
merge (merge x y) (mergePairs xs)
-- Extra convenience functions
{-| This function turns a list of key-value pairs into a `PairingHeap`.
Complexity: O(n)
-}
fromList : List ( comparable, a ) -> PairingHeap comparable a
fromList =
List.foldl (\( k, v ) -> insert k v) empty
{-| This function turns a `PairingHeap` into a sorted list of key-value pairs.
Complexity: O(n log n)
-}
toSortedList : PairingHeap comparable a -> List ( comparable, a )
toSortedList heap =
case heap of
Empty ->
[]
Heap k v _ ->
( k, v ) :: toSortedList (deleteMin heap)

View File

@ -0,0 +1,69 @@
module Parser.Extra exposing (deadEndsToString)
{-| [No implementation for deadEndsToString · Issue #9 · elm/parser](https://github.com/elm/parser/issues/9)
-}
import Parser exposing (DeadEnd, Problem(..))
deadEndsToString : List DeadEnd -> String
deadEndsToString deadEnds =
String.join "\n" (List.map deadEndToString deadEnds)
deadEndToString : DeadEnd -> String
deadEndToString deadEnd =
problemToString deadEnd.problem
++ " at "
++ deadEndToRowColString deadEnd
problemToString : Problem -> String
problemToString prob =
case prob of
Expecting s ->
"Expecting " ++ s
ExpectingInt ->
"Expecting Int"
ExpectingHex ->
"Expecting Hex"
ExpectingOctal ->
"Expecting Octal"
ExpectingBinary ->
"Expecting Binary"
ExpectingFloat ->
"Expecting Float"
ExpectingNumber ->
"Expecting Number"
ExpectingVariable ->
"Expecting Variable"
ExpectingSymbol s ->
"Expecting Symbol " ++ s
ExpectingKeyword s ->
"Expecting Keyword " ++ s
ExpectingEnd ->
"Expecting End"
UnexpectedChar ->
"Unexpected Char"
Problem s ->
"Problem: " ++ s
BadRepeat ->
"Bad Repeat"
deadEndToRowColString : DeadEnd -> String
deadEndToRowColString deadEnd =
"row " ++ String.fromInt deadEnd.row ++ ", " ++ "col " ++ String.fromInt deadEnd.col

View File

@ -0,0 +1,33 @@
module Parser.Extra.String exposing (string)
import Parser exposing (..)
string : Parser String
string =
succeed identity
|. token "\""
|= loop [] stringHelp
stringHelp : List String -> Parser (Step (List String) String)
stringHelp revChunks =
oneOf
[ succeed (\chunk -> Loop (chunk :: revChunks))
|. token "\\"
|= oneOf
[ map (\_ -> "\n") (token "n")
, map (\_ -> "\t") (token "t")
, map (\_ -> "\u{000D}") (token "r")
]
, token "\""
|> map (\_ -> Done (String.join "" (List.reverse revChunks)))
, chompWhile isUninteresting
|> getChompedString
|> map (\chunk -> Loop (chunk :: revChunks))
]
isUninteresting : Char -> Bool
isUninteresting char =
char /= '\\' && char /= '"'

View File

@ -0,0 +1,369 @@
module ProgramTest.ComplexQuery exposing (ComplexQuery, Failure(..), FailureContext, FailureContext1(..), Highlight, Priority, check, exactlyOneOf, find, findButNot, run, simulate, succeed)
import Json.Encode as Json
import ProgramTest.TestHtmlHacks as TestHtmlHacks
import ProgramTest.TestHtmlParser as TestHtmlParser
import Set exposing (Set)
import Test.Html.Event
import Test.Html.Query as Query
import Test.Html.Selector as Selector exposing (Selector)
import Test.Runner
type ComplexQuery a
= QueryResult State Highlight (List FailureContext1) (Result Failure a)
succeed : a -> ComplexQuery a
succeed a =
QueryResult initState Set.empty [] (Ok a)
type alias Priority =
Int
type alias State =
{ priority : Priority
}
initState : State
initState =
{ priority = 0
}
type Failure
= QueryFailed (List (Result String String))
| SimulateFailed String
| NoMatches String (List ( String, Priority, ( List FailureContext1, Failure ) ))
| TooManyMatches String (List ( String, Priority, List FailureContext1 ))
type alias FailureContext =
List FailureContext1
type FailureContext1
= FindSucceeded (Maybe String) (() -> List String)
| CheckSucceeded String (List FailureContext1)
| Description (Result String String)
type alias Highlight =
Set String
run : ComplexQuery a -> ( Highlight, Result ( List FailureContext1, Failure ) a )
run (QueryResult _ highlight context result) =
( highlight
, case result of
Ok a ->
Ok a
Err error ->
Err ( List.reverse context, error )
)
find : Maybe String -> List String -> List Selector -> ComplexQuery (Query.Single msg) -> ComplexQuery (Query.Single msg)
find description highlight selectors prev =
case prev of
QueryResult _ _ _ (Err _) ->
prev
QueryResult state prevHighlight prevContext (Ok source) ->
case Test.Runner.getFailureReason (Query.has [ Selector.all selectors ] source) of
Just _ ->
let
error =
firstErrorOf source
[ selectors
, [ Selector.all selectors ]
]
context =
case description of
Nothing ->
[]
Just desc ->
[ Description (Err desc) ]
in
QueryResult
{ state
| priority = state.priority + countSuccesses error
}
(Set.union (Set.fromList highlight) prevHighlight)
(context ++ prevContext)
(Err (QueryFailed error))
Nothing ->
QueryResult
{ state
| priority = state.priority + List.length selectors
}
(Set.union (Set.fromList highlight) prevHighlight)
(FindSucceeded description (\() -> TestHtmlHacks.getPassingSelectors selectors source) :: prevContext)
(Ok (Query.find selectors source))
exactlyOneOf : String -> List ( String, ComplexQuery a -> ComplexQuery b ) -> ComplexQuery a -> ComplexQuery b
exactlyOneOf description options prev =
case prev of
QueryResult state prevHighlight prevContext (Err err) ->
QueryResult state prevHighlight prevContext (Err err)
QueryResult state prevHighlight prevContext (Ok _) ->
let
results : List ( String, ComplexQuery b )
results =
List.map (Tuple.mapSecond (\option -> option prev)) options
successes : List ( String, ComplexQuery b )
successes =
List.filter (isSuccess << Tuple.second) results
isSuccess res =
case res of
QueryResult _ _ _ (Err _) ->
False
QueryResult _ _ _ (Ok _) ->
True
collectHighlight (QueryResult _ highlight _ _) =
highlight
highlights =
List.map (collectHighlight << Tuple.second) results
|> List.foldl Set.union Set.empty
collectError ( desc, QueryResult newState _ context result ) =
case result of
Ok _ ->
Nothing
Err x ->
Just
( desc
, newState.priority
, ( List.reverse context, x )
)
in
case successes of
[ ( _, one ) ] ->
one
[] ->
let
failures =
List.filterMap collectError results
in
QueryResult
state
(Set.union highlights prevHighlight)
prevContext
(Err (NoMatches description failures))
many ->
let
matches =
List.map
(\( desc, QueryResult newState _ context _ ) ->
( desc, newState.priority, context )
)
many
in
QueryResult
state
(Set.union highlights prevHighlight)
prevContext
(Err (TooManyMatches description matches))
{-|
- `good`: the primary selector that must match
- `bads`: a list of selectors that must NOT match
- `onError`: the selector to use to produce an error message if any of the checks fail
-}
findButNot :
Maybe String
-> List String
->
{ good : List Selector
, bads : List (List Selector)
, onError : List Selector
}
-> ComplexQuery (Query.Single msg)
-> ComplexQuery (Query.Single msg)
findButNot description highlight { good, bads, onError } prev =
case prev of
QueryResult _ _ _ (Err _) ->
prev
QueryResult state prevHighlight prevContext (Ok source) ->
-- This is tricky because Test.Html doesn't provide a way to search for an attribute being *not* present.
-- So we have to check if a selector we don't want *is* present, and manually force a failure if it is.
let
addDescription =
case description of
Nothing ->
[]
Just desc ->
[ Description (Err desc) ]
checkBads : Priority -> List (List Selector) -> Query.Single msg -> ComplexQuery (Query.Single msg)
checkBads extraPriority bads_ found =
case bads_ of
[] ->
QueryResult
{ state | priority = state.priority + extraPriority + 1 }
(Set.union (Set.fromList highlight) prevHighlight)
-- TODO: add the not bads to the context (or alternatively, add the "onErrors", but convert them all to successes)
(FindSucceeded description (\() -> TestHtmlHacks.getPassingSelectors good source) :: prevContext)
(Ok found)
nextBad :: rest ->
let
isBad =
Query.has [ Selector.all nextBad ] source
in
case Test.Runner.getFailureReason isBad of
Nothing ->
-- the element matches the bad selectors; produce a Query using the onError selectors that will fail that will show a reasonable failure message
let
error =
firstErrorOf source
[ good
, [ Selector.all good ]
, onError
, [ Selector.all onError ]
]
in
QueryResult
{ state | priority = state.priority + extraPriority + countSuccesses error }
(Set.union (Set.fromList highlight) prevHighlight)
(addDescription ++ prevContext)
(Err (QueryFailed error))
Just _ ->
-- the element we found is not bad; continue on to the next check
checkBads (extraPriority + List.length nextBad) rest found
isGood =
Query.has [ Selector.all good ] source
in
case Test.Runner.getFailureReason isGood of
Just _ ->
-- Couldn't find it, so report the best error message we can
let
error =
firstErrorOf source
[ good
, [ Selector.all good ]
]
in
QueryResult
{ state | priority = state.priority + countSuccesses error }
(Set.union (Set.fromList highlight) prevHighlight)
prevContext
(Err (QueryFailed error))
Nothing ->
Query.find good source
|> checkBads (List.length good) bads
simulate : ( String, Json.Value ) -> ComplexQuery (Query.Single msg) -> ComplexQuery msg
simulate event prev =
case prev of
QueryResult state prevHighlight prevContext (Err err) ->
QueryResult state prevHighlight prevContext (Err err)
QueryResult state prevHighlight prevContext (Ok source) ->
case
source
|> Test.Html.Event.simulate event
|> Test.Html.Event.toResult
of
Err message ->
QueryResult
state
prevHighlight
(Description (Err ("simulate " ++ Tuple.first event)) :: prevContext)
(Err (SimulateFailed (TestHtmlHacks.parseSimulateFailure message)))
Ok msg ->
QueryResult state prevHighlight prevContext (Ok msg)
{-| Ensure that the given query succeeds, but then ignore its result.
-}
check : String -> (ComplexQuery a -> ComplexQuery ignored) -> ComplexQuery a -> ComplexQuery a
check description checkQuery prev =
case prev of
QueryResult _ _ _ (Err _) ->
prev
QueryResult state prevHighlight prevContext (Ok source) ->
let
(QueryResult checkedState highlight checkContext checkResult) =
checkQuery (QueryResult state prevHighlight [] (Ok source))
in
case checkResult of
Err failure ->
QueryResult
checkedState
(Set.union highlight prevHighlight)
(Description (Err description) :: checkContext ++ prevContext)
(Err failure)
Ok _ ->
QueryResult
{ state | priority = checkedState.priority }
(Set.union highlight prevHighlight)
(CheckSucceeded description checkContext :: prevContext)
(Ok source)
firstErrorOf : Query.Single msg -> List (List Selector) -> List (Result String String)
firstErrorOf source choices =
case choices of
[] ->
[ Err "PLEASE REPORT THIS AT <https://github.com/avh4/elm-program-test/issues>: firstErrorOf: asked to report an error but none of the choices failed" ]
next :: rest ->
case Test.Runner.getFailureReason (Query.has next source) of
Just reason ->
case TestHtmlHacks.parseFailureReportWithoutHtml reason.description of
Ok (TestHtmlParser.QueryFailure _ _ (TestHtmlParser.Has _ results)) ->
results
Ok (TestHtmlParser.EventFailure name _) ->
[ Err ("PLEASE REPORT THIS AT <https://github.com/avh4/elm-program-test/issues>: firstErrorOf: got unexpected EventFailure \"" ++ name ++ "\"") ]
Err err ->
[ Err ("PLEASE REPORT THIS AT <https://github.com/avh4/elm-program-test/issues>: firstErrorOf: couldn't parse failure report: " ++ err) ]
Nothing ->
firstErrorOf source rest
countSuccesses : List (Result String String) -> Int
countSuccesses results =
List.length (List.filter isOk results)
isOk : Result x a -> Bool
isOk result =
case result of
Ok _ ->
True
Err _ ->
False

View File

@ -0,0 +1,122 @@
module ProgramTest.EffectSimulation exposing
( EffectSimulation
, SimulationState
, clearOutgoingPortValues
, emptySimulationState
, init
, outgoingPortValues
, queueTask
, stepWorkQueue
)
import Dict exposing (Dict)
import Fifo exposing (Fifo)
import Json.Encode
import MultiDict exposing (MultiDict)
import PairingHeap exposing (PairingHeap)
import SimulatedEffect exposing (SimulatedEffect, SimulatedTask)
import Time
type alias EffectSimulation msg effect =
{ deconstructEffect :
effect
-> SimulatedEffect msg -- TODO: this should not be in here
, workQueue : Fifo (SimulatedTask msg msg)
, state : SimulationState msg
, outgoingPortValues : Dict String (List Json.Encode.Value)
}
init : (effect -> SimulatedEffect msg) -> EffectSimulation msg effect
init f =
{ deconstructEffect = f
, workQueue = Fifo.empty
, state = emptySimulationState
, outgoingPortValues = Dict.empty
}
type alias SimulationState msg =
{ http : MultiDict ( String, String ) (SimulatedEffect.HttpRequest msg msg)
, futureTasks : PairingHeap Int (() -> SimulatedTask msg msg)
, nowMs : Int
}
emptySimulationState : SimulationState msg
emptySimulationState =
{ http = MultiDict.empty
, futureTasks = PairingHeap.empty
, nowMs = 0
}
queueTask : SimulatedTask msg msg -> EffectSimulation msg effect -> EffectSimulation msg effect
queueTask task simulation =
{ simulation
| workQueue = Fifo.insert task simulation.workQueue
}
stepWorkQueue : EffectSimulation msg effect -> Maybe ( EffectSimulation msg effect, Maybe msg )
stepWorkQueue simulation =
case Fifo.remove simulation.workQueue of
( Nothing, _ ) ->
Nothing
( Just task, rest ) ->
let
( newState, msg ) =
simulateTask task simulation.state
in
Just
( { simulation
| workQueue = rest
, state = newState
}
, msg
)
simulateTask : SimulatedTask msg msg -> SimulationState msg -> ( SimulationState msg, Maybe msg )
simulateTask task simulationState =
case task of
SimulatedEffect.Succeed msg ->
( simulationState, Just msg )
SimulatedEffect.Fail msg ->
( simulationState, Just msg )
SimulatedEffect.HttpTask request ->
( { simulationState
| http =
MultiDict.insert ( request.method, request.url )
request
simulationState.http
}
, Nothing
)
SimulatedEffect.SleepTask delay onResult ->
( { simulationState
| futureTasks =
PairingHeap.insert (simulationState.nowMs + round delay) onResult simulationState.futureTasks
}
, Nothing
)
SimulatedEffect.NowTask onResult ->
simulateTask (onResult (Time.millisToPosix simulationState.nowMs)) simulationState
outgoingPortValues : String -> EffectSimulation msg effect -> List Json.Encode.Value
outgoingPortValues portName simulation =
Dict.get portName simulation.outgoingPortValues
|> Maybe.withDefault []
|> List.reverse
clearOutgoingPortValues : String -> EffectSimulation msg effect -> EffectSimulation msg effect
clearOutgoingPortValues portName simulation =
{ simulation | outgoingPortValues = Dict.remove portName simulation.outgoingPortValues }

View File

@ -0,0 +1,360 @@
module ProgramTest.Failure exposing (Failure(..), toString)
import Html exposing (Html)
import ProgramTest.ComplexQuery as ComplexQuery exposing (Failure(..), FailureContext1(..))
import ProgramTest.TestHtmlHacks as TestHtmlHacks
import Set
import String.Extra
import Test.Html.Query as Query
import Test.Runner.Failure
import Url exposing (Url)
type Failure
= ChangedPage String Url
-- Errors
| ExpectFailed String String Test.Runner.Failure.Reason
| SimulateFailed String String
| SimulateFailedToFindTarget String String
| SimulateLastEffectFailed String
| InvalidLocationUrl String String
| InvalidFlags String String
| ProgramDoesNotSupportNavigation String
| NoBaseUrl String String
| NoMatchingHttpRequest Int Int String { method : String, url : String } (List ( String, String ))
| MultipleMatchingHttpRequest Int Int String { method : String, url : String } (List ( String, String ))
| EffectSimulationNotConfigured String
| ViewAssertionFailed String (Html ()) ComplexQuery.Highlight ( ComplexQuery.FailureContext, ComplexQuery.Failure )
| CustomFailure String String
toString : Failure -> String
toString failure =
case failure of
ChangedPage cause finalLocation ->
cause ++ " caused the program to end by navigating to " ++ String.Extra.escape (Url.toString finalLocation) ++ ". NOTE: If this is what you intended, use ProgramTest.expectPageChange to end your test."
ExpectFailed expectationName description reason ->
expectationName ++ ":\n" ++ Test.Runner.Failure.format description reason
SimulateFailed functionName message ->
functionName ++ ":\n" ++ message
SimulateFailedToFindTarget functionName message ->
functionName ++ ":\n" ++ message
SimulateLastEffectFailed message ->
"simulateLastEffect failed: " ++ message
InvalidLocationUrl functionName invalidUrl ->
functionName ++ ": " ++ "Not a valid absolute URL:\n" ++ String.Extra.escape invalidUrl
InvalidFlags functionName message ->
functionName ++ ":\n" ++ message
ProgramDoesNotSupportNavigation functionName ->
functionName ++ ": Program does not support navigation. Use ProgramTest.createApplication to create a ProgramTest that supports navigation."
NoBaseUrl functionName relativeUrl ->
functionName ++ ": The ProgramTest does not have a base URL and cannot resolve the relative URL " ++ String.Extra.escape relativeUrl ++ ". Use ProgramTest.withBaseUrl before calling ProgramTest.start to create a ProgramTest that can resolve relative URLs."
NoMatchingHttpRequest expected actual functionName request pendingRequests ->
String.concat
[ functionName
, ": "
, "Expected "
, case expected of
1 ->
"HTTP request"
_ ->
"at least " ++ String.fromInt expected ++ " HTTP requests"
, " ("
, request.method
, " "
, request.url
, ") to have been made and still be pending, "
, case actual of
0 ->
"but no such requests were made."
_ ->
"but only " ++ String.fromInt actual ++ " such requests were made."
, "\n"
, case pendingRequests of
[] ->
" No requests were made."
_ ->
String.concat
[ " The following requests were made:\n"
, String.join "\n" <|
List.map (\( method, url ) -> " - " ++ method ++ " " ++ url) pendingRequests
]
]
MultipleMatchingHttpRequest expected actual functionName request pendingRequests ->
String.concat
[ functionName
, ": "
, "Expected "
, case expected of
1 ->
"a single HTTP request"
_ ->
String.fromInt expected ++ " HTTP requests"
, " ("
, request.method
, " "
, request.url
, ") to have been made, but "
, String.fromInt actual
, " such requests were made.\n"
, case pendingRequests of
[] ->
" No requests were made."
_ ->
String.concat
[ " The following requests were made:\n"
, String.join "\n" <|
List.map (\( method, url ) -> " - " ++ method ++ " " ++ url) pendingRequests
]
, if expected == 1 && actual > 1 then
let
useInstead =
if String.startsWith "simulate" functionName then
"simulateHttpResponseAdvanced"
else if String.startsWith "expect" functionName then
"expectHttpRequests"
else
"ensureHttpRequests"
in
"\n\nNOTE: If you want to allow multiple requests to the same endpoint, use ProgramTest." ++ useInstead ++ "."
else
""
]
EffectSimulationNotConfigured functionName ->
"TEST SETUP ERROR: In order to use " ++ functionName ++ ", you MUST use ProgramTest.withSimulatedEffects before calling ProgramTest.start"
ViewAssertionFailed functionName html highlight reason ->
let
highlighter =
if Set.isEmpty highlight then
\_ _ _ -> True
else
\tag attrs children ->
Set.member tag highlight
in
String.join "\n"
[ TestHtmlHacks.renderHtml showColors.dim highlighter (Query.fromHtml html)
, ""
, " " ++ functionName
, ""
, renderQueryFailureWithContext renderQueryFailure 0 True reason
]
CustomFailure assertionName message ->
assertionName ++ ": " ++ message
renderQueryFailureWithContext : (Int -> Bool -> a -> String) -> Int -> Bool -> ( ComplexQuery.FailureContext, a ) -> String
renderQueryFailureWithContext renderInner indent color failure =
let
indentS =
String.repeat indent " "
in
case failure of
( [], inner ) ->
renderInner indent color inner
( (Description description) :: baseFailure, inner ) ->
String.join "\n" <|
List.filter ((/=) "")
[ indentS ++ renderDescriptionResult (colorsFor color) description ++ ":"
, renderQueryFailureWithContext renderInner (indent + 2) color ( baseFailure, inner )
]
( (CheckSucceeded description checkContext) :: baseFailure, inner ) ->
String.join "\n" <|
List.filter ((/=) "")
[ indentS ++ renderDescriptionResult (colorsFor color) (Ok description) ++ ":"
, renderQueryFailureWithContext_ (\_ _ () -> "") (indent + 2) color ( checkContext, () )
, renderQueryFailureWithContext renderInner indent color ( baseFailure, inner )
]
( (FindSucceeded (Just description) successfulChecks) :: baseFailure, inner ) ->
String.join "\n" <|
List.filter ((/=) "")
[ indentS ++ renderDescriptionResult (colorsFor color) (Ok description) ++ ":"
, renderSelectorResults (indent + 2) (colorsFor color) (List.map Ok (successfulChecks ()))
, renderQueryFailureWithContext renderInner indent color ( baseFailure, inner )
]
( (FindSucceeded Nothing successfulChecks) :: baseFailure, inner ) ->
String.join "\n" <|
List.filter ((/=) "")
[ renderSelectorResults indent (colorsFor color) (List.map Ok (successfulChecks ()))
, renderQueryFailureWithContext renderInner indent color ( baseFailure, inner )
]
renderQueryFailureWithContext_ : (Int -> Bool -> a -> String) -> Int -> Bool -> ( ComplexQuery.FailureContext, a ) -> String
renderQueryFailureWithContext_ =
renderQueryFailureWithContext
renderQueryFailure : Int -> Bool -> ComplexQuery.Failure -> String
renderQueryFailure indent color failure =
let
indentS =
String.repeat indent " "
in
case failure of
QueryFailed failureReason ->
renderSelectorResults indent (colorsFor color) failureReason
ComplexQuery.SimulateFailed string ->
let
colors =
colorsFor color
in
indentS ++ renderSelectorResult colors (Err string)
NoMatches description options ->
let
sortedByPriority =
options
|> List.sortBy (\( _, prio, _ ) -> -prio)
maxPriority =
List.head sortedByPriority
|> Maybe.map (\( _, prio, _ ) -> prio)
|> Maybe.withDefault 0
in
String.join "\n" <|
List.concat
[ [ indentS ++ description ++ ":" ]
, sortedByPriority
|> List.filter (\( _, prio, _ ) -> prio > maxPriority - 2)
|> List.map (\( desc, prio, reason ) -> indentS ++ "- " ++ desc ++ "\n" ++ renderQueryFailureWithContext renderQueryFailure (indent + 4) (color && prio >= maxPriority - 1) reason)
]
TooManyMatches description matches ->
String.join "\n" <|
List.concat
[ [ indentS ++ description ++ ", but there were multiple successful matches:" ]
, matches
|> List.sortBy (\( _, prio, _ ) -> -prio)
|> List.map (\( desc, _, todo ) -> indentS ++ "- " ++ desc)
, [ ""
, "If that's what you intended, use `ProgramTest.within` to focus in on a portion of"
, "the view that contains only one of the matches."
]
]
renderSelectorResults : Int -> Colors -> List (Result String String) -> String
renderSelectorResults indent colors results =
let
indentS =
String.repeat indent " "
in
List.map ((++) indentS << renderSelectorResult colors) (upToFirstErr results)
|> String.join "\n"
renderSelectorResult : Colors -> Result String String -> String
renderSelectorResult colors result =
case result of
Ok selector ->
String.concat
[ colors.green ""
, " "
, colors.bold selector
]
Err selector ->
colors.red <|
String.concat
[ ""
, " "
, selector
]
renderDescriptionResult : Colors -> Result String String -> String
renderDescriptionResult colors result =
case result of
Ok selector ->
String.concat
[ colors.green ""
, " "
, selector
]
Err selector ->
String.concat
[ colors.red ""
, " "
, selector
]
upToFirstErr : List (Result x a) -> List (Result x a)
upToFirstErr results =
let
step acc results_ =
case results_ of
[] ->
acc
(Err x) :: _ ->
Err x :: acc
(Ok a) :: rest ->
step (Ok a :: acc) rest
in
step [] results
|> List.reverse
type alias Colors =
{ bold : String -> String
, red : String -> String
, green : String -> String
, dim : String -> String
}
colorsFor : Bool -> Colors
colorsFor show =
if show then
showColors
else
noColors
showColors : Colors
showColors =
{ bold = \s -> String.concat [ "\u{001B}[1m", s, "\u{001B}[0m" ]
, red = \s -> String.concat [ "\u{001B}[31m", s, "\u{001B}[0m" ]
, green = \s -> String.concat [ "\u{001B}[32m", s, "\u{001B}[0m" ]
, dim = \s -> String.concat [ "\u{001B}[2m", s, "\u{001B}[0m" ]
}
noColors : Colors
noColors =
{ bold = identity
, red = identity
, green = identity
, dim = identity
}

View File

@ -0,0 +1,116 @@
module ProgramTest.HtmlHighlighter exposing (Attribute, Node(..), NodeF(..), fold, foldWithOriginal, highlight, isNonHiddenElement)
import Html.Parser
type NodeF a
= TextF String
| ElementF String (List Attribute) (List a)
| CommentF String
type alias Attribute =
( String, String )
fold : (NodeF a -> a) -> Html.Parser.Node -> a
fold f node =
case node of
Html.Parser.Text text ->
f (TextF text)
Html.Parser.Element tag attrs children ->
f (ElementF tag attrs (List.map (fold f) children))
Html.Parser.Comment string ->
f (CommentF string)
foldWithOriginal : (NodeF ( Html.Parser.Node, a ) -> a) -> Html.Parser.Node -> a
foldWithOriginal f node =
case node of
Html.Parser.Text text ->
f (TextF text)
Html.Parser.Element tag attrs children ->
f (ElementF tag attrs (List.map (\child -> ( child, foldWithOriginal f child )) children))
Html.Parser.Comment string ->
f (CommentF string)
type Node
= Text String
| Element String (List Attribute) (List Node)
| Comment String
| Hidden String
highlight : (String -> List Attribute -> List Html.Parser.Node -> Bool) -> Html.Parser.Node -> Node
highlight predicate =
foldWithOriginal <|
\node ->
case node of
TextF text ->
Text text
ElementF tag attrs children ->
let
foldedChildren =
List.map Tuple.second children
in
if predicate tag attrs (List.map Tuple.first children) || List.any isNonHiddenElement foldedChildren then
Element tag attrs foldedChildren
else
let
bestId =
List.concatMap identity
[ List.filter (Tuple.first >> (==) "id") attrs
, List.filter (Tuple.first >> (==) "name") attrs
, List.filter (Tuple.first >> (==) "class") attrs
]
|> List.head
|> Maybe.map (\( name, value ) -> " " ++ name ++ "=\"" ++ value ++ "\"")
|> Maybe.withDefault ""
bestContent =
case foldedChildren of
[] ->
""
[ Text single ] ->
truncate 15 (String.trim single)
_ ->
"..."
in
Hidden ("<" ++ tag ++ bestId ++ ">" ++ bestContent ++ "</" ++ tag ++ ">")
CommentF string ->
Comment string
isNonHiddenElement : Node -> Bool
isNonHiddenElement node =
case node of
Text _ ->
False
Element _ _ _ ->
True
Comment _ ->
False
Hidden _ ->
False
truncate : Int -> String -> String
truncate max input =
if String.length input < max - 3 then
input
else
String.left (max - 3) input ++ "..."

View File

@ -0,0 +1,58 @@
module ProgramTest.HtmlParserHacks exposing (parse, trimText)
import Html.Parser
import Parser
import ProgramTest.StringLines as StringLines
parse : String -> Result (List Parser.DeadEnd) (List Html.Parser.Node)
parse input =
case Html.Parser.run input of
Ok nodes ->
Ok nodes
Err errs ->
case fixError errs input of
Nothing ->
Err errs
Just nodes ->
Ok nodes
fixError : List Parser.DeadEnd -> String -> Maybe (List Html.Parser.Node)
fixError errs input =
case errs of
[] ->
Nothing
{ row, col, problem } :: rest ->
case problem of
Parser.UnexpectedChar ->
case StringLines.charAt row (col - 1) input of
Just "<" ->
parse (StringLines.replaceAt row (col - 1) "&lt;" input)
|> Result.toMaybe
_ ->
fixError rest input
_ ->
fixError rest input
trimText : Html.Parser.Node -> Html.Parser.Node
trimText node =
case node of
Html.Parser.Text string ->
Html.Parser.Text (String.trim string)
Html.Parser.Element string list nodes ->
Html.Parser.Element string
list
(List.map trimText nodes
|> List.filter ((/=) (Html.Parser.Text ""))
)
Html.Parser.Comment string ->
Html.Parser.Comment string

View File

@ -0,0 +1,73 @@
module ProgramTest.HtmlRenderer exposing (render)
import ProgramTest.HtmlHighlighter as HtmlHighlighter exposing (Node(..))
render : (String -> String) -> Int -> List HtmlHighlighter.Node -> String
render colorHidden indent nodes =
case nodes of
[] ->
""
(Text text) :: rest ->
case String.trim (String.replace "\n" " " text) of
"" ->
render colorHidden indent rest
trimmed ->
String.repeat indent " " ++ trimmed ++ "\n" ++ render colorHidden indent rest
(Comment text) :: rest ->
String.repeat indent " " ++ "<!--" ++ text ++ "-->\n" ++ render colorHidden indent rest
(Element tag attrs []) :: rest ->
String.repeat indent " "
++ "<"
++ tag
++ renderAttrs attrs
++ "></"
++ tag
++ ">\n"
++ render colorHidden indent rest
(Element tag attrs children) :: rest ->
String.repeat indent " "
++ "<"
++ tag
++ renderAttrs attrs
++ ">\n"
++ render colorHidden (indent + 4) children
++ String.repeat indent " "
++ "</"
++ tag
++ ">\n"
++ render colorHidden indent rest
(Hidden short) :: rest ->
String.repeat indent " " ++ colorHidden short ++ "\n" ++ render colorHidden indent rest
renderAttrs : List HtmlHighlighter.Attribute -> String
renderAttrs attrs =
case attrs of
[] ->
""
some ->
" " ++ String.join " " (List.map renderAttr some)
renderAttr : ( String, String ) -> String
renderAttr ( name, value ) =
case ( name, value ) of
( "htmlfor", _ ) ->
"for=\"" ++ value ++ "\""
( _, "true" ) ->
name ++ "=true"
( _, "false" ) ->
name ++ "=false"
_ ->
name ++ "=\"" ++ value ++ "\""

View File

@ -0,0 +1,28 @@
module ProgramTest.Program exposing (Program, renderView)
import Html exposing (Html)
import Test.Html.Query as Query
import Url exposing (Url)
{-| Since we can't inspect `Platform.Program`s in Elm,
this type represents the same thing as a record that we can access.
Note that we also parameterize `effect` and `sub` separately because
`Platform.Cmd` and `Platform.Sub` are not inspectable in Elm.
-}
type alias Program model msg effect sub =
{ update : msg -> model -> ( model, effect )
, view : model -> Html msg
, onRouteChange : Url -> Maybe msg
, subscriptions : Maybe (model -> sub)
, withinFocus : Query.Single msg -> Query.Single msg
}
renderView : Program model msg effect sub -> model -> Query.Single msg
renderView program model =
program.view model
|> Query.fromHtml
|> program.withinFocus

View File

@ -0,0 +1,26 @@
module ProgramTest.StringLines exposing (charAt, replaceAt)
charAt : Int -> Int -> String -> Maybe String
charAt row col input =
String.lines input
|> List.drop (row - 1)
|> List.head
|> Maybe.map
(String.dropLeft (col - 1)
>> String.left 1
)
replaceAt : Int -> Int -> String -> String -> String
replaceAt row col replacement input =
String.lines input
|> List.indexedMap
(\i line ->
if i == (row - 1) then
String.left (col - 1) line ++ replacement ++ String.dropLeft col line
else
line
)
|> String.join "\n"

View File

@ -0,0 +1,132 @@
module ProgramTest.TestHtmlHacks exposing (getPassingSelectors, parseFailureReport, parseFailureReportWithoutHtml, parseSimulateFailure, renderHtml)
import Html.Parser
import Parser
import Parser.Extra
import ProgramTest.HtmlHighlighter as HtmlHighlighter
import ProgramTest.HtmlRenderer as HtmlRenderer
import ProgramTest.TestHtmlParser as TestHtmlParser exposing (Assertion(..), FailureReport(..))
import Test.Html.Query as Query
import Test.Html.Selector as Selector exposing (Selector)
import Test.Runner
pleaseReport description =
"PLEASE REPORT THIS AT <https://github.com/avh4/elm-program-test/issues>: " ++ description
renderHtml : (String -> String) -> (String -> List Html.Parser.Attribute -> List Html.Parser.Node -> Bool) -> Query.Single any -> String
renderHtml colorHidden highlightPredicate single =
case forceFailureReport [] single of
Ok (QueryFailure node _ _) ->
let
tryHighlight =
HtmlHighlighter.highlight highlightPredicate
node
finalHighlighted =
if HtmlHighlighter.isNonHiddenElement tryHighlight then
tryHighlight
else
HtmlHighlighter.highlight (\_ _ _ -> True)
node
in
" Query.fromHtml\n\n"
++ HtmlRenderer.render colorHidden 4 [ finalHighlighted ]
Ok (EventFailure name _) ->
pleaseReport ("renderHtml: unexpected EventFailure: \"" ++ name ++ "\"")
Err err ->
pleaseReport ("renderHtml: couldn't parse failure report: " ++ err)
getPassingSelectors : List Selector -> Query.Single msg -> List String
getPassingSelectors selectors single =
case forceFailureReportWithoutHtml selectors single of
Ok (QueryFailure _ _ (Has _ results)) ->
case List.reverse results of
(Ok _) :: _ ->
[ pleaseReport "getPassingSelectors: forced selector didn't fail" ]
_ ->
List.filterMap Result.toMaybe results
Ok (EventFailure name _) ->
[ pleaseReport ("getPassingSelectors: got unexpected EventFailure \"" ++ name ++ "\"") ]
Err err ->
[ pleaseReport ("getPassingSelectors: couldn't parse failure report: " ++ err) ]
forceFailureReport : List Selector -> Query.Single any -> Result String (FailureReport Html.Parser.Node)
forceFailureReport selectors =
forceFailureReport_ parseFailureReport selectors "ProgramTest.TestHtmlHacks is trying to force a failure to collect the error message %%"
forceFailureReportWithoutHtml : List Selector -> Query.Single any -> Result String (FailureReport ())
forceFailureReportWithoutHtml selectors =
forceFailureReport_ parseFailureReportWithoutHtml selectors "ProgramTest.TestHtmlHacks is trying to force a failure to collect the error message %%"
forceFailureReport_ : (String -> result) -> List Selector -> String -> Query.Single any -> result
forceFailureReport_ parseFailure selectors unique single =
case
single
|> Query.has (selectors ++ [ Selector.text unique ])
|> Test.Runner.getFailureReason
of
Nothing ->
-- We expect the fake query to fail -- if it doesn't for some reason, just try recursing with a different fake matching string until it does fail
forceFailureReport_ parseFailure selectors (unique ++ "_") single
Just reason ->
parseFailure reason.description
parseFailureReport : String -> Result String (FailureReport Html.Parser.Node)
parseFailureReport string =
Parser.run TestHtmlParser.parser string
|> Result.mapError Parser.Extra.deadEndsToString
parseFailureReportWithoutHtml : String -> Result String (FailureReport ())
parseFailureReportWithoutHtml string =
Parser.run TestHtmlParser.parserWithoutHtml string
|> Result.mapError Parser.Extra.deadEndsToString
partitionSections_ : List String -> List (List String) -> List String -> List (List String)
partitionSections_ accLines accSections remaining =
case remaining of
[] ->
case List.reverse (List.reverse accLines :: accSections) of
[] :: rest ->
rest
all ->
all
next :: rest ->
if String.startsWith " " next then
partitionSections_ [ next ] (List.reverse accLines :: accSections) rest
else
partitionSections_ (next :: accLines) accSections rest
parseSimulateFailure : String -> String
parseSimulateFailure string =
let
simpleFailure result =
case result of
EventFailure name html ->
Ok ("Event.expectEvent: I found a node, but it does not listen for \"" ++ name ++ "\" events like I expected it would.")
_ ->
Err (pleaseReport "Got a failure message from Test.Html.Query that we couldn't parse: " ++ string)
in
parseFailureReport string
|> Result.andThen simpleFailure
|> Result.withDefault (pleaseReport "Got a failure message from Test.Html.Query that we couldn't parse: " ++ string)

View File

@ -0,0 +1,201 @@
module ProgramTest.TestHtmlParser exposing (Assertion(..), FailureReport(..), Selector(..), Step(..), parser, parserWithoutHtml)
import Html.Parser
import Parser exposing ((|.), (|=), Parser)
import Parser.Extra.String
import ProgramTest.HtmlParserHacks as HtmlParserHacks
type FailureReport html
= QueryFailure html (List (Step html)) Assertion
| EventFailure String html
type Step html
= FindStep (List Selector) html
type Selector
= Tag String
| Containing (List Selector)
| Text String
| Attribute String String
| All (List Selector)
type Assertion
= Has (List Selector) (List (Result String String))
parser_ : Parser html -> Parser (FailureReport html)
parser_ parseHtml =
Parser.oneOf
[ Parser.succeed QueryFailure
|. Parser.keyword " Query.fromHtml"
|. Parser.symbol "\n\n "
|= parseHtml
|= stepsParser parseHtml
|= assertionParser
|. Parser.end
, Parser.succeed EventFailure
|. Parser.keyword "Event.expectEvent:"
|. Parser.symbol " I found a node, but it does not listen for \""
|= (Parser.getChompedString <| Parser.chompUntil "\"")
|. Parser.symbol "\" events like I expected it would.\n\n"
|= parseHtml
|. Parser.end
]
parser : Parser (FailureReport Html.Parser.Node)
parser =
parser_ trimmedHtml
trimmedHtml : Parser Html.Parser.Node
trimmedHtml =
Parser.map HtmlParserHacks.trimText Html.Parser.node
|. Parser.oneOf
[ Parser.symbol "\n\n\n"
, Parser.end
]
parserWithoutHtml : Parser (FailureReport ())
parserWithoutHtml =
parser_ ignoreHtml
ignoreHtml : Parser ()
ignoreHtml =
Parser.chompUntilEndOr ""
stepsParser : Parser html -> Parser (List (Step html))
stepsParser parseHtml =
Parser.loop [] <|
\acc ->
Parser.oneOf
[ Parser.succeed (\stmt -> Parser.Loop (stmt :: acc))
|= stepParser parseHtml
, Parser.succeed ()
|> Parser.map (\_ -> Parser.Done (List.reverse acc))
]
stepParser : Parser html -> Parser (Step html)
stepParser parseHtml =
Parser.oneOf
[ Parser.succeed FindStep
|. Parser.keyword " Query.find "
|= selectorsParser
|. Parser.symbol "\n\n 1) "
|= parseHtml
]
selectorsParser : Parser (List Selector)
selectorsParser =
Parser.sequence
{ start = "[ "
, separator = ", "
, end = " ]"
, spaces = Parser.succeed ()
, item = selectorParser
, trailing = Parser.Forbidden
}
selectorParser : Parser Selector
selectorParser =
-- As of elm-explorations/test 1.2.2, `Selector.all` renders simply as a space-separated sequence of selectors
let
done acc =
case acc of
[ single ] ->
single
more ->
All (List.reverse more)
in
singleSelectorParser
|> Parser.andThen
(\first ->
Parser.loop [ first ] <|
\acc ->
Parser.oneOf
[ Parser.succeed (\stmt -> Parser.Loop (stmt :: acc))
|. Parser.backtrackable (Parser.symbol " ")
|= singleSelectorParser
, Parser.succeed ()
|> Parser.map (\_ -> Parser.Done (done acc))
]
)
singleSelectorParser : Parser Selector
singleSelectorParser =
Parser.oneOf
[ Parser.succeed Tag
|. Parser.keyword "tag "
|= Parser.Extra.String.string
, Parser.succeed Text
|. Parser.keyword "text "
|= Parser.Extra.String.string
, Parser.succeed Attribute
|. Parser.keyword "attribute "
|= Parser.Extra.String.string
|. Parser.symbol " "
|= Parser.oneOf
[ Parser.Extra.String.string
, Parser.succeed "true"
|. Parser.keyword "True"
, Parser.succeed "false"
|. Parser.keyword "False"
]
, Parser.succeed Containing
|. Parser.keyword "containing "
|= Parser.lazy (\() -> selectorsParser)
|. Parser.symbol " "
]
assertionParser : Parser Assertion
assertionParser =
Parser.oneOf
[ Parser.succeed Has
|. Parser.keyword " Query.has "
|= selectorsParser
|. Parser.symbol "\n\n"
|= selectorResultsParser
]
selectorResultsParser : Parser (List (Result String String))
selectorResultsParser =
let
help acc =
Parser.oneOf
[ Parser.succeed (\stmt -> Parser.Loop (stmt :: acc))
|= selectorResultParser
, Parser.succeed ()
|> Parser.map (\_ -> Parser.Done (List.reverse acc))
]
in
Parser.loop [] help
selectorResultParser : Parser (Result String String)
selectorResultParser =
Parser.oneOf
[ Parser.succeed (Ok << String.trim)
|. Parser.symbol " "
|. Parser.commit ()
|= (Parser.getChompedString <| Parser.chompUntilEndOr "\n")
|. Parser.chompWhile ((==) '\n')
, Parser.succeed (Err << String.trim)
|. Parser.symbol " "
|. Parser.commit ()
|= (Parser.getChompedString <| Parser.chompUntilEndOr "\n")
|. Parser.chompWhile ((==) '\n')
]

View File

@ -0,0 +1,55 @@
module Query.Extra exposing (oneOf)
import Html
import Test.Html.Query as Query
import Test.Html.Selector as Selector
import Test.Runner
{-| This is a hack for the fact that elm-html-test does not provide a way to force a `Query.Single` into an error state.
-}
fail : String -> Query.Single msg -> Query.Single msg
fail message _ =
Html.text ("ERROR: " ++ message)
|> Query.fromHtml
|> Query.find [ Selector.text "SHOULD NOT HAVE ERROR" ]
{-| Determines whether the given `Query.Single` is an error (failed to match a node)
-}
isFailed : Query.Single msg -> Bool
isFailed single =
case single |> Query.has [] |> Test.Runner.getFailureReason of
Just _ ->
True
Nothing ->
False
{-| TODO: Is it strange that this takes a `List (Single -> Single)`? Is it safer or more sensible to take `List (List Selector)` and then implicily only work with `Query.find`?
-}
oneOf : List (Query.Single msg -> Query.Single msg) -> Query.Single msg -> Query.Single msg
oneOf options single =
if isFailed single then
-- the input single is an error, so just return that
single
else
case options of
[] ->
fail "Query.Extra.oneOf was given an empty list of options" single
[ last ] ->
-- this is the last option, so if it fails, we want to return that failure
-- TODO: if the all failed, give a better error message about everything that failed
single |> last
next :: rest1 :: rest ->
if isFailed (next single) then
-- this option failed, so try the remaining ones
oneOf (rest1 :: rest) single
else
-- this option passed, so return success
next single

View File

@ -0,0 +1,6 @@
module String.Extra exposing (escape)
escape : String -> String
escape s =
"\"" ++ s ++ "\""

View File

@ -0,0 +1,145 @@
module Test.Http exposing
( expectJsonBody, HttpRequest, hasHeader
, timeout, networkError, httpResponse
)
{-| Convenience functions for testing HTTP requests.
_Pull requests are welcome to add more useful functions._
## Expectations
These functions provide some convenient checks that can be used with [`ProgramTest.expectHttpRequest`](ProgramTest#expectHttpRequest).
@docs expectJsonBody, HttpRequest, hasHeader
## Responses
These are ways to easily make `Http.Response` values for use with [`ProgramTest.simulateHttpResponse`](ProgramTest#simulateHttpResponse).
@docs timeout, networkError, httpResponse
-}
import Dict exposing (Dict)
import Expect exposing (Expectation)
import Http
import Json.Decode
import SimulatedEffect exposing (SimulatedTask)
{-| -}
type alias HttpRequest x a =
{ method : String
, url : String
, body : String
, headers : List ( String, String )
, onRequestComplete : Http.Response String -> SimulatedTask x a
}
{-| A convenient way to check something about the request body of a pending HTTP request.
...
|> ProgramTest.expectHttpRequest "POST"
"https://example.com/ok"
(Test.Http.expectJsonBody
(Json.Decode.field "version" Json.Decode.string)
(Expect.equal "3.1.5")
)
-}
expectJsonBody :
Json.Decode.Decoder requestBody
-> (requestBody -> Expectation)
-> HttpRequest x a
-> Expectation
expectJsonBody decoder check request =
case Json.Decode.decodeString decoder request.body of
Err err ->
Expect.fail ("expectJsonBody: Failed to decode HTTP request body: " ++ Json.Decode.errorToString err)
Ok responseBody ->
check responseBody
{-| Assert that the given HTTP request has the specified header.
...
|> ProgramTest.expectHttpRequest "POST"
"https://example.com/ok"
(Test.Http.hasHeader "Content-Type" "application/json")
-}
hasHeader : String -> String -> HttpRequest x a -> Expectation
hasHeader key value { headers } =
let
key_ =
String.toLower key
value_ =
String.toLower value
matches ( k, v ) =
( String.toLower k, String.toLower v )
== ( key_, value_ )
in
if List.any matches headers then
Expect.pass
else
Expect.fail <|
String.join "\n"
[ "Expected HTTP header " ++ key ++ ": " ++ value
, "but got headers:"
, List.map (\( k, v ) -> " " ++ k ++ ": " ++ v) headers
|> String.join "\n"
]
{-| This is the same as `Http.Timeout_`,
but is exposed here so that your test doesn't need to import both `Http` and `Test.Http`.
-}
timeout : Http.Response body
timeout =
Http.Timeout_
{-| This is the same as `Http.NetworkError_`,
but is exposed here so that your test doesn't need to import both `Http` and `Test.Http`.
-}
networkError : Http.Response body
networkError =
Http.NetworkError_
{-| This is a more convenient way to create `Http.BadStatus_` and `Http.GoodStatus_` values.
Following the [logic in elm/http](https://github.com/elm/http/blob/2.0.0/src/Elm/Kernel/Http.js#L65),
this will produce `Http.GoodStatus_` if the given status code is in the 200 series, otherwise
it will produce `Http.BadStatus_`.
-}
httpResponse :
{ statusCode : Int
, headers : List ( String, String )
, body : body
}
-> Http.Response body
httpResponse response =
let
variant =
if response.statusCode >= 200 && response.statusCode < 300 then
Http.GoodStatus_
else
Http.BadStatus_
in
variant
{ url = ""
, statusCode = response.statusCode
, statusText = "TODO: if you need this, please report to https://github.com/avh4/elm-program-test/issues"
, headers = Dict.fromList response.headers
}
response.body

View File

@ -0,0 +1,35 @@
module TestResult exposing (TestResult, andThen, fail)
import ProgramTest.Failure exposing (Failure)
import TestState exposing (TestState)
{-| TODO: what's a better name?
-}
type alias TestResult model msg effect =
Result
{ reason : Failure
}
(TestState model msg effect)
fail : Failure -> TestState model msg effect -> TestResult model msg effect
fail failure state =
Err
{ reason = failure
}
andThen : (TestState model msg effect -> Result Failure (TestState model msg effect)) -> TestResult model msg effect -> TestResult model msg effect
andThen f testResult =
case testResult of
Ok state ->
case f state of
Err failure ->
fail failure state
Ok newState ->
Ok newState
Err _ ->
testResult

View File

@ -0,0 +1,285 @@
module TestState exposing (TestState, advanceTime, drain, queueEffect, routeChangeHelper, simulateLoadUrlHelper, update, withSimulation)
import Dict
import PairingHeap
import ProgramTest.EffectSimulation as EffectSimulation exposing (EffectSimulation)
import ProgramTest.Failure exposing (Failure(..))
import ProgramTest.Program exposing (Program)
import SimulatedEffect exposing (SimulatedEffect, SimulatedSub)
import String.Extra
import Url exposing (Url)
import Url.Extra
{-| TODO: what's a better name?
-}
type alias TestState model msg effect =
{ currentModel : model
, lastEffect : effect
, navigation :
Maybe
{ currentLocation : Url
, browserHistory : List Url
}
, effectSimulation : Maybe (EffectSimulation msg effect)
}
update : msg -> Program model msg effect sub -> TestState model msg effect -> Result Failure (TestState model msg effect)
update msg program state =
let
( newModel, newEffect ) =
program.update msg state.currentModel
in
{ state
| currentModel = newModel
, lastEffect = newEffect
}
|> queueEffect program newEffect
|> Result.andThen (drain program)
queueEffect : Program model msg effect sub -> effect -> TestState model msg effect -> Result Failure (TestState model msg effect)
queueEffect program effect state =
case state.effectSimulation of
Nothing ->
Ok state
Just simulation ->
queueSimulatedEffect program (simulation.deconstructEffect effect) state
queueSimulatedEffect : Program model msg effect sub -> SimulatedEffect msg -> TestState model msg effect -> Result Failure (TestState model msg effect)
queueSimulatedEffect program effect state =
case state.effectSimulation of
Nothing ->
Ok state
Just simulation ->
case effect of
SimulatedEffect.None ->
Ok state
SimulatedEffect.Batch effects ->
List.foldl (\ef -> Result.andThen (queueSimulatedEffect program ef)) (Ok state) effects
SimulatedEffect.Task t ->
Ok
{ state
| effectSimulation =
Just (EffectSimulation.queueTask t simulation)
}
SimulatedEffect.PortEffect portName value ->
Ok
{ state
| effectSimulation =
Just
{ simulation
| outgoingPortValues =
Dict.update portName
(Maybe.withDefault [] >> (::) value >> Just)
simulation.outgoingPortValues
}
}
SimulatedEffect.PushUrl url ->
routeChangeHelper ("simulating effect: SimulatedEffect.Navigation.pushUrl " ++ String.Extra.escape url) 0 url program state
SimulatedEffect.ReplaceUrl url ->
routeChangeHelper ("simulating effect: SimulatedEffect.Navigation.replaceUrl " ++ String.Extra.escape url) 1 url program state
SimulatedEffect.Back n ->
case state.navigation of
Nothing ->
Ok state
Just { currentLocation, browserHistory } ->
if n <= 0 then
Ok state
else
case List.head (List.drop (n - 1) browserHistory) of
Nothing ->
-- n is bigger than the history;
-- in this case, browsers ignore the request
Ok state
Just first ->
routeChangeHelper ("simulating effect: SimulatedEffect.Navigation.Back " ++ String.fromInt n) 2 (Url.toString first) program state
SimulatedEffect.Load url ->
Err (simulateLoadUrlHelper ("simulating effect: SimulatedEffect.Navigation.load " ++ url) url state)
SimulatedEffect.Reload skipCache ->
let
functionName =
if skipCache then
"reloadAndSkipCache"
else
"reload"
in
case state.navigation of
Nothing ->
Err (ProgramDoesNotSupportNavigation functionName)
Just { currentLocation } ->
Err (ChangedPage ("simulating effect: SimulatedEffect.Navigation." ++ functionName) currentLocation)
simulateLoadUrlHelper : String -> String -> TestState model msg effect -> Failure
simulateLoadUrlHelper functionDescription href state =
case Maybe.map .currentLocation state.navigation of
Just location ->
ChangedPage functionDescription (Url.Extra.resolve location href)
Nothing ->
case Url.fromString href of
Nothing ->
NoBaseUrl functionDescription href
Just location ->
ChangedPage functionDescription location
routeChangeHelper : String -> Int -> String -> Program model msg effect sub -> TestState model msg effect -> Result Failure (TestState model msg effect)
routeChangeHelper functionName removeFromBackStack url program state =
case state.navigation of
Nothing ->
Err (ProgramDoesNotSupportNavigation functionName)
Just { currentLocation, browserHistory } ->
let
newLocation =
Url.Extra.resolve currentLocation url
processRouteChange =
case program.onRouteChange newLocation of
Nothing ->
Ok
Just msg ->
-- TODO: should this be set before or after?
update msg program
in
{ state
| navigation =
Just
{ currentLocation = newLocation
, browserHistory =
(currentLocation :: browserHistory)
|> List.drop removeFromBackStack
}
}
|> processRouteChange
drain : Program model msg effect sub -> TestState model msg effect -> Result Failure (TestState model msg effect)
drain program =
let
advanceTimeIfSimulating t state =
case state.effectSimulation of
Nothing ->
Ok state
Just _ ->
advanceTime "<UNKNOWN LOCATION: if you see this, please report it at https://github.com/avh4/elm-program-test/issues/>" t program state
in
advanceTimeIfSimulating 0
>> Result.andThen (drainWorkQueue program)
drainWorkQueue : Program model msg effect sub -> TestState model msg effect -> Result Failure (TestState model msg effect)
drainWorkQueue program state =
case state.effectSimulation of
Nothing ->
Ok state
Just simulation ->
case EffectSimulation.stepWorkQueue simulation of
Nothing ->
-- work queue is empty
Ok state
Just ( newSimulation, msg ) ->
let
updateMaybe tc =
case msg of
Nothing ->
Ok tc
Just m ->
update m program tc
in
{ state | effectSimulation = Just newSimulation }
|> updateMaybe
|> Result.andThen (drain program)
advanceTime : String -> Int -> Program model msg effect sub -> TestState model msg effect -> Result Failure (TestState model msg effect)
advanceTime functionName delta program state =
case state.effectSimulation of
Nothing ->
Err (EffectSimulationNotConfigured functionName)
Just simulation ->
advanceTo program functionName (simulation.state.nowMs + delta) state
advanceTo : Program model msg effect sub -> String -> Int -> TestState model msg effect -> Result Failure (TestState model msg effect)
advanceTo program functionName end state =
case state.effectSimulation of
Nothing ->
Err (EffectSimulationNotConfigured functionName)
Just simulation ->
let
ss =
simulation.state
in
case PairingHeap.findMin simulation.state.futureTasks of
Nothing ->
-- No future tasks to check
Ok
{ state
| effectSimulation =
Just
{ simulation
| state = { ss | nowMs = end }
}
}
Just ( t, task ) ->
if t <= end then
Ok
{ state
| effectSimulation =
Just
{ simulation
| state =
{ ss
| nowMs = t
, futureTasks = PairingHeap.deleteMin simulation.state.futureTasks
}
}
}
|> Result.map (withSimulation (EffectSimulation.queueTask (task ())))
|> Result.andThen (drain program)
|> Result.andThen (advanceTo program functionName end)
else
-- next task is further in the future than we are advancing
Ok
{ state
| effectSimulation =
Just
{ simulation
| state = { ss | nowMs = end }
}
}
withSimulation : (EffectSimulation msg effect -> EffectSimulation msg effect) -> TestState model msg effect -> TestState model msg effect
withSimulation f state =
{ state | effectSimulation = Maybe.map f state.effectSimulation }

View File

@ -0,0 +1,31 @@
module Url.Extra exposing (resolve)
{-| TODO: this module should implement the algorithm described at
<https://url.spec.whatwg.org/>
-}
import Url exposing (Url)
{-| This resolves a URL string (either an absolute or relative URL) against a base URL (given as a `Location`).
-}
resolve : Url -> String -> Url
resolve base url =
Url.fromString url
-- TODO: implement correct logic (current logic is only correct for "authority-relative" URLs without query or fragment strings)
|> Maybe.withDefault
{ base
| path =
if String.startsWith "/" url then
url
else
String.split "/" base.path
|> List.reverse
|> List.drop 1
|> List.reverse
|> (\l -> l ++ String.split "/" url)
|> String.join "/"
, query = Nothing
, fragment = Nothing
}

View File

@ -5,7 +5,8 @@
"app",
"../../src",
".elm-pages",
"../../plugins"
"../../plugins",
"elm-program-test-src"
],
"elm-version": "0.19.1",
"dependencies": {