feat: complete chapter 6 - testing

main
Louis Pearson 2024-01-31 02:15:36 -07:00
parent b6cb2a2750
commit 8d6cc1db71
4 changed files with 220 additions and 45 deletions

1
PhotoGroove/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
elm-stuff

View File

@ -23,7 +23,9 @@
} }
}, },
"test-dependencies": { "test-dependencies": {
"direct": {}, "direct": {
"elm-explorations/test": "2.2.0"
},
"indirect": {} "indirect": {}
} }
} }

View File

@ -1,22 +1,36 @@
port module PhotoGroove exposing (main) port module PhotoGroove exposing
( Model
, Msg(..)
, Photo
, Status(..)
, initialModel
, main
, photoDecoder
, photoFromUrl
, update
, urlPrefix
, view
)
import Array exposing (Array) import Array exposing (Array)
import Browser import Browser
import Html exposing (..) import Html exposing (..)
import Html.Attributes as Attr exposing (class, classList, id, name, src, title, type_) import Html.Attributes as Attr exposing (class, classList, id, name, src, title, type_)
import Html.Events exposing (onClick, on) import Html.Events exposing (on, onClick)
import Http import Http
import Json.Decode exposing (Decoder, at, int, list, string, succeed) import Json.Decode exposing (Decoder, at, int, list, string, succeed)
import Json.Decode.Pipeline exposing (optional, required) import Json.Decode.Pipeline exposing (optional, required)
import Json.Encode import Json.Encode
import Random import Random
urlPrefix : String urlPrefix : String
urlPrefix = urlPrefix =
"https://elm-in-action.com/" "https://elm-in-action.com/"
type Msg
= ClickedPhoto String type Msg
= ClickedPhoto String
| SetSize ThumbnailSize | SetSize ThumbnailSize
| ClickedSurpriseMe | ClickedSurpriseMe
| GotRandomPhoto Photo | GotRandomPhoto Photo
@ -26,6 +40,7 @@ type Msg
| SlidRipple Int | SlidRipple Int
| SlidNoise Int | SlidNoise Int
view : Model -> Html Msg view : Model -> Html Msg
view model = view model =
div [ class "content" ] <| div [ class "content" ] <|
@ -39,6 +54,7 @@ view model =
Errored errorMessage -> Errored errorMessage ->
[ text ("Error: " ++ errorMessage) ] [ text ("Error: " ++ errorMessage) ]
viewFilter : (Int -> Msg) -> String -> Int -> Html Msg viewFilter : (Int -> Msg) -> String -> Int -> Html Msg
viewFilter toMsg name magnitude = viewFilter toMsg name magnitude =
div [ class "filter-slider" ] div [ class "filter-slider" ]
@ -52,36 +68,39 @@ viewFilter toMsg name magnitude =
, label [] [ text (String.fromInt magnitude) ] , label [] [ text (String.fromInt magnitude) ]
] ]
viewLoaded : List Photo -> String -> Model -> List (Html Msg) viewLoaded : List Photo -> String -> Model -> List (Html Msg)
viewLoaded photos selectedUrl model = viewLoaded photos selectedUrl model =
[ h1 [] [ text "Photo Groove" ] [ h1 [] [ text "Photo Groove" ]
, button , button
[ onClick ClickedSurpriseMe ] [ onClick ClickedSurpriseMe ]
[ text "Surprise Me!" ] [ text "Surprise Me!" ]
, div [ class "activity" ] [ text model.activity ] , div [ class "activity" ] [ text model.activity ]
, div [ class "filters" ] , div [ class "filters" ]
[ viewFilter SlidHue "Hue" model.hue [ viewFilter SlidHue "Hue" model.hue
, viewFilter SlidRipple "Ripple" model.ripple , viewFilter SlidRipple "Ripple" model.ripple
, viewFilter SlidNoise "Noise" model.noise , viewFilter SlidNoise "Noise" model.noise
]
, h3 [] [ text "Thumbnail Size:" ]
, div [ id "choose-size" ]
(List.map viewSizeChooser [ Small, Medium, Large ])
, div [ id "thumbnails", class (sizeToString model.chosenSize) ]
(List.map (viewThumbnail selectedUrl) photos)
, canvas [ id "main-canvas", class "large" ] []
] ]
, h3 [] [ text "Thumbnail Size:" ]
, div [ id "choose-size" ]
(List.map viewSizeChooser [ Small, Medium, Large ])
, div [ id "thumbnails", class (sizeToString model.chosenSize) ]
(List.map (viewThumbnail selectedUrl) photos)
, canvas [ id "main-canvas", class "large" ] []
]
viewThumbnail : String -> Photo -> Html Msg viewThumbnail : String -> Photo -> Html Msg
viewThumbnail selectedUrl thumb = viewThumbnail selectedUrl thumb =
img img
[ src (urlPrefix ++ thumb.url) [ src (urlPrefix ++ thumb.url)
, title (thumb.title ++ " [" ++ String.fromInt thumb.size ++ " KB]") , title (thumb.title ++ " [" ++ String.fromInt thumb.size ++ " KB]")
, classList [ ( "selected", selectedUrl == thumb.url ) ] , classList [ ( "selected", selectedUrl == thumb.url ) ]
, onClick (ClickedPhoto thumb.url) , onClick (ClickedPhoto thumb.url)
] ]
[] []
viewSizeChooser : ThumbnailSize -> Html Msg viewSizeChooser : ThumbnailSize -> Html Msg
viewSizeChooser size = viewSizeChooser size =
label [] label []
@ -89,35 +108,45 @@ viewSizeChooser size =
, text (sizeToString size) , text (sizeToString size)
] ]
sizeToString : ThumbnailSize -> String sizeToString : ThumbnailSize -> String
sizeToString size = sizeToString size =
case size of case size of
Small -> Small ->
"small" "small"
Medium ->
Medium ->
"medium" "medium"
Large ->
Large ->
"large" "large"
type ThumbnailSize type ThumbnailSize
= Small = Small
| Medium | Medium
| Large | Large
port setFilters : FilterOptions -> Cmd msg port setFilters : FilterOptions -> Cmd msg
port activityChanges : (String -> msg) -> Sub msg port activityChanges : (String -> msg) -> Sub msg
type alias FilterOptions = type alias FilterOptions =
{ url : String { url : String
, filters : List { name : String, amount : Float } , filters : List { name : String, amount : Float }
} }
type alias Photo = type alias Photo =
{ url : String { url : String
, size : Int , size : Int
, title : String , title : String
} }
photoDecoder : Decoder Photo photoDecoder : Decoder Photo
photoDecoder = photoDecoder =
succeed Photo succeed Photo
@ -125,11 +154,13 @@ photoDecoder =
|> required "size" int |> required "size" int
|> optional "title" string "(untitled)" |> optional "title" string "(untitled)"
type Status type Status
= Loading = Loading
| Loaded (List Photo) String | Loaded (List Photo) String
| Errored String | Errored String
type alias Model = type alias Model =
{ status : Status { status : Status
, activity : String , activity : String
@ -139,31 +170,32 @@ type alias Model =
, noise : Int , noise : Int
} }
update : Msg -> Model -> ( Model, Cmd Msg ) update : Msg -> Model -> ( Model, Cmd Msg )
update msg model = update msg model =
case msg of case msg of
GotActivity activity -> GotActivity activity ->
( { model | activity = activity }, Cmd.none) ( { model | activity = activity }, Cmd.none )
GotPhotos (Ok photos) -> GotPhotos (Ok photos) ->
case photos of case photos of
first ::rest -> first :: rest ->
applyFilters applyFilters
{ model { model
| status = | status =
case List.head photos of case List.head photos of
Just photo -> Just photo ->
Loaded photos photo.url Loaded photos photo.url
Nothing -> Nothing ->
Loaded [] "" Loaded [] ""
} }
[] -> [] ->
( { model | status = Errored "0 photos found"}, Cmd.none ) ( { model | status = Errored "0 photos found" }, Cmd.none )
GotPhotos (Err _) -> GotPhotos (Err _) ->
( model, Cmd.none ) ( model, Cmd.none )
GotRandomPhoto photo -> GotRandomPhoto photo ->
applyFilters { model | status = selectUrl photo.url model.status } applyFilters { model | status = selectUrl photo.url model.status }
@ -174,7 +206,7 @@ update msg model =
SetSize size -> SetSize size ->
( { model | chosenSize = size }, Cmd.none ) ( { model | chosenSize = size }, Cmd.none )
ClickedSurpriseMe -> ClickedSurpriseMe ->
case model.status of case model.status of
Loaded (firstPhoto :: otherPhotos) _ -> Loaded (firstPhoto :: otherPhotos) _ ->
( model ( model
@ -191,7 +223,7 @@ update msg model =
Errored errorMessage -> Errored errorMessage ->
( model, Cmd.none ) ( model, Cmd.none )
SlidHue hue -> SlidHue hue ->
applyFilters { model | hue = hue } applyFilters { model | hue = hue }
SlidRipple ripple -> SlidRipple ripple ->
@ -200,19 +232,20 @@ update msg model =
SlidNoise noise -> SlidNoise noise ->
applyFilters { model | noise = noise } applyFilters { model | noise = noise }
applyFilters : Model -> ( Model, Cmd msg ) applyFilters : Model -> ( Model, Cmd msg )
applyFilters model = applyFilters model =
case model.status of case model.status of
Loaded photos selectedUrl -> Loaded photos selectedUrl ->
let let
filters = filters =
[ { name = "Hue", amount = toFloat model.hue / 11} [ { name = "Hue", amount = toFloat model.hue / 11 }
, { name = "Ripple", amount = toFloat model.ripple / 11} , { name = "Ripple", amount = toFloat model.ripple / 11 }
, { name = "Noise", amount = toFloat model.noise / 11} , { name = "Noise", amount = toFloat model.noise / 11 }
] ]
url = url =
urlPrefix ++ "large/" ++ selectedUrl urlPrefix ++ "large/" ++ selectedUrl
in in
( model, setFilters { url = url, filters = filters } ) ( model, setFilters { url = url, filters = filters } )
@ -222,16 +255,20 @@ applyFilters model =
Errored errorMessage -> Errored errorMessage ->
( model, Cmd.none ) ( model, Cmd.none )
selectUrl : String -> Status -> Status selectUrl : String -> Status -> Status
selectUrl url status = selectUrl url status =
case status of case status of
Loaded photos _ -> Loaded photos _ ->
Loaded photos url Loaded photos url
Loading -> Loading ->
status status
Errored errorMessage -> Errored errorMessage ->
status status
initialModel : Model initialModel : Model
initialModel = initialModel =
{ status = Loading { status = Loading
@ -240,7 +277,8 @@ initialModel =
, hue = 5 , hue = 5
, ripple = 5 , ripple = 5
, noise = 5 , noise = 5
} }
initialCmd : Cmd Msg initialCmd : Cmd Msg
initialCmd = initialCmd =
@ -249,6 +287,7 @@ initialCmd =
, expect = Http.expectJson GotPhotos (list photoDecoder) , expect = Http.expectJson GotPhotos (list photoDecoder)
} }
main : Program Float Model Msg main : Program Float Model Msg
main = main =
Browser.element Browser.element
@ -258,24 +297,33 @@ main =
, subscriptions = subscriptions , subscriptions = subscriptions
} }
init : Float -> ( Model, Cmd Msg ) init : Float -> ( Model, Cmd Msg )
init flags = init flags =
let let
activity = activity =
"Initializing Pasta v" ++ String.fromFloat flags "Initializing Pasta v" ++ String.fromFloat flags
in in
( { initialModel | activity = activity }, initialCmd ) ( { initialModel | activity = activity }, initialCmd )
subscriptions : Model -> Sub Msg subscriptions : Model -> Sub Msg
subscriptions model = subscriptions model =
activityChanges GotActivity activityChanges GotActivity
rangeSlider : List (Attribute msg) -> List (Html msg) -> Html msg rangeSlider : List (Attribute msg) -> List (Html msg) -> Html msg
rangeSlider attributes children = rangeSlider attributes children =
node "range-slider" attributes children node "range-slider" attributes children
onSlide : (Int -> msg) -> Attribute msg onSlide : (Int -> msg) -> Attribute msg
onSlide toMsg = onSlide toMsg =
at [ "detail", "userSlidTo" ] int at [ "detail", "userSlidTo" ] int
|> Json.Decode.map toMsg |> Json.Decode.map toMsg
|> on "slide" |> on "slide"
photoFromUrl : String -> Photo
photoFromUrl url =
{ url = url, size = 0, title = "" }

