Skip to content

Commit bcd5308

Browse files
authored
Merge pull request #31 from JacobHearst/unknown-assoc-value
Store the value of unimplemented enum cases
2 parents 594c804 + 25945db commit bcd5308

6 files changed

Lines changed: 265 additions & 61 deletions

File tree

Sources/ScryfallKit/Models/Card/Card+enums.swift

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -124,27 +124,27 @@ extension Card {
124124
/// Layouts for a Magic card
125125
///
126126
/// [Scryfall documentation](https://scryfall.com/docs/api/layouts)
127-
public enum Layout: String, CaseIterable, Codable, Sendable {
127+
public enum Layout: RawRepresentable, CaseIterable, Codable, Sendable, Equatable, Hashable {
128128
case normal, split, flip, transform, meld, leveler, saga, adventure, planar, scheme, vanguard,
129-
token, emblem, augment, host, `class`, battle, `case`, mutate, prototype, unknown
130-
case modalDfc = "modal_dfc"
131-
case doubleSided = "double_sided"
132-
case doubleFacedToken = "double_faced_token"
133-
case artSeries = "art_series"
134-
case reversibleCard = "reversible_card"
135-
136-
/// Codable initializer
137-
///
138-
/// 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.
139-
/// - Parameter decoder: The Decoder to try decoding a ``ScryfallKit/Card/Layout-swift.enum`` from
140-
public init(from decoder: Decoder) throws {
141-
self = (try? Self(rawValue: decoder.singleValueContainer().decode(RawValue.self))) ?? .unknown
142-
if self == .unknown, let rawValue = try? String(from: decoder) {
143-
if #available(iOS 14.0, macOS 11.0, *) {
144-
Logger.decoder.error("Decoded unknown Layout: \(rawValue)")
145-
} else {
146-
print("Decoded unknown Layout: \(rawValue)")
147-
}
129+
token, emblem, augment, host, `class`, battle, `case`, mutate, prototype, modalDfc, doubleSided, doubleFacedToken, artSeries, reversibleCard
130+
131+
/// A layout that hasn't been added to ScryfallKit yet
132+
case unknown(String)
133+
134+
/// All known Magic: the Gathering card layouts
135+
public static let allCases: [Card.Layout] = [
136+
.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,
137+
]
138+
139+
public var rawValue: String {
140+
switch self {
141+
case .modalDfc: "modal_dfc"
142+
case .doubleSided: "double_sided"
143+
case .doubleFacedToken: "double_faced_token"
144+
case .artSeries: "art_series"
145+
case .reversibleCard: "reversible_card"
146+
case .unknown(let string): string
147+
default: String(describing: self)
148148
}
149149
}
150150
}
@@ -198,23 +198,51 @@ extension Card {
198198
}
199199

200200
/// Effects applied to a Magic card frame
201-
///
201+
///
202202
/// [Scryfall documentation](https://scryfall.com/docs/api/frames#frame-effects)
203-
public enum FrameEffect: String, Codable, CaseIterable, Sendable {
204-
case legendary, miracle, nyxtouched, draft, devoid, tombstone, colorshifted, inverted,
205-
sunmoondfc, compasslanddfc, originpwdfc, mooneldrazidfc, waxingandwaningmoondfc, showcase,
206-
extendedart, companion, etched, snow, lesson, convertdfc, fandfc, battle, gravestone, fullart,
207-
vehicle, borderless, extended, spree, textless, unknown, enchantment, shatteredglass, upsidedowndfc
208-
209-
public init(from decoder: Decoder) throws {
210-
self =
211-
try FrameEffect(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
212-
if self == .unknown, let rawValue = try? String(from: decoder) {
213-
if #available(iOS 14.0, macOS 11.0, *) {
214-
Logger.decoder.error("Decoded unknown FrameEffect: \(rawValue)")
215-
} else {
216-
print("Decoded unknown FrameEffect: \(rawValue)")
217-
}
203+
public enum FrameEffect: RawRepresentable, Codable, Sendable, CaseIterable, Equatable, Hashable {
204+
case legendary, miracle, draft, devoid, tombstone, showcase, companion, etched, snow, lesson, battle, gravestone, vehicle, borderless, extended, spree, textless, enchantment, inverted
205+
case nyxTouched
206+
case colorShifted
207+
case sunMoonDfc
208+
case compassLandDfc
209+
case originPwDfc
210+
case moonEldraziDfc
211+
case waxingAndWaningMoonDfc
212+
case extendedArt
213+
case convertDfc
214+
case fAndFc
215+
case fullArt
216+
case shatteredGlass
217+
case upsideDownDfc
218+
/// A layout that hasn't been added to ScryfallKit yet
219+
case unknown(String)
220+
221+
/// All known Magic: the Gathering frame effects
222+
public static let allCases: [Card.FrameEffect] = [
223+
.legendary, .miracle, .nyxTouched, .draft, .devoid, .tombstone, .colorShifted, .inverted,
224+
.sunMoonDfc, .compassLandDfc, .originPwDfc, .moonEldraziDfc, .waxingAndWaningMoonDfc, .showcase,
225+
.extendedArt, .companion, .etched, .snow, .lesson, .convertDfc, .fAndFc, .battle, .gravestone, .fullArt,
226+
.vehicle, .borderless, .extended, .spree, .textless, .enchantment, .shatteredGlass, .upsideDownDfc,
227+
]
228+
229+
public var rawValue: String {
230+
switch self {
231+
case .unknown(let unknownRawValue): unknownRawValue
232+
case .nyxTouched: "nyxtouched"
233+
case .colorShifted: "colorshifted"
234+
case .sunMoonDfc: "sunmoondfc"
235+
case .compassLandDfc: "compasslanddfc"
236+
case .originPwDfc: "originpwdfc"
237+
case .moonEldraziDfc: "mooneldrazidfc"
238+
case .waxingAndWaningMoonDfc: "waxingandwaningmoondfc"
239+
case .extendedArt: "extendedart"
240+
case .convertDfc: "convertdfc"
241+
case .fAndFc: "fandfc"
242+
case .fullArt: "fullart"
243+
case .shatteredGlass: "shatteredglass"
244+
case .upsideDownDfc: "upsidedowndfc"
245+
default: String(describing: self)
218246
}
219247
}
220248
}

Sources/ScryfallKit/Models/Card/Card.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ public struct Card: Codable, Identifiable, Hashable, Sendable {
166166
/// A link to this card's set on Scryfall
167167
public var setSearchUri: URL
168168
/// The type of set this card was printed in
169-
public var setType: MTGSet.`Type`
169+
public var setType: MTGSet.Kind
170170
/// A link to this card's set object on the Scryfall API
171171
public var setUri: String
172172
/// This card's set code
@@ -254,7 +254,7 @@ public struct Card: Codable, Identifiable, Hashable, Sendable {
254254
scryfallSetUri: String,
255255
setName: String,
256256
setSearchUri: URL,
257-
setType: MTGSet.`Type`,
257+
setType: MTGSet.Kind,
258258
setUri: String,
259259
set: String,
260260
storySpotlight: Bool,

Sources/ScryfallKit/Models/MTGSet.swift

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,28 @@ public struct MTGSet: Codable, Identifiable, Hashable, Sendable {
3030
/// A machine-readable value describing the type of set this is.
3131
///
3232
/// See [Scryfall's docs](https://scryfall.com/docs/api/sets#set-types) for more information on set types
33-
public enum `Type`: String, Codable, Sendable {
33+
public enum Kind: RawRepresentable, Codable, Sendable, CaseIterable, Hashable, Equatable {
3434
// While "masters" is in fact not inclusive, it's also a name that we can't control
3535
// swiftlint:disable:next inclusive_language
3636
case core, expansion, masters, masterpiece, spellbook, commander, planechase, archenemy,
37-
vanguard, funny, starter, box, promo, token, memorabilia, arsenal, alchemy, minigame, unknown
38-
case fromTheVault = "from_the_vault"
39-
case premiumDeck = "premium_deck"
40-
case duelDeck = "duel_deck"
41-
case draftInnovation = "draft_innovation"
42-
case treasureChest = "treasure_chest"
37+
vanguard, funny, starter, box, promo, token, memorabilia, arsenal, alchemy, minigame, fromTheVault, premiumDeck, duelDeck, draftInnovation, treasureChest
38+
/// A layout that hasn't been added to ScryfallKit yet
39+
case unknown(String)
4340

44-
public init(from decoder: Decoder) throws {
45-
self = try Self(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
46-
if self == .unknown, let rawValue = try? String(from: decoder) {
47-
if #available(iOS 14.0, macOS 11.0, *) {
48-
Logger.main.warning("Decoded unknown MTGSet Type: \(rawValue)")
49-
} else {
50-
print("Decoded unknown MTGSet Type: \(rawValue)")
51-
}
41+
public static let allCases: [Kind] = [
42+
.core, .expansion, .masters, .masterpiece, .spellbook, .commander, .planechase, .archenemy,
43+
.vanguard, .funny, .starter, .box, .promo, .token, .memorabilia, .arsenal, .alchemy, .minigame, .fromTheVault, .premiumDeck, .duelDeck, .draftInnovation, .treasureChest
44+
]
45+
46+
public var rawValue: String {
47+
switch self {
48+
case .fromTheVault: "from_the_vault"
49+
case .premiumDeck: "premium_deck"
50+
case .duelDeck: "duel_deck"
51+
case .draftInnovation: "draft_innovation"
52+
case .treasureChest: "treasure_chest"
53+
case .unknown(let unknownValue): unknownValue
54+
default: String(describing: self)
5255
}
5356
}
5457
}
@@ -64,7 +67,7 @@ public struct MTGSet: Codable, Identifiable, Hashable, Sendable {
6467
/// The English name of the set.
6568
public var name: String
6669
/// A computer-readable classification for this set.
67-
public var setType: MTGSet.`Type`
70+
public var setType: Kind
6871
/// The date the set was released or the first card was printed in the set (in GMT-8 Pacific time).
6972
public var releasedAt: String?
7073
/// The block code for this set, if any.
@@ -100,7 +103,7 @@ public struct MTGSet: Codable, Identifiable, Hashable, Sendable {
100103
mtgoCode: String? = nil,
101104
tcgplayerId: Int? = nil,
102105
name: String,
103-
setType: MTGSet.`Type`,
106+
setType: Kind,
104107
releasedAt: String? = nil,
105108
blockCode: String? = nil,
106109
block: String? = nil,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// UnknownDecodable.swift
3+
//
4+
5+
/// A convenience protocol to reduce duplication of RawRepresentable and Decodable initializers
6+
protocol UnknownDecodable: Decodable, CaseIterable, RawRepresentable where RawValue == String {
7+
static func unknown(_ rawValue: String) -> Self
8+
}
9+
10+
extension UnknownDecodable {
11+
public init?(rawValue: String) {
12+
guard let match = Self.allCases.first(where: { $0.rawValue == rawValue }) else {
13+
return nil
14+
}
15+
16+
self = match
17+
}
18+
19+
public init(from decoder: any Decoder) throws {
20+
let rawValue = try decoder.singleValueContainer().decode(String.self)
21+
self = .init(rawValue: rawValue) ?? .unknown(rawValue)
22+
}
23+
}
24+
25+
extension Card.FrameEffect: UnknownDecodable {}
26+
extension Card.Layout: UnknownDecodable {}
27+
extension MTGSet.Kind: UnknownDecodable {}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//
2+
// CaseIterableTests.swift
3+
//
4+
5+
import XCTest
6+
import ScryfallKit
7+
8+
/// A test suite to remind maintainers to make sure that all the known cases for
9+
/// an enum's `allCases` property.
10+
///
11+
/// Some of the enums in ScryfallKit have to get updated a lot because WOTC
12+
/// is constantly playing with card design. To fill the gap between the release
13+
/// of a new enum case and the release of a supporting ScryfallKit version,
14+
/// the `unknown(String)` case was introduced.
15+
///
16+
/// Unfortunately, adding an associated value to an enum prevents the compiler
17+
/// from automatically synthesizing the `CaseIterable` conformance.
18+
///
19+
/// The manual conformance of `CaseIterable` for the affected types MUST include all cases
20+
/// _except_ the `unknown(String)` case. Manually providing this conformance introduces the
21+
/// risk that a new case will be added to one of these types but NOT added to the `allCases`
22+
/// array. This test suite aims to prevent that via (ab)use of switch exhaustivity. By switching
23+
/// on an arbitrary enum case in a unit test, the compiler will error out if a new case is added
24+
/// to the enum but not added to the switch statement in the test. This should hopefully
25+
/// remind maintainers to keep `allCases` up to date.
26+
///
27+
/// ##################################################
28+
/// # IF THESE TESTS ARE FAILING:
29+
/// You probably added a new case to one of the enums under test. This is your
30+
/// reminder to **make sure** that you added the new case to that enum's `allCases`
31+
/// array. Once you've done so, add your new case to this switch statement.
32+
/// ##################################################
33+
final class CaseIterableTests: XCTestCase {
34+
func testFrameEffect() {
35+
let stub = Card.FrameEffect.battle
36+
let contains = switch stub {
37+
case .legendary: Card.FrameEffect.allCases.contains(stub)
38+
case .miracle: Card.FrameEffect.allCases.contains(stub)
39+
case .nyxTouched: Card.FrameEffect.allCases.contains(stub)
40+
case .draft: Card.FrameEffect.allCases.contains(stub)
41+
case .devoid: Card.FrameEffect.allCases.contains(stub)
42+
case .tombstone: Card.FrameEffect.allCases.contains(stub)
43+
case .colorShifted: Card.FrameEffect.allCases.contains(stub)
44+
case .inverted: Card.FrameEffect.allCases.contains(stub)
45+
case .sunMoonDfc: Card.FrameEffect.allCases.contains(stub)
46+
case .compassLandDfc: Card.FrameEffect.allCases.contains(stub)
47+
case .originPwDfc: Card.FrameEffect.allCases.contains(stub)
48+
case .moonEldraziDfc: Card.FrameEffect.allCases.contains(stub)
49+
case .waxingAndWaningMoonDfc: Card.FrameEffect.allCases.contains(stub)
50+
case .showcase: Card.FrameEffect.allCases.contains(stub)
51+
case .extendedArt: Card.FrameEffect.allCases.contains(stub)
52+
case .companion: Card.FrameEffect.allCases.contains(stub)
53+
case .etched: Card.FrameEffect.allCases.contains(stub)
54+
case .snow: Card.FrameEffect.allCases.contains(stub)
55+
case .lesson: Card.FrameEffect.allCases.contains(stub)
56+
case .convertDfc: Card.FrameEffect.allCases.contains(stub)
57+
case .fAndFc: Card.FrameEffect.allCases.contains(stub)
58+
case .battle: Card.FrameEffect.allCases.contains(stub)
59+
case .gravestone: Card.FrameEffect.allCases.contains(stub)
60+
case .fullArt: Card.FrameEffect.allCases.contains(stub)
61+
case .vehicle: Card.FrameEffect.allCases.contains(stub)
62+
case .borderless: Card.FrameEffect.allCases.contains(stub)
63+
case .extended: Card.FrameEffect.allCases.contains(stub)
64+
case .spree: Card.FrameEffect.allCases.contains(stub)
65+
case .textless: Card.FrameEffect.allCases.contains(stub)
66+
case .enchantment: Card.FrameEffect.allCases.contains(stub)
67+
case .shatteredGlass: Card.FrameEffect.allCases.contains(stub)
68+
case .upsideDownDfc: Card.FrameEffect.allCases.contains(stub)
69+
case .unknown(let string):
70+
// Unknown case shouldn't be in allCases
71+
!Card.FrameEffect.allCases.contains(.unknown(string))
72+
}
73+
74+
XCTAssertTrue(contains)
75+
}
76+
77+
func testLayout() {
78+
let stub = Card.Layout.adventure
79+
let contains = switch stub {
80+
case .normal: Card.Layout.allCases.contains(.normal)
81+
case .split: Card.Layout.allCases.contains(.split)
82+
case .flip: Card.Layout.allCases.contains(.flip)
83+
case .transform: Card.Layout.allCases.contains(.transform)
84+
case .meld: Card.Layout.allCases.contains(.meld)
85+
case .leveler: Card.Layout.allCases.contains(.leveler)
86+
case .saga: Card.Layout.allCases.contains(.saga)
87+
case .adventure: Card.Layout.allCases.contains(.adventure)
88+
case .planar: Card.Layout.allCases.contains(.planar)
89+
case .scheme: Card.Layout.allCases.contains(.scheme)
90+
case .vanguard: Card.Layout.allCases.contains(.vanguard)
91+
case .token: Card.Layout.allCases.contains(.token)
92+
case .emblem: Card.Layout.allCases.contains(.emblem)
93+
case .augment: Card.Layout.allCases.contains(.augment)
94+
case .host: Card.Layout.allCases.contains(.host)
95+
case .class: Card.Layout.allCases.contains(.class)
96+
case .battle: Card.Layout.allCases.contains(.battle)
97+
case .case: Card.Layout.allCases.contains(.case)
98+
case .mutate: Card.Layout.allCases.contains(.mutate)
99+
case .prototype: Card.Layout.allCases.contains(.prototype)
100+
case .modalDfc: Card.Layout.allCases.contains(.modalDfc)
101+
case .doubleSided: Card.Layout.allCases.contains(.doubleSided)
102+
case .doubleFacedToken: Card.Layout.allCases.contains(.doubleFacedToken)
103+
case .artSeries: Card.Layout.allCases.contains(.artSeries)
104+
case .reversibleCard: Card.Layout.allCases.contains(.reversibleCard)
105+
case .unknown(let string):
106+
// Unknown case shouldn't be in allCases
107+
!Card.Layout.allCases.contains(.unknown(string))
108+
}
109+
110+
XCTAssertTrue(contains)
111+
}
112+
113+
func testSetType() {
114+
let stub = MTGSet.Kind.funny
115+
let contains = switch stub {
116+
case .core: MTGSet.Kind.allCases.contains(.core)
117+
case .expansion: MTGSet.Kind.allCases.contains(.expansion)
118+
case .masters: MTGSet.Kind.allCases.contains(.masters)
119+
case .masterpiece: MTGSet.Kind.allCases.contains(.masterpiece)
120+
case .spellbook: MTGSet.Kind.allCases.contains(.spellbook)
121+
case .commander: MTGSet.Kind.allCases.contains(.commander)
122+
case .planechase: MTGSet.Kind.allCases.contains(.planechase)
123+
case .archenemy: MTGSet.Kind.allCases.contains(.archenemy)
124+
case .vanguard: MTGSet.Kind.allCases.contains(.vanguard)
125+
case .funny: MTGSet.Kind.allCases.contains(.funny)
126+
case .starter: MTGSet.Kind.allCases.contains(.starter)
127+
case .box: MTGSet.Kind.allCases.contains(.box)
128+
case .promo: MTGSet.Kind.allCases.contains(.promo)
129+
case .token: MTGSet.Kind.allCases.contains(.token)
130+
case .memorabilia: MTGSet.Kind.allCases.contains(.memorabilia)
131+
case .arsenal: MTGSet.Kind.allCases.contains(.arsenal)
132+
case .alchemy: MTGSet.Kind.allCases.contains(.alchemy)
133+
case .minigame: MTGSet.Kind.allCases.contains(.minigame)
134+
case .fromTheVault: MTGSet.Kind.allCases.contains(.fromTheVault)
135+
case .premiumDeck: MTGSet.Kind.allCases.contains(.premiumDeck)
136+
case .duelDeck: MTGSet.Kind.allCases.contains(.duelDeck)
137+
case .draftInnovation: MTGSet.Kind.allCases.contains(.draftInnovation)
138+
case .treasureChest: MTGSet.Kind.allCases.contains(.treasureChest)
139+
case .unknown(let string):
140+
// Unknown case shouldn't be in allCases
141+
!MTGSet.Kind.allCases.contains(.unknown(string))
142+
}
143+
144+
XCTAssertTrue(contains)
145+
}
146+
}

0 commit comments

Comments
 (0)