Compare commits

...

2 Commits

Author SHA1 Message Date
Louis Pearson 29f8fbc385 feat: Finish chapter 7 2024-02-01 00:37:08 -07:00
Louis Pearson 8d6cc1db71 feat: complete chapter 6 - testing 2024-01-31 02:18:20 -07:00
7 changed files with 868 additions and 869 deletions

1
PhotoGroove/.gitignore vendored Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -39,21 +39,7 @@
<script src="http://elm-in-action.com/pasta.js"></script> <script src="http://elm-in-action.com/pasta.js"></script>
<script src="app.js"></script> <!-- PhotoGroove.elm will get compiled into app.js --!> <script src="app.js"></script> <!-- PhotoGroove.elm will get compiled into app.js --!>
<script> <script>
var app = Elm.PhotoGroove.init({ var app = Elm.PhotoFolders.init({ node: document.getElementById("app"), }); // Elm object comes from app.js
node: document.getElementById("app"),
flags: Pasta.version
}); // Elm object comes from app.js
app.ports.setFilters.subscribe(function(options) {
requestAnimationFrame(function() {
Pasta.apply(document.getElementById("main-canvas"), options);
});
});
Pasta.addActivityListener(function(activity) {
console.log("Got some activity to send to Elm:", activity);
app.ports.activityChanges.send(activity);
});
</script> </script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,258 @@
module PhotoFolders exposing (main)
import Browser
import Dict exposing (Dict)
import Html exposing (..)
import Html.Attributes exposing (class, src)
import Html.Events exposing (onClick)
import Http
import Json.Decode as Decode exposing (Decoder, int, list, string)
import Json.Decode.Pipeline exposing (required)
type Folder =
Folder
{ name : String
, photoUrls : List String
, subfolders : List Folder
, expanded : Bool
}
type alias Model =
{ selectedPhotoUrl : Maybe String
, photos : Dict String Photo
, root : Folder
}
initialModel : Model
initialModel =
{ selectedPhotoUrl = Nothing
, photos = Dict.empty
, root = Folder { name = "Loading...", expanded = True, photoUrls = [], subfolders = [] }
}
init : () -> ( Model, Cmd Msg )
init _ =
( initialModel
, Http.get
{ url = "http://elm-in-action.com/folders/list"
, expect = Http.expectJson GotInitialModel modelDecoder
}
)
modelDecoder : Decoder Model
modelDecoder =
Decode.map2
(\photos root ->
{ photos = photos, root = root, selectedPhotoUrl = Nothing }
)
modelPhotosDecoder
folderDecoder
type Msg
= ClickedPhoto String
| GotInitialModel (Result Http.Error Model)
| ClickedFolder FolderPath
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ClickedFolder path ->
( { model | root = toggleExpanded path model.root }, Cmd.none )
ClickedPhoto url ->
( { model | selectedPhotoUrl = Just url }, Cmd.none )
GotInitialModel (Ok newModel) ->
( newModel, Cmd.none )
GotInitialModel (Err _) ->
( model, Cmd.none )
view : Model -> Html Msg
view model =
let
photoByUrl : String -> Maybe Photo
photoByUrl url =
Dict.get url model.photos
selectedPhoto : Html Msg
selectedPhoto =
case Maybe.andThen photoByUrl model.selectedPhotoUrl of
Just photo ->
viewSelectedPhoto photo
Nothing ->
text ""
in
div [ class "content" ]
[ div [ class "folders" ]
[ h1 [] [ text "Folders" ]
, viewFolder End model.root
]
, div [ class "selected-photo"] [ selectedPhoto ]
]
main : Program () Model Msg
main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = \_ -> Sub.none
}
type alias Photo =
{ title : String
, size : Int
, relatedUrls : List String
, url : String
}
viewPhoto : String -> Html Msg
viewPhoto url =
div [ class "photo", onClick (ClickedPhoto url) ]
[ text url ]
viewSelectedPhoto : Photo -> Html Msg
viewSelectedPhoto photo =
div
[ class "selected-photo" ]
[ h2 [] [ text photo.title]
, img [ src (urlPrefix ++ "photos/" ++ photo.url ++ "/full") ] []
, span [] [ text (String.fromInt photo.size ++ "KB") ]
, h3 [] [ text "Related" ]
, div [ class "related-photos" ]
(List.map viewRelatedPhoto photo.relatedUrls)
]
viewRelatedPhoto : String -> Html Msg
viewRelatedPhoto url =
img
[ class "related-photo"
, onClick (ClickedPhoto url)
, src (urlPrefix ++ "photos/" ++ url ++ "/thumb")
]
[]
viewFolder : FolderPath -> Folder -> Html Msg
viewFolder path (Folder folder) =
let
viewSubfolder : Int -> Folder -> Html Msg
viewSubfolder index subfolder =
viewFolder (appendIndex index path) subfolder
folderLabel =
label [ onClick (ClickedFolder path) ] [ text folder.name ]
in
if folder.expanded then
let
contents =
List.append
(List.indexedMap viewSubfolder folder.subfolders)
(List.map viewPhoto folder.photoUrls)
in
div [ class "folder expanded" ]
[ folderLabel
, div [ class "contents" ] contents
]
else
div [ class "folder collapsed" ] [ folderLabel ]
appendIndex : Int -> FolderPath -> FolderPath
appendIndex index path =
case path of
End ->
Subfolder index End
Subfolder subfolderIndex remainingPath ->
Subfolder subfolderIndex (appendIndex index remainingPath)
urlPrefix : String
urlPrefix =
"http://elm-in-action.com/"
type FolderPath
= End
| Subfolder Int FolderPath
toggleExpanded : FolderPath -> Folder -> Folder
toggleExpanded path (Folder folder) =
case path of
End ->
Folder { folder | expanded = not folder.expanded }
Subfolder targetIndex remainingPath ->
let
subfolders : List Folder
subfolders =
List.indexedMap transform folder.subfolders
transform : Int -> Folder -> Folder
transform currentIndex currentSubfolder =
if currentIndex == targetIndex then
toggleExpanded remainingPath currentSubfolder
else
currentSubfolder
in
Folder { folder | subfolders = subfolders }
type alias JsonPhoto =
{ title : String
, size : Int
, relatedUrls : List String
}
jsonPhotoDecoder : Decoder JsonPhoto
jsonPhotoDecoder =
Decode.succeed JsonPhoto
|> required "title" string
|> required "size" int
|> required "related_photos" (list string)
finishPhoto : ( String, JsonPhoto ) -> ( String, Photo )
finishPhoto ( url, json ) =
( url
, { url = url
, size = json.size
, title = json.title
, relatedUrls = json.relatedUrls
}
)
fromPairs : List ( String, JsonPhoto ) -> Dict String Photo
fromPairs pairs =
pairs
|> List.map finishPhoto
|> Dict.fromList
photosDecoder : Decoder (Dict String Photo)
photosDecoder =
Decode.keyValuePairs jsonPhotoDecoder
|> Decode.map fromPairs
folderDecoder : Decoder Folder
folderDecoder =
Decode.succeed folderFromJson
|> required "name" string
|> required "photos" photosDecoder
|> required "subfolders" (Decode.lazy (\_ -> list folderDecoder))
folderFromJson : String -> Dict String Photo -> List Folder -> Folder
folderFromJson name photos subfolders =
Folder
{ name = name
, expanded = True
, subfolders = subfolders
, photoUrls = Dict.keys photos
}
modelPhotosDecoder : Decoder (Dict String Photo)
modelPhotosDecoder =
Decode.succeed modelPhotosFromJson
|> required "photos" photosDecoder
|> required "subfolders" (Decode.lazy (\_ -> list modelPhotosDecoder))
modelPhotosFromJson : Dict String Photo -> List (Dict String Photo) -> Dict String Photo
modelPhotosFromJson folderPhotos subfolderPhotos =
List.foldl Dict.union folderPhotos subfolderPhotos

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)