Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
This commit is contained in:
525
crates/mtgc-core/src/printings.rs
Normal file
525
crates/mtgc-core/src/printings.rs
Normal file
@@ -0,0 +1,525 @@
|
||||
//! Per-printing detail layered over the oracle-level library: which printings
|
||||
//! a card has, which printing + finish owned copies are, and which printing's
|
||||
//! art a deck slot shows.
|
||||
//!
|
||||
//! `library_cards.quantity_owned` stays the authoritative total. Copy groups
|
||||
//! (`library_card_copies`) annotate a subset of it, and two rules keep them
|
||||
//! consistent with that total:
|
||||
//!
|
||||
//! - **Grow:** tracking more copies of a printing than are unspecified raises
|
||||
//! `quantity_owned` to fit — recording a copy means you own it.
|
||||
//! - **Clamp:** lowering `quantity_owned` below the tracked total trims the
|
||||
//! most recently added groups first ([`Catalog::clamp_copies`]). Lowering a
|
||||
//! group never lowers `quantity_owned`; those copies become unspecified.
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::{LibraryCopy, PrintingRef};
|
||||
use crate::Catalog;
|
||||
|
||||
/// The finishes a copy can be tracked as — Scryfall's `finishes` values.
|
||||
pub const FINISHES: [&str; 3] = ["nonfoil", "foil", "etched"];
|
||||
|
||||
/// Map the ten printing columns starting at `at` (`scryfall_id, set_code,
|
||||
/// collector_number, finishes, rarity, image_uri, back_image_uri, price_usd,
|
||||
/// lang, released_at`) into a [`PrintingRef`], or `None` when `scryfall_id`
|
||||
/// is NULL (an unmatched LEFT JOIN).
|
||||
fn row_to_printing(r: &rusqlite::Row, at: usize) -> rusqlite::Result<Option<PrintingRef>> {
|
||||
let Some(scryfall_id) = r.get::<_, Option<String>>(at)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let finishes: String = r.get(at + 3)?;
|
||||
Ok(Some(PrintingRef {
|
||||
scryfall_id,
|
||||
set_code: r.get(at + 1)?,
|
||||
collector_number: r.get(at + 2)?,
|
||||
finishes: split_finishes(&finishes),
|
||||
rarity: r.get(at + 4)?,
|
||||
image_uri: r.get(at + 5)?,
|
||||
back_image_uri: r.get(at + 6)?,
|
||||
price_usd: r.get(at + 7)?,
|
||||
lang: r.get(at + 8)?,
|
||||
released_at: r.get(at + 9)?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn split_finishes(joined: &str) -> Vec<String> {
|
||||
joined
|
||||
.split(',')
|
||||
.filter(|f| !f.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The printing columns [`row_to_printing`] reads, for the printing
|
||||
/// aliased `p`. `lang`/`released_at` read as empty on a reference.db
|
||||
/// built before they shipped.
|
||||
fn printing_columns(&self) -> &'static str {
|
||||
if self.has_printing_details {
|
||||
"p.scryfall_id, p.set_code, p.collector_number, p.finishes, p.rarity,
|
||||
p.image_uri, p.back_image_uri, p.price_usd, p.lang, p.released_at"
|
||||
} else {
|
||||
"p.scryfall_id, p.set_code, p.collector_number, p.finishes, p.rarity,
|
||||
p.image_uri, p.back_image_uri, p.price_usd, '', ''"
|
||||
}
|
||||
}
|
||||
|
||||
/// Every paper printing of a card, newest first — the printing picker.
|
||||
/// Arena/MTGO-only printings are left out, since no physical copy can be
|
||||
/// one (and a deck's art should be something you could own).
|
||||
pub fn list_printings(&self, oracle_id: &str) -> Result<Vec<PrintingRef>> {
|
||||
// `digital` predates `released_at`, so a db can have the filter but
|
||||
// not the ordering. Checked here rather than cached on open: only the
|
||||
// picker needs it.
|
||||
let filter = if crate::db::has_reference_column(&self.conn, "printings", "digital")? {
|
||||
"AND p.digital = 0"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let order = if self.has_printing_details {
|
||||
"p.released_at DESC, p.rowid"
|
||||
} else {
|
||||
"p.rowid"
|
||||
};
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT {cols} FROM {REF_DB}.printings p
|
||||
WHERE p.oracle_id = ?1 {filter}
|
||||
ORDER BY {order}",
|
||||
cols = self.printing_columns()
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![oracle_id], |r| row_to_printing(r, 0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
/// The printing of `oracle_id` with this set code (case-insensitive) and
|
||||
/// collector number — how decklists name a printing (`(2X2) 315`).
|
||||
pub fn find_printing(
|
||||
&self,
|
||||
oracle_id: &str,
|
||||
set_code: &str,
|
||||
collector_number: &str,
|
||||
) -> Result<Option<String>> {
|
||||
Ok(self
|
||||
.conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT scryfall_id FROM {REF_DB}.printings
|
||||
WHERE oracle_id = ?1 AND lower(set_code) = lower(?2) AND collector_number = ?3
|
||||
LIMIT 1"
|
||||
),
|
||||
params![oracle_id, set_code, collector_number],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// A card's tracked copy groups, oldest first.
|
||||
pub fn list_library_copies(&self, oracle_id: &str) -> Result<Vec<LibraryCopy>> {
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT c.scryfall_id, c.finish, c.quantity, {cols}
|
||||
FROM library_card_copies c
|
||||
JOIN library_cards lc ON lc.id = c.library_card_id
|
||||
LEFT JOIN {REF_DB}.printings p ON p.scryfall_id = c.scryfall_id
|
||||
WHERE lc.oracle_id = ?1
|
||||
ORDER BY c.id",
|
||||
cols = self.printing_columns()
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![oracle_id], |r| {
|
||||
Ok(LibraryCopy {
|
||||
scryfall_id: r.get(0)?,
|
||||
finish: r.get(1)?,
|
||||
quantity: r.get(2)?,
|
||||
printing: row_to_printing(r, 3)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Check that `scryfall_id` is a printing of `oracle_id` and return the
|
||||
/// finishes it comes in (empty when Scryfall lists none).
|
||||
pub(crate) fn printing_finishes(&self, oracle_id: &str, scryfall_id: &str) -> Result<Vec<String>> {
|
||||
let finishes: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT finishes FROM {REF_DB}.printings WHERE scryfall_id = ?1 AND oracle_id = ?2"
|
||||
),
|
||||
params![scryfall_id, oracle_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
finishes
|
||||
.map(|f| split_finishes(&f))
|
||||
.ok_or_else(|| CoreError::Other("that isn't a printing of this card".into()))
|
||||
}
|
||||
|
||||
/// Set how many owned copies of a card are `scryfall_id` in `finish`.
|
||||
/// `quantity <= 0` removes the group (never validated against the
|
||||
/// reference db, so a group whose printing has since vanished can still
|
||||
/// be removed). Grows `quantity_owned` when the groups would otherwise
|
||||
/// exceed it. Returns the card's owned quantity afterwards.
|
||||
pub fn set_library_copy(
|
||||
&self,
|
||||
oracle_id: &str,
|
||||
scryfall_id: &str,
|
||||
finish: &str,
|
||||
quantity: i64,
|
||||
) -> Result<i64> {
|
||||
if quantity > 0 {
|
||||
if !FINISHES.contains(&finish) {
|
||||
return Err(CoreError::Other(format!("unknown finish \"{finish}\"")));
|
||||
}
|
||||
let finishes = self.printing_finishes(oracle_id, scryfall_id)?;
|
||||
if !finishes.is_empty() && !finishes.iter().any(|f| f == finish) {
|
||||
return Err(CoreError::Other(format!(
|
||||
"that printing doesn't come in {finish}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let tx = self.conn.unchecked_transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO library_cards(oracle_id) VALUES (?1) ON CONFLICT(oracle_id) DO NOTHING",
|
||||
params![oracle_id],
|
||||
)?;
|
||||
let card_id: i64 = tx.query_row(
|
||||
"SELECT id FROM library_cards WHERE oracle_id = ?1",
|
||||
params![oracle_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if quantity <= 0 {
|
||||
tx.execute(
|
||||
"DELETE FROM library_card_copies
|
||||
WHERE library_card_id = ?1 AND scryfall_id = ?2 AND finish = ?3",
|
||||
params![card_id, scryfall_id, finish],
|
||||
)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO library_card_copies(library_card_id, scryfall_id, finish, quantity)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(library_card_id, scryfall_id, finish)
|
||||
DO UPDATE SET quantity = excluded.quantity",
|
||||
params![card_id, scryfall_id, finish, quantity],
|
||||
)?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE library_cards SET quantity_owned = MAX(quantity_owned,
|
||||
(SELECT COALESCE(SUM(quantity), 0) FROM library_card_copies WHERE library_card_id = ?1))
|
||||
WHERE id = ?1",
|
||||
params![card_id],
|
||||
)?;
|
||||
let owned: i64 = tx.query_row(
|
||||
"SELECT quantity_owned FROM library_cards WHERE id = ?1",
|
||||
params![card_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(owned)
|
||||
}
|
||||
|
||||
/// Trim a card's copy groups until they fit within `quantity_owned`,
|
||||
/// most recently added first: the newest annotation is the likeliest to
|
||||
/// be the copy that just left, and older entries are kept intact.
|
||||
pub(crate) fn clamp_copies(&self, oracle_id: &str) -> Result<()> {
|
||||
let Some((card_id, owned)) = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT id, quantity_owned FROM library_cards WHERE oracle_id = ?1",
|
||||
params![oracle_id],
|
||||
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
|
||||
)
|
||||
.optional()?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let groups: Vec<(i64, i64)> = {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, quantity FROM library_card_copies WHERE library_card_id = ?1 ORDER BY id",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![card_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect::<std::result::Result<_, _>>()?
|
||||
};
|
||||
let mut excess = groups.iter().map(|(_, q)| q).sum::<i64>() - owned;
|
||||
for (id, qty) in groups.iter().rev() {
|
||||
if excess <= 0 {
|
||||
break;
|
||||
}
|
||||
let cut = (*qty).min(excess);
|
||||
if cut == *qty {
|
||||
self.conn
|
||||
.execute("DELETE FROM library_card_copies WHERE id = ?1", params![id])?;
|
||||
} else {
|
||||
self.conn.execute(
|
||||
"UPDATE library_card_copies SET quantity = ?1 WHERE id = ?2",
|
||||
params![qty - cut, id],
|
||||
)?;
|
||||
}
|
||||
excess -= cut;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Choose which printing's art a deck slot shows, or `None` for the
|
||||
/// card's default. Fails if the card isn't in the deck.
|
||||
pub fn set_deck_card_printing(
|
||||
&self,
|
||||
deck_id: i64,
|
||||
oracle_id: &str,
|
||||
scryfall_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if let Some(id) = scryfall_id {
|
||||
self.printing_finishes(oracle_id, id)?;
|
||||
}
|
||||
let changed = self.conn.execute(
|
||||
"UPDATE deck_cards SET preferred_scryfall_id = ?1 WHERE deck_id = ?2 AND oracle_id = ?3",
|
||||
params![scryfall_id, deck_id, oracle_id],
|
||||
)?;
|
||||
if changed == 0 {
|
||||
return Err(CoreError::Other("that card isn't in this deck".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test_support;
|
||||
use crate::Catalog;
|
||||
|
||||
/// Give Sol Ring two extra printings alongside the fixture's own
|
||||
/// ("print-sol-ring", no finishes listed, no date).
|
||||
fn add_sol_ring_printings(cat: &Catalog) {
|
||||
cat.connection()
|
||||
.execute_batch(
|
||||
"INSERT INTO ref.printings
|
||||
(scryfall_id, oracle_id, set_code, collector_number, finishes, image_uri, back_image_uri, released_at, digital)
|
||||
VALUES
|
||||
('sol-2x2', 'sol-ring', '2x2', '315', 'nonfoil,foil', 'http://img/2x2', '', '2022-07-08', 0),
|
||||
('sol-c21', 'sol-ring', 'c21', '263', 'nonfoil', 'http://img/c21', 'http://img/c21-back', '2021-04-23', 0),
|
||||
('sol-arena', 'sol-ring', 'ymid', '1', 'nonfoil', 'http://img/arena', '', '2023-01-01', 1);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_printings_is_newest_first_and_paper_only() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
|
||||
let ids: Vec<_> = cat
|
||||
.list_printings("sol-ring")
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.scryfall_id)
|
||||
.collect();
|
||||
// The fixture printing has no date, so it sorts last.
|
||||
assert_eq!(ids, vec!["sol-2x2", "sol-c21", "print-sol-ring"]);
|
||||
|
||||
let p = &cat.list_printings("sol-ring").unwrap()[0];
|
||||
assert_eq!(p.finishes, vec!["nonfoil", "foil"]);
|
||||
assert_eq!(p.set_code, "2x2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_printing_matches_set_case_insensitively() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
assert_eq!(
|
||||
cat.find_printing("sol-ring", "2X2", "315").unwrap().as_deref(),
|
||||
Some("sol-2x2")
|
||||
);
|
||||
assert_eq!(cat.find_printing("sol-ring", "2X2", "999").unwrap(), None);
|
||||
// Right set and number, wrong card.
|
||||
assert_eq!(cat.find_printing("lightning-bolt", "2X2", "315").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_groups_track_several_printings_of_one_card() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
cat.set_owned("sol-ring", 5).unwrap();
|
||||
|
||||
cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 2).unwrap();
|
||||
cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap();
|
||||
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 1).unwrap();
|
||||
|
||||
let copies = cat.list_library_copies("sol-ring").unwrap();
|
||||
let summary: Vec<_> = copies
|
||||
.iter()
|
||||
.map(|c| (c.scryfall_id.as_str(), c.finish.as_str(), c.quantity))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
vec![("sol-2x2", "nonfoil", 2), ("sol-2x2", "foil", 1), ("sol-c21", "nonfoil", 1)]
|
||||
);
|
||||
assert_eq!(copies[0].printing.as_ref().unwrap().set_code, "2x2");
|
||||
|
||||
// Tracking within the owned total doesn't change it.
|
||||
let lib = cat.list_library().unwrap();
|
||||
assert_eq!(lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap().quantity_owned, 5);
|
||||
|
||||
// Setting a group again updates it in place; zero removes it.
|
||||
cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 3).unwrap();
|
||||
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 0).unwrap();
|
||||
let copies = cat.list_library_copies("sol-ring").unwrap();
|
||||
assert_eq!(copies.len(), 2);
|
||||
assert_eq!(copies[0].quantity, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_more_copies_than_owned_grows_ownership() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
cat.set_owned("sol-ring", 1).unwrap();
|
||||
|
||||
assert_eq!(cat.set_library_copy("sol-ring", "sol-2x2", "foil", 3).unwrap(), 3);
|
||||
// Even for a card not yet in the library at all.
|
||||
assert_eq!(cat.set_library_copy("lightning-bolt", "print-lightning-bolt", "nonfoil", 2).unwrap(), 2);
|
||||
|
||||
// Lowering a group never lowers ownership.
|
||||
assert_eq!(cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowering_ownership_trims_the_newest_groups_first() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
cat.set_owned("sol-ring", 5).unwrap();
|
||||
cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 2).unwrap(); // oldest
|
||||
cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap();
|
||||
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 2).unwrap(); // newest
|
||||
|
||||
// 5 tracked, now 3 owned: dropping the newest group (2) is enough.
|
||||
cat.set_owned("sol-ring", 3).unwrap();
|
||||
let q: Vec<_> = cat
|
||||
.list_library_copies("sol-ring")
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|c| (c.scryfall_id, c.finish, c.quantity))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
q,
|
||||
vec![("sol-2x2".into(), "nonfoil".into(), 2), ("sol-2x2".into(), "foil".into(), 1)]
|
||||
);
|
||||
|
||||
// Through the decrement path too, partially trimming a group.
|
||||
cat.add_owned("sol-ring", -2).unwrap();
|
||||
let q: Vec<_> = cat
|
||||
.list_library_copies("sol-ring")
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|c| (c.finish, c.quantity))
|
||||
.collect();
|
||||
assert_eq!(q, vec![("nonfoil".into(), 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_are_validated_against_the_card_and_its_finishes() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
|
||||
// A printing of a different card.
|
||||
assert!(cat.set_library_copy("sol-ring", "print-lightning-bolt", "nonfoil", 1).is_err());
|
||||
// A finish this printing doesn't come in.
|
||||
assert!(cat.set_library_copy("sol-ring", "sol-c21", "foil", 1).is_err());
|
||||
// Not a finish at all.
|
||||
assert!(cat.set_library_copy("sol-ring", "sol-2x2", "shiny", 1).is_err());
|
||||
// A printing that lists no finishes accepts any real one.
|
||||
cat.set_library_copy("sol-ring", "print-sol-ring", "etched", 1).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_whose_printing_vanished_is_listed_and_removable() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 1).unwrap();
|
||||
// A re-ingest that no longer has it.
|
||||
cat.connection()
|
||||
.execute("DELETE FROM ref.printings WHERE scryfall_id = 'sol-c21'", [])
|
||||
.unwrap();
|
||||
|
||||
let copies = cat.list_library_copies("sol-ring").unwrap();
|
||||
assert_eq!(copies.len(), 1);
|
||||
assert!(copies[0].printing.is_none());
|
||||
|
||||
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 0).unwrap();
|
||||
assert!(cat.list_library_copies("sol-ring").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deck_slot_shows_its_chosen_printing() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
let d = cat.create_deck("Art", None).unwrap();
|
||||
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||
|
||||
let sol = |cat: &Catalog| {
|
||||
cat.deck_contents(d.id)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|e| e.card.oracle_id == "sol-ring")
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Unset: the default image.
|
||||
assert_eq!(sol(cat).card.image_uri, "http://img/sol-ring");
|
||||
assert_eq!(sol(cat).preferred_scryfall_id, None);
|
||||
|
||||
// Set: both faces come from the chosen printing.
|
||||
cat.set_deck_card_printing(d.id, "sol-ring", Some("sol-c21")).unwrap();
|
||||
let e = sol(cat);
|
||||
assert_eq!(e.preferred_scryfall_id.as_deref(), Some("sol-c21"));
|
||||
assert_eq!(e.card.image_uri, "http://img/c21");
|
||||
assert_eq!(e.card.back_image_uri, "http://img/c21-back");
|
||||
|
||||
// Survives a quantity/board change.
|
||||
cat.set_deck_card(d.id, "sol-ring", 2, false, true).unwrap();
|
||||
assert_eq!(sol(cat).card.image_uri, "http://img/c21");
|
||||
|
||||
// Kept by a duplicate.
|
||||
let copy = cat.duplicate_deck(d.id, "Art copy").unwrap();
|
||||
let dup = cat.deck_contents(copy.id).unwrap();
|
||||
assert_eq!(dup[0].preferred_scryfall_id.as_deref(), Some("sol-c21"));
|
||||
|
||||
// Dangling after a re-ingest: falls back to the default.
|
||||
cat.connection()
|
||||
.execute("DELETE FROM ref.printings WHERE scryfall_id = 'sol-c21'", [])
|
||||
.unwrap();
|
||||
let e = sol(cat);
|
||||
assert_eq!(e.card.image_uri, "http://img/sol-ring");
|
||||
assert_eq!(e.card.back_image_uri, "");
|
||||
|
||||
// Cleared.
|
||||
cat.set_deck_card_printing(d.id, "sol-ring", None).unwrap();
|
||||
assert_eq!(sol(cat).preferred_scryfall_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_printing_is_validated() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
add_sol_ring_printings(cat);
|
||||
let d = cat.create_deck("Art", None).unwrap();
|
||||
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||
|
||||
// Not a printing of this card.
|
||||
assert!(cat.set_deck_card_printing(d.id, "sol-ring", Some("print-lightning-bolt")).is_err());
|
||||
// Card not in the deck.
|
||||
assert!(cat.set_deck_card_printing(d.id, "lightning-bolt", Some("print-lightning-bolt")).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user