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.
526 lines
20 KiB
Rust
526 lines
20 KiB
Rust
//! 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());
|
|
}
|
|
}
|