diff --git a/src/Navigation/Extra.elm b/src/Navigation/Extra.elm index f53c215..4857c227 100644 --- a/src/Navigation/Extra.elm +++ b/src/Navigation/Extra.elm @@ -1,4 +1,4 @@ -module Navigation.Extra exposing (locationFromString) +module Navigation.Extra exposing (locationFromString, resolve) {-| TODO: this module should implement the algorithm described at @@ -14,16 +14,28 @@ NOTE: the behavior of when `Nothing` is returned may change when the correct imp -} locationFromString : String -> Maybe Navigation.Location locationFromString url = - Just - { hash = "TODO" - , host = "TODO" - , hostname = "TODO" - , href = url - , origin = "TODO" - , password = "TODO" - , pathname = "/" ++ (url |> String.split "/" |> List.drop 3 |> String.join "/") - , port_ = "TODO" - , protocol = "TODO" - , search = "TODO" - , username = "TODO" - } + if String.contains "://" url then + Just + { hash = "TODO" + , host = "TODO" + , hostname = "TODO" + , href = url + , origin = "TODO" + , password = "TODO" + , pathname = "/" ++ (url |> String.split "/" |> List.drop 3 |> String.join "/") + , port_ = "TODO" + , protocol = "TODO" + , search = "TODO" + , username = "TODO" + } + else + Nothing + + +{-| This resolves a URL string (either an absolute or relative URL) against a base URL (given as a `Location`). +-} +resolve : Navigation.Location -> String -> Navigation.Location +resolve base url = + locationFromString url + -- TODO: implment correct logic (current logic is only correct for "authority-relative" URLs without query or fragment strings) + |> Maybe.withDefault { base | pathname = url } diff --git a/src/TestContext.elm b/src/TestContext.elm index 99ed755..3d7ba27 100644 --- a/src/TestContext.elm +++ b/src/TestContext.elm @@ -4,6 +4,7 @@ module TestContext , clickButton , create , createWithFlags + , createWithJsonStringFlags , createWithNavigation , createWithNavigationAndFlags , createWithNavigationAndJsonStringFlags @@ -12,8 +13,10 @@ module TestContext , expectModel , expectView , expectViewHas + , fail , routeChange , shouldHave + , shouldHaveLastEffect , shouldHaveView , shouldNotHave , simulate @@ -26,7 +29,7 @@ module TestContext ## Creating @docs TestContext -@docs create, createWithFlags +@docs create, createWithFlags, createWithJsonStringFlags @docs createWithNavigation, createWithNavigationAndFlags, createWithNavigationAndJsonStringFlags @@ -43,7 +46,8 @@ module TestContext ## Final assertions -@docs expectViewHas, expectView, expectLastEffect, expectModel +@docs expectViewHas, expectView +@docs expectLastEffect, expectModel ## Intermediate assertions @@ -51,6 +55,14 @@ module TestContext These functions can be used to make assertions on a `TestContext` without ending the test. @docs shouldHave, shouldNotHave, shouldHaveView +@docs shouldHaveLastEffect + + +## Custom assertions + +These functions may be useful if you are writing your own custom assertion functions. + +@docs fail -} @@ -68,7 +80,7 @@ import Test.Runner.Failure type TestContext msg model effect - = TestContext (Result Failure ( TestProgram msg model effect, ( model, effect ) )) + = TestContext (Result Failure ( TestProgram msg model effect, ( model, effect ), Maybe Navigation.Location )) type alias TestProgram msg model effect = @@ -84,6 +96,8 @@ type Failure | SimulateFailedToFindTarget String String | InvalidLocationUrl String String | InvalidFlags String String + | ProgramDoesNotSupportNavigation String + | CustomFailure String String createHelper : @@ -91,6 +105,7 @@ createHelper : , update : msg -> model -> ( model, effect ) , view : model -> Html msg , onRouteChange : Navigation.Location -> Maybe msg + , initialLocation : Maybe Navigation.Location } -> TestContext msg model effect createHelper program = @@ -101,6 +116,7 @@ createHelper program = , onRouteChange = program.onRouteChange } , program.init + , program.initialLocation ) @@ -116,6 +132,7 @@ create program = , update = program.update , view = program.view , onRouteChange = \_ -> Nothing + , initialLocation = Nothing } @@ -132,9 +149,34 @@ createWithFlags program flags = , update = program.update , view = program.view , onRouteChange = \_ -> Nothing + , initialLocation = Nothing } +createWithJsonStringFlags : + Json.Decode.Decoder flags + -> + { init : flags -> ( model, effect ) + , update : msg -> model -> ( model, effect ) + , view : model -> Html msg + } + -> String + -> TestContext msg model effect +createWithJsonStringFlags flagsDecoder program flagsJson = + case Json.Decode.decodeString flagsDecoder flagsJson of + Err message -> + TestContext <| Err (InvalidFlags "createWithJsonStringFlags" message) + + Ok flags -> + createHelper + { init = program.init flags + , update = program.update + , view = program.view + , onRouteChange = \_ -> Nothing + , initialLocation = Nothing + } + + createWithNavigation : (Navigation.Location -> msg) -> @@ -155,6 +197,7 @@ createWithNavigation onRouteChange program initialUrl = , update = program.update , view = program.view , onRouteChange = onRouteChange >> Just + , initialLocation = Just location } @@ -179,6 +222,7 @@ createWithNavigationAndFlags onRouteChange program initialUrl flags = , update = program.update , view = program.view , onRouteChange = onRouteChange >> Just + , initialLocation = Just location } @@ -209,6 +253,7 @@ createWithNavigationAndJsonStringFlags flagsDecoder onRouteChange program initia , update = program.update , view = program.view , onRouteChange = onRouteChange >> Just + , initialLocation = Just location } @@ -219,10 +264,11 @@ update msg (TestContext result) = Err err -> Err err - Ok ( program, ( model, _ ) ) -> + Ok ( program, ( model, _ ), currentLocation ) -> Ok ( program , program.update msg model + , currentLocation ) @@ -232,7 +278,7 @@ simulateHelper functionDescription findTarget event (TestContext result) = Err err -> TestContext <| Err err - Ok ( program, ( model, _ ) ) -> + Ok ( program, ( model, _ ), _ ) -> let targetQuery = program.view model @@ -281,24 +327,27 @@ clickButton buttonText testContext = testContext +{-| `url` may be an absolute URL or relative URL +-} routeChange : String -> TestContext msg model effect -> TestContext msg model effect routeChange url (TestContext result) = case result of Err err -> TestContext <| Err err - Ok ( program, model ) -> - case Navigation.Extra.locationFromString url of - Nothing -> - TestContext <| Err (InvalidLocationUrl "routeChange" url) + Ok ( program, _, Nothing ) -> + TestContext <| Err (ProgramDoesNotSupportNavigation "routeChange") - Just location -> - case program.onRouteChange location of - Nothing -> - TestContext result + Ok ( program, _, Just currentLocation ) -> + case + Navigation.Extra.resolve currentLocation url + |> program.onRouteChange + of + Nothing -> + TestContext result - Just msg -> - update msg (TestContext result) + Just msg -> + update msg (TestContext result) expectModel : (model -> Expectation) -> TestContext msg model effect -> Expectation @@ -309,30 +358,41 @@ expectModel assertion (TestContext result) = Err err -> Err err - Ok ( program, ( model, lastEffect ) ) -> + Ok ( _, ( model, _ ), _ ) -> case assertion model |> Test.Runner.getFailureReason of Nothing -> - Ok ( program, ( model, lastEffect ) ) + result Just reason -> Err (ExpectFailed "expectModel" reason.description reason.reason) -expectLastEffect : (effect -> Expectation) -> TestContext msg model effect -> Expectation -expectLastEffect assertion (TestContext result) = - done <| - TestContext <| - case result of - Err err -> - Err err +expectLastEffectHelper : String -> (effect -> Expectation) -> TestContext msg model effect -> TestContext msg model effect +expectLastEffectHelper functionName assertion (TestContext result) = + TestContext <| + case result of + Err err -> + Err err - Ok ( program, ( model, lastEffect ) ) -> - case assertion lastEffect |> Test.Runner.getFailureReason of - Nothing -> - Ok ( program, ( model, lastEffect ) ) + Ok ( _, ( _, lastEffect ), _ ) -> + case assertion lastEffect |> Test.Runner.getFailureReason of + Nothing -> + result - Just reason -> - Err (ExpectFailed "expectLastEffect" reason.description reason.reason) + Just reason -> + Err (ExpectFailed functionName reason.description reason.reason) + + +shouldHaveLastEffect : (effect -> Expectation) -> TestContext msg model effect -> TestContext msg model effect +shouldHaveLastEffect assertion testContext = + expectLastEffectHelper "shouldHaveLastEffect" assertion testContext + + +expectLastEffect : (effect -> Expectation) -> TestContext msg model effect -> Expectation +expectLastEffect assertion testContext = + testContext + |> expectLastEffectHelper "expectLastEffect" assertion + |> done expectViewHelper : String -> (Query.Single msg -> Expectation) -> TestContext msg model effect -> TestContext msg model effect @@ -342,7 +402,7 @@ expectViewHelper functionName assertion (TestContext result) = Err err -> Err err - Ok ( program, ( model, lastEffect ) ) -> + Ok ( program, ( model, _ ), _ ) -> case model |> program.view @@ -351,7 +411,7 @@ expectViewHelper functionName assertion (TestContext result) = |> Test.Runner.getFailureReason of Nothing -> - Ok ( program, ( model, lastEffect ) ) + result Just reason -> Err (ExpectFailed functionName reason.description reason.reason) @@ -406,3 +466,39 @@ done (TestContext result) = Err (InvalidFlags functionName message) -> Expect.fail (functionName ++ ":\n" ++ message) + + Err (ProgramDoesNotSupportNavigation functionName) -> + Expect.fail (functionName ++ ": Program does not support navigation. Use TestContext.createWithNavigation or related function to create a TestContext that supports navigation.") + + Err (CustomFailure assertionName message) -> + Expect.fail (assertionName ++ ": " ++ message) + + +{-| `fail` can be used to report custom errors if you are writing your own convenience functions to deal with test contexts. + +Example (this is a function that checks for a particular structure in the program's view, +but will also fail the TestContext if the `expectedCount` parameter is invalid): + + expectNotificationCount : Int -> TestContext Msg Model effect -> TestContext Msg Model effect + expectNotificationCount expectedCount testContext = + if expectedCount <= 0 then + testContext + |> TestContext.fail "expectNotificationCount" + ("expectedCount must be positive, but was: " ++ toString expectedCount) + else + testContext + |> shouldHave + [ Test.Html.Selector.class "notifications" + , Test.Html.Selector.text (toString expectedCount) + ] + +-} +fail : String -> String -> TestContext msg model effect -> TestContext msg model effect +fail assertionName failureMessage (TestContext result) = + TestContext <| + case result of + Err err -> + Err err + + Ok _ -> + Err (CustomFailure assertionName failureMessage) diff --git a/tests/TestContextTests.elm b/tests/TestContextTests.elm index 87378e7..0dcfe71 100644 --- a/tests/TestContextTests.elm +++ b/tests/TestContextTests.elm @@ -8,6 +8,7 @@ import Json.Encode import Test exposing (..) import Test.Html.Query as Query import Test.Html.Selector as Selector +import Test.Runner import TestContext exposing (TestContext) @@ -74,6 +75,16 @@ all = } "flags" |> TestContext.expectModel (Expect.equal "") + , test "can create with JSON string flags" <| + \() -> + TestContext.createWithJsonStringFlags + (Json.Decode.field "y" Json.Decode.string) + { init = \flags -> ( "", NoOp ) + , update = testUpdate + , view = testView + } + """{"y": "fromJson"}""" + |> TestContext.expectModel (Expect.equal "") , test "can create with navigation" <| \() -> TestContext.createWithNavigation @@ -95,6 +106,17 @@ all = "https://example.com/path" |> TestContext.routeChange "https://example.com/new" |> TestContext.expectModel (Expect.equal ";/new") + , test "can simulate a route change with a relative URL" <| + \() -> + TestContext.createWithNavigation + .pathname + { init = \location -> ( "", NoOp ) + , update = testUpdate + , view = testView + } + "https://example.com/path" + |> TestContext.routeChange "/new" + |> TestContext.expectModel (Expect.equal ";/new") , test "can create with navigation and flags" <| \() -> TestContext.createWithNavigationAndFlags @@ -159,4 +181,19 @@ all = testContext |> TestContext.clickButton "Click Me" |> TestContext.expectLastEffect (Expect.equal (LogUpdate "CLICK")) + , test "can assert on the last effect as an intermediate assertion" <| + \() -> + testContext + |> TestContext.shouldHaveLastEffect (Expect.equal NoOp) + |> TestContext.clickButton "Click Me" + |> TestContext.shouldHaveLastEffect (Expect.equal (LogUpdate "CLICK")) + |> TestContext.done + , test "can be forced into failure" <| + \() -> + testContext + |> TestContext.fail "custom" "Because I said so" + |> TestContext.done + |> Test.Runner.getFailureReason + |> Maybe.map .description + |> Expect.equal (Just "custom: Because I said so") ]