View File

@ -0,0 +1,124 @@
module PhotoGrooveTests exposing (..)
import Expect exposing (Expectation)
import Fuzz exposing (Fuzzer, int, list, string)
import Html.Attributes as Attr exposing (src)
import Json.Decode as Decode exposing (decodeValue)
import Json.Encode as Encode
import PhotoGroove
exposing
( Model
, Msg(..)
, Photo
, Status(..)
, initialModel
, photoFromUrl
, update
, urlPrefix
, view
)
import Test exposing (..)
import Test.Html.Event as Event
import Test.Html.Query as Query
import Test.Html.Selector exposing (attribute, tag, text)
decoderTest : Test
decoderTest =
fuzz2 string int "title defaults to (untitled)" <|
\url size ->
[ ( "url", Encode.string url )
, ( "size", Encode.int size )
]
|> Encode.object
|> decodeValue PhotoGroove.photoDecoder
|> Result.map .title
|> Expect.equal (Ok "(untitled)")
sliders : Test
sliders =
describe "Slider sets the desired field in the model"
[ testSlider "SlidHue" SlidHue .hue
, testSlider "SlidRipple" SlidRipple .ripple
, testSlider "SlidNoise" SlidNoise .noise
]
testSlider : String -> (Int -> Msg) -> (Model -> Int) -> Test
testSlider description toMsg amountFromModel =
fuzz int description <|
\amount ->
initialModel
|> update (toMsg amount)
|> Tuple.first
|> amountFromModel
|> Expect.equal amount
noPhotosNoThumbnails : Test
noPhotosNoThumbnails =
test "No thumbnails render when there are no photos to render." <|
\_ ->
initialModel
|> PhotoGroove.view
|> Query.fromHtml
|> Query.findAll [ tag "img" ]
|> Query.count (Expect.equal 0)
thumbnailRendered : String -> Query.Single msg -> Expectation
thumbnailRendered url query =
query
|> Query.findAll [ tag "img", attribute (Attr.src (urlPrefix ++ url)) ]
|> Query.count (Expect.atLeast 1)
thumbnailsWork : Test
thumbnailsWork =
fuzz urlFuzzer "URLs render as thumbnail" <|
\urls ->
let
thumbnailChecks : List (Query.Single msg -> Expectation)
thumbnailChecks =
List.map thumbnailRendered urls
in
{ initialModel | status = Loaded (List.map photoFromUrl urls) "" }
|> view
|> Query.fromHtml
|> Expect.all thumbnailChecks
urlFuzzer : Fuzzer (List String)
urlFuzzer =
Fuzz.intRange 1 5
|> Fuzz.map urlsFromCount
urlsFromCount : Int -> List String
urlsFromCount urlCount =
List.range 1 urlCount
|> List.map (\num -> String.fromInt num ++ ".png")
clickThumbnail : Test
clickThumbnail =
fuzz3 urlFuzzer string urlFuzzer "clicking a thumbnail selects it" <|
\urlsBefore urlToSelect urlsAfter ->
let
url =
urlToSelect ++ ".jpeg"
photos =
(urlsBefore ++ url :: urlsAfter)
|> List.map photoFromUrl
srcToClick =
urlPrefix ++ url
in
{ initialModel | status = Loaded photos "" }
|> view
|> Query.fromHtml
|> Query.find [ tag "img", attribute (Attr.src srcToClick) ]
|> Event.simulate Event.click
|> Event.expect (ClickedPhoto url)