Spare/rarity filters, owned-only suggestions, and a Game night tab
Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
This commit is contained in:
@@ -10,8 +10,8 @@ use crate::model::{LibraryCard, LibraryFilter};
|
||||
use crate::Catalog;
|
||||
|
||||
/// Append the card-data predicates shared by [`Catalog::search_library`] and
|
||||
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`,
|
||||
/// which only make sense against the owned library. Caller has already
|
||||
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`/
|
||||
/// `spares_only`, which read the library tables. Caller has already
|
||||
/// written the `FROM ... WHERE <ownership predicate>` prefix.
|
||||
fn push_card_filters(
|
||||
sql: &mut String,
|
||||
@@ -95,6 +95,28 @@ fn push_card_filters(
|
||||
));
|
||||
vals.push(Value::Text(code.clone()));
|
||||
}
|
||||
if !filter.rarities.is_empty() {
|
||||
let ph = vec!["?"; filter.rarities.len()].join(",");
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.printings p
|
||||
WHERE p.oracle_id = oc.oracle_id AND p.rarity IN ({ph})"
|
||||
));
|
||||
vals.extend(filter.rarities.iter().map(|r| Value::Text(r.to_lowercase())));
|
||||
if let Some(code) = filter.set_code.as_ref().filter(|c| !c.is_empty()) {
|
||||
// Browsing a release for its rares means rare *there*, not rare
|
||||
// in some later reprint.
|
||||
sql.push_str(" AND p.set_code = ? COLLATE NOCASE");
|
||||
vals.push(Value::Text(code.clone()));
|
||||
} else if has_universes_beyond {
|
||||
// Arena-only printings re-rarity some paper cards (Historic
|
||||
// Anthologies, Alchemy sets), which would otherwise make a paper
|
||||
// common read as rare too. They still count for a card that only
|
||||
// exists digitally. `digital` shipped alongside the origin flags,
|
||||
// hence that capability check.
|
||||
sql.push_str(" AND (p.digital = 0 OR oc.digital_only = 1)");
|
||||
}
|
||||
sql.push(')');
|
||||
}
|
||||
if filter.colorless_identity {
|
||||
sql.push_str(" AND oc.color_identity = 0");
|
||||
}
|
||||
@@ -150,6 +172,14 @@ fn push_card_filters(
|
||||
}
|
||||
}
|
||||
|
||||
/// `spares_only`: more copies owned than the decks' main boards use. Both
|
||||
/// searches alias the library row `lc` (LEFT JOINed by the reference search,
|
||||
/// hence the COALESCE). Mirrors [`Catalog::assigned_totals_by_oracle`], which
|
||||
/// is what the over-assigned ⚠ is computed from.
|
||||
const SPARES_PREDICATE: &str = " AND COALESCE(lc.quantity_owned, 0) > COALESCE(
|
||||
(SELECT SUM(dc.quantity) FROM deck_cards dc
|
||||
WHERE dc.oracle_id = oc.oracle_id AND dc.is_maybeboard = 0), 0)";
|
||||
|
||||
/// Enrich raw `(oracle_id, quantity_owned, location, CardRef)` rows with deck
|
||||
/// context, shared by [`Catalog::search_library`], [`Catalog::search_reference`],
|
||||
/// and [`Catalog::list_library`].
|
||||
@@ -205,6 +235,9 @@ impl Catalog {
|
||||
sql.push_str(" AND LOWER(lc.location) LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
|
||||
}
|
||||
if filter.spares_only {
|
||||
sql.push_str(SPARES_PREDICATE);
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
|
||||
|
||||
@@ -257,6 +290,9 @@ impl Catalog {
|
||||
if filter.not_owned {
|
||||
sql.push_str(" AND COALESCE(lc.quantity_owned, 0) = 0");
|
||||
}
|
||||
if filter.spares_only {
|
||||
sql.push_str(SPARES_PREDICATE);
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY ");
|
||||
// Same reasoning as `NameIndex::search`: a card whose name *is* the query
|
||||
@@ -830,6 +866,102 @@ mod tests {
|
||||
assert!(unowned.iter().any(|c| c.card.oracle_id == "lightning-bolt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spares_only_keeps_cards_with_copies_beyond_what_decks_use() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("sol-ring", 2).unwrap(); // one in a deck → one spare
|
||||
cat.add_owned("lightning-bolt", 1).unwrap(); // its only copy is in a deck
|
||||
cat.add_owned("serra-angel", 1).unwrap(); // in no deck → spare
|
||||
cat.add_owned("llanowar-elves", 1).unwrap(); // only on a maybeboard → spare
|
||||
let deck = cat.create_deck("Deck", None).unwrap();
|
||||
cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap();
|
||||
cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap();
|
||||
cat.set_deck_card(deck.id, "llanowar-elves", 1, false, true).unwrap();
|
||||
// Unowned, but wanted by a deck: over-assigned, nothing to spare.
|
||||
cat.set_deck_card(deck.id, "deathrite-shaman", 1, false, false).unwrap();
|
||||
|
||||
let f = LibraryFilter { spares_only: true, ..Default::default() };
|
||||
let expected = vec!["Llanowar Elves", "Serra Angel", "Sol Ring"];
|
||||
assert_eq!(names(&cat.search_library(&f).unwrap()), expected);
|
||||
|
||||
// Card Search honors it too, rather than quietly listing everything.
|
||||
let mut from_reference = names(&cat.search_reference(&f, 50).unwrap());
|
||||
from_reference.sort();
|
||||
assert_eq!(from_reference, expected);
|
||||
|
||||
assert_eq!(cat.search_library(&LibraryFilter::default()).unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rarity_matches_any_printing_and_any_chosen_rarity() {
|
||||
let fx = owned_fixture();
|
||||
let conn = &fx.catalog.conn;
|
||||
for (id, oid, set, rarity) in [
|
||||
("p1", "lightning-bolt", "lea", "common"),
|
||||
("p2", "lightning-bolt", "sld", "rare"),
|
||||
("p3", "serra-angel", "m19", "uncommon"),
|
||||
("p4", "sol-ring", "c21", "uncommon"),
|
||||
("p5", "llanowar-elves", "dom", "mythic"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO ref.printings(scryfall_id, oracle_id, set_code, rarity)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![id, oid, set, rarity],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let by = |rarities: &[&str], set: Option<&str>| {
|
||||
names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
rarities: rarities.iter().map(|r| r.to_string()).collect(),
|
||||
set_code: set.map(Into::into),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
|
||||
assert_eq!(by(&["rare"], None), vec!["Lightning Bolt"], "a rare reprint counts");
|
||||
assert_eq!(by(&["common"], None), vec!["Lightning Bolt"]);
|
||||
assert_eq!(
|
||||
by(&["uncommon", "mythic"], None),
|
||||
vec!["Llanowar Elves", "Serra Angel", "Sol Ring"],
|
||||
"several rarities are alternatives, not all required"
|
||||
);
|
||||
// Within a set it's that set's printing that has to match.
|
||||
assert!(by(&["rare"], Some("lea")).is_empty());
|
||||
assert_eq!(by(&["common"], Some("LEA")), vec!["Lightning Bolt"]);
|
||||
// No rarity chosen is no filter.
|
||||
assert_eq!(by(&[], None).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rarity_ignores_arena_printings_of_paper_cards() {
|
||||
let fx = owned_fixture();
|
||||
let conn = &fx.catalog.conn;
|
||||
conn.execute_batch(
|
||||
"INSERT INTO ref.printings(scryfall_id, oracle_id, rarity, digital)
|
||||
VALUES ('paper', 'serra-angel', 'uncommon', 0),
|
||||
('arena', 'serra-angel', 'rare', 1),
|
||||
('alchemy', 'sol-ring', 'rare', 1);
|
||||
UPDATE ref.oracle_cards SET digital_only = 1 WHERE oracle_id = 'sol-ring';",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let rares = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter { rarities: vec!["rare".into()], ..Default::default() })
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
rares,
|
||||
vec!["Sol Ring"],
|
||||
"an Arena re-rarity doesn't make a paper card rare, but a digital-only card keeps its own"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_reference_respects_limit() {
|
||||
let fx = test_support::make();
|
||||
|
||||
@@ -29,7 +29,7 @@ pub use model::{
|
||||
LibraryFilter, LibraryStats,
|
||||
};
|
||||
pub use search::NameIndex;
|
||||
pub use synergy::{CardSuggestion, SynergyReason};
|
||||
pub use synergy::{CardSuggestion, SuggestOptions, SynergyReason};
|
||||
|
||||
/// A handle to an open catalog: the library db with the reference db attached.
|
||||
///
|
||||
|
||||
@@ -347,6 +347,18 @@ pub struct LibraryFilter {
|
||||
/// Search tab's "Not owned" quick filter.
|
||||
#[serde(default)]
|
||||
pub not_owned: bool,
|
||||
/// Only cards with copies to spare: more owned than the decks' main
|
||||
/// boards use between them (the opposite of the over-assigned ⚠). A card
|
||||
/// in no deck at all has every copy spare. Maybeboards don't count, the
|
||||
/// same as for over-assignment.
|
||||
#[serde(default)]
|
||||
pub spares_only: bool,
|
||||
/// Only cards printed at one of these rarities ("common", "uncommon",
|
||||
/// "rare", "mythic"); any one of them matches. The library tracks cards
|
||||
/// rather than printings, so any printing counts — narrowed to the
|
||||
/// printing in [`Self::set_code`] when that's set.
|
||||
#[serde(default)]
|
||||
pub rarities: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -34,7 +34,7 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::params;
|
||||
use rusqlite::types::Value;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::{CoreError, Result};
|
||||
@@ -109,6 +109,10 @@ pub struct SynergyReason {
|
||||
pub struct CardSuggestion {
|
||||
pub card: CardRef,
|
||||
pub quantity_owned: i64,
|
||||
/// Copies other decks' main boards already use. Never this deck's: cards
|
||||
/// already in it aren't suggested. Lets the UI tell a spare copy from one
|
||||
/// you'd have to pull out of another deck.
|
||||
pub quantity_assigned: i64,
|
||||
pub score: f64,
|
||||
pub reasons: Vec<SynergyReason>,
|
||||
/// Scryfall's EDHREC popularity rank (lower = more common in Commander);
|
||||
@@ -134,6 +138,18 @@ const ARCHETYPE_CAP: usize = 3;
|
||||
/// to invert the popularity tiebreak toward the least-played matches.
|
||||
const UNUSUAL_RANK_THRESHOLD: i64 = 2000;
|
||||
|
||||
/// How [`Catalog::suggest_cards`] picks its candidate pool.
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SuggestOptions {
|
||||
/// Favor cards that fit but aren't widely played — see
|
||||
/// [`UNUSUAL_RANK_THRESHOLD`].
|
||||
pub unusual: bool,
|
||||
/// Only cards you own at least one copy of: build from the collection
|
||||
/// rather than a shopping list.
|
||||
pub owned_only: bool,
|
||||
}
|
||||
|
||||
/// A candidate row pulled from the reference db before scoring.
|
||||
struct Candidate {
|
||||
card: CardRef,
|
||||
@@ -146,7 +162,8 @@ impl Catalog {
|
||||
/// Suggest up to `limit` cards (skipping the first `offset`, for a
|
||||
/// "suggest more" continuation) that synergize with `deck_id`, scored and
|
||||
/// explained across the four synergy families. Pulls from the whole
|
||||
/// reference db (ownership is flagged via `quantity_owned`, not filtered),
|
||||
/// reference db (ownership is flagged via `quantity_owned`, and filtered
|
||||
/// only on request — see [`SuggestOptions`]),
|
||||
/// excludes cards already on any board of the deck, and hard-filters to the
|
||||
/// deck's (or commander's) color identity.
|
||||
///
|
||||
@@ -157,17 +174,19 @@ impl Catalog {
|
||||
/// candidate pool and can shift later pages by one; acceptable for this
|
||||
/// feature's scope).
|
||||
///
|
||||
/// When `unusual` is set, candidates ranked below [`UNUSUAL_RANK_THRESHOLD`]
|
||||
/// (Scryfall's own popularity signal) or entirely unranked are favored
|
||||
/// instead of the usual "float the staples" behavior, surfacing
|
||||
/// underappreciated cards that still fit the deck's strategy.
|
||||
/// When `opts.unusual` is set, candidates ranked below
|
||||
/// [`UNUSUAL_RANK_THRESHOLD`] (Scryfall's own popularity signal) or
|
||||
/// entirely unranked are favored instead of the usual "float the staples"
|
||||
/// behavior, surfacing underappreciated cards that still fit the deck's
|
||||
/// strategy. `opts.owned_only` narrows the pool to cards you own.
|
||||
pub fn suggest_cards(
|
||||
&self,
|
||||
deck_id: i64,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
unusual: bool,
|
||||
opts: SuggestOptions,
|
||||
) -> Result<Vec<CardSuggestion>> {
|
||||
let SuggestOptions { unusual, owned_only } = opts;
|
||||
// The `themes`/`edhrec_rank` columns only exist in a reference.db built
|
||||
// after this feature shipped; an older ingest lacks them. Detect that
|
||||
// and return a clear, actionable message instead of a raw "no such
|
||||
@@ -360,6 +379,11 @@ impl Catalog {
|
||||
sql.push_str(" AND (oc.edhrec_rank IS NULL OR oc.edhrec_rank >= ?)");
|
||||
vals.push(Value::Integer(UNUSUAL_RANK_THRESHOLD));
|
||||
}
|
||||
if owned_only {
|
||||
// Filtered before scoring and paging, so "Suggest more" keeps
|
||||
// finding owned cards instead of pages thinning out to nothing.
|
||||
sql.push_str(" AND COALESCE(lc.quantity_owned, 0) > 0");
|
||||
}
|
||||
|
||||
let candidates: Vec<Candidate> = {
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
@@ -392,6 +416,7 @@ impl Catalog {
|
||||
let cand_ids: Vec<String> = candidates.iter().map(|c| c.card.oracle_id.clone()).collect();
|
||||
let sub_map = self.batch_attr(&cand_ids, "card_subtypes", "subtype")?;
|
||||
let kw_map = self.batch_attr(&cand_ids, "card_keywords", "keyword")?;
|
||||
let assigned = self.assigned_totals_by_oracle()?;
|
||||
let empty: Vec<String> = Vec::new();
|
||||
|
||||
let mut out: Vec<CardSuggestion> = Vec::new();
|
||||
@@ -484,6 +509,7 @@ impl Catalog {
|
||||
continue;
|
||||
}
|
||||
out.push(CardSuggestion {
|
||||
quantity_assigned: assigned.get(oid).copied().unwrap_or(0),
|
||||
card: cand.card,
|
||||
quantity_owned: cand.quantity_owned,
|
||||
score,
|
||||
@@ -691,7 +717,7 @@ mod tests {
|
||||
cat.set_deck_card(d.id, "deathrite-shaman", 1, false, false).unwrap();
|
||||
cat.set_deck_card(d.id, "elf-c", 1, false, false).unwrap();
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();
|
||||
let ids: Vec<&str> = out.iter().map(|s| s.card.oracle_id.as_str()).collect();
|
||||
|
||||
// Both Elf candidates appear with a Tribal reason mentioning Elf.
|
||||
@@ -710,7 +736,7 @@ mod tests {
|
||||
// `unusual` mode excludes the widely-played candidate entirely and
|
||||
// keeps the obscure one.
|
||||
add_ref_card(cat, "elf-unranked", "Unranked Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None);
|
||||
let out = cat.suggest_cards(d.id, 50, 0, true).unwrap();
|
||||
let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions { unusual: true, ..Default::default() }).unwrap();
|
||||
let ids: Vec<&str> = out.iter().map(|s| s.card.oracle_id.as_str()).collect();
|
||||
assert!(!ids.contains(&"elf-lord"), "rank 5 is well below the unusual threshold");
|
||||
assert!(ids.contains(&"elf-rare"));
|
||||
@@ -738,7 +764,7 @@ mod tests {
|
||||
// An Elf candidate whose only possible signal is tribal.
|
||||
add_ref_card(cat, "lone-elf", "Lone Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None);
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();
|
||||
assert!(!out.iter().any(|s| s.card.oracle_id == "lone-elf"));
|
||||
}
|
||||
|
||||
@@ -756,7 +782,7 @@ mod tests {
|
||||
add_ref_card(cat, "landfall-payoff", "Landfall Payoff", "Creature — Elemental", "G", "", &[], &["Landfall"], 0, None);
|
||||
add_ref_card(cat, "just-a-flyer", "Just A Flyer", "Creature — Bird", "G", "", &[], &["Flying"], 0, None);
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();
|
||||
let payoff = out.iter().find(|s| s.card.oracle_id == "landfall-payoff");
|
||||
assert!(payoff.is_some(), "non-evergreen keyword should drive suggestions");
|
||||
assert!(payoff.unwrap().reasons.iter().any(|r| r.category == "Mechanic" && r.detail.contains("Landfall")));
|
||||
@@ -776,7 +802,7 @@ mod tests {
|
||||
add_ref_card(cat, "green-elf", "Green Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None);
|
||||
add_ref_card(cat, "black-elf", "Black Elf", "Creature — Elf", "B", "", &["Elf"], &[], 0, None);
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();
|
||||
let green = out.iter().find(|s| s.card.oracle_id == "green-elf");
|
||||
assert!(green.is_some(), "commander's own type should be a tribal target");
|
||||
assert!(green.unwrap().reasons.iter().any(|r| r.detail.contains("commander's type")));
|
||||
@@ -793,7 +819,7 @@ mod tests {
|
||||
cat.set_deck_card(d.id, "deathrite-shaman", 1, false, false).unwrap();
|
||||
add_ref_card(cat, "swamp-dork", "Swamp Dork", "Creature — Elf", "B", "B", &["Elf"], &[], 0, None);
|
||||
|
||||
let out = cat.suggest_cards(d.id, 50, 0, false).unwrap();
|
||||
let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();
|
||||
let dork = out.iter().find(|s| s.card.oracle_id == "swamp-dork").unwrap();
|
||||
assert!(dork.reasons.iter().any(|r| r.category == "Mana base" && r.detail.contains("{B}")));
|
||||
}
|
||||
@@ -803,7 +829,34 @@ mod tests {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("Empty", None).unwrap();
|
||||
assert!(cat.suggest_cards(d.id, 50, 0, false).unwrap().is_empty());
|
||||
assert!(cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_only_keeps_your_cards_and_reports_copies_other_decks_use() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_ref_card(cat, "elf-c", "Elf Cleric", "Creature — Elf Cleric", "G", "", &["Elf"], &[], 0, None);
|
||||
add_ref_card(cat, "owned-elf", "Owned Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None);
|
||||
add_ref_card(cat, "shop-elf", "Shop Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None);
|
||||
let d = cat.create_deck("Elfball", None).unwrap();
|
||||
for oid in ["llanowar-elves", "deathrite-shaman", "elf-c"] {
|
||||
cat.set_deck_card(d.id, oid, 1, false, false).unwrap();
|
||||
}
|
||||
cat.add_owned("owned-elf", 2).unwrap();
|
||||
let other = cat.create_deck("Other", None).unwrap();
|
||||
cat.set_deck_card(other.id, "owned-elf", 1, false, false).unwrap();
|
||||
|
||||
let all = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap();
|
||||
assert!(all.iter().any(|s| s.card.oracle_id == "shop-elf"));
|
||||
|
||||
let owned = cat
|
||||
.suggest_cards(d.id, 50, 0, SuggestOptions { owned_only: true, ..Default::default() })
|
||||
.unwrap();
|
||||
let ids: Vec<&str> = owned.iter().map(|s| s.card.oracle_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["owned-elf"]);
|
||||
assert_eq!(owned[0].quantity_owned, 2);
|
||||
assert_eq!(owned[0].quantity_assigned, 1, "one copy is already in the other deck");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user