From b7b28d7ba04fab210a63815c5e3b36bc3249baa9 Mon Sep 17 00:00:00 2001 From: Adam Young Date: Tue, 11 Jun 2024 21:40:50 +0100 Subject: [PATCH] FEATURE: Movie list item --- .../TMDb/Domain/Models/MovieListItem.swift | 204 ++++++++++++++++++ .../Domain/Models/MoviePageableList.swift | 2 +- .../Domain/Models/MovieListItemTests.swift | 56 +++++ .../Models/MoviePageableListTests.swift | 27 ++- .../Mocks/Models/MovieListItem+Mocks.swift | 128 +++++++++++ .../Models/MoviePageableList+Mocks.swift | 2 +- .../Resources/json/movie-list-item.json | 18 ++ .../Resources/json/movie-pageable-list.json | 20 +- 8 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 Sources/TMDb/Domain/Models/MovieListItem.swift create mode 100644 Tests/TMDbTests/Domain/Models/MovieListItemTests.swift create mode 100644 Tests/TMDbTests/Mocks/Models/MovieListItem+Mocks.swift create mode 100644 Tests/TMDbTests/Resources/json/movie-list-item.json diff --git a/Sources/TMDb/Domain/Models/MovieListItem.swift b/Sources/TMDb/Domain/Models/MovieListItem.swift new file mode 100644 index 00000000..830834a3 --- /dev/null +++ b/Sources/TMDb/Domain/Models/MovieListItem.swift @@ -0,0 +1,204 @@ +// +// MovieListItem.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// +/// A model representing a movie. +/// +public struct MovieListItem: Identifiable, Codable, Equatable, Hashable, Sendable { + + /// + /// Movie identifier. + /// + public let id: Int + + /// + /// Movie title. + /// + public let title: String + + /// + /// Original movie title. + /// + public let originalTitle: String + + /// + /// Original language of the movie. + /// + public let originalLanguage: String + + /// + /// Movie overview. + /// + public let overview: String + + /// + /// Movie genre identifiers. + /// + public let genreIDs: [Genre.ID] + + /// + /// Movie release date. + /// + public let releaseDate: Date? + + /// + /// Movie poster path. + /// + /// To generate a full URL see . + /// + public let posterPath: URL? + + /// + /// Movie poster backdrop path. + /// + /// To generate a full URL see . + /// + public let backdropPath: URL? + + /// + /// Current popularity. + /// + public let popularity: Double? + + /// + /// Average vote score. + /// + public let voteAverage: Double? + + /// + /// Number of votes. + /// + public let voteCount: Int? + + /// + /// Has video. + /// + public let hasVideo: Bool? + + /// + /// Is the movie only suitable for adults. + /// + public let isAdultOnly: Bool? + + /// + /// Creates a movie list item object. + /// + /// - Parameters: + /// - id: Movie identifier. + /// - title: Movie title. + /// - tagline: Movie tagline. + /// - originalTitle: Original movie title. + /// - originalLanguage: Original language of the movie. + /// - overview: Movie overview. + /// - genreID: Movie genre identifiers. + /// - releaseDate: Movie release date. + /// - posterPath: Movie poster path. + /// - backdropPath: Movie poster backdrop path. + /// - popularity: Current popularity. + /// - voteAverage: Average vote score. + /// - voteCount: Number of votes. + /// - hasVideo: Has video. + /// - isAdultOnly: Is the movie only suitable for adults. + /// + public init( + id: Int, + title: String, + originalTitle: String, + originalLanguage: String, + overview: String, + genreIDs: [Genre.ID], + releaseDate: Date? = nil, + posterPath: URL? = nil, + backdropPath: URL? = nil, + popularity: Double? = nil, + voteAverage: Double? = nil, + voteCount: Int? = nil, + hasVideo: Bool? = nil, + isAdultOnly: Bool? = nil + ) { + self.id = id + self.title = title + self.originalTitle = originalTitle + self.originalLanguage = originalLanguage + self.overview = overview + self.genreIDs = genreIDs + self.releaseDate = releaseDate + self.posterPath = posterPath + self.backdropPath = backdropPath + self.popularity = popularity + self.voteAverage = voteAverage + self.voteCount = voteCount + self.hasVideo = hasVideo + self.isAdultOnly = isAdultOnly + } + +} + +extension MovieListItem { + + private enum CodingKeys: String, CodingKey { + case id + case title + case originalTitle + case originalLanguage + case overview + case genreIDs = "genreIds" + case releaseDate + case posterPath + case backdropPath + case popularity + case voteAverage + case voteCount + case hasVideo = "video" + case isAdultOnly = "adult" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let container2 = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(Int.self, forKey: .id) + self.title = try container.decode(String.self, forKey: .title) + self.originalTitle = try container.decode(String.self, forKey: .originalTitle) + self.originalLanguage = try container.decode(String.self, forKey: .originalLanguage) + self.overview = try container.decode(String.self, forKey: .overview) + self.genreIDs = try container.decode([Genre.ID].self, forKey: .genreIDs) + + // Need to deal with empty strings - date decoding will fail with an empty string + let releaseDateString = try container.decodeIfPresent(String.self, forKey: .releaseDate) + self.releaseDate = try { + guard let releaseDateString, !releaseDateString.isEmpty else { + return nil + } + + return try container2.decodeIfPresent(Date.self, forKey: .releaseDate) + }() + + self.posterPath = try container.decodeIfPresent(URL.self, forKey: .posterPath) + self.backdropPath = try container.decodeIfPresent(URL.self, forKey: .backdropPath) + self.popularity = try container.decodeIfPresent(Double.self, forKey: .popularity) + self.voteAverage = try container.decodeIfPresent(Double.self, forKey: .voteAverage) + self.voteCount = try container.decodeIfPresent(Int.self, forKey: .voteCount) + self.hasVideo = try container.decodeIfPresent(Bool.self, forKey: .hasVideo) + self.isAdultOnly = try container.decodeIfPresent(Bool.self, forKey: .isAdultOnly) + } + +} diff --git a/Sources/TMDb/Domain/Models/MoviePageableList.swift b/Sources/TMDb/Domain/Models/MoviePageableList.swift index 2260bc51..1c4f6030 100644 --- a/Sources/TMDb/Domain/Models/MoviePageableList.swift +++ b/Sources/TMDb/Domain/Models/MoviePageableList.swift @@ -22,4 +22,4 @@ import Foundation /// /// A model representing a pageable list of movies. /// -public typealias MoviePageableList = PageableListResult +public typealias MoviePageableList = PageableListResult diff --git a/Tests/TMDbTests/Domain/Models/MovieListItemTests.swift b/Tests/TMDbTests/Domain/Models/MovieListItemTests.swift new file mode 100644 index 00000000..2f9f0b70 --- /dev/null +++ b/Tests/TMDbTests/Domain/Models/MovieListItemTests.swift @@ -0,0 +1,56 @@ +// +// MovieListItemTests.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@testable import TMDb +import XCTest + +final class MovieListItemTests: XCTestCase { + + func testDecodeReturnsMovieListItem() throws { + let result = try JSONDecoder.theMovieDatabase.decode(MovieListItem.self, fromResource: "movie-list-item") + + XCTAssertEqual(result, movie) + } + +} + +extension MovieListItemTests { + + // swiftlint:disable line_length + private var movie: MovieListItem { + .init( + id: 437_342, + title: "The First Omen", + originalTitle: "The First Omen", + originalLanguage: "en", + overview: "When a young American woman is sent to Rome to begin a life of service to the church, she encounters a darkness that causes her to question her own faith and uncovers a terrifying conspiracy that hopes to bring about the birth of evil incarnate.", + genreIDs: [27], + releaseDate: DateFormatter.theMovieDatabase.date(from: "2024-04-05"), + posterPath: URL(string: "/uGyiewQnDHPuiHN9V4k2t9QBPnh.jpg"), + backdropPath: URL(string: "/tkHQ7tnYYUEnqlrKuhufIsSVToU.jpg"), + popularity: 1080.713, + voteAverage: 6.768, + voteCount: 455, + hasVideo: false, + isAdultOnly: false + ) + } + // swiftlint:enable line_length + +} diff --git a/Tests/TMDbTests/Domain/Models/MoviePageableListTests.swift b/Tests/TMDbTests/Domain/Models/MoviePageableListTests.swift index 3e39cad3..ef673f32 100644 --- a/Tests/TMDbTests/Domain/Models/MoviePageableListTests.swift +++ b/Tests/TMDbTests/Domain/Models/MoviePageableListTests.swift @@ -35,9 +35,30 @@ final class MoviePageableListTests: XCTestCase { private let list = MoviePageableList( page: 1, results: [ - Movie(id: 1, title: "Movie 1"), - Movie(id: 2, title: "Movie 2"), - Movie(id: 3, title: "Movie 3") + MovieListItem( + id: 1, + title: "Movie 1", + originalTitle: "Movie 1 - a", + originalLanguage: "en", + overview: "Overview 1", + genreIDs: [1, 2, 3] + ), + MovieListItem( + id: 2, + title: "Movie 2", + originalTitle: "Movie 2 - a", + originalLanguage: "en", + overview: "Overview 2", + genreIDs: [4, 5, 6] + ), + MovieListItem( + id: 3, + title: "Movie 3", + originalTitle: "Movie 3 - a", + originalLanguage: "en", + overview: "Overview 3", + genreIDs: [7, 8, 9] + ) ], totalResults: 3, totalPages: 1 diff --git a/Tests/TMDbTests/Mocks/Models/MovieListItem+Mocks.swift b/Tests/TMDbTests/Mocks/Models/MovieListItem+Mocks.swift new file mode 100644 index 00000000..e00b064a --- /dev/null +++ b/Tests/TMDbTests/Mocks/Models/MovieListItem+Mocks.swift @@ -0,0 +1,128 @@ +// +// MovieListItem+Mocks.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +@testable import TMDb + +extension MovieListItem { + + static func mock( + id: Int = Int.randomID, + title: String = "Movie", + originalTitle: String = "Movie a", + originalLanguage: String = "en", + overview: String = .randomString, + genreIDs: [Genre.ID] = [Genre].mocks.map(\.id), + releaseDate: Date? = .random, + posterPath: URL? = .randomImagePath, + backdropPath: URL? = .randomImagePath, + popularity: Double? = Double.random(in: 1 ... 10), + voteAverage: Double? = Double.random(in: 1 ... 10), + voteCount: Int? = Int.random(in: 1 ... 1000), + hasVideo: Bool? = .random(), + isAdultOnly: Bool? = .random() + ) -> Self { + .init( + id: id, + title: title, + originalTitle: originalTitle, + originalLanguage: originalLanguage, + overview: overview, + genreIDs: genreIDs, + releaseDate: releaseDate, + posterPath: posterPath, + backdropPath: backdropPath, + popularity: popularity, + voteAverage: voteAverage, + voteCount: voteCount, + hasVideo: hasVideo, + isAdultOnly: isAdultOnly + ) + } + + static var bulletTrain: Self { + .mock( + id: 718_930, + title: "Bullet Train", + overview: """ + Unlucky assassin Ladybug is determined to do his job peacefully after one too many gigs gone \ + off the rails. Fate, however, may have other plans, as Ladybug's latest mission puts him on a collision \ + course with lethal adversaries from around the globe—all with connected, yet conflicting, objectives—on \ + the world's fastest train. + """, + releaseDate: DateFormatter.theMovieDatabase.date(from: "2022-07-03") + ) + } + + static var thorLoveAndThunder: Self { + .mock( + id: 616_037, + title: "Thor: Love and Thunder", + overview: """ + After his retirement is interrupted by Gorr the God Butcher, a galactic killer who seeks the \ + extinction of the gods, Thor Odinson enlists the help of King Valkyrie, Korg, and ex-girlfriend Jane \ + Foster, who now inexplicably wields Mjolnir as the Relatively Mighty Girl Thor. Together they embark \ + upon a harrowing cosmic adventure to uncover the mystery of the God Butcher's vengeance and stop him \ + before it's too late. + """, + releaseDate: DateFormatter.theMovieDatabase.date(from: "2022-07-06") + ) + } + + static var jurassicWorldDominion: Self { + .mock( + id: 507_086, + title: "Jurassic World Dominion", + overview: """ + Four years after Isla Nublar was destroyed, dinosaurs now live—and hunt—alongside humans all \ + over the world. This fragile balance will reshape the future and determine, once and for all, whether \ + human beings are to remain the apex predators on a planet they now share with history's most fearsome \ + creatures. + """, + releaseDate: DateFormatter.theMovieDatabase.date(from: "2022-06-01") + ) + } + + static var topGunMaverick: Self { + .mock( + id: 361_743, + title: "Top Gun: Maverick", + overview: """ + After more than thirty years of service as one of the Navy's top aviators, and dodging the \ + advancement in rank that would ground him, Pete “Maverick” Mitchell finds himself training a detachment \ + of TOP GUN graduates for a specialized mission the likes of which no living pilot has ever seen. + """, + releaseDate: DateFormatter.theMovieDatabase.date(from: "2022-05-24") + ) + } + +} + +extension [MovieListItem] { + + static var mocks: [Element] { + [ + .bulletTrain, + .thorLoveAndThunder, + .jurassicWorldDominion, + .topGunMaverick + ] + } + +} diff --git a/Tests/TMDbTests/Mocks/Models/MoviePageableList+Mocks.swift b/Tests/TMDbTests/Mocks/Models/MoviePageableList+Mocks.swift index 839786a3..02226104 100644 --- a/Tests/TMDbTests/Mocks/Models/MoviePageableList+Mocks.swift +++ b/Tests/TMDbTests/Mocks/Models/MoviePageableList+Mocks.swift @@ -24,7 +24,7 @@ extension MoviePageableList { static func mock( page: Int = .random(in: 1 ... 5), - results: [Movie] = .mocks, + results: [MovieListItem] = .mocks, totalResults: Int = .random(in: 1 ... 100), totalPages: Int = .random(in: 1 ... 5) ) -> Self { diff --git a/Tests/TMDbTests/Resources/json/movie-list-item.json b/Tests/TMDbTests/Resources/json/movie-list-item.json new file mode 100644 index 00000000..ac2e0bd3 --- /dev/null +++ b/Tests/TMDbTests/Resources/json/movie-list-item.json @@ -0,0 +1,18 @@ +{ + "adult": false, + "backdrop_path": "/tkHQ7tnYYUEnqlrKuhufIsSVToU.jpg", + "genre_ids": [ + 27 + ], + "id": 437342, + "original_language": "en", + "original_title": "The First Omen", + "overview": "When a young American woman is sent to Rome to begin a life of service to the church, she encounters a darkness that causes her to question her own faith and uncovers a terrifying conspiracy that hopes to bring about the birth of evil incarnate.", + "popularity": 1080.713, + "poster_path": "/uGyiewQnDHPuiHN9V4k2t9QBPnh.jpg", + "release_date": "2024-04-05", + "title": "The First Omen", + "video": false, + "vote_average": 6.768, + "vote_count": 455 +} diff --git a/Tests/TMDbTests/Resources/json/movie-pageable-list.json b/Tests/TMDbTests/Resources/json/movie-pageable-list.json index 8554e042..ac634312 100644 --- a/Tests/TMDbTests/Resources/json/movie-pageable-list.json +++ b/Tests/TMDbTests/Resources/json/movie-pageable-list.json @@ -3,17 +3,29 @@ "results": [ { "id": 1, - "title": "Movie 1" + "title": "Movie 1", + "original_title": "Movie 1 - a", + "original_language": "en", + "overview": "Overview 1", + "genre_ids": [1, 2, 3] }, { "id": 2, - "title": "Movie 2" + "title": "Movie 2", + "original_title": "Movie 2 - a", + "original_language": "en", + "overview": "Overview 2", + "genre_ids": [4, 5, 6] }, { "id": 3, - "title": "Movie 3" + "title": "Movie 3", + "original_title": "Movie 3 - a", + "original_language": "en", + "overview": "Overview 3", + "genre_ids": [7, 8, 9] } ], "total_pages": 1, "total_results": 3 -} \ No newline at end of file +}