Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 37 additions & 35 deletions Sources/ScryfallKit/Models/Card/Card+enums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,27 +124,27 @@ extension Card {
/// Layouts for a Magic card
///
/// [Scryfall documentation](https://scryfall.com/docs/api/layouts)
public enum Layout: String, CaseIterable, Codable, Sendable {
public enum Layout: RawRepresentable, CaseIterable, Codable, Sendable, Equatable, Hashable {
case normal, split, flip, transform, meld, leveler, saga, adventure, planar, scheme, vanguard,
token, emblem, augment, host, `class`, battle, `case`, mutate, prototype, unknown
case modalDfc = "modal_dfc"
case doubleSided = "double_sided"
case doubleFacedToken = "double_faced_token"
case artSeries = "art_series"
case reversibleCard = "reversible_card"

/// Codable initializer
///
/// If this initializer fails to decode a value, instead of throwing an error, it will decode as the ``ScryfallKit/Card/Layout-swift.enum/unknown`` type and print a message to the logs.
/// - Parameter decoder: The Decoder to try decoding a ``ScryfallKit/Card/Layout-swift.enum`` from
public init(from decoder: Decoder) throws {
self = (try? Self(rawValue: decoder.singleValueContainer().decode(RawValue.self))) ?? .unknown
if self == .unknown, let rawValue = try? String(from: decoder) {
if #available(iOS 14.0, macOS 11.0, *) {
Logger.decoder.error("Decoded unknown Layout: \(rawValue)")
} else {
print("Decoded unknown Layout: \(rawValue)")
}
token, emblem, augment, host, `class`, battle, `case`, mutate, prototype, modalDfc, doubleSided, doubleFacedToken, artSeries, reversibleCard

/// A layout that hasn't been added to ScryfallKit yet
case unknown(String)

/// All known Magic: the Gathering card layouts
public static let allCases: [Card.Layout] = [
.normal, .split, .flip, .transform, .meld, .leveler, .saga, .adventure, .planar, .scheme, .vanguard, .token, .emblem, .augment, .host, .class, .battle, .case, .mutate, .prototype, .modalDfc, .doubleSided, .doubleFacedToken, .artSeries, .reversibleCard,
]

public var rawValue: String {
switch self {
case .modalDfc: "modal_dfc"
case .doubleSided: "double_sided"
case .doubleFacedToken: "double_faced_token"
case .artSeries: "art_series"
case .reversibleCard: "reversible_card"
case .unknown(let string): string
default: String(describing: self)
}
}
}
Expand Down Expand Up @@ -198,23 +198,25 @@ extension Card {
}

/// Effects applied to a Magic card frame
///
///
/// [Scryfall documentation](https://scryfall.com/docs/api/frames#frame-effects)
public enum FrameEffect: String, Codable, CaseIterable, Sendable {
public enum FrameEffect: RawRepresentable, Codable, Sendable, CaseIterable, Equatable, Hashable {
case legendary, miracle, nyxtouched, draft, devoid, tombstone, colorshifted, inverted,
sunmoondfc, compasslanddfc, originpwdfc, mooneldrazidfc, waxingandwaningmoondfc, showcase,
extendedart, companion, etched, snow, lesson, convertdfc, fandfc, battle, gravestone, fullart,
vehicle, borderless, extended, spree, textless, unknown, enchantment, shatteredglass, upsidedowndfc

public init(from decoder: Decoder) throws {
self =
try FrameEffect(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
if self == .unknown, let rawValue = try? String(from: decoder) {
if #available(iOS 14.0, macOS 11.0, *) {
Logger.decoder.error("Decoded unknown FrameEffect: \(rawValue)")
} else {
print("Decoded unknown FrameEffect: \(rawValue)")
}
sunmoondfc, compasslanddfc, originpwdfc, mooneldrazidfc, waxingandwaningmoondfc, showcase,
extendedart, companion, etched, snow, lesson, convertdfc, fandfc, battle, gravestone, fullart,
vehicle, borderless, extended, spree, textless, enchantment, shatteredglass, upsidedowndfc
/// A layout that hasn't been added to ScryfallKit yet
case unknown(String)

/// All known Magic: the Gathering frame effects
public static let allCases: [Card.FrameEffect] = [
.legendary, .miracle, .nyxtouched, .draft, .devoid, .tombstone, .colorshifted, .inverted, .sunmoondfc, .compasslanddfc, .originpwdfc, .mooneldrazidfc, .waxingandwaningmoondfc, .showcase, .extendedart, .companion, .etched, .snow, .lesson, .convertdfc, .fandfc, .battle, .gravestone, .fullart, .vehicle, .borderless, .extended, .spree, .textless, .enchantment,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also take the opportunity to make these cases camelCase to align with all the other enums we have?

]

public var rawValue: String {
switch self {
case .unknown(let unknownRawValue): unknownRawValue
default: String(describing: self)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/ScryfallKit/Models/Card/Card.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ public struct Card: Codable, Identifiable, Hashable, Sendable {
/// A link to this card's set on Scryfall
public var setSearchUri: URL
/// The type of set this card was printed in
public var setType: MTGSet.`Type`
public var setType: MTGSet.Kind
/// A link to this card's set object on the Scryfall API
public var setUri: String
/// This card's set code
Expand Down Expand Up @@ -254,7 +254,7 @@ public struct Card: Codable, Identifiable, Hashable, Sendable {
scryfallSetUri: String,
setName: String,
setSearchUri: URL,
setType: MTGSet.`Type`,
setType: MTGSet.Kind,
setUri: String,
set: String,
storySpotlight: Bool,
Expand Down
37 changes: 20 additions & 17 deletions Sources/ScryfallKit/Models/MTGSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,28 @@ public struct MTGSet: Codable, Identifiable, Hashable, Sendable {
/// A machine-readable value describing the type of set this is.
///
/// See [Scryfall's docs](https://scryfall.com/docs/api/sets#set-types) for more information on set types
public enum `Type`: String, Codable, Sendable {
public enum Kind: RawRepresentable, Codable, Sendable, CaseIterable, Hashable, Equatable {
// While "masters" is in fact not inclusive, it's also a name that we can't control
// swiftlint:disable:next inclusive_language
case core, expansion, masters, masterpiece, spellbook, commander, planechase, archenemy,
vanguard, funny, starter, box, promo, token, memorabilia, arsenal, alchemy, minigame, unknown
case fromTheVault = "from_the_vault"
case premiumDeck = "premium_deck"
case duelDeck = "duel_deck"
case draftInnovation = "draft_innovation"
case treasureChest = "treasure_chest"
vanguard, funny, starter, box, promo, token, memorabilia, arsenal, alchemy, minigame, fromTheVault, premiumDeck, duelDeck, draftInnovation, treasureChest
/// A layout that hasn't been added to ScryfallKit yet
case unknown(String)

public init(from decoder: Decoder) throws {
self = try Self(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
if self == .unknown, let rawValue = try? String(from: decoder) {
if #available(iOS 14.0, macOS 11.0, *) {
Logger.main.warning("Decoded unknown MTGSet Type: \(rawValue)")
} else {
print("Decoded unknown MTGSet Type: \(rawValue)")
}
public static let allCases: [Kind] = [
.core, .expansion, .masters, .masterpiece, .spellbook, .commander, .planechase, .archenemy,
.vanguard, .funny, .starter, .box, .promo, .token, .memorabilia, .arsenal, .alchemy, .minigame, .fromTheVault, .premiumDeck, .duelDeck, .draftInnovation, .treasureChest
]

public var rawValue: String {
switch self {
case .fromTheVault: "from_the_vault"
case .premiumDeck: "premium_deck"
case .duelDeck: "duel_deck"
case .draftInnovation: "draft_innovation"
case .treasureChest: "treasure_chest"
case .unknown(let unknownValue): unknownValue
default: String(describing: self)
}
}
}
Expand All @@ -64,7 +67,7 @@ public struct MTGSet: Codable, Identifiable, Hashable, Sendable {
/// The English name of the set.
public var name: String
/// A computer-readable classification for this set.
public var setType: MTGSet.`Type`
public var setType: Kind
/// The date the set was released or the first card was printed in the set (in GMT-8 Pacific time).
public var releasedAt: String?
/// The block code for this set, if any.
Expand Down Expand Up @@ -100,7 +103,7 @@ public struct MTGSet: Codable, Identifiable, Hashable, Sendable {
mtgoCode: String? = nil,
tcgplayerId: Int? = nil,
name: String,
setType: MTGSet.`Type`,
setType: Kind,
releasedAt: String? = nil,
blockCode: String? = nil,
block: String? = nil,
Expand Down
27 changes: 27 additions & 0 deletions Sources/ScryfallKit/Models/UnknownDecodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// UnknownDecodable.swift
//

/// A convenience protocol to reduce duplication of RawRepresentable and Decodable initializers
protocol UnknownDecodable: Decodable, CaseIterable, RawRepresentable where RawValue == String {
static func unknown(_ rawValue: String) -> Self
}

extension UnknownDecodable {
public init?(rawValue: String) {
guard let match = Self.allCases.first(where: { $0.rawValue == rawValue }) else {
return nil
}

self = match
}

public init(from decoder: any Decoder) throws {
let rawValue = try decoder.singleValueContainer().decode(String.self)
self = .init(rawValue: rawValue) ?? .unknown(rawValue)
}
}

extension Card.FrameEffect: UnknownDecodable {}
extension Card.Layout: UnknownDecodable {}
extension MTGSet.Kind: UnknownDecodable {}
139 changes: 139 additions & 0 deletions Tests/ScryfallKitTests/CaseIterableTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// CaseIterableTests.swift
//

import XCTest
import ScryfallKit

/// A test suite to remind maintainers to make sure that all the known cases for
/// an enum's `allCases` property.
///
/// Some of the enums in ScryfallKit have to get updated a lot because WOTC
/// is constantly playing with card design. To fill the gap between the release
/// of a new enum case and the release of a supporting ScryfallKit version,
/// the `unknown(String)` case was introduced.
///
/// Unfortunately, adding an associated value to an enum prevents the compiler
/// from automatically synthesizing the `CaseIterable` conformance.
///
/// The manual conformance of `CaseIterable` for the affected types MUST include all cases
/// _except_ the `unknown(String)` case. Manually providing this conformance introduces the
/// risk that a new case will be added to one of these types but NOT added to the `allCases`
/// array. This test suite aims to prevent that via (ab)use of switch exhaustivity. By switching
/// on an arbitrary enum case in a unit test, the compiler will error out if a new case is added
/// to the enum but not added to the switch statement in the test. This should hopefully
/// remind maintainers to keep `allCases` up to date.
final class CaseIterableTests: XCTestCase {
func testFrameEffect() {
let stub = Card.FrameEffect.battle
let contains = switch stub {
case .legendary: Card.FrameEffect.allCases.contains(.legendary)
case .miracle: Card.FrameEffect.allCases.contains(.miracle)
case .nyxtouched: Card.FrameEffect.allCases.contains(.nyxtouched)
case .draft: Card.FrameEffect.allCases.contains(.draft)
case .devoid: Card.FrameEffect.allCases.contains(.devoid)
case .tombstone: Card.FrameEffect.allCases.contains(.tombstone)
case .colorshifted: Card.FrameEffect.allCases.contains(.colorshifted)
case .inverted: Card.FrameEffect.allCases.contains(.inverted)
case .sunmoondfc: Card.FrameEffect.allCases.contains(.sunmoondfc)
case .compasslanddfc: Card.FrameEffect.allCases.contains(.compasslanddfc)
case .originpwdfc: Card.FrameEffect.allCases.contains(.originpwdfc)
case .mooneldrazidfc: Card.FrameEffect.allCases.contains(.mooneldrazidfc)
case .waxingandwaningmoondfc: Card.FrameEffect.allCases.contains(.waxingandwaningmoondfc)
case .showcase: Card.FrameEffect.allCases.contains(.showcase)
case .extendedart: Card.FrameEffect.allCases.contains(.extendedart)
case .companion: Card.FrameEffect.allCases.contains(.companion)
case .etched: Card.FrameEffect.allCases.contains(.etched)
case .snow: Card.FrameEffect.allCases.contains(.snow)
case .lesson: Card.FrameEffect.allCases.contains(.lesson)
case .convertdfc: Card.FrameEffect.allCases.contains(.convertdfc)
case .fandfc: Card.FrameEffect.allCases.contains(.fandfc)
case .battle: Card.FrameEffect.allCases.contains(.battle)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand this test correctly, I think only .battle will be tested?, might want to use fall through to make sure all cases are covered.

case .gravestone: Card.FrameEffect.allCases.contains(.gravestone)
case .fullart: Card.FrameEffect.allCases.contains(.fullart)
case .vehicle: Card.FrameEffect.allCases.contains(.vehicle)
case .borderless: Card.FrameEffect.allCases.contains(.borderless)
case .extended: Card.FrameEffect.allCases.contains(.extended)
case .spree: Card.FrameEffect.allCases.contains(.spree)
case .textless: Card.FrameEffect.allCases.contains(.textless)
case .enchantment: Card.FrameEffect.allCases.contains(.enchantment)
case .shatteredglass: Card.FrameEffect.allCases.contains(.shatteredglass)
case .upsidedowndfc: Card.FrameEffect.allCases.contains(.upsidedowndfc)
case .unknown(let string):
// Unknown case shouldn't be in allCases
!Card.FrameEffect.allCases.contains(.unknown(string))
}

XCTAssertTrue(contains)
}

func testLayout() {
let stub = Card.Layout.adventure
let contains = switch stub {
case .normal: Card.Layout.allCases.contains(.normal)
case .split: Card.Layout.allCases.contains(.split)
case .flip: Card.Layout.allCases.contains(.flip)
case .transform: Card.Layout.allCases.contains(.transform)
case .meld: Card.Layout.allCases.contains(.meld)
case .leveler: Card.Layout.allCases.contains(.leveler)
case .saga: Card.Layout.allCases.contains(.saga)
case .adventure: Card.Layout.allCases.contains(.adventure)
case .planar: Card.Layout.allCases.contains(.planar)
case .scheme: Card.Layout.allCases.contains(.scheme)
case .vanguard: Card.Layout.allCases.contains(.vanguard)
case .token: Card.Layout.allCases.contains(.token)
case .emblem: Card.Layout.allCases.contains(.emblem)
case .augment: Card.Layout.allCases.contains(.augment)
case .host: Card.Layout.allCases.contains(.host)
case .class: Card.Layout.allCases.contains(.class)
case .battle: Card.Layout.allCases.contains(.battle)
case .case: Card.Layout.allCases.contains(.case)
case .mutate: Card.Layout.allCases.contains(.mutate)
case .prototype: Card.Layout.allCases.contains(.prototype)
case .modalDfc: Card.Layout.allCases.contains(.modalDfc)
case .doubleSided: Card.Layout.allCases.contains(.doubleSided)
case .doubleFacedToken: Card.Layout.allCases.contains(.doubleFacedToken)
case .artSeries: Card.Layout.allCases.contains(.artSeries)
case .reversibleCard: Card.Layout.allCases.contains(.reversibleCard)
case .unknown(let string):
// Unknown case shouldn't be in allCases
!Card.Layout.allCases.contains(.unknown(string))
}

XCTAssertTrue(contains)
}

func testSetType() {
let stub = MTGSet.Kind.funny
let contains = switch stub {
case .core: MTGSet.Kind.allCases.contains(.core)
case .expansion: MTGSet.Kind.allCases.contains(.expansion)
case .masters: MTGSet.Kind.allCases.contains(.masters)
case .masterpiece: MTGSet.Kind.allCases.contains(.masterpiece)
case .spellbook: MTGSet.Kind.allCases.contains(.spellbook)
case .commander: MTGSet.Kind.allCases.contains(.commander)
case .planechase: MTGSet.Kind.allCases.contains(.planechase)
case .archenemy: MTGSet.Kind.allCases.contains(.archenemy)
case .vanguard: MTGSet.Kind.allCases.contains(.vanguard)
case .funny: MTGSet.Kind.allCases.contains(.funny)
case .starter: MTGSet.Kind.allCases.contains(.starter)
case .box: MTGSet.Kind.allCases.contains(.box)
case .promo: MTGSet.Kind.allCases.contains(.promo)
case .token: MTGSet.Kind.allCases.contains(.token)
case .memorabilia: MTGSet.Kind.allCases.contains(.memorabilia)
case .arsenal: MTGSet.Kind.allCases.contains(.arsenal)
case .alchemy: MTGSet.Kind.allCases.contains(.alchemy)
case .minigame: MTGSet.Kind.allCases.contains(.minigame)
case .fromTheVault: MTGSet.Kind.allCases.contains(.fromTheVault)
case .premiumDeck: MTGSet.Kind.allCases.contains(.premiumDeck)
case .duelDeck: MTGSet.Kind.allCases.contains(.duelDeck)
case .draftInnovation: MTGSet.Kind.allCases.contains(.draftInnovation)
case .treasureChest: MTGSet.Kind.allCases.contains(.treasureChest)
case .unknown(let string):
// Unknown case shouldn't be in allCases
!MTGSet.Kind.allCases.contains(.unknown(string))
}

XCTAssertTrue(contains)
}
}
8 changes: 4 additions & 4 deletions Tests/ScryfallKitTests/SmokeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ final class SmokeTests: XCTestCase {
func testLayouts() async throws {
// Verify that we can handle all layout types
// Skip double sided because there aren't any double_sided or battle cards being returned by Scryfall
for layout in Card.Layout.allCases where ![.doubleSided, .unknown, .battle].contains(layout) {
for layout in Card.Layout.allCases where ![.doubleSided, .battle].contains(layout) {
let cards = try await client.searchCards(query: "layout:\(layout.rawValue)")
checkForUnknowns(in: cards.data)
}
Expand Down Expand Up @@ -175,11 +175,11 @@ final class SmokeTests: XCTestCase {

private func checkForUnknowns(in cards: [Card]) {
for card in cards {
XCTAssertNotEqual(card.layout, .unknown, "Unknown layout on \(card.name)")
XCTAssertNotEqual(card.setType, .unknown, "Unknown set type on \(card.name)")
if let frameEffects = card.frameEffects {
for effect in frameEffects {
XCTAssertNotEqual(effect, .unknown, "Unknown frame effect on \(card.name) [\(card.set)]")
if case .unknown(let string) = effect {
XCTFail("Unknown frame effect: \(string)")
}
}
}
}
Expand Down