diff --git a/CLAUDE.md b/CLAUDE.md index f5396d0c..67b61162 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -Collector is a self-hostable, multi-tenant web app for tracking collectibles, starting with Magic: The Gathering cards. The Rails app skeleton exists (stack, RSpec, `bin/ci`, Compose and Kamal deployment). Feature 002 added a collectible-agnostic catalog core (`app/models/catalog/`) with an MTG extension (`app/models/mtg/`), Scryfall bulk-data ingestion via a background refresh, and a card search at `/catalog/entries`, which has needed sign-in since accounts (spec 004) unless the operator opens it with `COLLECTOR_PUBLIC_CATALOG` (spec 023). Accounts (row-level tenancy through `Current.account`) and collections of lots (spec 004, list view spec 006) exist, and the card scanner (specs 005, 007–009) adds scanned cards to the collection through its confirm flow. Admins start and watch catalog refreshes and art index builds at `/admin/catalog`, and see, retry and discard background jobs at `/admin/jobs` (spec 015). A collector's data has a documented export format, and a collector downloads, previews and applies an export file from the account menu or More (`CollectionExport`, `CollectionImport`, `CollectionImport::Apply`; [`docs/architecture/data-portability.md`](docs/architecture/data-portability.md)). A collector adds cards from a set by collector number at `/collection/number_entry` ("Add by number", spec 026): one line per number, quantity and finish, each added at once to a per-account list with Undo (`NumberEntry`, `NumberEntry::Line`), which Done clears; the list is working state, left out of the export. A collector manages the places their collection is kept in at `/locations` (`Location`, spec 029) and records which location a lot is in when adding or editing it, or moves some or all of a lot's copies with Move copies (`Lot::Move`, spec 032; a location that holds copies can't be removed; export format version 3) and moves every copy of many lots at once from bulk mode with Move to… (`Lot::BulkMove`, spec 041), opens a location's lots in bulk mode from its removal refusal page with one button (`Locations::SelectionsController`, spec 059), and the collection's grid tiles and table rows show it (spec 033) and filter by it (spec 037). A collector creates, renames and removes decks at `/decks` (`Deck`, `Deck::Creation`, `Catalog::Formats`, spec 062, [ADR 0028](docs/adr/0028-decks-are-locations-with-zones.md)): a deck is a location of kind `deck` whose zones are child locations that hold its copies, the collectible extension registers the formats, a lot in a zone is shown as "Deck › Zone", the export format (version 4) carries decks, a collector changes a deck's format at `/decks/:id/format/edit` (`Deck::FormatChange`, spec 064), opens a deck's lots in bulk mode from its removal refusal page (`Decks::SelectionsController`, spec 067), and opens a deck at `/decks/:id` to read the cards they own in it, zone by zone, with their cost and legality (`Deck::Page`, the seam `Catalog::DeckCards`, spec 066), and the deck's shape, its mana curve, card types and coloured mana symbols, in a panel beside the zones (`Catalog::DeckShape`, `MTG::DeckShape`, spec 069), and records the cards a deck's zone needs and they don't own as proxies, shown beside the owned copies and counted in a deck's cards but never as owned (`Deck::Proxy`, spec 068): a change of format moves them into Considering and the export carries them (format version 4 has `proxies`). A collector swaps a proxy for a free copy they own (Use my copy) or a new copy (Add a new copy) from the row's menu, and the proxy shrinks or goes in the same write (`Deck::Proxy::UseCopies`, `Deck::Proxy::AddCopy`, `Lot.free`, `Lot::MoveIntoZone`, spec 070). A collector adds a card to a deck from its card page, their free copies or a proxy (`Deck::Addition`, `Lot::FreeCopies`, spec 071). The card page shows how many copies are free to use and which decks hold the rest, in its stats and an "In decks" table (`Catalog::CardOverview`, `Deck::CardUses`, spec 073). An operator backs up a running instance and restores it onto a fresh volume with two rake tasks, `backup:create[DIRECTORY]` and `backup:restore[FILE]` (`Collector::Backup`, `lib/collector/backup/`, spec 030, ADR 0024). CI proves an upgrade from the previous release's published image (`bin/upgrade-smoke`, `script/upgrade/`, spec 031, #42; [release and deploy](docs/architecture/release-and-deploy.md)). License: AGPL-3.0. +Collector is a self-hostable, multi-tenant web app for tracking collectibles, starting with Magic: The Gathering cards. The Rails app skeleton exists (stack, RSpec, `bin/ci`, Compose and Kamal deployment). Feature 002 added a collectible-agnostic catalog core (`app/models/catalog/`) with an MTG extension (`app/models/mtg/`), Scryfall bulk-data ingestion via a background refresh, and a card search at `/catalog/entries`, which has needed sign-in since accounts (spec 004) unless the operator opens it with `COLLECTOR_PUBLIC_CATALOG` (spec 023). Accounts (row-level tenancy through `Current.account`) and collections of lots (spec 004, list view spec 006) exist, and the card scanner (specs 005, 007–009) adds scanned cards to the collection through its confirm flow. Admins start and watch catalog refreshes and art index builds at `/admin/catalog`, and see, retry and discard background jobs at `/admin/jobs` (spec 015). A collector's data has a documented export format, and a collector downloads, previews and applies an export file from the account menu or More (`CollectionExport`, `CollectionImport`, `CollectionImport::Apply`; [`docs/architecture/data-portability.md`](docs/architecture/data-portability.md)). A collector adds cards from a set by collector number at `/collection/number_entry` ("Add by number", spec 026): one line per number, quantity and finish, each added at once to a per-account list with Undo (`NumberEntry`, `NumberEntry::Line`), which Done clears; the list is working state, left out of the export. A collector manages the places their collection is kept in at `/locations` (`Location`, spec 029) and records which location a lot is in when adding or editing it, or moves some or all of a lot's copies with Move copies (`Lot::Move`, spec 032; a location that holds copies can't be removed; export format version 3) and moves every copy of many lots at once from bulk mode with Move to… (`Lot::BulkMove`, spec 041), opens a location's lots in bulk mode from its removal refusal page with one button (`Locations::SelectionsController`, spec 059), and the collection's grid tiles and table rows show it (spec 033) and filter by it (spec 037). A collector creates, renames and removes decks at `/decks` (`Deck`, `Deck::Creation`, `Catalog::Formats`, spec 062, [ADR 0028](docs/adr/0028-decks-are-locations-with-zones.md)): a deck is a location of kind `deck` whose zones are child locations that hold its copies, the collectible extension registers the formats, a lot in a zone is shown as "Deck › Zone", the export format (version 4) carries decks, a collector changes a deck's format at `/decks/:id/format/edit` (`Deck::FormatChange`, spec 064), opens a deck's lots in bulk mode from its removal refusal page (`Decks::SelectionsController`, spec 067), and opens a deck at `/decks/:id` to read the cards they own in it, zone by zone, with their cost and legality (`Deck::Page`, the seam `Catalog::DeckCards`, spec 066), and the deck's shape, its mana curve, card types and coloured mana symbols, in a panel beside the zones (`Catalog::DeckShape`, `MTG::DeckShape`, spec 069), and records the cards a deck's zone needs and they don't own as proxies, shown beside the owned copies and counted in a deck's cards but never as owned (`Deck::Proxy`, spec 068): a change of format moves them into Considering and the export carries them (format version 4 has `proxies`). A collector swaps a proxy for a free copy they own (Use my copy) or a new copy (Add a new copy) from the row's menu, and the proxy shrinks or goes in the same write (`Deck::Proxy::UseCopies`, `Deck::Proxy::AddCopy`, `Lot.free`, `Lot::MoveIntoZone`, spec 070). A collector adds a card to a deck from its card page, their free copies or a proxy (`Deck::Addition`, `Lot::FreeCopies`, spec 071). The card page shows how many copies are free to use and which decks hold the rest, in its stats and an "In decks" table (`Catalog::CardOverview`, `Deck::CardUses`, spec 073). A collector checks a deck on demand from its page, its size, copy limits and the cards with owned copies and proxies in one zone, and nothing about the check is stored (`Catalog::DeckChecks`, `MTG::Collecting.deck_rules`, `Decks::ChecksController`, spec 079). An operator backs up a running instance and restores it onto a fresh volume with two rake tasks, `backup:create[DIRECTORY]` and `backup:restore[FILE]` (`Collector::Backup`, `lib/collector/backup/`, spec 030, ADR 0024). CI proves an upgrade from the previous release's published image (`bin/upgrade-smoke`, `script/upgrade/`, spec 031, #42; [release and deploy](docs/architecture/release-and-deploy.md)). License: AGPL-3.0. Mission and principles: `.claude/memory/foundation.md`. Goal and spec rules: `docs/north-star.md`. Order of work: `docs/roadmap.md`. Decisions: `docs/adr/` (index in its README). Current rules per subsystem: `docs/architecture/` (area docs). Detailed coding rules: `.claude/rules/`. Feature specs: `docs/specs/` (index: `docs/specs/README.md`). @@ -42,7 +42,7 @@ Ruby 4.0.7, Rails 8.1.4, SQLite in every environment (including production), Hot - **Card scanner (specs 005, 007–011, 040, 042, 047):** `/scanner`, linked from the navigation ("Scan") and the collection page. How it reads a card, ranks candidates (with the optional art match), adds a copy, keeps the sitting with its condition and place, and records findings is in [`docs/architecture/scanner.md`](docs/architecture/scanner.md). Before you look: the OCR engine is fetched and checksum-verified into the ignored `vendor/ocr/v7.0.0/` by `bin/fetch-ocr-engine` (run by `bin/setup` and the Dockerfile), and scanner specs fail until it's installed; art matching (opt-in, `COLLECTOR_MTG_ART_MATCHING`) needs ImageMagick (`magick` or `convert`) for its build and specs; only scanner pages send a Content-Security-Policy (`ScannerPage` concern); the sitting's files name no collectible (`spec/scanner_sitting_core_spec.rb`); `script/scanner/detector_parity.rb` checks the detector (`app/javascript/scanner/detector.js`) against the spike's; measurement mode (`/scanner/measurement`, with the `scanner:` rake tasks and `script/scanner/` for the findings) is development-only (`config.x.scanner_measurement`; `COLLECTOR_SCANNER_MANIFEST`, `COLLECTOR_SCANNER_RUN_DIR`, `COLLECTOR_SCANNER_KEEP_FRAMES=1` keeps live frames) and writes outside the repo; the spikes are in `spikes/card_scanner/`. Phones need HTTPS: `bin/dev-certificate` makes a self-signed certificate outside the repo and prints the `bin/dev -b "ssl://…"` bind (alternative: a tunnel with `RAILS_DEVELOPMENT_HOSTS` + `COLLECTOR_HTTPS=true`). - **Export format and import (specs 017, 022, 025, 028, 032, 062, 068; ADRs 0020–0023, 0025, 0028):** the current rules are in [`docs/architecture/data-portability.md`](docs/architecture/data-portability.md); the format is defined by `docs/export-format/README.md`. Version 4 is current (`ExportFormat::Rules::VERSIONS` is `[1, 2, 3, 4]`); a new table with `account_id` or `user_id`, or a new column on `accounts`, `lots`, `users` or `locations`, fails the suite until the document's Coverage tables list it; a new field of collector-owned data raises the version. - **Import preview and apply (specs 025, 028):** area doc above. The stored upload's retention and sweep (`ImportUpload`, `ImportUpload::SweepJob`, ADR 0022) and the 40 MB request body caps (Puma, `MAX_REQUEST_BODY` in the `Dockerfile`, `config/deploy.yml`) are in [`docs/architecture/release-and-deploy.md`](docs/architecture/release-and-deploy.md). `Location.upsert_all` and `CollectionImport::Apply#upsert_lots!` (a bound multi-row `INSERT .. ON CONFLICT`) skip validations on purpose (the reader already checked each row). -- **Collection and lots (specs 004, 006, 021, 026, 029, 032–034, 041, 059, 062, 064–068; ADRs 0019, 0025, 0028):** a lot's identity (printing, finish, condition, price paid and place, `lot_key`), the 9,999 cap, merging, Move copies, the grid and table, bulk mode and its Undo, number entry, locations and their names, and decks, their zones and the label "Deck › Zone" are in [`docs/architecture/collection-and-lots.md`](docs/architecture/collection-and-lots.md); how the export, preview and apply cover lots, locations and decks is in [`docs/architecture/data-portability.md`](docs/architecture/data-portability.md). Guards an agent trips first: `Location::Name` is the one definition of a name (`spec/models/location/name_parity_spec.rb`); five places build a `lot_key` and all must carry the place; `Account` declares `lots` before `locations` because the restrict foreign key needs lots deleted first (`spec/models/account_spec.rb`); a location has a kind, so a list says which one it means (every picker and the collection filter offer places and zones through `Location::Choices`, and a write names its location through `Location.find_placeable!`; the Locations page and the export stay places only; [`collection-and-lots.md`](docs/architecture/collection-and-lots.md)), and the deck files (the deck page's `Catalog::DeckCards` and `Catalog::DeckShape` seams among them) name no collectible, format or zone key but `considering` (`spec/deck_core_spec.rb`); number entry and the location files name no collectible type (`spec/number_entry_core_spec.rb`, `spec/import_apply_core_spec.rb`). +- **Collection and lots (specs 004, 006, 021, 026, 029, 032–034, 041, 059, 062, 064–068; ADRs 0019, 0025, 0028):** a lot's identity (printing, finish, condition, price paid and place, `lot_key`), the 9,999 cap, merging, Move copies, the grid and table, bulk mode and its Undo, number entry, locations and their names, and decks, their zones and the label "Deck › Zone" are in [`docs/architecture/collection-and-lots.md`](docs/architecture/collection-and-lots.md); how the export, preview and apply cover lots, locations and decks is in [`docs/architecture/data-portability.md`](docs/architecture/data-portability.md). Guards an agent trips first: `Location::Name` is the one definition of a name (`spec/models/location/name_parity_spec.rb`); five places build a `lot_key` and all must carry the place; `Account` declares `lots` before `locations` because the restrict foreign key needs lots deleted first (`spec/models/account_spec.rb`); a location has a kind, so a list says which one it means (every picker and the collection filter offer places and zones through `Location::Choices`, and a write names its location through `Location.find_placeable!`; the Locations page and the export stay places only; [`collection-and-lots.md`](docs/architecture/collection-and-lots.md)), and the deck files (the deck page's `Catalog::DeckCards`, `Catalog::DeckShape` and `Catalog::DeckChecks` seams among them) name no collectible, format or zone key but `considering` (`spec/deck_core_spec.rb`); number entry and the location files name no collectible type (`spec/number_entry_core_spec.rb`, `spec/import_apply_core_spec.rb`). - **Backup and restore (spec 030, ADR 0024):** what a backup holds, the restore's refusals and the rules of both are in the [release and deploy](docs/architecture/release-and-deploy.md) area doc; `docs/backup-restore.md` has the file's layout, `README.md` the procedure. The two tasks don't depend on `:environment`, so they boot nothing; their code is in `lib/collector/backup/`, required by path and not autoloaded (`config/application.rb`), and opens snapshots with `SQLite3::Database`, never Active Record. The suite's backup examples commit rows (`:committed`, `spec/support/backup_helpers.rb`); a model with a new attachment, or a fifth production database, fails `spec/backup_guards_spec.rb` until the backup's contents are decided. - **Off-limits for reads (permission-denied):** `.kamal/secrets`, `config/master.key`, `.env*`, `storage/`. Don't try to read them. diff --git a/app/assets/stylesheets/collector/additions.css b/app/assets/stylesheets/collector/additions.css index ef9146af..5424b5cd 100644 --- a/app/assets/stylesheets/collector/additions.css +++ b/app/assets/stylesheets/collector/additions.css @@ -523,6 +523,27 @@ body:has(.c-locations) .c-appbar__back { min-height:var(--touch-floor); min-widt .c-zone .c-section__actions { margin-left:auto; } } +/* ---------- DeckCheck (spec 079): the Check deck results between the deck page's head and its zones ---------- */ +/* A row is a status word with its icon and a sentence; the word and the icon say pass or fault, so colour is never the only + signal (decision 8). A row breaks inside a word: a card or zone name may be 60 characters with no space (AC-6.4). The + class is `c-deckcheck`, distinct from Form's `c-check` checkbox row. The 12px icon takes the 2.5 stroke, as a badge's + does (Iconography). */ +.c-deckcheck { margin-top:var(--space-6); display:flex; flex-direction:column; gap:var(--space-3); min-width:0; } +.c-deckcheck__title:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius-sm); } +.c-deckcheck__summary { margin:0; font:400 13px/18px var(--font-mono); color:var(--ink-muted); } +.c-deckcheck__list { list-style:none; margin:0; padding:0; background:var(--surface-raised); border:1px solid var(--line); border-radius:var(--radius-md); } +.c-deckcheck__row { display:grid; grid-template-columns:minmax(0, 1fr); gap:var(--space-1); padding:var(--space-3) var(--space-4); border-top:1px solid var(--line); overflow-wrap:anywhere; } +.c-deckcheck__row:first-child { border-top:0; } +.c-deckcheck__status { display:inline-flex; align-items:center; gap:var(--space-2); font:600 13px/18px var(--font-sans); color:var(--ink); } +.c-deckcheck__status svg { width:12px; height:12px; flex:none; stroke-width:2.5; } +.c-deckcheck__row--pass .c-deckcheck__status svg { color:var(--success); } +.c-deckcheck__row--fault .c-deckcheck__status svg { color:var(--warning); } +.c-deckcheck__text { font:400 15px/22px var(--font-sans); color:var(--ink); } +@container page (min-width: 640px) { + .c-deckcheck__row { grid-template-columns:11rem minmax(0, 1fr); align-items:baseline; gap:var(--space-4); } + .c-deckcheck__row--plain .c-deckcheck__text { grid-column:1 / -1; } +} + /* ---------- Deck page zones (spec 060): a table per zone, grouped rows, one shared column layout ---------- */ /* From 640px of the table's container every zone table has the same fixed widths, so Cost, Owned, Proxy, Total and the menu sit at one x in every zone (decision 25). th is border-box: a width is the whole column, padding included. A pip is diff --git a/app/controllers/decks/checks_controller.rb b/app/controllers/decks/checks_controller.rb new file mode 100644 index 00000000..da8cb205 --- /dev/null +++ b/app/controllers/decks/checks_controller.rb @@ -0,0 +1,17 @@ +# Check deck (spec 079): the deck page with its checks' results. A read-only request, so the link to it needs no form and the +# result is never stored: any later request of the deck page has none (decision 7). The deck comes from the signed-in +# account's decks, so another account's is a 404 (`FindsDeck`). It builds the page and the shape panel as +# `DecksController#show` does and adds one report; the report reads the loaded rows and runs no statement of its own (AC-5.1). +class Decks::ChecksController < ApplicationController + include FindsDeck + + before_action :set_deck + + # Renders the deck page with `@check` set. + def show + @page = Deck::Page.new(@deck) + @shape = Catalog::DeckShape.answer(@deck.collectible_type, @page.zones) + @check = Catalog::DeckChecks.report(@deck.collectible_type, @deck.format, @page.zones) + render "decks/show" + end +end diff --git a/app/models/catalog/deck_checks.rb b/app/models/catalog/deck_checks.rb new file mode 100644 index 00000000..82afa269 --- /dev/null +++ b/app/models/catalog/deck_checks.rb @@ -0,0 +1,176 @@ +# What a deck page's Check deck answers (spec 079): the deck's size and copy limits judged by the collectible's own rules, and +# every card with owned copies and proxies in one zone, which needs no rule and runs for every deck. Like +# `Catalog::DeckShape`, an optional answer on the collecting vocabulary (`Catalog.collecting_for`), so the core counts and +# words the results and never names a collectible, a format or a zone key but `Catalog::Formats::CONSIDERING`. +# +# An extension contract: a vocabulary that has rules for a format provides +# * `deck_rules(format_key)` returning {Rules}, or nil for a format it has no rules for (the deck's stored key, registered or not). +# * `Rules#sizes`: {SizeRule}s in the order they are shown. A rule holds the `zone_keys` it counts (the extension names them; +# the core matches each to a zone row's key) and one of three shapes: `min` only, `max` only, or `min == max` (an exact +# size); a range with two different bounds is not offered. The extension supplies no words. +# * `Rules#copies`: a {CopyRule} or nil. Its `zone_keys` are counted together; `limit_for` is called with one counted printing +# (a `Catalog::Entry` whose `extension` may be nil) and returns a {Limit} (`count`, and a `reason` sentence part such as +# "restricted to 1", or nil) or nil for a card that is never limited. For a printing with no extension record it returns +# the format's ordinary limit. It reads loaded records only, never a query. +# The core counts copies by catalog identity over every counted printing and holds a card to the lowest number its printings +# answer; nil is above every number, so a card is unlimited only when every counted printing answers nil, and the reason is +# the one given with the lowest number (the first printing in page order on a tie). A card of another collectible than the +# deck's is left out of every check. A size or copy rule counts only the zones it names, and the core never counts +# Considering for one, even when a rule names its key (decision 3): such a rule still names it in a size line's subject. +# The owned-plus-proxy check judges every zone, Considering included (decision 5). +# A vocabulary without `deck_rules`, a nil answer, a type no longer registered and a deck whose format is no longer registered +# have no size or copy rules. +# @see file:collection-and-lots.md +module Catalog::DeckChecks + # One size rule: the zone keys it counts together and a minimum, a maximum, or both equal (an exact size). + SizeRule = Data.define(:zone_keys, :min, :max) + # A card's limit: the most copies a deck may hold, and an optional sentence part that says why ("restricted to 1"). + Limit = Data.define(:count, :reason) + # The copy rule: the zone keys counted together and a callable from a loaded printing to a {Limit} or nil. + CopyRule = Data.define(:zone_keys, :limit_for) + # What a vocabulary answers for a format: its size rules and its copy rule (nil for none). + Rules = Data.define(:sizes, :copies) + # One line of a report: a status word (nil for a line that is no check), its sentence, and whether it is a finding. + Line = Data.define(:status, :sentence, :fault) + # The results of one check request, in the order they are shown. + Report = Data.define(:lines) do + # The number of findings. + def faults = lines.count(&:fault) + + # The summary line: "3 findings", "1 finding" or "No findings". + def summary = faults.zero? ? "No findings" : "#{faults} #{faults == 1 ? 'finding' : 'findings'}" + end + # A row of the deck's collectible with its zone and the zone's position in page order. + # @api private + Counted = Data.define(:zone, :position, :row) + + # The report for a deck. + # + # @param deck_type [String] the deck's collectible type + # @param format_key [String] the deck's stored format key, registered or not + # @param zones [Array] `Deck::Page#zones`; read from memory, no statement runs + # @return [Report] never nil, and never raises for an unregistered type or format + def self.report(deck_type, format_key, zones) + counted = counted_rows(deck_type, zones) + rules = rules_for(deck_type, format_key) + lines = rules ? rule_lines(rules, deck_type, format_key, zones, counted) : [ no_rules_line(deck_type, format_key) ] + Report.new(lines: lines + split_lines(counted)) + end + + # @api private + def self.rules_for(deck_type, format_key) + return unless Catalog.collecting.key?(deck_type) + + vocabulary = Catalog.collecting_for(deck_type) + vocabulary.deck_rules(format_key) if vocabulary.respond_to?(:deck_rules) + end + private_class_method :rules_for + + # Every row of the deck's collectible with its zone's position; another collectible's rows are left out (decision 4). + # @api private + def self.counted_rows(deck_type, zones) + zones.each_with_index.flat_map do |zone, position| + zone.groups.flat_map(&:rows).select { it.entry.collectible_type == deck_type }.map { Counted.new(zone:, position:, row: it) } + end + end + private_class_method :counted_rows + + # The rows a size or copy rule counts: those in a zone the rule names, never Considering (decision 3), so a vocabulary that + # lists Considering among a rule's zones still has none of its cards counted. + # @api private + def self.in_zones(rule, counted) + counted.select { rule.zone_keys.include?(it.zone.location.zone) && it.zone.location.zone != Catalog::Formats::CONSIDERING } + end + private_class_method :in_zones + + # @api private + def self.rule_lines(rules, deck_type, format_key, zones, counted) + sizes = rules.sizes.map { size_line(it, deck_type, format_key, zones, counted) } + sizes + (rules.copies ? copy_lines(rules.copies, counted) : []) + end + private_class_method :rule_lines + + # The copy check (decisions 1, 3, 4): copies by catalog identity over the rule's zones, owned plus proxy; one finding per + # card over its limit, ordered by the first zone that holds it, then its name, then its identity; or one pass line. A card + # is held to the lowest number its printings answer; `min_by` keeps the first printing in page order on a tie. + # @api private + def self.copy_lines(rule, counted) + in_rule = in_zones(rule, counted) + findings = in_rule.group_by { it.row.entry.catalog_identity_id }.filter_map do |identity_id, cards| + copies = cards.sum { it.row.total } + limit = cards.map { it.row.entry }.uniq.filter_map { rule.limit_for.call(it) }.min_by(&:count) + next if limit.nil? || copies <= limit.count + + [ cards.map(&:position).min, cards.first.row.entry.name, identity_id, copies, limit ] + end + return [ Line.new(status: "Pass", sentence: "No card is over its limit.", fault: false) ] if findings.empty? + + findings.sort_by { it.first(3) }.map do |_position, name, _identity_id, copies, limit| + Line.new(status: "Over the limit", sentence: "#{name}: #{counted_words(copies, 'copy')}; #{limit.reason || "the limit is #{limit.count}"}.", fault: true) + end + end + private_class_method :copy_lines + + # One size result (decision 6). Zones are named in the rule's key order: by the zone row's shown name, else by the format's + # registration, else by the key. + # @api private + def self.size_line(rule, deck_type, format_key, zones, counted) + total = in_zones(rule, counted).sum { it.row.total } + subject = rule.zone_keys.map { zone_name(it, deck_type, format_key, zones) }.join(" + ") + status = if rule.min && total < rule.min then "Short" elsif rule.max && total > rule.max then "Over" else "Pass" end + format_name = Catalog::Formats.name_for(deck_type, format_key) + Line.new(status:, sentence: "#{subject}: #{counted_words(total, 'card')}; #{format_name} #{size_need(rule)}.", fault: status != "Pass") + end + private_class_method :size_line + + # The rule's requirement in words: "needs at least 60", "allows at most 15" or "needs exactly 100". + # @api private + def self.size_need(rule) + return "needs exactly #{delimited(rule.min)}" if rule.min && rule.min == rule.max + return "needs at least #{delimited(rule.min)}" if rule.min + + "allows at most #{delimited(rule.max)}" + end + private_class_method :size_need + + # @api private + def self.zone_name(key, deck_type, format_key, zones) + zones.find { it.location.zone == key }&.name || Catalog::Formats.zones_for(deck_type, format_key)&.find { it.key == key }&.name || key + end + private_class_method :zone_name + + # "1 card", "5 copies": the count with delimiters and the singular for one. + # @api private + def self.counted_words(number, word) = "#{delimited(number)} #{word.pluralize(number)}" + private_class_method :counted_words + + # @api private + def self.no_rules_line(deck_type, format_key) + Line.new(status: nil, sentence: "#{Catalog::Formats.name_for(deck_type, format_key)} has no rules to check.", fault: false) + end + private_class_method :no_rules_line + + # A count with delimiters ("1,234"), as the deck page's heads and spec 069's panel write one. + # @api private + def self.delimited(number) = ActiveSupport::NumberHelper.number_to_delimited(number) + private_class_method :delimited + + # The owned-plus-proxy check (decision 5): each zone alone, any printing of the card; findings ordered by the zone's + # position, then the card's name, then its identity; or one pass line. + # @api private + def self.split_lines(counted) + findings = counted.group_by { [ it.position, it.row.entry.catalog_identity_id ] }.filter_map do |(position, identity_id), cards| + owned = cards.sum { it.row.owned } + proxy = cards.sum { it.row.proxy } + next unless owned.positive? && proxy.positive? + + [ position, cards.first.row.entry.name, identity_id, cards.first.zone.name, owned, proxy ] + end + return [ Line.new(status: "Pass", sentence: "No card has both owned copies and proxies in one zone.", fault: false) ] if findings.empty? + + findings.sort_by { it.first(3) }.map do |_position, name, _identity_id, zone_name, owned, proxy| + Line.new(status: "Owned and proxy", sentence: "#{name} in #{zone_name}: #{delimited(owned)} owned, #{delimited(proxy)} proxy.", fault: true) + end + end + private_class_method :split_lines +end diff --git a/app/models/mtg/collecting.rb b/app/models/mtg/collecting.rb index 0c294d65..237dcbce 100644 --- a/app/models/mtg/collecting.rb +++ b/app/models/mtg/collecting.rb @@ -69,6 +69,13 @@ def self.deck_shape(zones) Catalog::DeckShape::Answer.new(partial: "mtg/deck_shapes/parts", figures:, scope: figures.scope, facts: figures.facts) end + # The deck checks' rules for one format (spec 079, the extension side of `Catalog::DeckChecks`): size and copy limits. + # See `MTG::DeckRules`. + # + # @param format_key [String] the deck's stored format key + # @return [Catalog::DeckChecks::Rules, nil] nil for casual and for a key with no rules + def self.deck_rules(format_key) = MTG::DeckRules.for(format_key.to_s) + # A printing with no extension record or no face data is not judged: it has no card data to be a legal or a banned card, # so it gets no marker (spec 066 AC-6.6). # @api private diff --git a/app/models/mtg/deck_rules.rb b/app/models/mtg/deck_rules.rb new file mode 100644 index 00000000..209c252c --- /dev/null +++ b/app/models/mtg/deck_rules.rb @@ -0,0 +1,80 @@ +# Magic's deck rules for the deck checks (spec 079 decision 3, the extension side of `Catalog::DeckChecks`): size and copy +# limits per format, and each printing's limit read from its first face's type line, its rules text and its legalities. +# Reads the loaded printing record only, so no query runs per card. +class MTG::DeckRules + # The formats that share one rule set: the main zone's minimum, the sideboard's maximum and a limit of four. + CONSTRUCTED = %w[standard pioneer modern legacy vintage pauper].freeze + # A constructed deck holds at least this many cards in its main zone (spec 079 decision 3). + MAIN_MINIMUM = 60 + # A constructed deck's sideboard holds at most this many cards. + SIDEBOARD_MAXIMUM = 15 + # The copies of one card a constructed deck may hold (a basic land and the cards that say otherwise excepted). + CONSTRUCTED_COPIES = 4 + # The copies of one card a Commander deck may hold: the singleton rule. + COMMANDER_COPIES = 1 + # A Commander deck's commander and main zones hold exactly this many cards together. + COMMANDER_SIZE = 100 + # Rules text of a card a deck may hold any number of: Scryfall's own words (confirmed in verification.md, T0.2). + ANY_NUMBER = /a deck can have any number of cards named/i + # Rules text of a card a deck may hold up to a stated number of; the number is the word after "up to". + STATED_NUMBER = /a deck can have up to (\w+) cards named/i + # The number words the stated limits use (Seven Dwarves: seven; Nazgûl: nine). + NUMBER_WORDS = %w[two three four five six seven eight nine ten].each_with_index.to_h { |word, index| [ word, index + 2 ] }.freeze + + # The rules of one format. + # + # @param format_key [String] the deck's stored format key + # @return [Catalog::DeckChecks::Rules, nil] nil for casual and for a key the collectible has no rules for + def self.for(format_key) + return commander if format_key == "commander" + + constructed(format_key) if CONSTRUCTED.include?(format_key) + end + + # A printing's limit in a format. Order matters: a basic land and any-number text are exempt first, then a stated number, + # then Vintage's restriction, then the ordinary limit. A printing with no card data is never exempt and never restricted. + # + # @param entry [Catalog::Entry] a printing, its `extension` loaded or nil + # @param format_key [String] + # @param ordinary [Integer] the format's ordinary limit + # @return [Catalog::DeckChecks::Limit, nil] nil for a card that is never limited + def self.limit_for(entry, format_key, ordinary) + printing = entry.extension + return Catalog::DeckChecks::Limit.new(count: ordinary, reason: nil) if printing.nil? + + faces = Array(printing.faces) + return if basic_land?(faces.first) + + text = faces.filter_map { it["oracle_text"] }.join("\n") + return if text.match?(ANY_NUMBER) + + stated = NUMBER_WORDS[text[STATED_NUMBER, 1].to_s.downcase] + return Catalog::DeckChecks::Limit.new(count: stated, reason: nil) if stated + return Catalog::DeckChecks::Limit.new(count: 1, reason: "restricted to 1") if format_key == "vintage" && printing.legalities.to_h["vintage"] == "restricted" + + Catalog::DeckChecks::Limit.new(count: ordinary, reason: nil) + end + + # @api private + def self.basic_land?(face) = face&.dig("type_line").to_s.split("—").first.to_s.split.include?("Basic") + private_class_method :basic_land? + + # @api private + def self.constructed(format_key) + Catalog::DeckChecks::Rules.new( + sizes: [ Catalog::DeckChecks::SizeRule.new(zone_keys: %w[main], min: MAIN_MINIMUM, max: nil), + Catalog::DeckChecks::SizeRule.new(zone_keys: %w[sideboard], min: nil, max: SIDEBOARD_MAXIMUM) ], + copies: Catalog::DeckChecks::CopyRule.new(zone_keys: %w[main sideboard], limit_for: ->(entry) { limit_for(entry, format_key, CONSTRUCTED_COPIES) }) + ) + end + private_class_method :constructed + + # @api private + def self.commander + Catalog::DeckChecks::Rules.new( + sizes: [ Catalog::DeckChecks::SizeRule.new(zone_keys: %w[commander main], min: COMMANDER_SIZE, max: COMMANDER_SIZE) ], + copies: Catalog::DeckChecks::CopyRule.new(zone_keys: %w[commander main], limit_for: ->(entry) { limit_for(entry, "commander", COMMANDER_COPIES) }) + ) + end + private_class_method :commander +end diff --git a/app/views/decks/_check.html.erb b/app/views/decks/_check.html.erb new file mode 100644 index 00000000..de3a958f --- /dev/null +++ b/app/views/decks/_check.html.erb @@ -0,0 +1,14 @@ +
+

Deck check

+

<%= check.summary %>

+ +
diff --git a/app/views/decks/show.html.erb b/app/views/decks/show.html.erb index 1912b2de..d0dfe69b 100644 --- a/app/views/decks/show.html.erb +++ b/app/views/decks/show.html.erb @@ -1,4 +1,6 @@ <% content_for :title, "#{@deck.name} · Collector" %> +<%# The results page isn't kept for Back: Back asks for the check again, so it never shows results from before an edit (spec 079 decision 7). %> +<% content_for :head, tag.meta(name: "turbo-cache-control", content: "no-cache") if @check %> <%= render "layouts/appbar", section: :decks, detail: true, back_path: decks_path %>
@@ -8,6 +10,8 @@
<%= link_to "Edit deck", edit_deck_path(@deck), class: "c-btn c-btn--secondary" %> + <%# No hover prefetch: it would run a check nobody asked for, wasted work that could show results from before an edit (spec 079 decision 7). %> + <%= link_to "Check deck", deck_check_path(@deck), class: "c-btn c-btn--secondary", data: { turbo_prefetch: false } %>
<%= render "icons/more" %>
@@ -18,6 +22,7 @@
+ <%= render "decks/check", check: @check if @check %>
<% if @shape %> <%= render "decks/shape", answer: @shape, copy: :fold %> diff --git a/config/routes.rb b/config/routes.rb index 6c68a2a0..0d467d99 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -49,6 +49,7 @@ resource :removal, only: :new, module: :decks resource :format, only: %i[edit update], module: :decks # spec 064: change a deck's format resource :selection, only: :create, module: :decks # spec 067: the refusal page's shortcut into bulk mode + resource :check, only: :show, module: :decks # spec 079: Check deck, the deck page with its checks # Spec 068: a proxy is named by its own id under its deck (the deck's location id is :deck_id). scope module: :decks do resources :proxies, only: %i[edit update destroy] do diff --git a/docs/architecture/collection-and-lots.md b/docs/architecture/collection-and-lots.md index 9018da6d..3c51633e 100644 --- a/docs/architecture/collection-and-lots.md +++ b/docs/architecture/collection-and-lots.md @@ -15,7 +15,7 @@ What a collector owns and where it is kept: lots and their identity, the ways co - Bulk mode: the selection, Set condition, Move to…, the bulk removal and its Undo (`BulkSelection`, `Collections::SelectionsController`, `Locations::SelectionsController`, `Decks::SelectionsController`, `BulkModeShortcut`, `Lot::ConditionChange`, `Lot::BulkMove`, `Collections::MovesController`, `BulkRemoval`) (spec 006 Stories 4–8; spec 032 Story 7; spec 041; spec 059; spec 065 Story 3; spec 067). - Number entry: `NumberEntry`, `NumberEntry::Line`, `NumberEntry::LineForm`, `NumberEntry::SetChoice`, `/collection/number_entry` (spec 026, all but the list being left out of the export; spec 034; spec 065 Story 4). - Locations: `Location`, `Location::Name`, `Location::Holdings`, `/locations` (`LocationsController`, `Locations::RemovalsController`, `Locations::SelectionsController`), which knows places only since decks exist (spec 029, all but its format, export, preview and apply parts; spec 032 Stories 4, 5; spec 059; spec 062 AC-3.8, AC-4.8). -- Decks as locations and the Decks pages: the kinds of `Location` (`place`, `deck`, `deck_zone`), `Deck`, `Deck::Creation`, `Deck::FormatChange`, `Deck::Listing`, `Deck::Page`, `Deck::Proxy`, `Deck::Proxy::Addition`, `Deck::Proxy::UseCopies`, `Deck::Proxy::AddCopy`, `Lot.free`, `Lot::MoveIntoZone`, `Location::Placement`, the format seam `Catalog::Formats` (MTG's registration is `MTG::Collecting::FORMATS`) and the deck page's seams `Catalog::DeckCards` (MTG's answer is `MTG::Collecting.deck_card`) and `Catalog::DeckShape` (MTG's is `MTG::Collecting.deck_shape`, with `MTG::DeckShape`), `DecksController`, `Decks::RemovalsController`, `Decks::FormatsController`, `Decks::ProxiesController`, `Decks::Proxies::RemovalsController`, `Decks::Proxies::ReplacementsController`, `Decks::Proxies::CopiesController`, `/decks`, and the one label "Deck › Zone" wherever a lot's place is shown (spec 062 decisions 1–3, 5–7, Stories 1–5, FR-1, FR-2, FR-4, AC-9.1–AC-9.6, AC-9.11; spec 064; spec 066; spec 068; spec 069; spec 070; ADR 0028). How an export, a preview and an apply cover decks and zones is data portability's. +- Decks as locations and the Decks pages: the kinds of `Location` (`place`, `deck`, `deck_zone`), `Deck`, `Deck::Creation`, `Deck::FormatChange`, `Deck::Listing`, `Deck::Page`, `Deck::Proxy`, `Deck::Proxy::Addition`, `Deck::Proxy::UseCopies`, `Deck::Proxy::AddCopy`, `Lot.free`, `Lot::MoveIntoZone`, `Location::Placement`, the format seam `Catalog::Formats` (MTG's registration is `MTG::Collecting::FORMATS`) and the deck page's seams `Catalog::DeckCards` (MTG's answer is `MTG::Collecting.deck_card`) and `Catalog::DeckShape` (MTG's is `MTG::Collecting.deck_shape`, with `MTG::DeckShape`), the deck checks' seam `Catalog::DeckChecks` (MTG's answer is `MTG::Collecting.deck_rules`, with `MTG::DeckRules`) and `Decks::ChecksController`, `DecksController`, `Decks::RemovalsController`, `Decks::FormatsController`, `Decks::ProxiesController`, `Decks::Proxies::RemovalsController`, `Decks::Proxies::ReplacementsController`, `Decks::Proxies::CopiesController`, `/decks`, and the one label "Deck › Zone" wherever a lot's place is shown (spec 062 decisions 1–3, 5–7, Stories 1–5, FR-1, FR-2, FR-4, AC-9.1–AC-9.6, AC-9.11; spec 064; spec 066; spec 068; spec 069; spec 070; spec 079; ADR 0028). How an export, a preview and an apply cover decks and zones is data portability's. - Adding a card to a deck from its card page: `DeckAdditionsController`, `Deck::Addition`, `Lot::FreeCopies`, `DeckAdditionsHelper`, the free-copies rule and the take (spec 071), and the card page's Free to use and In decks (spec 073). - Tenant isolation for all of these (spec 004 AC-12.1–AC-12.3, AC-12.5; spec 006 Story 8; spec 029 Story 4; spec 032 Story 6). That every collection table carries `account_id` with a foreign key and an index (spec 004 AC-12.4) is [tenancy and accounts](tenancy-and-accounts.md)'. @@ -55,6 +55,7 @@ What a collector owns and where it is kept: lots and their identity, the ways co - **Number entry / list / line / line key:** the "Add by number" page; the account's added lines until Done; one submitted number, quantity, finish and place; the random token that makes a line add at most once (spec 026 Terms, decision 10). - **Holdings:** what an account's lots hold in one location: lots and copies; a deck holds what its zones hold together (spec 032 P1, P2; spec 062 AC-3.3). - **Collection mark:** `from=collection` on a card page URL, which makes its breadcrumb and current section Collection (spec 004 AC-8.9). +- **Check / finding / pass:** a check is one question Check deck puts to a deck (its size, its copy limits, its owned-plus-proxy cards); a finding is a check's answer that something is wrong; a check that finds nothing passes. A rule's zones are the zone keys the extension names for it; a zone no rule names is unnamed (spec 079 Terms). ## Invariants @@ -75,8 +76,8 @@ What a collector owns and where it is kept: lots and their identity, the ways co - **Everything is scoped to `Current.account`.** Another account's lot, location or number-entry line answers 404, unchanged (spec 004 AC-12.2; spec 026 AC-3.8; spec 029 AC-4.1; spec 032 AC-6.1); lot ids in a bulk submission that aren't the account's are ignored, not answered with 404 (spec 006 AC-8.1), while a foreign location id for a bulk move is 404 (spec 041 AC-2.3, AC-5.2); an Undo of another session's removal is 404 (spec 006 AC-8.3). No form takes an account, user or admin value (spec 004 AC-12.3; spec 006 AC-8.4; spec 029 AC-4.3). - **The operator sees no collection detail.** No admin page shows a location, its name, a lot's place or a per-location count (hosted-door rule 2; spec 029 AC-4.5; spec 032 AC-6.4); the admin users page's per-account copy count is [tenancy and accounts](tenancy-and-accounts.md)'. - **A catalog refresh never changes a lot, a selection or an undo record,** and lots of retired printings are kept, shown, restored and can be quick-added (spec 004 FR-6, AC-11.9, Error Scenarios; spec 006 AC-2.7, AC-7.7, NFR Reliability). -- **The core names no collectible.** Finishes, the finish order, special finishes and conditions come from the vocabulary; the core stores finish and condition as values it doesn't interpret (spec 004 FR-6; spec 006 FR-2; spec 026 FR-1; spec 029 FR-2; spec 033 FR-6). `spec/number_entry_core_spec.rb` checks number entry's files and `spec/import_apply_core_spec.rb` the location, Move copies and bulk Move to… files (spec 041 FR-4) and the refusal page's shortcut controller, `app/controllers/locations/selections_controller.rb` (spec 059 FR-3), and the shortcut's shared definition, `app/controllers/concerns/bulk_mode_shortcut.rb` (spec 067 FR-3), and `app/models/lot/move_into_zone.rb`, a `Lot` file the deck guard doesn't match (spec 070 decision 8); `spec/deck_core_spec.rb` lists the 54 deck files, spec 068's proxy files among them (AC-8.7) and spec 070's six (the replacements and copies controllers, `Deck::Proxy::UseCopies` and `AddCopy`, and the two new views; spec 070 AC-7.1) (the decks controllers, `Decks::SelectionsController` among them, and views, `Deck`, `Deck::Creation`, `Deck::Listing`, `Location::Placement`, `Catalog::Formats`, the deck helper and icon, the import's deck files, and the picker's `Location::Choices` and `LocationChoicesHelper`, spec 065 AC-8.3) and fails if one names `mtg`, `scryfall` or `magic`, one of MTG's eight format keys, or the zone keys `sideboard` and `commander`: the one zone key core code names is `considering` (spec 062 AC-9.11, decision 2); `spec/collection_places_core_spec.rb` lists the seven core files spec 033 changed (the grid, table and filter models, the lots helper, and the tile, table and row-cell views) and fails if one names `mtg`, `scryfall` or `magic` in any spelling, `scryfall_id` included (spec 048 AC-2.4); no core guard lists `Lot`, `LotsController`, `Lot::Form`, `Lot::ConditionChange`, `BulkRemoval`, `BulkSelection`, `CollectionTable::Sort` or the collection views beyond those three. Every core guard (`spec/*_core_spec.rb`, and `spec/export_format_document_spec.rb`'s check that the format document and its schema name no collectible outside the types section) matches a type or source name (`mtg`, `scryfall`, `magic`) anywhere in a word, so `scryfall_id` and `MTGPrinting` fail it; a word that is also part of ordinary words (`art`, and the finish names `foil`, `nonfoil` and `etched`) is matched as `(? · owned" and the Proxy tag for a deck that has proxies. With no deck the page says "No decks yet. Add a deck to start building one." and keeps "New deck" (spec 062 AC-4.3–AC-4.6, decision 5). The deck's name is a link to the deck page (spec 066 decision 1, AC-1.5; spec 062 decision 5 kept it plain text until #253; the page's markup is `DecksIndex` in the design system). -`GET /decks/:id` is the deck page (spec 066, #253; the markup is `DeckPage`): the id is the deck's location id and another account's deck, a place's id, a zone's id and an id that is not digits only are 404 ([tenancy and accounts](tenancy-and-accounts.md); AC-1.2). It reads only, through `Deck::Page`, and adds no way to add or move copies (AC-4.2). The head reads " · 69 cards · 58 owned · 11 proxy" (every zone, Considering included; cards are owned plus proxy) and a zone's head "12 cards · 12 owned · 0 proxy" with "Add a proxy", a link, at its end (spec 068 decisions 5, 7, AC-2.3). The zones are in the format's registered order, a zone the format does not list and every zone of an unregistered format after them by id, so a zone a change of format added later still reads Main, Sideboard, Considering (decision 3). A zone is one table, one row per printing: its lots of every finish, condition and price are summed into Owned (decision 2); the columns are Card, Cost, Owned, Proxy, Total and the row menu: a row with proxies shows the Proxy tag (the sum of its finishes' proxies) and a menu whose items are links, in this order: "Use my copy", "Add a new copy", "Change proxy count", a separator and "Remove proxy" (danger, last); a row with proxies of two finishes has each item for each finish, the finish named as the vocabulary labels it and the items grouped by verb ("Use my copy for Foil proxy", "Use my copy for Nonfoil proxy", then both "Add a new copy for …", both Change items, the separator and both Remove items), and a proxy whose printing's collectible the instance no longer registers has Change and Remove only (spec 070 decision 1, AC-1.1–AC-1.4, which supersede spec 068 decision 5 and AC-2.6), Total is owned plus proxy, a printing with a proxy and no copy is a row of Owned 0, and a row with no proxy has an empty menu cell (spec 068 decisions 5, 8, AC-2.1, AC-2.6, AC-2.7's "no `form`" and "no Move copies"; spec 066 decision 7 and 9 stand for the rest), and below 640px of the table's container Owned and Proxy are hidden (spec 060 decision 9, decision 7). Rows are grouped by the collectible's groups and ordered by the shown name, set code, collector number as a person reads it (`Catalog::Entry::NUMBER_HAS_DIGITS_FIRST`, `NUMBER_VALUE`), number text and language (decision 3, `Catalog::Entry::DISPLAY_NAME_SQL`). The card's name links to the card page in the collection's context at its copies, where Move copies per lot lives (decisions 9, 10). `Deck::Page#zones` runs at most 6 statements (3 for a deck with no lot and no proxy: the zones, the lot sums, the proxies, then the entries, their sets and one extension read per type; spec 068 AC-2.10) and the controller 2 more for the deck, however many zones, rows, lots and proxies there are (AC-2.10). A deck whose collectible draws a shape panel shows it in the right column, in two copies before the zones; its rules are in the two paragraphs after this one (spec 069, #256). It supersedes spec 066 AC-1.6's "no shape panel" and AC-5.1's "the second column is empty" for such a deck; a deck whose collectible draws none still leaves the column empty (spec 060 AC-3.12). +`GET /decks/:id` is the deck page (spec 066, #253; the markup is `DeckPage`): the id is the deck's location id and another account's deck, a place's id, a zone's id and an id that is not digits only are 404 ([tenancy and accounts](tenancy-and-accounts.md); AC-1.2). It reads only, through `Deck::Page`, and adds no way to add or move copies (AC-4.2). The head reads " · 69 cards · 58 owned · 11 proxy" (every zone, Considering included; cards are owned plus proxy) and a zone's head "12 cards · 12 owned · 0 proxy" with "Add a proxy", a link, at its end (spec 068 decisions 5, 7, AC-2.3). The zones are in the format's registered order, a zone the format does not list and every zone of an unregistered format after them by id, so a zone a change of format added later still reads Main, Sideboard, Considering (decision 3). A zone is one table, one row per printing: its lots of every finish, condition and price are summed into Owned (decision 2); the columns are Card, Cost, Owned, Proxy, Total and the row menu: a row with proxies shows the Proxy tag (the sum of its finishes' proxies) and a menu whose items are links, in this order: "Use my copy", "Add a new copy", "Change proxy count", a separator and "Remove proxy" (danger, last); a row with proxies of two finishes has each item for each finish, the finish named as the vocabulary labels it and the items grouped by verb ("Use my copy for Foil proxy", "Use my copy for Nonfoil proxy", then both "Add a new copy for …", both Change items, the separator and both Remove items), and a proxy whose printing's collectible the instance no longer registers has Change and Remove only (spec 070 decision 1, AC-1.1–AC-1.4, which supersede spec 068 decision 5 and AC-2.6), Total is owned plus proxy, a printing with a proxy and no copy is a row of Owned 0, and a row with no proxy has an empty menu cell (spec 068 decisions 5, 8, AC-2.1, AC-2.6, AC-2.7's "no `form`" and "no Move copies"; spec 066 decision 7 and 9 stand for the rest), and below 640px of the table's container Owned and Proxy are hidden (spec 060 decision 9, decision 7). Rows are grouped by the collectible's groups and ordered by the shown name, set code, collector number as a person reads it (`Catalog::Entry::NUMBER_HAS_DIGITS_FIRST`, `NUMBER_VALUE`), number text and language (decision 3, `Catalog::Entry::DISPLAY_NAME_SQL`). The card's name links to the card page in the collection's context at its copies, where Move copies per lot lives (decisions 9, 10). `Deck::Page#zones` runs at most 6 statements (3 for a deck with no lot and no proxy: the zones, the lot sums, the proxies, then the entries, their sets and one extension read per type; spec 068 AC-2.10) and the controller 2 more for the deck, however many zones, rows, lots and proxies there are (AC-2.10). A deck whose collectible draws a shape panel shows it in the right column, in two copies before the zones; its rules are in the two paragraphs after this one (spec 069, #256). It supersedes spec 066 AC-1.6's "no shape panel" and AC-5.1's "the second column is empty" for such a deck; a deck whose collectible draws none still leaves the column empty (spec 060 AC-3.12). The head's actions are Edit deck, Check deck and a "…" menu of Change format and Remove deck; Check deck's page is the deck page with the check's results, below (spec 079 decision 8, #258). -The page's collectible part is two optional answers of the collecting vocabulary, after its first, `formats`: `deck_card(entry, format_key)` returns a `Catalog::DeckCards::Answer` (group, position, legality word, whether a `deck_cost` partial draws the cost); the contract is written in `Catalog::DeckCards`' comment, and a vocabulary that answers nothing gets one flat table, a muted dash and no marker (spec 066 decision 5, AC-6.1). Magic groups by the first face's type line: a Creature is a Creature, otherwise a Land is a Land, otherwise the first of Planeswalkers, Battles, Instants, Sorceries, Artifacts, Enchantments, then Other (decision 6, `MTG::DeckGroups`); the cost is the first face's (decision 11, `mtg/printings/_deck_cost`); the legality marker is the printing's stored legality in the deck's own format, nothing for Casual or an unregistered format, "Not legal" for a missing entry, written twice and shown once by width (decision 8, `MTG::Collecting::LEGALITY_FORMATS`). The third answer is `deck_shape(zones)` (spec 069 decision 1, #256), below. +The page's collectible part is three optional answers of the collecting vocabulary, after its first, `formats` (the third since spec 079): `deck_card(entry, format_key)` returns a `Catalog::DeckCards::Answer` (group, position, legality word, whether a `deck_cost` partial draws the cost); the contract is written in `Catalog::DeckCards`' comment, and a vocabulary that answers nothing gets one flat table, a muted dash and no marker (spec 066 decision 5, AC-6.1). Magic groups by the first face's type line: a Creature is a Creature, otherwise a Land is a Land, otherwise the first of Planeswalkers, Battles, Instants, Sorceries, Artifacts, Enchantments, then Other (decision 6, `MTG::DeckGroups`); the cost is the first face's (decision 11, `mtg/printings/_deck_cost`); the legality marker is the printing's stored legality in the deck's own format, nothing for Casual or an unregistered format, "Not legal" for a missing entry, written twice and shown once by width (decision 8, `MTG::Collecting::LEGALITY_FORMATS`). The third answer is `deck_shape(zones)` (spec 069 decision 1, #256) and the fourth `deck_rules(format_key)` (spec 079 decision 1, #258), both below. The shape panel is the collecting vocabulary's third answer, after `formats` and `deck_card`: `deck_shape(zones)` takes `Deck::Page#zones` as the page built them and returns a `Catalog::DeckShape::Answer` (`partial`, `figures`, `scope`, `facts`) or nil, and `DecksController#show` asks once through `Catalog::DeckShape.answer`, one line after building the page; a vocabulary without it, a collectible type no longer registered and a nil answer draw no panel and leave the second column empty from 992px; the contract is written in `Catalog::DeckShape`'s comment, which names no collectible, format or zone (spec 069 decision 1, FR-1, AC-1.4; spec 060 AC-3.12). The core partial `decks/_shape` draws each copy's frame (the `aside`'s `section`, `h2` and scope line; the `details`' summary with the facts line and its `c-shape--bare` box) and renders the extension's partial inside, in the `details` copy, then the `aside` copy, before the zones; the figures are built once and rendered twice (decisions 1, 12, AC-1.1, AC-6.3). Magic's answer is `MTG::Collecting.deck_shape` over the figures object `MTG::DeckShape` and its partial `mtg/deck_shapes/_parts`. It counts the Commander and Main zones only (`MTG::DeckShape::COUNTED_ZONES`, in the deck's zone order; Sideboard and Considering never count, and the extension, not the core, names them), so the scope line reads "Main · 60 cards" or "Commander + Main · 100 cards" ("1 card"), a deck with neither zone has no panel, a deck of a format no longer registered keeps its Main zone's panel, and a deck with no card shows the panel's empty states (decisions 2, 10, 14, AC-1.5, AC-1.8, AC-2.8). A card is one copy or one proxy, so owned and proxy count together; a row of another collectible's card (page answer `Catalog::DeckCards::NONE`, no position) is skipped by every part, the scope line's number included (decision 3, AC-3.5). Card types: one type per card, the zone tables' own group (`row.answer.position`, `MTG::DeckGroups`) in the order of `MTG::DeckGroups::NAMES`, only the groups that have a card, each with its count and "N proxy" when it has proxies, so the counts add up to the scope line's number (decision 3, AC-3.1–AC-3.4). The curve counts nonland cards (not in Lands, so Dryad Arbor counts and an Artifact Land does not) whose card has a mana value, from `mtg_cards.mana_value` through the printing's identity (Scryfall's number, spec 058); column `n` from 0 to 5 holds the values from `n` up to but not including `n + 1` (floor, so 0.5 is in 0 and 2.5 in 2) and 6+ holds every value from 6, owned and proxy kept apart (decision 4, AC-2.1–AC-2.3, AC-2.6). The average is the exact mean of the curve's cards (rational arithmetic, so a mean of 0.15 shows 0.2), shown to one decimal rounded half up with the trailing zero kept ("3.0") and thousands delimited, and left out when no card is in the curve (decision 5, AC-2.4). A nonland card with no mana value is left out of the curve, the average and the legend, never counted as 0, and a second muted line under the average says "N nonland cards have no mana value yet." ("1 nonland card has no mana value yet."); the card is still in its type and the scope line (decision 6, AC-2.5). The mana bar counts coloured symbols of the first face's `mana_cost` on the loaded printing, the cost the zone tables show, never the joined `mtg_cards.mana_cost`, for every card in the counted zones, Lands included, each symbol times the card's count: `{W}`, `{U}`, `{B}`, `{R}` and `{G}` count one toward their colour; a hybrid (`{W/U}`) counts one toward each colour; a Phyrexian symbol (`{G/P}`) counts one toward its colour and a Phyrexian hybrid (`{G/W/P}`) one toward each; `{2/W}` and `{C/W}` count one toward the colour only; generic, `{X}`, `{C}`, snow and half symbols (`{HW}`) are never counted (decisions 7, 8, AC-4.1–AC-4.5). Each colour present shows its count and its share of all coloured symbols, each percentage rounded half up on its own so a caption may add to 99 or 101, in W, U, B, R, G order; a segment under 10% of the symbols (`count × 100 < total × 10`) is narrow and shows no label inside, its count staying in the caption; with no coloured symbol the part says "No coloured mana symbols." and an empty Card types part says "No cards yet." (decisions 9, 14, AC-4.6–AC-4.8). The visible number in a curve column's count and in a mana segment's label is abbreviated from 1,000 (`MTG::DeckShape#short_count`: 1,357 is "1.4k", 12,345 "12k", 1,250,000 "1.3m", half up on integers, at most four characters through 999m), because both sit in a slot of fixed width; every other number and every `aria-label` stays exact and delimited; the caption is a run of unbreakable items (`caption_items`, one per colour, so a line never starts with "·"); and the Card types list is a grid whose rows are subgrids, so the counts line up whatever the suffix (spec 077 decisions 1 to 5, #320; it supersedes, in part, spec 060 decision 2, AC-2.2, AC-2.4, AC-2.5 and AC-2.6 (the written-in-full numbers, the fixed 4.5rem minimum, the 279px) and spec 069 decision 14 for those two numbers). A mana label is drawn only if it fits its segment on one line (a segment under 10% never has one, spec 060 decision 4): the browser's own line breaking moves a label that cannot fit onto a second line that `overflow:clip` hides, and the caption carries that colour's count (spec 078, #330, which closes the limit spec 077 decision 2 had recorded; see `DeckShape.md`). **A split card's second half adds no symbol to the bar, and that is deliberate:** a card is read through its first face for its type, cost and symbols, as the zone tables read it, while its mana value is Scryfall's number for the card, both halves added, so a split card is in the curve column of that sum while its symbols are the first half's only (spec 069 Non-Goals, decision 7, AC-4.5). The panel costs one statement of its own, `MTG::Card` for the identity ids of the counted zones' printings, none when those zones hold no card, and reads everything else from the rows `Deck::Page` loaded, so a page's count is the same however large the deck is (decision 11, AC-6.1, AC-6.2); it stores nothing and the export is unchanged (decision 13). +**Check deck** (spec 079, [#258](https://github.com/plainprogrammer/Collector/issues/258); the markup is `DeckCheck`). Check deck is a link styled as a secondary button, after Edit deck in the head, to `GET /decks/:id/check` (`Decks::ChecksController#show`), so neither page has a `form` (decision 7, AC-1.9). The request finds the deck as the deck page does (another account's deck, a zone's id and an id that isn't digits only are 404, AC-1.6; a visitor gets the sign-in redirect, AC-5.2), builds the page and the shape panel as `DecksController#show` does, adds one report and renders the deck page with it: a full-width section between the head and the zones, so a phone reads it first, with the heading "Deck check" (which takes keyboard focus on load: `autofocus` on a heading with `tabindex="-1"`, which Turbo honours on a link visit), the summary line and one row per result; the head, the panel, the zones and every link of the plain page are as on the plain page, all enabled, and Check deck stays and checks again (decision 8, AC-1.2, AC-1.4, AC-1.5, AC-6.3). The link turns Turbo's hover prefetch off (`data-turbo-prefetch="false"`): a prefetch would compute a check nobody asked for, and since nothing is stored it would be wasted work that could show results from before an edit (spec 079 decision 7). Nothing is stored: any later request of the deck page (the redirect after an edit, a change of format, a visit) has no results and shows the button again, and the results page alone carries `turbo-cache-control: no-cache`, so Turbo keeps no snapshot of it and Back to it is a fresh check of the deck as it then is (decisions 7, 9, AC-1.3, AC-1.8). The checks are advice: no finding disables, hides or refuses anything, and the request writes no lot, proxy or location (FR-3, AC-4.8). The plain page runs the statements it ran before the feature, and the check request runs the same number as the plain page, for a deck of 5 rows and of 500 (NFR Performance, AC-5.1; `spec/requests/deck_checks_spec.rb`). Nothing is exported, imported or backed up, and the export format stays at version 4 (decision 9, AC-5.3). + +The report is the core's `Catalog::DeckChecks.report` over `Deck::Page#zones` as the page built them, with the deck's collectible type and stored format key. Its rules are the collecting vocabulary's fourth answer, `deck_rules(format_key)`: the format's size rules (each the zone keys it counts and a minimum, a maximum or an exact size) and its copy rule (the zone keys counted together and the limit of a counted printing), or nil. **The contract is written in `Catalog::DeckChecks`' comment, the one place it is written down**; this doc does not restate it. A vocabulary without `deck_rules`, a nil answer, a collectible type the instance no longer registers and a deck whose format it no longer registers have no size or copy rules (decision 1, FR-1, AC-5.5). The core owns the counting, the words and the order, reads only the rows and extension records the page loaded (never a record by an id from the request, never a statement per row) and names no collectible, format or zone key but `considering` (decision 1, FR-1, AC-5.4; the deck guard, below). Magic's answer is `MTG::Collecting.deck_rules`, which returns `MTG::DeckRules.for(format_key)`. + +- **What is counted** (decisions 3, 4). A card is one owned copy or one proxy, so owned and proxy count together in every check (spec 060 decision 12; AC-2.6, AC-3.3). The copies of a card are summed by catalog identity over every printing of it in the rule's zones (AC-3.3, AC-3.15). A size rule counts the deck's zone rows with its keys; a key the deck has no zone row for counts 0 cards and is named from the format's registration, or by its key when the registration lacks it (decision 1, AC-2.12). Considering and any zone no rule names are counted by no size or copy rule (decision 3, AC-2.5, AC-2.11, AC-3.8); the core leaves Considering out even when a rule names its key, and such a rule still names it in its size line, so the rule holds for every collectible and not only because Magic's rules never name it (decision 3; the implementation review's first follow-up, spec 079 `verification.md`). A card of another collectible than the deck's is left out of every check, owned plus proxy included, and named in no finding, as spec 069 decision 3 does for the panel, so a size count can be smaller than the zone head's "N cards" (decision 4, AC-2.10, AC-3.11, AC-4.9). A token or any other kind of card counts like any card. A printing with no card data (no extension record: the catalog hasn't been refreshed since it was added) counts by its identity like any card and is never exempt and never restricted, because nothing says it is, so before a refresh a basic land with no record is judged like any card (decision 4, AC-3.14). A card's limit is the lowest number any counted printing of it answers; it is unlimited only when every counted printing answers none, so a basic land with one printing that has card data and one that has none is held to the ordinary limit (decision 1's mixed case, AC-3.16). +- **Owned plus proxy is the core's own check and runs for every deck** (decisions 2, 5): Casual, a format the instance no longer registers and a collectible with no rules included (AC-2.7, AC-4.5). A card is flagged in a zone that holds at least one owned copy and at least one proxy of it, of one printing or of two, in any zone, Considering and a zone no rule names included (AC-4.1, AC-4.2, AC-4.10). Each zone is judged alone: owned in Main and a proxy in Considering is no finding, because a proxy in Considering is a plan and not a leftover (AC-4.3), and a card with both in Main and in Sideboard has a finding per zone, each with its zone's split (AC-4.6). A finding names a proxy and never changes one (spec 079 Non-Goals; the issue design's decision 8). +- **Magic's rules** (decision 3; `MTG::DeckRules`, constants below). Standard, Pioneer, Modern, Legacy, Vintage and Pauper: Main at least 60 cards, Sideboard at most 15, and at most 4 copies of a card across Main and Sideboard. Vintage also holds a card to 1, "restricted to 1", when a counted printing's stored legalities list it as restricted in Vintage; the same card in Legacy is held to 4 (AC-3.7). Commander: Commander and Main together exactly 100 cards, and at most 1 copy of a card across them. Casual: no rules. Never limited, in any format: a basic land (its first face's type line has the supertype Basic, Snow-Covered ones included; AC-3.4, AC-3.6) and a card whose rules text says a deck can have any number of cards with its name (AC-3.5). A card whose text states a number ("A deck can have up to seven cards named Seven Dwarves.", "up to nine" for Nazgûl) is held to that number in every format, Commander included (the maintainer's ruling, 2026-11-12; AC-3.12); a number word outside `MTG::DeckRules::NUMBER_WORDS` falls through to the ordinary limit (plan, Risks). The wording the two texts are matched on is Scryfall's, checked against its API (spec 079 `verification.md`, Scryfall facts); the order of the tests is `MTG::DeckRules.limit_for`'s comment. +- **The order** (decisions 6, 10). The summary line reads "3 findings", "1 finding" or "No findings" (AC-1.7). The results are the size rules in the extension's order, then the copy check, then owned plus proxy; a deck with no size or copy rules shows the no-rules line, then owned plus proxy (AC-1.2, AC-2.7). Copy findings are ordered by the position, in the page's zone order, of the first counted zone that holds the card, then by name, then by identity id (AC-3.9); owned-plus-proxy findings by their own zone's position, then by name, then by identity id. + +The words (decision 6). Every result has a status word and a sentence, and the word with its icon (a tick or a triangle) says pass or fault, so no result depends on colour alone (AC-6.2). The status words are Pass, Short, Over, Over the limit and Owned and proxy; the no-rules line has none: + +| Result | Status word | Sentence | +|---|---|---| +| Size below a minimum, or below an exact size | Short | "Main: 58 cards; Standard needs at least 60." / "Commander + Main: 99 cards; Commander needs exactly 100." | +| Size above a maximum, or above an exact size | Over | "Sideboard: 16 cards; Modern allows at most 15." / "Commander + Main: 101 cards; Commander needs exactly 100." | +| Size right | Pass | The same sentence with the true count, one line per size rule: "Main: 60 cards; Standard needs at least 60." | +| A card over its limit | Over the limit | "Lightning Bolt: 5 copies; the limit is 4.", or with a reason, "Black Lotus: 2 copies; restricted to 1." | +| No card over its limit | Pass | "No card is over its limit." | +| Owned and proxy in one zone | Owned and proxy | "Lightning Bolt in Main: 2 owned, 1 proxy." | +| No such card | Pass | "No card has both owned copies and proxies in one zone." | +| No size or copy rules | none | "Casual has no rules to check." (the format's shown name, or its stored key when the instance no longer registers it) | + +Counts are delimited ("1,234 cards") and singular for one ("1 card", "1 copy"); the split's "proxy" never takes a plural, as the deck page's heads write it. A card is named by `Catalog::Entry#name`, the English name Scryfall gives every printing of it, never a non-English printing's localised name (AC-3.13). A size result names its rule's zones by their shown names in the rule's key order, joined with " + " (AC-2.13). The format is named by `Catalog::Formats.name_for`. + +The checks do in part the "deck rules" that spec 069 left as a non-goal (the 2.0 milestone, #258): size and copy limits. Colour identity, banned cards, restricted cards outside Vintage's limit of one, the number of commanders and a whole-deck "is this deck legal" verdict stay out, and spec 066's per-card marker stays the legality view; nor does any check run unasked, block an edit or offer a fix (spec 079 Non-Goals, AC-7.1). Spec 079 supersedes nothing. + | From | Action | Result | |---|---|---| | none | `POST /decks` from `/decks/new`: Name and Format, whose option values are `/` (the default format selected; grouped under each collectible's category name only when more than one type registers formats) | One transaction writes the deck's location (kind `deck`), its `decks` row (type and key) and one zone per zone of the format, in the format's order. 303 to `/decks`, "Added “”." Refused with 422 and the typed text kept: the Locations page's sentences on Name, and "Choose a format." on Format for a blank, unregistered or other-type pair; a request without `deck` is 400; an account, kind, parent, zone or collectible type in the request is never read (spec 062 AC-1.1–AC-1.11) | @@ -248,6 +275,10 @@ Search and the printings page show each owned printing's quantity ("×3") on its | The narrow threshold | 10% of the coloured symbols (`count × 100 < total × 10`) | `MTG::DeckShape#symbols` (no constant) | A mana bar segment under it shows no label, the caption carrying its count | spec 069 decision 9, AC-4.6; #256 | | `MTG::DeckShape::SHORT_COUNT_STEP` | 1,000 | `app/models/mtg/deck_shape.rb` | A count in a slot of fixed width is written in full below it and takes a unit from it, each unit 1,000 of the one before | spec 077 decision 5; #320 | | `MTG::DeckShape::SHORT_COUNT_UNITS` | `k`, `m`, `b` | `app/models/mtg/deck_shape.rb` | The units of an abbreviated count | spec 077 decision 5; #320 | +| `MTG::DeckRules::CONSTRUCTED` | standard, pioneer, modern, legacy, vintage, pauper | `app/models/mtg/deck_rules.rb` | The formats that share one rule set for the deck checks; Vintage adds its restricted limit of 1 inside it | spec 079 decision 3; #258 | +| `MTG::DeckRules::MAIN_MINIMUM`, `SIDEBOARD_MAXIMUM`, `CONSTRUCTED_COPIES` | 60; 15; 4 | `app/models/mtg/deck_rules.rb` | A constructed deck's Main minimum, Sideboard maximum and copies of one card across both (basic lands and the cards that say otherwise excepted) | spec 079 decision 3; #258 | +| `MTG::DeckRules::COMMANDER_SIZE`, `COMMANDER_COPIES` | 100; 1 | `app/models/mtg/deck_rules.rb` | A Commander deck's Commander and Main together, exactly; the singleton rule across them | spec 079 decision 3; #258 | +| `MTG::DeckRules::NUMBER_WORDS` | two to ten, as 2 to 10 | `app/models/mtg/deck_rules.rb` | The number words a card's "up to N" text is read with; the two real cards say seven and nine (Scryfall, checked in spec 079's `verification.md`) | spec 079 decision 3, plan Risks; #258 | | `Lot::FULL_MESSAGE` | "One lot can hold at most 9,999 copies." | `app/models/lot.rb` | The form and number-entry message at the cap | spec 004 AC-9.3; spec 026 AC-4.4 | | `Catalog::QuickAddsController::FULL_LOT` | "You already have the most copies one lot can hold (9,999)." | `app/controllers/catalog/quick_adds_controller.rb` | The quick add's cap message | spec 004 AC-7.3a | | `CollectionGrid::PER_PAGE`, `CollectionTable::PER_PAGE` | 120 | `app/models/collection_grid.rb`, `collection_table.rb` | Tiles and rows per page | spec 004 AC-11.3; spec 006 AC-2.4 | @@ -307,14 +338,16 @@ Measured bars, not constants (recorded in each spec's `verification.md` where on - **An unowned search tile keeps its image unfaded and says "Not owned"** (maintainer, 2026-10-13; spec 021 decision 1), as a rule of the project's own in `additions.css`; `components.css` still carries the fade it overrides (spec 021 decision 8; spec 057 decision 12). - **A count takes free lots in a stated order** (the shown printing first, then newer printings, then the card page's order); the alternatives, the lowest condition first and the collector's own pick of lots, were not chosen: the first needs a ranking of conditions the core doesn't own, the second is #257's page (spec 071 decision 5). - **A proxy of a printing with several finishes is one option per finish** (spec 071 decision 9; supersedes spec 060 decision 14's single option for such printings). +- **A deck is checked on demand, the check is advice and stored nowhere, and a format's rules are code in its collectible's extension** (the maintainer chose the Check deck button and size and copy limits only, 2026-11-12; spec 079 decisions 1, 7, 9). A table of formats in the core was rejected: the core would name formats, which `spec/deck_core_spec.rb` forbids (decision 1). A card that states a number is held to it in every format, Commander included (the maintainer's ruling, 2026-11-12; decision 3). **Accepted limits and open issues:** - The card page's "Your copies" shows its Location column, every row "No location", for an account with no location; whether it should hide as the grid and table do is open for the maintainer (spec 032 `verification.md`, Implementation review). - An unknown sort direction with a known column sorts that column ascending, not the default order spec 006 AC-3.7 names (`CollectionTable::Sort.parse`). - No sort by location (spec 037 Non-Goals); a move's merged lots stay merged when moved back (spec 041 decision 1); no partial lots in bulk (decision 2). -- [#50](https://github.com/plainprogrammer/Collector/issues/50) built the card page's Free to use and In decks (spec 073). [#49](https://github.com/plainprogrammer/Collector/issues/49) built the deck itself and [#252](https://github.com/plainprogrammer/Collector/issues/252) put copies into it: a copy reaches a zone from the lot form, Move copies, bulk Move to…, number entry and the scanner, and the filter finds a zone or a whole deck (spec 065). The deck page is built (#253, spec 066), with proxies (#254, spec 068), Add to deck from the card page (#255, spec 071), replacing a proxy with a copy (#257, spec 070: "Use my copy" takes only free copies, so a copy in another deck's zone or another zone of this deck is moved out first with Move copies) and its shape panel (#256, spec 069); a deck's format changes in the app since spec 064 ([#251](https://github.com/plainprogrammer/Collector/issues/251)), and the "Free to use" rule for Considering and the shape panel's counted zones are under "A deck" above (spec 062 Non-Goals, superseded by spec 064 for the format). The panel's limits: the mana-label band that spec 077 decision 2 left open ([#330](https://github.com/plainprogrammer/Collector/issues/330)) is closed by spec 078 and the other limits of [#320](https://github.com/plainprogrammer/Collector/issues/320) were fixed by spec 077; the one that remains is a Card types row with a five-digit count and suffix at a 320px viewport, whose name wraps (`DeckShape.md`). +- [#50](https://github.com/plainprogrammer/Collector/issues/50) built the card page's Free to use and In decks (spec 073). [#49](https://github.com/plainprogrammer/Collector/issues/49) built the deck itself and [#252](https://github.com/plainprogrammer/Collector/issues/252) put copies into it: a copy reaches a zone from the lot form, Move copies, bulk Move to…, number entry and the scanner, and the filter finds a zone or a whole deck (spec 065). The deck page is built (#253, spec 066), with proxies (#254, spec 068), Add to deck from the card page (#255, spec 071), replacing a proxy with a copy (#257, spec 070: "Use my copy" takes only free copies, so a copy in another deck's zone or another zone of this deck is moved out first with Move copies) and its shape panel (#256, spec 069), and its checks (#258, spec 079: size and copy limits and owned plus proxy, the first of the 2.0 milestone's deck work); a deck's format changes in the app since spec 064 ([#251](https://github.com/plainprogrammer/Collector/issues/251)), and the "Free to use" rule for Considering and the shape panel's counted zones are under "A deck" above (spec 062 Non-Goals, superseded by spec 064 for the format). The panel's limits: the mana-label band that spec 077 decision 2 left open ([#330](https://github.com/plainprogrammer/Collector/issues/330)) is closed by spec 078 and the other limits of [#320](https://github.com/plainprogrammer/Collector/issues/320) were fixed by spec 077; the one that remains is a Card types row with a five-digit count and suffix at a 320px viewport, whose name wraps (`DeckShape.md`). - The card page's `Add to deck` is hidden for a retired printing or a token, even for My copies (spec 071 decision 2). +- A zone name of 60 characters with no space overflows the zone's head and the shape fold of the deck page at a phone's width; the check's results wrap it. No registered format has such a name (a zone's name is copied from its format's registration), so it is left (spec 079 `verification.md`, Findings and Deviations). - `Deck#zones` is ordered by id, which is the format's order only while every zone was written with the deck: a zone the import's repair adds later, and now a zone a change of format adds (Commander to Modern lists Sideboard after Considering), gets a later id, so it shows last there. The deck page orders what it reads by the format's registered order (spec 066 decision 3, `Deck::Page`), so it reads Main, Sideboard, Considering; `Deck#zones` itself is unchanged (spec 062 verification, open items; spec 064 "Supersedes and updates"). - `POST /collection/selection` accepts any location id as a stored filter (spec 037 decision 7), so any id can be stored, a deck's own among them, although no page offers one (a zone's and `deck:` are values the filter offers since spec 065, a deck's own id is not); `spec/requests/bulk_zone_filter_spec.rb` pins that each bulk path then acts on the account's own lots in that zone, keeps a lot in its zone unless the collector moves it, and never unplaces one by accident (spec 062 decision 6). - Collector numbers with symbols a keyboard lacks match only when typed exactly; such cards are added from their card page (spec 026 decision 8). @@ -353,11 +386,12 @@ Measured bars, not constants (recorded in each spec's `verification.md` where on - [Spec 073](../specs/073-card-page-deck-stats/spec.md) (issue [#50](https://github.com/plainprogrammer/Collector/issues/50)): decisions 1 to 10, Stories 1 to 6 and FR-1 to FR-3 (the card page's stats and In decks section, the card as the scope, the reads, `Lot.free` as the one definition of free). It supersedes spec 004 FR-9's single stat, its Non-Goal and Out of Scope entries for "In decks" and "Free to use" and AC-8.1's order in part, and fulfils spec 071 AC-1.4 and the Non-Goals of specs 029, 032, 033, 057 and 070 that name #50 (spec 073 "Supersedes and updates"; no spec but 073 is edited). - [Spec 077](../specs/077-deck-shape-extreme-counts/spec.md) (issues [#320](https://github.com/plainprogrammer/Collector/issues/320) and [#330](https://github.com/plainprogrammer/Collector/issues/330)): all (the deck shape panel at extreme counts: `MTG::DeckShape#short_count` and the abbreviated curve count and segment label, the Card types grid, the caption's unbreakable items, `caption_items`, the mana-label band as a documented limit, ruled option A (closed by spec 078)). It supersedes in part spec 060 decision 2 and AC-2.2, AC-2.4 to AC-2.6 and spec 069 decision 14 and its Findings 1 to 4 (neither is edited). - [Spec 078](../specs/078-mana-bar-label-fit/spec.md) (issue [#330](https://github.com/plainprogrammer/Collector/issues/330)): the mana bar's fit rule (a label is drawn on one line inside its segment or clipped, `additions.css`). It supersedes in part spec 077 decision 2's documented limit, its Goal 1 qualification, Open Questions, Error Scenarios row, AC-5.1 and AC-5.3 and the 060 AC-2.4 exception (neither spec is edited). +- [Spec 079](../specs/079-deck-checks/spec.md) (issue [#258](https://github.com/plainprogrammer/Collector/issues/258)): decisions 1–10, Stories 1–6, FR-1–FR-3 and the NFRs (Check deck and its page, `Decks::ChecksController`, the seam `Catalog::DeckChecks` and Magic's answer `MTG::Collecting.deck_rules` over `MTG::DeckRules`, what is counted, owned plus proxy for every deck, the words and the order, the statement count, nothing stored); the check route's 404 rule (AC-1.6) follows the deck page's in [tenancy and accounts](tenancy-and-accounts.md); the `DeckCheck` markup is the design system's. It does in part spec 069's non-goal "deck rules" and supersedes nothing. - [Spec 057](../specs/057-deck-design-rules/spec.md): decision 12 (the design system is the project's own, so the notes that said an override waits for a re-export are retired). - Cited for a neighbour's fact only: [spec 009](../specs/009-card-scanner-confirm-flow/spec.md) AC-8.2 (the collection page's "Scan cards"), [spec 023](../specs/023-signed-out-catalog-browsing/spec.md) FR-3 (a visitor holds nothing), [spec 028](../specs/028-apply-import/spec.md) decision 7 (an apply clears working state). - ADRs [0019](../adr/0019-lot-as-the-unit-of-ownership.md), [0025](../adr/0025-a-lots-location-is-part-of-its-identity.md), [0028](../adr/0028-decks-are-locations-with-zones.md); [0023](../adr/0023-locations-make-export-format-version-2.md) as far as locations and their names use it; [0017](../adr/0017-row-level-tenancy-one-user-per-account.md) and [0018](../adr/0018-collectible-agnostic-catalog-core.md) as far as lots use them; [0015](../adr/0015-admin-progress-by-polling.md) for number entry's refresh. - Reference: [design system](../design-system/README.md). -- Issues [#43](https://github.com/plainprogrammer/Collector/issues/43), [#44](https://github.com/plainprogrammer/Collector/issues/44), [#45](https://github.com/plainprogrammer/Collector/issues/45), [#46](https://github.com/plainprogrammer/Collector/issues/46), [#47](https://github.com/plainprogrammer/Collector/issues/47), [#51](https://github.com/plainprogrammer/Collector/issues/51), [#64](https://github.com/plainprogrammer/Collector/issues/64), [#65](https://github.com/plainprogrammer/Collector/issues/65), [#56](https://github.com/plainprogrammer/Collector/issues/56) (spec 040), [#48](https://github.com/plainprogrammer/Collector/issues/48) (spec 041), [#57](https://github.com/plainprogrammer/Collector/issues/57) (spec 042), [#142](https://github.com/plainprogrammer/Collector/issues/142) (spec 048), [#209](https://github.com/plainprogrammer/Collector/issues/209) (the core guards' patterns), [#208](https://github.com/plainprogrammer/Collector/issues/208) (the wide table's name column gives), [#187](https://github.com/plainprogrammer/Collector/issues/187) (spec 059), [#288](https://github.com/plainprogrammer/Collector/issues/288) and [#271](https://github.com/plainprogrammer/Collector/issues/271) (spec 061), [#49](https://github.com/plainprogrammer/Collector/issues/49) (spec 062: create, rename and remove a deck), [#251](https://github.com/plainprogrammer/Collector/issues/251) (spec 064: change a deck's format), [#252](https://github.com/plainprogrammer/Collector/issues/252) (spec 065: zones in every picker and in the filter), [#302](https://github.com/plainprogrammer/Collector/issues/302) (spec 067: the deck refusal page's bulk-mode shortcut); open: [#105](https://github.com/plainprogrammer/Collector/issues/105) (this doc), [#50](https://github.com/plainprogrammer/Collector/issues/50) (spec 073: built), [#253](https://github.com/plainprogrammer/Collector/issues/253), [#254](https://github.com/plainprogrammer/Collector/issues/254), [#257](https://github.com/plainprogrammer/Collector/issues/257) (spec 070: built; closed by its pull request). +- Issues [#43](https://github.com/plainprogrammer/Collector/issues/43), [#44](https://github.com/plainprogrammer/Collector/issues/44), [#45](https://github.com/plainprogrammer/Collector/issues/45), [#46](https://github.com/plainprogrammer/Collector/issues/46), [#47](https://github.com/plainprogrammer/Collector/issues/47), [#51](https://github.com/plainprogrammer/Collector/issues/51), [#64](https://github.com/plainprogrammer/Collector/issues/64), [#65](https://github.com/plainprogrammer/Collector/issues/65), [#56](https://github.com/plainprogrammer/Collector/issues/56) (spec 040), [#48](https://github.com/plainprogrammer/Collector/issues/48) (spec 041), [#57](https://github.com/plainprogrammer/Collector/issues/57) (spec 042), [#142](https://github.com/plainprogrammer/Collector/issues/142) (spec 048), [#209](https://github.com/plainprogrammer/Collector/issues/209) (the core guards' patterns), [#208](https://github.com/plainprogrammer/Collector/issues/208) (the wide table's name column gives), [#187](https://github.com/plainprogrammer/Collector/issues/187) (spec 059), [#288](https://github.com/plainprogrammer/Collector/issues/288) and [#271](https://github.com/plainprogrammer/Collector/issues/271) (spec 061), [#49](https://github.com/plainprogrammer/Collector/issues/49) (spec 062: create, rename and remove a deck), [#251](https://github.com/plainprogrammer/Collector/issues/251) (spec 064: change a deck's format), [#252](https://github.com/plainprogrammer/Collector/issues/252) (spec 065: zones in every picker and in the filter), [#302](https://github.com/plainprogrammer/Collector/issues/302) (spec 067: the deck refusal page's bulk-mode shortcut); open: [#258](https://github.com/plainprogrammer/Collector/issues/258) (spec 079: built; closed by its pull request), [#105](https://github.com/plainprogrammer/Collector/issues/105) (this doc), [#50](https://github.com/plainprogrammer/Collector/issues/50) (spec 073: built), [#253](https://github.com/plainprogrammer/Collector/issues/253), [#254](https://github.com/plainprogrammer/Collector/issues/254), [#257](https://github.com/plainprogrammer/Collector/issues/257) (spec 070: built; closed by its pull request). ## Distillation log @@ -387,3 +421,4 @@ Measured bars, not constants (recorded in each spec's `verification.md` where on | 2026-11-11 | Spec 073 (the card page's "Free to use" and "In decks", #50: the three-stat strip, the one definition of free read through `Lot::FreeCopies` with "In decks" as its complement, the "In decks" section from `Deck::CardUses` and its head count, the card as the scope with the Proxy column counting any printing, a visitor's overview reading nothing, the constant number of statements, the three scoped CSS rules and "Your copies"' place class; the Considering and card-page paragraphs, scope, sources and the open-issues line; supersedes spec 004 FR-9's single stat and fulfils spec 071 AC-1.4) | [#333](https://github.com/plainprogrammer/Collector/pull/333) | | 2026-11-11 | Spec 077 (the deck shape panel at extreme counts, #320: `MTG::DeckShape#short_count` and `#caption_items`, the curve column's count and the mana segment's label abbreviated from 1,000 and every other number exact, the Card types list a grid of subgrid rows, the caption's unbreakable items, the mana-label band recorded as a limit and offered as #330; the two `SHORT_COUNT_*` Named constants; the Issues bullet; specs 060 and 069 superseded in part) | [#331](https://github.com/plainprogrammer/Collector/pull/331) | | 2026-11-12 | Spec 078 (a mana bar label fits its segment or isn't drawn, #330: the fit rule in `additions.css`, a wrapping flex segment with `overflow:clip` and a first-item `::before`, so the browser's line breaking clips a label that cannot fit; the 10% narrow class kept; the panel's paragraph, the Issues bullet and Sources; spec 077 decision 2's documented limit superseded in part) | [#336](https://github.com/plainprogrammer/Collector/pull/336) | +| 2026-11-12 | Spec 079 (the deck checks, #258: Check deck, a link to `GET /decks/:id/check` and `Decks::ChecksController`, the results section between the head and the zones with its focus, no stored result and the `no-cache` tag, prefetch off; the fourth vocabulary answer `deck_rules` and the core `Catalog::DeckChecks`, whose comment holds the contract; what is counted (owned plus proxy, by catalog identity, Considering and unnamed zones never, another collectible's cards never, a printing with no card data like any card, the lowest limit), owned plus proxy for every deck, Magic's rules in `MTG::DeckRules`, the words, the order and the summary line; the statement count; the glossary, the head's actions, the deck guard's count, the Named constants, a decision, an accepted limit, Sources; spec 069's non-goal "deck rules" done in part; supersedes nothing) | spec 079's pull request | diff --git a/docs/design-system/README.md b/docs/design-system/README.md index 11a51415..c3a36975 100644 --- a/docs/design-system/README.md +++ b/docs/design-system/README.md @@ -102,7 +102,7 @@ The words every deck screen uses (spec 057 FR-3). They name no format and no col ## Components - Components are plain CSS classes (prefix `c-`) in `collector/components.css`, with the app's additions in `collector/additions.css`, built only on these tokens. There is no JavaScript bundle: each component's README gives the HTML, which you wrap in a Rails partial or ViewComponent. -- Current set: `AppHeader`, `TabBar`, `Button`, `Menu`, `Input`, `Chip` (category chip, filter chip, tag), `Badge`, `ManaCost`, `ViewSwitch`, `FilterBar`, `ItemTile` (with `c-grid`), `CollectionTable` (with its `c-bulkbar`), `DeckShape` and `ProxyTag` (parts). Full screens: `CollectionPage` and `ItemPage` (desktop), `CollectionPagePhone` and `ItemPagePhone`, `DeckPage`, `DecksIndex` (with its New deck, Rename and removal pages), `AddToDeck`, `UseMyCopy` (built by spec 070, #257) and `AddProxy` (with its Add a new copy page, a use of `lots/_form`, spec 070). Card-page parts: `c-crumbs`, `c-item`, `c-rules`, `c-stats`, `c-section`, `c-list`, `c-legality`. +- Current set: `AppHeader`, `TabBar`, `Button`, `Menu`, `Input`, `Chip` (category chip, filter chip, tag), `Badge`, `ManaCost`, `ViewSwitch`, `FilterBar`, `ItemTile` (with `c-grid`), `CollectionTable` (with its `c-bulkbar`), `DeckShape`, `DeckCheck` and `ProxyTag` (parts). Full screens: `CollectionPage` and `ItemPage` (desktop), `CollectionPagePhone` and `ItemPagePhone`, `DeckPage`, `DecksIndex` (with its New deck, Rename and removal pages), `AddToDeck`, `UseMyCopy` (built by spec 070, #257) and `AddProxy` (with its Add a new copy page, a use of `lots/_form`, spec 070). Card-page parts: `c-crumbs`, `c-item`, `c-rules`, `c-stats`, `c-section`, `c-list`, `c-legality`. - Build new components from these before inventing new styles; a new pattern gets its own entry here first. - The design system is the project's own, in code (spec 057 decision 12). A new pattern goes in `collector/additions.css`, with a doc under `components/` and a line in the App additions list below; a rule changes in this README, in the same change as the code. @@ -146,6 +146,8 @@ App additions (spec 063, in `collector/additions.css`): the stroke of small icon App additions (spec 074, #326, in `collector/additions.css`): two blocks, no new class. `Menu`'s clearance of the phone tab bar (`scroll-margin-top` and `scroll-margin-bottom` on `.c-menu__list` and a room box, an `::after` on the open list, below 640px on a tabbed shell; the controller's scroll is in `menu_controller.js`), and `DeckPage`'s zone tables' menu column floor (`th.c-zone__c-menu`, 4rem below 640px of the table's container). See `Menu` and `DeckPage`. +App additions (spec 079, #258, in `collector/additions.css`): `DeckCheck` (`c-deckcheck`, `c-deckcheck__title`, `c-deckcheck__summary`, `c-deckcheck__list`, `c-deckcheck__row` with `--pass`, `--fault` and `--plain`, `c-deckcheck__status`, `c-deckcheck__text`), the Check deck results. + ## Logo - The mark is three fanned cards, the front one vault teal with a foil diamond: a collection, one piece of which is special. It is deliberately game-neutral so it grows past trading cards. @@ -155,5 +157,5 @@ App additions (spec 074, #326, in `collector/additions.css`): two blocks, no new ## Iconography - No icon set chosen yet. Until one is, use Lucide (ISC-licensed) in `currentColor`, which matches Plex's weight. Flag any other set here before adopting it. -- Stroke follows the size an icon is drawn at (spec 063). A stroke scales with its icon, so a Lucide stroke of 1.5 draws 1.25px on screen at 20px but 0.75px at 12px, a hairline beside a 600-weight word. Below 20px an icon keeps about 1.25px on screen: the stroke is `30 ÷ size`, with the size in px, rounded to a half, never below 1.5 or above 2.5. The app draws 1.5 at 20px and above (the tab bar's 22px), 2 at 14px to 16px (buttons, inputs, menu items, the view switch, filter chips) and 2.5 at 12px (badges, legality marks). +- Stroke follows the size an icon is drawn at (spec 063). A stroke scales with its icon, so a Lucide stroke of 1.5 draws 1.25px on screen at 20px but 0.75px at 12px, a hairline beside a 600-weight word. Below 20px an icon keeps about 1.25px on screen: the stroke is `30 ÷ size`, with the size in px, rounded to a half, never below 1.5 or above 2.5. The app draws 1.5 at 20px and above (the tab bar's 22px), 2 at 14px to 16px (buttons, inputs, menu items, the view switch, filter chips) and 2.5 at 12px (badges, legality marks, `DeckCheck`'s status icons). - Every icon keeps `stroke-width="1.5"` in its markup (the weight at 20px, so a copied icon looks right on its own). `additions.css` sets the heavier stroke by the component that draws the icon, so an icon placed in one of those components needs nothing more. An icon drawn at a size no component sets gets its stroke by the rule above, and its size and stroke are added to the list of sizes above. diff --git a/docs/design-system/components/DeckCheck.md b/docs/design-system/components/DeckCheck.md new file mode 100644 index 00000000..6e21bd5c --- /dev/null +++ b/docs/design-system/components/DeckCheck.md @@ -0,0 +1,53 @@ +# DeckCheck + +The results of Check deck on the deck page: a full-width section between the page head and the zones, with a heading, a summary line and one row per result (spec 079, #258). The section names no game, format or zone; the core draws it (`decks/_check`) from `Catalog::DeckChecks`' report. + +**Markup** — a `section.c-deckcheck` labelled by its heading, then the summary and a `ul.c-deckcheck__list` of rows. A row is a status word with its icon (`c-deckcheck__status`) and a sentence (`c-deckcheck__text`), or a sentence alone for a line that is no check. + +```html +
+

Deck check

+

2 findings

+
    +
  • ShortMain: 58 cards; Standard needs at least 60.
  • +
  • PassSideboard: 0 cards; Standard allows at most 15.
  • +
  • Owned and proxyLightning Bolt in Main: 2 owned, 1 proxy.
  • +
+
+``` + +A deck whose format has no rules starts its list with a plain row, then owned plus proxy: + +```html +
  • Casual has no rules to check.
  • +``` + +**Classes** + +- `c-deckcheck`: the section, a column with a `space-3` gap and `space-6` above it, as sections separate. It is not `Form`'s `c-check` (a checkbox or radio row). +- `c-deckcheck__title`: the heading, "Deck check", in `c-section__title`'s `heading` style. +- `c-deckcheck__summary`: "3 findings", "1 finding" or "No findings", in `data` (mono 13/18), `ink-muted`. +- `c-deckcheck__list`: the rows on `surface-raised` with a 1px `line` border and `radius-md` corners; rows are divided by `line`. +- `c-deckcheck__row`: one result, `space-3` by `space-4` padding. +- `c-deckcheck__status`: the status word at 13/18, weight 600, `ink`, after its 12px icon. +- `c-deckcheck__text`: the sentence in `body` (15/22), `ink`. + +**States** (spec 079 decision 6, AC-6.2) + +- `c-deckcheck__row--pass`: the word "Pass" with `icons/check` in `success`. +- `c-deckcheck__row--fault`: a finding, its word "Short", "Over", "Over the limit" or "Owned and proxy", with `icons/alert` in `warning`. A finding is advice, never an error: the check blocks nothing, so it is never `danger`. +- `c-deckcheck__row--plain`: no status, only the sentence ("Casual has no rules to check."), across the row's full width. + +**Colour is never the only signal.** The status word says pass or fault and the icon's shape repeats it (a tick or a triangle); the icon's colour only repeats both. The word stays `ink`, so it reads at 4.5:1 on `surface-raised` in both themes. + +**Focus** (AC-6.3). The heading has `tabindex="-1"` and `autofocus`: following Check deck lands keyboard and screen reader focus on "Deck check", and the next Tab goes on through the results and the zones in reading order, the section being before the zones in the DOM. The heading shows the 2px `focus` ring at `radius-sm` when focus is visible. + +**Widths** (AC-6.1, AC-6.4). The section answers to the page container (`@container page`). + +- **Below 640px:** a row is one column: the status word on its line, the sentence under it. +- **From 640px:** two columns, the status in 11rem (wide enough for "Owned and proxy" on one line) and the sentence in the rest, on one baseline with a `space-4` gap; a plain row's sentence spans both. +- A row breaks inside a word (`overflow-wrap: anywhere`, `minmax(0, 1fr)`), so a 60-character card or zone name with no space wraps inside the section at 360px in either theme. + +**Icon.** `icons/check` and `icons/alert` drawn at 12px with a stroke of 2.5, as a badge's icon is (`.c-deckcheck__status svg`; README, Iconography). The markup keeps `stroke-width="1.5"`. + +**Why not a badge or a list.** `c-badge` is one line and one or two words, and "Over the limit" and "Owned and proxy" are three; `c-list` rows do not wrap; and the deck page had no block between its head and `c-deck`. diff --git a/docs/design-system/components/DeckPage.md b/docs/design-system/components/DeckPage.md index eca0ffe9..e55c8a3b 100644 --- a/docs/design-system/components/DeckPage.md +++ b/docs/design-system/components/DeckPage.md @@ -2,7 +2,7 @@ One deck at desktop and phone width: its zones as tables and, beside or above them, the shape panel. Shown for a Magic: The Gathering deck; the shell is collectible-agnostic (spec 060 decision 21). -**As built (specs 068, 069 and 070, #254, #256 and #257, on spec 066's page):** the page head's line reads `Format · N cards · N owned · N proxy`; a zone's head is its name, `N cards · N owned · N proxy` and "Add a proxy" (a link to the add-a-proxy pages, `AddProxy`); the table has Card, Cost, Owned, Proxy, Total and the row menu; a row with proxies shows the `ProxyTag` in its Proxy cell and in the line under its name on a phone, and its menu lists "Use my copy", "Add a new copy", "Change proxy count" and, after a separator, "Remove proxy" (spec 070, #257; the finish named in each item when the row has proxies of two finishes); a row with no proxy has an empty menu cell; a printing with a proxy and no owned copy is a row of Owned 0; Total is owned plus proxy and a group row's count is its Total; the card name links to the card page at its copies (`/catalog/entries/:key?from=collection#copies`). The page has no `form` (every action is a link). Spec 069 (#256) renders the shape panel when the deck's collectible draws one: the `details` copy, then the `aside` copy, then the zones, inside `c-deck` (the core partial `decks/_shape` draws each copy's frame, the extension's partial the three parts), so the second column holds the panel from 992px; a deck whose collectible draws none renders neither copy and leaves the column empty. The page's `main` carries `c-deckpage`, whose only rules let the head's name wrap anywhere (`overflow-wrap:anywhere`, `min-width:0`) so a 60-character deck name with no space stays inside a 360px screen. A group is its own `tbody` whose first row is the `th scope="rowgroup"` group row (a collectible that groups nothing gets one `tbody` and no group row) +**As built (specs 068, 069 and 070, #254, #256 and #257, on spec 066's page):** the page head's line reads `Format · N cards · N owned · N proxy`; a zone's head is its name, `N cards · N owned · N proxy` and "Add a proxy" (a link to the add-a-proxy pages, `AddProxy`); the table has Card, Cost, Owned, Proxy, Total and the row menu; a row with proxies shows the `ProxyTag` in its Proxy cell and in the line under its name on a phone, and its menu lists "Use my copy", "Add a new copy", "Change proxy count" and, after a separator, "Remove proxy" (spec 070, #257; the finish named in each item when the row has proxies of two finishes); a row with no proxy has an empty menu cell; a printing with a proxy and no owned copy is a row of Owned 0; Total is owned plus proxy and a group row's count is its Total; the card name links to the card page at its copies (`/catalog/entries/:key?from=collection#copies`). The page has no `form` (every action is a link). Spec 079 (#258) adds "Check deck" to the head's actions, after "Edit deck" and before the "…" `Menu`: a secondary button that is a link (a GET to `/decks/:id/check`, so the page still has no `form`), with `data-turbo-prefetch="false"` so hovering it never runs a check nobody asked for (spec 079 decision 7). Its page is the deck page with the results, a `DeckCheck` section between the head and `c-deck`, so a phone reads them first; the head, the panel and the zones are drawn as on the plain page, the button stays and checks again, and the page carries `turbo-cache-control: no-cache`, so Back to it asks for the check again. The three head actions fit one line at 360px. Spec 069 (#256) renders the shape panel when the deck's collectible draws one: the `details` copy, then the `aside` copy, then the zones, inside `c-deck` (the core partial `decks/_shape` draws each copy's frame, the extension's partial the three parts), so the second column holds the panel from 992px; a deck whose collectible draws none renders neither copy and leaves the column empty. The page's `main` carries `c-deckpage`, whose only rules let the head's name wrap anywhere (`overflow-wrap:anywhere`, `min-width:0`) so a 60-character deck name with no space stays inside a 360px screen. A group is its own `tbody` whose first row is the `th scope="rowgroup"` group row (a collectible that groups nothing gets one `tbody` and no group row) **What the page provides**: the deck's name, its format and counts, its zones with their rows, and the extension's contribution. The shell's rules below name no format and no game. The extension supplies the shape panel (`DeckShape`), the Cost column (`ManaCost` or a muted dash), the legality marker and the groups of a zone's rows; the sample's groups are a Magic deck's top-level card types. diff --git a/docs/roadmap.md b/docs/roadmap.md index 38be1636..a8b86135 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -47,6 +47,7 @@ The design system's "where is it" ([docs/design-system/README.md](design-system/ - 14k. [#256](https://github.com/plainprogrammer/Collector/issues/256) Deck shape panel: mana curve, card types and coloured mana symbols (after 14d, 14h and 14i). [Spec 069](specs/069-deck-shape-panel/spec.md). - 14l. [#257](https://github.com/plainprogrammer/Collector/issues/257) Replace a proxy with a copy (after 14i). **Built 2026-11-11, done when its pull request merges:** [spec 070](specs/070-replace-proxy-with-copy/spec.md): a proxy row's menu gains "Use my copy" (free copies moved into the zone, the proxy lowered by the copies used) and "Add a new copy" (the Add a copy form for the proxy's printing); the shared `Lot.free` and `Lot::MoveIntoZone` are what 14j builds on (the pull request is recorded here when it merges). - 14m. [#302](https://github.com/plainprogrammer/Collector/issues/302) Open a deck's lots in bulk mode from its removal refusal page (the shortcut spec 062 decision 7 and spec 065 left out; after 14g). [Spec 067](specs/067-deck-refusal-bulk-mode/spec.md). + - 14n. [#258](https://github.com/plainprogrammer/Collector/issues/258) Deck checks, the first of the 2.0 milestone's deck work (after 14h, 14i and 14k). **Built 2026-11-12, done when its pull request merges:** [spec 079](specs/079-deck-checks/spec.md): a "Check deck" link on the deck page shows, on demand and stored nowhere, the deck's size and copy limits judged by its format's rules (Magic's in code in its extension) and every card with owned copies and proxies in one zone, for any deck (the pull request is recorded here when it merges). 15. [#50](https://github.com/plainprogrammer/Collector/issues/50) "Free to use" and "In decks" on the card page (after 14e and 14i). **Done 2026-11-12 ([#333](https://github.com/plainprogrammer/Collector/pull/333), merged):** [spec 073](specs/073-card-page-deck-stats/spec.md): the card page's stats (Owned, In decks, Free to use) and an "In decks" table of the decks and zones that hold the card's copies and proxies. 16. [#51](https://github.com/plainprogrammer/Collector/issues/51) Choose a location during typed entry. **Built 2026-10-26, done when its pull request merges:** [spec 034](specs/034-location-in-typed-entry/spec.md). diff --git a/docs/specs/079-deck-checks/plan.md b/docs/specs/079-deck-checks/plan.md new file mode 100644 index 00000000..7417ecde --- /dev/null +++ b/docs/specs/079-deck-checks/plan.md @@ -0,0 +1,1181 @@ +# Implementation Plan: Deck Checks (spec 079, issue #258) + +**Spec:** `docs/specs/079-deck-checks/spec.md` (Approved v0.2.1) · **Issue:** #258 · **Branch:** `079-deck-checks` · **Created:** 2026-11-12 +**Approved 2026-11-12** (the maintainer); plan review by Opus (read-only) 2026-11-12: APPROVE WITH CHANGES, applied. Retired by `distill-feature` after the merge (spec 027). + +## Context + +A collector can't tell whether a deck meets its format's rules. Spec 079 adds a **Check deck** link to the deck page; following it shows, on that page, the deck's size and copy-limit results and every card that has owned copies and proxies in one zone. The result is computed on request (a GET), never stored, and never blocks an edit. A game's rules live behind the collecting vocabulary (a third optional seam answer, `deck_rules`), so the core names no collectible, format or zone key (`spec/deck_core_spec.rb`). Owned-plus-proxy is a core check and runs for every deck. + +## Global Constraints + +- **Repo rules:** `CLAUDE.md`, `.claude/rules/*.md`. Specs tag their type (`type: :model|:request|:system`); `RSpec/ExampleLength` max 15 lines; `RSpec/MultipleMemoizedHelpers` max 5 (use `def`, not `let`); multi-expectation examples use `:aggregate_failures`; a multi-case AC is one example per case in an `:aggregate_failures` group (spec 079, Interactions). +- **Core files name nothing.** `spec/deck_core_spec.rb` reads whole files, comments included, with `/mtg|scryfall|magic/i` anywhere and `/(? [ true, true ]` becomes three; `:249` and `:258` need no edit) and `:264-273` (heights ≥ 36 now also cover the new link). `spec/requests/deck_pages_spec.rb:64-74` is scoped to the Edit link and stays. +- **Fixtures that can fail:** each counting AC has a fixture whose wrong answer differs from the right one. The statement-count example and each narrow-width assertion are broken once on purpose (mutation proofs, recorded in `verification.md`). + +## Goal + +Press **Check deck** on any deck page and see, between the head and the zones, a "Deck check" section: a summary ("3 findings", "1 finding", "No findings") and, in order, one result per size rule, the copy check and owned-plus-proxy. A deck whose format has no rules gets the no-rules line, then owned-plus-proxy. + +## Pre-implementation gates + +- **Simplicity (≤3 components): pass.** (1) `Catalog::DeckChecks` (core seam, counting, words), (2) `MTG::DeckRules` + `MTG::Collecting.deck_rules` (Magic's table and limits), (3) `Decks::ChecksController` + `decks/_check` + one CSS block. No gem, model or table. +- **Anti-abstraction: pass.** The `Data.define` types are the contract itself, not wrappers; the controller renders the existing `decks/show` with one more ivar. The three lines that build `Deck::Page` and the shape answer are repeated in the controller, not extracted (two callers). +- **Integration-first: pass.** The seam's types and contract comment, with their specs, are Phase 1, before Magic, the controller or the view. +- **Complexity note (T4.1):** a new design-system pattern (`DeckCheck`) is needed. No class gives a wrapping status-word + sentence row: `c-badge` is nowrap and limited to two words ("Over the limit" and "Owned and proxy" are three), `c-list li` doesn't wrap, and the deck page has no block between head and `c-deck`. Documented as an addition per `CLAUDE.md`. + +## Files + +| File | Change | Responsibility | +|---|---|---| +| `app/models/catalog/deck_checks.rb` | create | core seam: types, `report`, the three checks, words, the contract comment | +| `app/models/mtg/deck_rules.rb` | create | Magic's rules table and each printing's limit | +| `app/models/mtg/collecting.rb` | edit | `deck_rules(format_key)` delegating to `MTG::DeckRules` | +| `config/routes.rb` | edit | `resource :check, only: :show, module: :decks` | +| `app/controllers/decks/checks_controller.rb` | create | GET: build page, shape and report; render `decks/show` | +| `app/views/decks/show.html.erb`, `_check.html.erb` | edit, create | the link, the no-cache tag, the results section | +| `app/assets/stylesheets/collector/additions.css` | edit | the `c-check` block | +| `docs/design-system/components/DeckCheck.md`, `DeckPage.md`, `docs/design-system/README.md` | create, edit | the pattern's document; the page's head link and section; the additions line | +| `spec/deck_core_spec.rb`, `spec/system/deck_page_spec.rb` | edit | guard list and count; the head's three buttons | +| `spec/models/catalog/deck_checks_spec.rb`, `spec/models/mtg/deck_rules_spec.rb`, `spec/models/mtg/deck_checks_for_magic_spec.rb`, `spec/requests/deck_checks_spec.rb`, `spec/system/deck_checks_spec.rb` | create | the proofs | +| `docs/architecture/collection-and-lots.md`, `CLAUDE.md`, `docs/roadmap.md`, `docs/specs/README.md`, `docs/specs/079-deck-checks/verification.md` | edit, create | Story 7 | + +## Phase 0: Preflight (no code) + +**Implements:** execution preamble · **Satisfies:** supports AC-3.4, 3.5, 3.12, 3.13 + +- [ ] **T0.1** `git switch -c 079-deck-checks` from `main` (uncommitted spec, plan and index row travel with it), then one commit: `docs(decks): spec and plan for deck checks (#258)`. +- [ ] **T0.2 Confirm the unverified Scryfall facts**, once, with `curl` (`User-Agent: Collector-dev` and `Accept: application/json` as `.claude/rules/external-data-and-portability.md` asks, 100 ms between calls, no private data): `https://api.scryfall.com/cards/named?exact=Relentless+Rats`, `…exact=Seven+Dwarves`, `…exact=Nazg%C3%BBl` and `https://api.scryfall.com/cards/search?q=%21%22Lightning+Bolt%22+lang%3Aja&unique=prints`. Check the `oracle_text` wording ("A deck can have any number of cards named …", "… up to seven cards named …", "… up to nine …") and that `name` is English while `printed_name` is Japanese. Quote the lines in `verification.md` ("Scryfall facts"). **If a wording differs, update `MTG::DeckRules::ANY_NUMBER` / `STATED_NUMBER` and the T2.1 fixtures before T2.1; if `name` isn't English for a non-English printing, stop and raise it (spec decision 6).** + +## Phase 1: The core seam (contracts first) + +**Implements:** FR-1, FR-2; decisions 1, 2, 4, 5, 6, 10 · **Satisfies:** AC-1.7, 2.1–2.6, 2.9–2.13 (words and counts), 3.1–3.3, 3.5, 3.6, 3.8–3.11, 3.13, 3.15 (core), 4.1–4.10, 5.4, 5.5 +**Files:** `app/models/catalog/deck_checks.rb`, `spec/models/catalog/deck_checks_spec.rb`, `spec/deck_core_spec.rb` +**Interfaces:** Produces `Catalog::DeckChecks.report(deck_type, format_key, zones) → Report`; `SizeRule(zone_keys, min, max)`, `Limit(count, reason)`, `CopyRule(zone_keys, limit_for)`, `Rules(sizes, copies)`, `Line(status, sentence, fault)`, `Report(lines)` with `#faults` and `#summary`. Consumes `Deck::Page::Zone/Group/Row/Line`, `Catalog::Formats.name_for/zones_for`, `Catalog.collecting`. + +A fake vocabulary stands in for the game, so these examples prove the core with no Magic in them. Top of `spec/models/catalog/deck_checks_spec.rb` (shared helpers, defined once and used by every task below): + +```ruby +require "rails_helper" + +RSpec.describe Catalog::DeckChecks, type: :model do + def zone(key, name, *rows) + location = Struct.new(:zone, :name).new(key, name) + Deck::Page::Zone.new(location:, groups: rows.empty? ? [] : [ Deck::Page::Group.new(name: nil, rows:) ]) + end + + def card(name, id, type: "game") + build_stubbed(:catalog_entry, identity: build_stubbed(:catalog_identity, id:, name:), name:, collectible_type: type) + end + + def row(entry, owned: 0, proxy: 0) + lines = proxy.zero? ? [] : [ Deck::Page::Line.new(id: 1, finish: "nonfoil", quantity: proxy) ] + Deck::Page::Row.new(entry:, owned:, lines:, answer: Catalog::DeckCards::NONE) + end + + def size(keys, min: nil, max: nil) = Catalog::DeckChecks::SizeRule.new(zone_keys: keys, min:, max:) + def limit(count, reason = nil) = ->(_entry) { Catalog::DeckChecks::Limit.new(count:, reason:) } + def copies(keys, limit_for) = Catalog::DeckChecks::CopyRule.new(zone_keys: keys, limit_for:) + + def register(sizes: [], copies: nil) + rules = Catalog::DeckChecks::Rules.new(sizes:, copies:) + stub_const("GameVocabulary", Module.new { define_singleton_method(:deck_rules) { |_key| rules } }) + Catalog.collecting["game"] = "GameVocabulary" + end + + def report(*zones) = described_class.report("game", "tight", zones) + def lines(report) = report.lines.map { [ it.status, it.sentence ] } + + after { Catalog.collecting.delete("game") } + + # … the describe blocks of T1.1, T1.2 and T1.3 go here … +end +``` + +### T1.1 Types, contract, the no-rules path and owned plus proxy + +- [ ] **Test (red).** Inside the `RSpec.describe`: + +```ruby + describe ".report with no rules" do + it "says the format has no rules and still runs owned-plus-proxy", :aggregate_failures do + result = described_class.report("game", "tight", [ zone("main", "Main", row(card("Bolt", 1), owned: 2, proxy: 1)) ]) + expect(lines(result)).to eq([ [ nil, "tight has no rules to check." ], [ "Owned and proxy", "Bolt in Main: 2 owned, 1 proxy." ] ]) + expect([ result.lines.first.fault, result.summary ]).to eq([ false, "1 finding" ]) + end + + it "treats a missing method, a nil answer and an unregistered type alike (AC-5.5)", :aggregate_failures do + stub_const("SilentVocabulary", Module.new) + stub_const("NilVocabulary", Module.new { def self.deck_rules(_key) = nil }) + expected = [ [ nil, "tight has no rules to check." ], [ "Pass", "No card has both owned copies and proxies in one zone." ] ] + %w[SilentVocabulary NilVocabulary].each do |vocabulary| + Catalog.collecting["game"] = vocabulary + expect(lines(described_class.report("game", "tight", []))).to eq(expected) + end + expect(described_class.report("gone", "tight", []).summary).to eq("No findings") + end + end + + describe "owned-plus-proxy results" do + let(:bolt) { card("Lightning Bolt", 1) } + + it "flags a zone that holds both, across printings, naming the split", :aggregate_failures do + result = report(zone("main", "Main", row(bolt, owned: 2), row(card("Lightning Bolt", 1), proxy: 1))) + expect(lines(result)).to include([ "Owned and proxy", "Lightning Bolt in Main: 2 owned, 1 proxy." ]) + expect(result.faults).to eq(1) + end + + it "judges each zone alone, includes zones no rule names, and skips Considering's lone proxy", :aggregate_failures do + zones = [ zone("main", "Main", row(bolt, owned: 1), row(bolt, proxy: 1)), zone("stash", "Stash", row(bolt, owned: 2, proxy: 3)), + zone("considering", "Considering", row(bolt, proxy: 1)) ] + sentences = lines(report(*zones)).map(&:last) + expect(sentences).to include("Lightning Bolt in Main: 1 owned, 1 proxy.", "Lightning Bolt in Stash: 2 owned, 3 proxy.") + expect(sentences).not_to include(a_string_matching(/Considering/)) + end + + it "does not flag owned only, proxy only, owned in one zone with a proxy in another, or another collectible", :aggregate_failures do + stray = card("Other", 9, type: "stranger") + zones = [ zone("main", "Main", row(bolt, owned: 2)), zone("considering", "Considering", row(bolt, proxy: 1), row(stray, owned: 1, proxy: 1)) ] + expect(lines(report(*zones)).last).to eq([ "Pass", "No card has both owned copies and proxies in one zone." ]) + end + + it "delimits the split's numbers as the deck page's heads do", :aggregate_failures do + result = report(zone("main", "Main", row(bolt, owned: 1_234, proxy: 5_678))) + expect(lines(result)).to include([ "Owned and proxy", "Lightning Bolt in Main: 1,234 owned, 5,678 proxy." ]) + end + + it "orders findings by zone, then name, then identity", :aggregate_failures do + zones = [ zone("a", "Alpha", row(card("Zed", 1), owned: 1, proxy: 1), row(card("Abe", 2), owned: 1, proxy: 1)), zone("b", "Beta", row(card("Abe", 2), owned: 1, proxy: 1)) ] + expect(lines(report(*zones)).map(&:last)).to eq([ "Abe in Alpha: 1 owned, 1 proxy.", "Zed in Alpha: 1 owned, 1 proxy.", "Abe in Beta: 1 owned, 1 proxy." ]) + end + end +``` + + Run `bin/rspec spec/models/catalog/deck_checks_spec.rb` → **FAIL** (`uninitialized constant Catalog::DeckChecks`). + +- [ ] **Implement** `app/models/catalog/deck_checks.rb`. The header comment is the extension contract and names no format, zone key or game: + +```ruby +# What a deck page's Check deck answers (spec 079): the deck's size and copy limits judged by the collectible's own rules, and +# every card with owned copies and proxies in one zone, which needs no rule and runs for every deck. Like +# `Catalog::DeckShape`, an optional answer on the collecting vocabulary (`Catalog.collecting_for`), so the core counts and +# words the results and never names a collectible, a format or a zone key but `Catalog::Formats::CONSIDERING`. +# +# An extension contract: a vocabulary that has rules for a format provides +# * `deck_rules(format_key)` returning {Rules}, or nil for a format it has no rules for (the deck's stored key, registered or not). +# * `Rules#sizes`: {SizeRule}s in the order they are shown. A rule holds the `zone_keys` it counts (the extension names them; +# the core matches each to a zone row's key) and one of three shapes: `min` only, `max` only, or `min == max` (an exact +# size); a range with two different bounds is not offered. The extension supplies no words. +# * `Rules#copies`: a {CopyRule} or nil. Its `zone_keys` are counted together; `limit_for` is called with one counted printing +# (a `Catalog::Entry` whose `extension` may be nil) and returns a {Limit} (`count`, and a `reason` sentence part such as +# "restricted to 1", or nil) or nil for a card that is never limited. For a printing with no extension record it returns +# the format's ordinary limit. It reads loaded records only, never a query. +# The core counts copies by catalog identity over every counted printing and holds a card to the lowest number its printings +# answer; nil is above every number, so a card is unlimited only when every counted printing answers nil, and the reason is +# the one given with the lowest number (the first printing in page order on a tie). A card of another collectible than the +# deck's is left out of every check. Considering and any zone no rule names are counted by no size or copy rule. +# A vocabulary without `deck_rules`, a nil answer, a type no longer registered and a deck whose format is no longer registered +# have no size or copy rules. +# @see file:collection-and-lots.md +module Catalog::DeckChecks + # One size rule: the zone keys it counts together and a minimum, a maximum, or both equal (an exact size). + SizeRule = Data.define(:zone_keys, :min, :max) + # A card's limit: the most copies a deck may hold, and an optional sentence part that says why ("restricted to 1"). + Limit = Data.define(:count, :reason) + # The copy rule: the zone keys counted together and a callable from a loaded printing to a {Limit} or nil. + CopyRule = Data.define(:zone_keys, :limit_for) + # What a vocabulary answers for a format: its size rules and its copy rule (nil for none). + Rules = Data.define(:sizes, :copies) + # One line of a report: a status word (nil for a line that is no check), its sentence, and whether it is a finding. + Line = Data.define(:status, :sentence, :fault) + # The results of one check request, in the order they are shown. + Report = Data.define(:lines) do + # The number of findings. + def faults = lines.count(&:fault) + + # The summary line: "3 findings", "1 finding" or "No findings". + def summary = faults.zero? ? "No findings" : "#{faults} #{faults == 1 ? 'finding' : 'findings'}" + end + # A row of the deck's collectible with its zone and the zone's position in page order. + # @api private + Counted = Data.define(:zone, :position, :row) + + # The report for a deck. + # + # @param deck_type [String] the deck's collectible type + # @param format_key [String] the deck's stored format key, registered or not + # @param zones [Array] `Deck::Page#zones`; read from memory, no statement runs + # @return [Report] never nil, and never raises for an unregistered type or format + def self.report(deck_type, format_key, zones) + counted = counted_rows(deck_type, zones) + rules = rules_for(deck_type, format_key) + lines = rules ? rule_lines(rules, deck_type, format_key, zones, counted) : [ no_rules_line(deck_type, format_key) ] + Report.new(lines: lines + split_lines(counted)) + end + + # @api private + def self.rules_for(deck_type, format_key) + return unless Catalog.collecting.key?(deck_type) + + vocabulary = Catalog.collecting_for(deck_type) + vocabulary.deck_rules(format_key) if vocabulary.respond_to?(:deck_rules) + end + private_class_method :rules_for + + # Every row of the deck's collectible with its zone's position; another collectible's rows are left out (decision 4). + # @api private + def self.counted_rows(deck_type, zones) + zones.each_with_index.flat_map do |zone, position| + zone.groups.flat_map(&:rows).select { it.entry.collectible_type == deck_type }.map { Counted.new(zone:, position:, row: it) } + end + end + private_class_method :counted_rows + + # @api private + def self.rule_lines(_rules, _deck_type, _format_key, _zones, _counted) = [] + private_class_method :rule_lines + + # @api private + def self.no_rules_line(deck_type, format_key) + Line.new(status: nil, sentence: "#{Catalog::Formats.name_for(deck_type, format_key)} has no rules to check.", fault: false) + end + private_class_method :no_rules_line + + # A count with delimiters ("1,234"), as the deck page's heads and spec 069's panel write one. + # @api private + def self.delimited(number) = ActiveSupport::NumberHelper.number_to_delimited(number) + private_class_method :delimited + + # The owned-plus-proxy check (decision 5): each zone alone, any printing of the card; findings ordered by the zone's + # position, then the card's name, then its identity; or one pass line. + # @api private + def self.split_lines(counted) + findings = counted.group_by { [ it.position, it.row.entry.catalog_identity_id ] }.filter_map do |(position, identity_id), cards| + owned = cards.sum { it.row.owned } + proxy = cards.sum { it.row.proxy } + next unless owned.positive? && proxy.positive? + + [ position, cards.first.row.entry.name, identity_id, cards.first.zone.name, owned, proxy ] + end + return [ Line.new(status: "Pass", sentence: "No card has both owned copies and proxies in one zone.", fault: false) ] if findings.empty? + + findings.sort_by { it.first(3) }.map do |_position, name, _identity_id, zone_name, owned, proxy| + Line.new(status: "Owned and proxy", sentence: "#{name} in #{zone_name}: #{delimited(owned)} owned, #{delimited(proxy)} proxy.", fault: true) + end + end + private_class_method :split_lines +end +``` + +- [ ] **Guard.** In `spec/deck_core_spec.rb` add `app/models/catalog/deck_checks.rb` to the `patterns` list beside the other `catalog/*.rb` files and append `+ 1` to the total, with the comment clause `spec 079 adds the seam app/models/catalog/deck_checks.rb (a new pattern)`. +- [ ] Run `bin/rspec spec/models/catalog/deck_checks_spec.rb spec/deck_core_spec.rb` → PASS. `bin/rubocop -a` on both; `bin/yard doc --no-cache` has no warning. Commit `feat(decks): add Catalog::DeckChecks and its owned-plus-proxy check (#258)`. + +### T1.2 Size results + +- [ ] **Test (red).** Add inside the `RSpec.describe`: + +```ruby + describe "size results" do + it "judges a minimum with the true count in every sentence", :aggregate_failures do + register(sizes: [ size(%w[main], min: 60) ]) + bolt = card("Bolt", 1) + expect(lines(report(zone("main", "Main", row(bolt, owned: 58))))).to include([ "Short", "Main: 58 cards; tight needs at least 60." ]) + expect(lines(report(zone("main", "Main", row(bolt, owned: 59, proxy: 1))))).to include([ "Pass", "Main: 60 cards; tight needs at least 60." ]) + expect(lines(report(zone("main", "Main", row(bolt, owned: 61))))).to include([ "Pass", "Main: 61 cards; tight needs at least 60." ]) + end + + it "names a rule's zones in its key order and a zone the deck lacks by its key", :aggregate_failures do + register(sizes: [ size(%w[bench], max: 15), size(%w[lead main], min: 100, max: 100) ]) + zones = [ zone("main", "Main", row(card("A", 1), owned: 98)), zone("lead", "Lead", row(card("B", 2), owned: 1)) ] + expect(lines(report(*zones))).to start_with([ "Pass", "bench: 0 cards; tight allows at most 15." ], [ "Short", "Lead + Main: 99 cards; tight needs exactly 100." ]) + end + + it "says Over past a maximum and past an exact size, and the singular for one card", :aggregate_failures do + register(sizes: [ size(%w[bench], max: 15), size(%w[main], min: 1, max: 1) ]) + zones = [ zone("bench", "Bench", row(card("A", 1), owned: 16)), zone("main", "Main", row(card("B", 2), owned: 2)) ] + expect(lines(report(*zones))).to start_with([ "Over", "Bench: 16 cards; tight allows at most 15." ], [ "Over", "Main: 2 cards; tight needs exactly 1." ]) + expect(lines(report(zone("main", "Main", row(card("B", 2), owned: 1))))).to include([ "Pass", "Main: 1 card; tight needs exactly 1." ]) + end + + it "counts nothing in Considering or an unnamed zone, and passes at exactly the exact size", :aggregate_failures do + register(sizes: [ size(%w[main], min: 100, max: 100) ]) + zones = [ zone("main", "Main", row(card("A", 1), owned: 100)), zone("considering", "Considering", row(card("B", 2), owned: 30)) ] + expect(lines(report(*zones)).first).to eq([ "Pass", "Main: 100 cards; tight needs exactly 100." ]) + end + + it "separates thousands in a count" do + register(sizes: [ size(%w[main], min: 60) ]) + expect(lines(report(zone("main", "Main", row(card("A", 1), owned: 1_234)))).first.last).to eq("Main: 1,234 cards; tight needs at least 60.") + end + + it "leaves out another collectible's cards and zones no rule names", :aggregate_failures do + register(sizes: [ size(%w[main], min: 1) ]) + zones = [ zone("main", "Main", row(card("Other", 9, type: "stranger"), owned: 5)), zone("stash", "Stash", row(card("A", 1), owned: 7)) ] + expect(lines(report(*zones)).first).to eq([ "Short", "Main: 0 cards; tight needs at least 1." ]) + end + end +``` + + Run → **FAIL** (the rule lines are empty). + +- [ ] **Implement.** Replace the `rule_lines` stub and add the size helpers: + +```ruby + # @api private + def self.rule_lines(rules, deck_type, format_key, zones, counted) + rules.sizes.map { size_line(it, deck_type, format_key, zones, counted) } + end + private_class_method :rule_lines + + # One size result (decision 6). Zones are named in the rule's key order: by the zone row's shown name, else by the format's + # registration, else by the key. + # @api private + def self.size_line(rule, deck_type, format_key, zones, counted) + total = counted.select { rule.zone_keys.include?(it.zone.location.zone) }.sum { it.row.total } + subject = rule.zone_keys.map { zone_name(it, deck_type, format_key, zones) }.join(" + ") + status = if rule.min && total < rule.min then "Short" elsif rule.max && total > rule.max then "Over" else "Pass" end + format_name = Catalog::Formats.name_for(deck_type, format_key) + Line.new(status:, sentence: "#{subject}: #{counted_words(total, 'card')}; #{format_name} #{size_need(rule)}.", fault: status != "Pass") + end + private_class_method :size_line + + # The rule's requirement in words: "needs at least 60", "allows at most 15" or "needs exactly 100". + # @api private + def self.size_need(rule) + return "needs exactly #{delimited(rule.min)}" if rule.min && rule.min == rule.max + return "needs at least #{delimited(rule.min)}" if rule.min + + "allows at most #{delimited(rule.max)}" + end + private_class_method :size_need + + # @api private + def self.zone_name(key, deck_type, format_key, zones) + zones.find { it.location.zone == key }&.name || Catalog::Formats.zones_for(deck_type, format_key)&.find { it.key == key }&.name || key + end + private_class_method :zone_name + + # "1 card", "5 copies": the count with delimiters and the singular for one. + # @api private + def self.counted_words(number, word) = "#{delimited(number)} #{word.pluralize(number)}" + private_class_method :counted_words +``` + +- [ ] Run → PASS; rubocop; commit `feat(decks): size results for the deck checks (#258)`. + +### T1.3 Copy limits + +- [ ] **Test (red).** Add inside the `RSpec.describe`: + +```ruby + describe "copy results" do + let(:bolt) { card("Lightning Bolt", 1) } + + it "names a card over its limit across zones, printings and owned plus proxy", :aggregate_failures do + register(copies: copies(%w[main bench], limit(4))) + zones = [ zone("main", "Main", row(bolt, owned: 3), row(card("Lightning Bolt", 1), proxy: 1)), zone("bench", "Bench", row(bolt, owned: 1)) ] + expect(lines(report(*zones))).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + end + + it "passes at the limit and counts no copy in an unnamed zone", :aggregate_failures do + register(copies: copies(%w[main], limit(4))) + zones = [ zone("main", "Main", row(bolt, owned: 4)), zone("considering", "Considering", row(bolt, owned: 9)) ] + expect(lines(report(*zones))).to include([ "Pass", "No card is over its limit." ]) + end + + it "passes at the limit across two named zones (4 of 3 + 1)" do + register(copies: copies(%w[main bench], limit(4))) + zones = [ zone("main", "Main", row(bolt, owned: 3)), zone("bench", "Bench", row(bolt, owned: 1)) ] + expect(lines(report(*zones))).to include([ "Pass", "No card is over its limit." ]) + end + + it "breaks a tie on the lowest limit by the first printing in page order", :aggregate_failures do + first = card("Black Lotus", 2) + second = card("Black Lotus", 2) + answer = ->(entry) { Catalog::DeckChecks::Limit.new(count: 1, reason: entry.equal?(first) ? "restricted to 1" : "banned past 1") } + register(copies: copies(%w[main], answer)) + expect(lines(report(zone("main", "Main", row(second, owned: 1), row(first, owned: 1))))).to include([ "Over the limit", "Black Lotus: 2 copies; banned past 1." ]) + end + + it "orders two findings of one zone by name, then by identity when the names match", :aggregate_failures do + register(copies: copies(%w[main], limit(0))) + zones = [ zone("main", "Main", row(card("Zed", 1), owned: 1), row(card("Abe", 5), owned: 2), row(card("Abe", 2), owned: 1)) ] + expect(lines(report(*zones)).first(3).map(&:last)).to eq([ "Abe: 1 copy; the limit is 0.", "Abe: 2 copies; the limit is 0.", "Zed: 1 copy; the limit is 0." ]) + end + + it "holds a card to the lowest limit its printings answer, with that reason", :aggregate_failures do + one = card("Black Lotus", 2) + two = card("Black Lotus", 2) + answer = ->(entry) { entry.equal?(one) ? Catalog::DeckChecks::Limit.new(count: 4, reason: nil) : Catalog::DeckChecks::Limit.new(count: 1, reason: "restricted to 1") } + register(copies: copies(%w[main], answer)) + expect(lines(report(zone("main", "Main", row(one, owned: 1), row(two, owned: 1))))).to include([ "Over the limit", "Black Lotus: 2 copies; restricted to 1." ]) + end + + it "treats nil as above every number: unlimited only when every printing says nil", :aggregate_failures do + free = card("Plains", 3) + mixed = card("Plains", 3) + register(copies: copies(%w[main], ->(entry) { Catalog::DeckChecks::Limit.new(count: 4, reason: nil) unless entry.equal?(free) })) + expect(lines(report(zone("main", "Main", row(free, owned: 20))))).to include([ "Pass", "No card is over its limit." ]) + expect(lines(report(zone("main", "Main", row(free, owned: 3), row(mixed, owned: 2))))).to include([ "Over the limit", "Plains: 5 copies; the limit is 4." ]) + end + + it "orders findings by first zone, then name, then identity; leaves out another collectible", :aggregate_failures do + register(copies: copies(%w[main bench], limit(1))) + zones = [ zone("main", "Main", row(card("Zed", 1), owned: 2)), zone("bench", "Bench", row(card("Abe", 2), owned: 2), row(card("Other", 3, type: "stranger"), owned: 9)) ] + expect(lines(report(*zones)).map(&:last)).to start_with("Zed: 2 copies; the limit is 1.", "Abe: 2 copies; the limit is 1.") + expect(lines(report(*zones)).map(&:last)).not_to include(a_string_matching(/Other/)) + end + + it "says the singular for one copy" do + register(copies: copies(%w[main], limit(0))) + expect(lines(report(zone("main", "Main", row(bolt, owned: 1))))).to include([ "Over the limit", "Lightning Bolt: 1 copy; the limit is 0." ]) + end + end +``` + + Run → **FAIL**. + +- [ ] **Implement.** Make `rule_lines` return the sizes plus the copy lines, and add `copy_lines`: + +```ruby + # @api private + def self.rule_lines(rules, deck_type, format_key, zones, counted) + sizes = rules.sizes.map { size_line(it, deck_type, format_key, zones, counted) } + sizes + (rules.copies ? copy_lines(rules.copies, counted) : []) + end + private_class_method :rule_lines + + # The copy check (decisions 1, 3, 4): copies by catalog identity over the rule's zones, owned plus proxy; one finding per + # card over its limit, ordered by the first zone that holds it, then its name, then its identity; or one pass line. A card + # is held to the lowest number its printings answer; `min_by` keeps the first printing in page order on a tie. + # @api private + def self.copy_lines(rule, counted) + in_rule = counted.select { rule.zone_keys.include?(it.zone.location.zone) } + findings = in_rule.group_by { it.row.entry.catalog_identity_id }.filter_map do |identity_id, cards| + copies = cards.sum { it.row.total } + limit = cards.map { it.row.entry }.uniq.filter_map { rule.limit_for.call(it) }.min_by(&:count) + next if limit.nil? || copies <= limit.count + + [ cards.map(&:position).min, cards.first.row.entry.name, identity_id, copies, limit ] + end + return [ Line.new(status: "Pass", sentence: "No card is over its limit.", fault: false) ] if findings.empty? + + findings.sort_by { it.first(3) }.map do |_position, name, _identity_id, copies, limit| + Line.new(status: "Over the limit", sentence: "#{name}: #{counted_words(copies, 'copy')}; #{limit.reason || "the limit is #{limit.count}"}.", fault: true) + end + end + private_class_method :copy_lines +``` + +- [ ] Run the whole file and `spec/deck_core_spec.rb` → PASS; rubocop; `bin/yard doc --no-cache`; commit `feat(decks): copy-limit results for the deck checks (#258)`. + +## Phase 2: Magic's rules + +**Implements:** decisions 1 and 3 (Magic side), FR-2 · **Satisfies:** AC-2.1–2.4, 2.7, 2.8, 3.1–3.7, 3.12, 3.14, 3.16, 4.5 +**Files:** `app/models/mtg/deck_rules.rb`, `app/models/mtg/collecting.rb`, `spec/models/mtg/deck_rules_spec.rb`, `spec/models/mtg/deck_checks_for_magic_spec.rb` +**Interfaces:** Produces `MTG::DeckRules.for(format_key) → Rules | nil`, `MTG::DeckRules.limit_for(entry, format_key, ordinary)`, `MTG::Collecting.deck_rules(format_key)`. Consumes the Phase 1 types and the loaded `MTG::Printing` (`faces[].type_line`, `faces[].oracle_text`, `legalities`). + +### T2.1 `MTG::DeckRules` and the vocabulary answer + +- [ ] **Test (red)** `spec/models/mtg/deck_rules_spec.rb`: + +```ruby +require "rails_helper" + +RSpec.describe MTG::DeckRules, type: :model do + def entry(name = "Card", type_line: "Instant", text: nil, legalities: {}, record: true) + entry = create(:catalog_entry, name:) + create(:mtg_printing, entry:, legalities:, faces: [ { "name" => name, "type_line" => type_line, "oracle_text" => text, "image_uris" => {} } ]) if record + Catalog::Entry.preload_extensions([ entry ]).first + end + + def limit(format, entry) = described_class.for(format).copies.limit_for.call(entry)&.then { [ it.count, it.reason ] } + def shape(rule) = [ rule.zone_keys, rule.min, rule.max ] + + describe ".for" do + it "has size and copy rules for each constructed format", :aggregate_failures do + %w[standard pioneer modern legacy vintage pauper].each do |key| + rules = described_class.for(key) + expect(rules.sizes.map { shape(it) }).to eq([ [ %w[main], 60, nil ], [ %w[sideboard], nil, 15 ] ]) + expect(rules.copies.zone_keys).to eq(%w[main sideboard]) + end + end + + it "has Commander's exact size, a singleton copy rule, and none for casual or an unknown key", :aggregate_failures do + commander = described_class.for("commander") + expect(commander.sizes.map { shape(it) }).to eq([ [ %w[commander main], 100, 100 ] ]) + expect(commander.copies.zone_keys).to eq(%w[commander main]) + expect([ described_class.for("casual"), described_class.for("retired"), MTG::Collecting.deck_rules("casual") ]).to all(be_nil) + end + end + + describe "a card's limit" do + it "is 4 in constructed formats and 1 in Commander", :aggregate_failures do + bolt = entry("Lightning Bolt") + expect([ limit("standard", bolt), limit("pauper", bolt), limit("vintage", bolt), limit("commander", bolt) ]).to eq([ [ 4, nil ], [ 4, nil ], [ 4, nil ], [ 1, nil ] ]) + end + + it "is none for a basic land, Snow-Covered ones included", :aggregate_failures do + plains = entry("Plains", type_line: "Basic Land — Plains") + snow = entry("Snow-Covered Island", type_line: "Basic Snow Land — Island") + expect([ limit("standard", plains), limit("commander", snow) ]).to all(be_nil) + end + + it "is none for any-number text, in every format", :aggregate_failures do + rats = entry("Relentless Rats", type_line: "Creature — Rat", text: "Relentless Rats gets +1/+1 for each other creature you control named Relentless Rats.\nA deck can have any number of cards named Relentless Rats.") + expect([ limit("modern", rats), limit("commander", rats) ]).to all(be_nil) + end + + it "is the stated number for up-to text, in every format", :aggregate_failures do + dwarves = entry("Seven Dwarves", type_line: "Creature — Dwarf", text: "Seven Dwarves gets +1/+1 for each other creature named Seven Dwarves you control.\nA deck can have up to seven cards named Seven Dwarves.") + expect([ limit("standard", dwarves), limit("commander", dwarves) ]).to eq([ [ 7, nil ], [ 7, nil ] ]) + end + + it "is 1, restricted, in Vintage only", :aggregate_failures do + lotus = entry("Black Lotus", type_line: "Artifact", legalities: { "vintage" => "restricted", "legacy" => "banned" }) + expect([ limit("vintage", lotus), limit("legacy", lotus) ]).to eq([ [ 1, "restricted to 1" ], [ 4, nil ] ]) + end + + it "is the ordinary limit for a printing with no card data, a basic land included", :aggregate_failures do + bare = entry("Plains", record: false) + expect([ limit("standard", bare), limit("commander", bare) ]).to eq([ [ 4, nil ], [ 1, nil ] ]) + end + end +end +``` + + Run `bin/rspec spec/models/mtg/deck_rules_spec.rb` → **FAIL** (`uninitialized constant MTG::DeckRules`). + +- [ ] **Implement** `app/models/mtg/deck_rules.rb` (Magic's side may name formats and zones; it is not a core file): + +```ruby +# Magic's deck rules for the deck checks (spec 079 decision 3, the extension side of `Catalog::DeckChecks`): size and copy +# limits per format, and each printing's limit read from its first face's type line, its rules text and its legalities. +# Reads the loaded printing record only, so no query runs per card. +class MTG::DeckRules + # The formats that share one rule set: the main zone's minimum, the sideboard's maximum and a limit of four. + CONSTRUCTED = %w[standard pioneer modern legacy vintage pauper].freeze + # A constructed deck holds at least this many cards in its main zone (spec 079 decision 3). + MAIN_MINIMUM = 60 + # A constructed deck's sideboard holds at most this many cards. + SIDEBOARD_MAXIMUM = 15 + # The copies of one card a constructed deck may hold (a basic land and the cards that say otherwise excepted). + CONSTRUCTED_COPIES = 4 + # The copies of one card a Commander deck may hold: the singleton rule. + COMMANDER_COPIES = 1 + # A Commander deck's commander and main zones hold exactly this many cards together. + COMMANDER_SIZE = 100 + # Rules text of a card a deck may hold any number of: Scryfall's own words (confirmed in verification.md, T0.2). + ANY_NUMBER = /a deck can have any number of cards named/i + # Rules text of a card a deck may hold up to a stated number of; the number is the word after "up to". + STATED_NUMBER = /a deck can have up to (\w+) cards named/i + # The number words the stated limits use (Seven Dwarves: seven; Nazgûl: nine). + NUMBER_WORDS = %w[two three four five six seven eight nine ten].each_with_index.to_h { |word, index| [ word, index + 2 ] }.freeze + + # The rules of one format. + # + # @param format_key [String] the deck's stored format key + # @return [Catalog::DeckChecks::Rules, nil] nil for casual and for a key the collectible has no rules for + def self.for(format_key) + return commander if format_key == "commander" + + constructed(format_key) if CONSTRUCTED.include?(format_key) + end + + # A printing's limit in a format. Order matters: a basic land and any-number text are exempt first, then a stated number, + # then Vintage's restriction, then the ordinary limit. A printing with no card data is never exempt and never restricted. + # + # @param entry [Catalog::Entry] a printing, its `extension` loaded or nil + # @param format_key [String] + # @param ordinary [Integer] the format's ordinary limit + # @return [Catalog::DeckChecks::Limit, nil] nil for a card that is never limited + def self.limit_for(entry, format_key, ordinary) + printing = entry.extension + return Catalog::DeckChecks::Limit.new(count: ordinary, reason: nil) if printing.nil? + + faces = Array(printing.faces) + return if basic_land?(faces.first) + + text = faces.filter_map { it["oracle_text"] }.join("\n") + return if text.match?(ANY_NUMBER) + + stated = NUMBER_WORDS[text[STATED_NUMBER, 1].to_s.downcase] + return Catalog::DeckChecks::Limit.new(count: stated, reason: nil) if stated + return Catalog::DeckChecks::Limit.new(count: 1, reason: "restricted to 1") if format_key == "vintage" && printing.legalities.to_h["vintage"] == "restricted" + + Catalog::DeckChecks::Limit.new(count: ordinary, reason: nil) + end + + # @api private + def self.basic_land?(face) = face&.dig("type_line").to_s.split("—").first.to_s.split.include?("Basic") + private_class_method :basic_land? + + # @api private + def self.constructed(format_key) + Catalog::DeckChecks::Rules.new( + sizes: [ Catalog::DeckChecks::SizeRule.new(zone_keys: %w[main], min: MAIN_MINIMUM, max: nil), + Catalog::DeckChecks::SizeRule.new(zone_keys: %w[sideboard], min: nil, max: SIDEBOARD_MAXIMUM) ], + copies: Catalog::DeckChecks::CopyRule.new(zone_keys: %w[main sideboard], limit_for: ->(entry) { limit_for(entry, format_key, CONSTRUCTED_COPIES) }) + ) + end + private_class_method :constructed + + # @api private + def self.commander + Catalog::DeckChecks::Rules.new( + sizes: [ Catalog::DeckChecks::SizeRule.new(zone_keys: %w[commander main], min: COMMANDER_SIZE, max: COMMANDER_SIZE) ], + copies: Catalog::DeckChecks::CopyRule.new(zone_keys: %w[commander main], limit_for: ->(entry) { limit_for(entry, "commander", COMMANDER_COPIES) }) + ) + end + private_class_method :commander +end +``` + + In `app/models/mtg/collecting.rb`, after `deck_shape`: + +```ruby + # The deck checks' rules for one format (spec 079, the extension side of `Catalog::DeckChecks`): size and copy limits. + # See `MTG::DeckRules`. + # + # @param format_key [String] the deck's stored format key + # @return [Catalog::DeckChecks::Rules, nil] nil for casual and for a key with no rules + def self.deck_rules(format_key) = MTG::DeckRules.for(format_key.to_s) +``` + +- [ ] Run → PASS; `bin/rubocop -a`; `bin/yard doc --no-cache`; commit `feat(decks): Magic's deck rules for the deck checks (#258)`. + +### T2.2 Magic decks end to end through the seam + +- [ ] **Test** `spec/models/mtg/deck_checks_for_magic_spec.rb` (real decks, real `Deck::Page`; AC-2.1–2.4, 2.7, 2.8, 3.1–3.7, 3.12, 3.14, 3.16, 4.5): + +```ruby +require "rails_helper" + +RSpec.describe "Catalog::DeckChecks for a Magic deck", type: :model do + let(:account) { create(:user).account } + + def deck_of(format) = create(:deck, account:, format:, name: "Deck #{format}") + + def card(name, type_line: "Instant", text: nil, legalities: {}, identity: create(:catalog_identity, name:), **attributes) + entry = create(:catalog_entry, identity:, name:, **attributes) + create(:mtg_printing, entry:, legalities:, faces: [ { "name" => name, "type_line" => type_line, "oracle_text" => text, "image_uris" => {} } ]) + entry + end + + def land(name = "Plains") = card(name, type_line: "Basic Land — #{name}") + def own(deck, zone, entry, quantity) = create_lot_in_zone(deck, zone, entry:, quantity:) + def proxy(deck, zone, entry) = create(:deck_proxy, zone: deck.zone(zone), account:, entry:, finish: "nonfoil", quantity: 1) + + def lines(deck) + page = Deck::Page.new(Deck.includes(:location).find_by!(location_id: deck.location_id)) + Catalog::DeckChecks.report("mtg", deck.format, page.zones).lines.map { [ it.status, it.sentence ] } + end + + it "judges Main and Sideboard in each constructed format's own name", :aggregate_failures do + %w[standard pioneer modern legacy vintage pauper].each do |key| + deck = deck_of(key) + own(deck, "main", land, 58) + own(deck, "sideboard", land("Island"), 16) + expect(lines(deck)).to include([ "Short", "Main: 58 cards; #{key.titleize} needs at least 60." ], [ "Over", "Sideboard: 16 cards; #{key.titleize} allows at most 15." ]) + end + end + + it "counts a proxy toward Main and passes at 60 and at 15", :aggregate_failures do + deck = deck_of("standard") + own(deck, "main", land, 59) + proxy(deck, "main", land("Island")) + own(deck, "sideboard", land("Swamp"), 15) + expect(lines(deck)).to include([ "Pass", "Main: 60 cards; Standard needs at least 60." ], [ "Pass", "Sideboard: 15 cards; Standard allows at most 15." ]) + end + + it "judges Commander's two zones together", :aggregate_failures do + deck = deck_of("commander") + own(deck, "commander", card("Krenko"), 1) + own(deck, "main", land, 98) + expect(lines(deck)).to include([ "Short", "Commander + Main: 99 cards; Commander needs exactly 100." ]) + own(deck, "main", land("Island"), 2) + expect(lines(deck)).to include([ "Over", "Commander + Main: 101 cards; Commander needs exactly 100." ]) + end + + it "says Casual and an unregistered format have no rules but still flag a forgotten proxy", :aggregate_failures do + deck = deck_of("casual") + bolt = card("Lightning Bolt") + own(deck, "main", bolt, 2) + proxy(deck, "main", bolt) + expect(lines(deck)).to eq([ [ nil, "Casual has no rules to check." ], [ "Owned and proxy", "Lightning Bolt in Main: 2 owned, 1 proxy." ] ]) + deck.update!(format: "retired") + expect(lines(deck).first).to eq([ nil, "retired has no rules to check." ]) + end + + it "adds a card's copies across printings, zones, owned and proxy", :aggregate_failures do + deck = deck_of("standard") + bolt = create(:catalog_identity, name: "Lightning Bolt") + one = card("Lightning Bolt", identity: bolt) + two = card("Lightning Bolt", identity: bolt, set: create(:catalog_set)) + own(deck, "main", one, 3) + own(deck, "sideboard", two, 1) + proxy(deck, "main", two) + expect(lines(deck)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + end + + it "names a card by its English name when its first row in page order is a Japanese printing", :aggregate_failures do + deck = deck_of("standard") # Main is read before Sideboard, and the Japanese printing is the only row of Main + bolt = create(:catalog_identity, name: "Lightning Bolt") + own(deck, "main", card("Lightning Bolt", identity: bolt, language: "ja", localized_name: "稲妻"), 3) + own(deck, "sideboard", card("Lightning Bolt", identity: bolt, set: create(:catalog_set)), 2) + expect(lines(deck)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + expect(lines(deck).map(&:last)).not_to include(a_string_matching(/稲妻/)) + end + + it "names a Sideboard the deck has no zone row for from the registration (AC-2.12)" do + deck = deck_of("standard") + deck.zone("sideboard").destroy! + expect(lines(deck)).to include([ "Pass", "Sideboard: 0 cards; Standard allows at most 15." ]) + end + + it "passes Commander at exactly 100 while cards in Considering count nowhere (AC-2.4, 2.5)" do + commander = deck_of("commander") + own(commander, "commander", card("Krenko"), 1) + own(commander, "main", land, 99) + own(commander, "considering", land("Island"), 30) + expect(lines(commander)).to include([ "Pass", "Commander + Main: 100 cards; Commander needs exactly 100." ]) + end + + it "exempts basic lands and any-number cards, and holds Commander to one of the rest", :aggregate_failures do + standard = deck_of("standard") + own(standard, "main", land, 20) + rats = card("Relentless Rats", type_line: "Creature — Rat", text: "A deck can have any number of cards named Relentless Rats.") + own(standard, "main", rats, 9) + expect(lines(standard)).to include([ "Pass", "No card is over its limit." ]) + commander = deck_of("commander") + krenko = create(:catalog_identity, name: "Krenko") + own(commander, "commander", card("Krenko", identity: krenko), 1) + own(commander, "main", card("Krenko", identity: krenko, set: create(:catalog_set)), 1) + own(commander, "main", card("Snow-Covered Island", type_line: "Basic Snow Land — Island"), 2) + expect(lines(commander)).to include([ "Over the limit", "Krenko: 2 copies; the limit is 1." ]) + expect(lines(commander).map(&:last)).not_to include(a_string_matching(/Snow-Covered/)) + end + + it "holds a numbered card to its number in every format, and passes at that number", :aggregate_failures do + text = "A deck can have up to seven cards named Seven Dwarves." + %w[standard commander].each do |key| + over = deck_of(key) + own(over, "main", card("Seven Dwarves", type_line: "Creature — Dwarf", text:), 8) + expect(lines(over)).to include([ "Over the limit", "Seven Dwarves: 8 copies; the limit is 7." ]) + exact = create(:deck, account:, format: key, name: "Exact #{key}") + own(exact, "main", card("Seven Dwarves", type_line: "Creature — Dwarf", text:), 7) + expect(lines(exact)).to include([ "Pass", "No card is over its limit." ]) + end + end + + it "restricts a card to one in Vintage only", :aggregate_failures do + legalities = { "vintage" => "restricted" } + vintage = deck_of("vintage") + own(vintage, "main", card("Black Lotus", type_line: "Artifact", legalities:), 2) + expect(lines(vintage)).to include([ "Over the limit", "Black Lotus: 2 copies; restricted to 1." ]) + legacy = deck_of("legacy") + own(legacy, "main", card("Black Lotus", type_line: "Artifact", legalities:), 2) + expect(lines(legacy)).to include([ "Pass", "No card is over its limit." ]) + end + + it "counts a basic land's two printings, one with no card data, at the ordinary limit (AC-3.14, 3.16)", :aggregate_failures do + deck = deck_of("standard") + identity = create(:catalog_identity, name: "Plains") + own(deck, "main", card("Plains", type_line: "Basic Land — Plains", identity:), 1) + own(deck, "main", create(:catalog_entry, identity:, name: "Plains", set: create(:catalog_set)), 4) + expect(lines(deck)).to include([ "Over the limit", "Plains: 5 copies; the limit is 4." ]) + end + + it "leaves out another collectible's cards", :aggregate_failures do + deck = deck_of("standard") + own(deck, "main", create(:catalog_entry, collectible_type: "stranger", name: "Odd"), 70) + expect(lines(deck)).to include([ "Short", "Main: 0 cards; Standard needs at least 60." ], [ "Pass", "No card is over its limit." ]) + end +end +``` + +- [ ] Run → these should pass on first run, since T2.1 is in; this task is the end-to-end proof, not new behaviour. **Mutation proof:** set `MAIN_MINIMUM` to 61 once, see the Standard examples fail, restore. Record it in `verification.md`. Commit `test(decks): Magic deck checks end to end through the seam (#258)`. + +## Phase 3: The request and the page + +**Implements:** FR-3; decisions 7, 8, 9 · **Satisfies:** AC-1.1–1.9, 5.1, 5.2, 5.3 +**Files:** `config/routes.rb`, `app/controllers/decks/checks_controller.rb`, `app/views/decks/show.html.erb`, `app/views/decks/_check.html.erb`, `spec/requests/deck_checks_spec.rb`, `spec/deck_core_spec.rb`, `spec/system/deck_page_spec.rb` +**Interfaces:** Produces `GET /decks/:deck_id/check` (`deck_check_path(deck)`, `Decks::ChecksController#show`) and `@check` (a `Catalog::DeckChecks::Report`) in `decks/show`. Consumes Phases 1 and 2, `FindsDeck#set_deck`, `Deck::Page`, `Catalog::DeckShape.answer`. + +### T3.1 Route, controller, link, results section (plain markup) + +- [ ] **Test (red)** `spec/requests/deck_checks_spec.rb`: + +```ruby +require "rails_helper" + +RSpec.describe "Deck checks", type: :request do + let(:user) { create(:user) } + let(:account) { user.account } + let(:deck) { create(:deck, account:, name: "Izzet Tempo", format: "standard") } + + def html = Nokogiri::HTML5(response.body) + + def printing(name: "Lightning Bolt", type_line: "Instant") + entry = create(:catalog_entry, identity: create(:catalog_identity, name:), name:) + create(:mtg_printing, entry:, faces: [ { "name" => name, "type_line" => type_line, "mana_cost" => "{R}", "image_uris" => {} } ]) + entry + end + + def plains = printing(name: "Plains", type_line: "Basic Land — Plains") + def own(zone, entry, quantity: 1) = create_lot_in_zone(deck, zone, entry:, quantity:) + def proxy_in(zone, entry) = create(:deck_proxy, zone: deck.zone(zone), account:, entry:, finish: "nonfoil", quantity: 1) + def rows = html.css(".c-check__row").map { it.text.squish } + + context "when signed in" do + before { sign_in_as(user) } + + it "shows the button and no results on the plain deck page (AC-1.1, 1.9)", :aggregate_failures do + get deck_path(deck) + expect(html.at_css(".c-item__actions > a.c-btn--secondary[href='#{deck_check_path(deck)}']").text).to eq("Check deck") + expect(html.at_css("#deck-check-title")).to be_nil + expect(html.at_css("main form")).to be_nil + expect(response.body).not_to include('name="turbo-cache-control"') + end + + it "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)", :aggregate_failures do + own("main", plains, quantity: 58) + get deck_check_path(deck) + expect(response).to have_http_status(:ok) + expect([ html.at_css("#deck-check-title").text, html.at_css(".c-check__summary").text ]).to eq([ "Deck check", "1 finding" ]) + expect(rows).to eq([ "Short Main: 58 cards; Standard needs at least 60.", "Pass Sideboard: 0 cards; Standard allows at most 15.", + "Pass No card is over its limit.", "Pass No card has both owned copies and proxies in one zone." ]) + expect(html.css("main > *").first(3).map { it["class"].to_s.split.first }).to eq(%w[c-item__head c-check c-deck]) + expect(response.body).to include('') + expect(html.at_css("main form")).to be_nil + end + + it "shows the same results again and every link of the plain page (AC-1.4, 1.5)", :aggregate_failures do + own("main", plains, quantity: 58) + get deck_path(deck) + plain = html.css("main a[href]").map { it["href"] } + get deck_check_path(deck) + first = rows + get deck_check_path(deck) + expect(rows).to eq(first) + expect(html.css("main a[href]").map { it["href"] }).to include(*plain) + expect(html.css("main a, main button").map { it["disabled"] || it["aria-disabled"] }.compact).to be_empty + get edit_deck_path(deck) # following one of them works as from the plain page + expect(response).to have_http_status(:ok) + end + + it "changes nothing: no write reaches lots, proxies or locations (AC-4.8)", :aggregate_failures do + entry = printing + own("main", entry) + proxy_in("main", entry) + before = [ Lot.sum(:quantity), Deck::Proxy.sum(:quantity), Location.count ] + writes = [ Lot, Deck::Proxy, Location ].flat_map { |model| write_statements_to(model.table_name) { get deck_check_path(deck) } } + expect(writes).to be_empty + expect([ Lot.sum(:quantity), Deck::Proxy.sum(:quantity), Location.count ]).to eq(before) + end + + it "shows no results on the next deck page request after the deck changes (AC-1.3)", :aggregate_failures do + entry = printing + own("main", entry) + proxy = proxy_in("main", entry) + get deck_check_path(deck) + expect(rows).to include("Owned and proxy Lightning Bolt in Main: 1 owned, 1 proxy.") + delete deck_proxy_path(deck, proxy) + follow_redirect! + expect(html.at_css("#deck-check-title")).to be_nil + expect(html.at_css("a[href='#{deck_check_path(deck)}']")).to be_present + get deck_check_path(deck) # pressing the button again judges the changed deck + expect(rows).not_to include(a_string_matching(/Owned and proxy/)) + end + + it "words no findings, one finding and several (AC-1.7)", :aggregate_failures do + own("main", plains, quantity: 60) + get deck_check_path(deck) + expect(html.at_css(".c-check__summary").text).to eq("No findings") + own("sideboard", plains, quantity: 16) + get deck_check_path(deck) + expect(html.at_css(".c-check__summary").text).to eq("1 finding") + own("main", printing(name: "Shock"), quantity: 5) + get deck_check_path(deck) + expect(html.at_css(".c-check__summary").text).to eq("2 findings") + end + + it "gives a deck with no rules the no-rules line and the owned-plus-proxy result (AC-2.7)" do + get deck_check_path(create(:deck, account:, name: "Pile", format: "casual")) + expect(rows).to eq([ "Casual has no rules to check.", "Pass No card has both owned copies and proxies in one zone." ]) + end + + it "is a 404 for another account's deck, a zone and a bad id (AC-1.6)", :aggregate_failures do + theirs = "Rival #{SecureRandom.hex(4)}" # built at run time: the test 404 page prints the spec's own source lines (docs/lessons 2026-11-11) + other = create(:deck, account: create(:user).account, name: theirs) + [ other.to_param, deck.zone("main").id, 0, "abc" ].each do |id| + get deck_check_path(id) + expect(response).to have_http_status(:not_found), "#{id.inspect} was not a 404" + expect(response.body).not_to include(theirs) + end + end + end + + it "redirects a visitor who is not signed in (AC-5.2)" do + get deck_check_path(deck) + expect(response).to redirect_to(new_session_path) + end +end +``` + + Run `bin/rspec spec/requests/deck_checks_spec.rb` → **FAIL** (`undefined local variable deck_check_path`). + +- [ ] **Implement.** + - `config/routes.rb`, after `resource :selection, only: :create, module: :decks` (line 51): `resource :check, only: :show, module: :decks # spec 079: Check deck, the deck page with its checks`. + - `app/controllers/decks/checks_controller.rb`: + +```ruby +# Check deck (spec 079): the deck page with its checks' results. A read-only request, so the link to it needs no form and the +# result is never stored: any later request of the deck page has none (decision 7). The deck comes from the signed-in +# account's decks, so another account's is a 404 (`FindsDeck`). It builds the page and the shape panel as +# `DecksController#show` does and adds one report; the report reads the loaded rows and runs no statement of its own (AC-5.1). +class Decks::ChecksController < ApplicationController + include FindsDeck + + before_action :set_deck + + # Renders the deck page with `@check` set. + def show + @page = Deck::Page.new(@deck) + @shape = Catalog::DeckShape.answer(@deck.collectible_type, @page.zones) + @check = Catalog::DeckChecks.report(@deck.collectible_type, @deck.format, @page.zones) + render "decks/show" + end +end +``` + + - `app/views/decks/show.html.erb`: after line 1 add `<%# The results page isn't kept for Back: Back asks for the check again, so it never shows results from before an edit (spec 079 decision 7). %>` and `<% content_for :head, tag.meta(name: "turbo-cache-control", content: "no-cache") if @check %>`; after the Edit deck link add `<%= link_to "Check deck", deck_check_path(@deck), class: "c-btn c-btn--secondary", data: { turbo_prefetch: false } %>` (a hover prefetch would compute a check nobody asked for); between the head's closing `
    ` and `
    ` add `<%= render "decks/check", check: @check if @check %>`. + - `app/views/decks/_check.html.erb`: + +```erb +
    +

    Deck check

    +

    <%= check.summary %>

    +
      + <% check.lines.each do |line| %> +
    • "> + <% if line.status %> + <%= render(line.fault ? "icons/alert" : "icons/check") %><%= line.status %> + <% end %> + <%= line.sentence %> +
    • + <% end %> +
    +
    +``` + + - `spec/deck_core_spec.rb`: raise the total by `+ 2` with the clause `spec 079 adds the check's controller and the results partial (the globs already cover them)`. + - `spec/system/deck_page_spec.rb:259`: the head now has three `.c-btn` (Edit deck, Check deck, More): change `[ true, true ]` to `[ true, true, true ]`; read `:264-273` (the link is a `c-btn--secondary`, so heights ≥ 36 hold). +- [ ] Run `bin/rspec spec/requests/deck_checks_spec.rb spec/requests/deck_pages_spec.rb spec/deck_core_spec.rb spec/system/deck_page_spec.rb` → PASS (the last is the file this task edits). Rubocop; commit `feat(decks): Check deck on the deck page (#258)`. + +### T3.2 The statement count + +- [ ] **Test** append to `spec/requests/deck_checks_spec.rb` (AC-5.1; the form of `spec/requests/query_counts_spec.rb:227-253`, the bulk fixtures of `spec/requests/collection_import_proxy_timing_spec.rb:43-60`): + +```ruby + describe "statements" do + before { sign_in_as(user) } + + # `count_queries` is `spec/support/query_counting.rb`'s (cached, SCHEMA and TRANSACTION statements are not counted). + def queries_for(path) + count = count_queries { get path } + expect(response).to have_http_status(:ok) + count + end + + # +rows+ printings of one identity, bulk-inserted and owned in the deck's Main zone, ten of them also as proxies. + def fill(deck, rows, label) + set = create(:catalog_set, code: label) + identity = create(:catalog_identity, name: "Bulk #{label}") + now = Time.current + Catalog::Entry.insert_all(Array.new(rows) { |i| { collectible_type: "mtg", external_key: "#{label}-#{i}", catalog_set_id: set.id, catalog_identity_id: identity.id, number: i.to_s, language: "en", name: "Card #{i}", kind: "card", content_digest: "d", created_at: now, updated_at: now } }) + ids = Catalog::Entry.where(catalog_set_id: set.id).pluck(:id) + MTG::Printing.insert_all(ids.map { |id| { catalog_entry_id: id, rarity: "common", finishes: %w[nonfoil], layout: "normal", scryfall_uri: "https://example.test/#{id}", faces: [], created_at: now, updated_at: now } }) + location = deck.zone("main") + Lot.insert_all(ids.map { |id| { account_id: account.id, catalog_entry_id: id, quantity: 2, finish: "nonfoil", price_paid_cents: nil, location_id: location.id, lot_key: Lot.key_for("nonfoil", nil, nil, location.id), created_at: now, updated_at: now } }) + ids.first(10).each { |id| create(:deck_proxy, zone: location, account:, entry: Catalog::Entry.find(id), finish: "nonfoil", quantity: 1) } + end + + it "runs the plain deck page's statements however many rows the deck has", :aggregate_failures do + small = create(:deck, account:, name: "Small", format: "standard") + big = create(:deck, account:, name: "Big", format: "standard") + fill(small, 5, "small") + fill(big, 500, "big") + [ small, big ].each { |deck| get deck_path(deck) } # warm the page once + expect(queries_for(deck_check_path(small))).to eq(queries_for(deck_path(small))) + expect(queries_for(deck_check_path(big))).to eq(queries_for(deck_path(big))) + expect(queries_for(deck_check_path(big))).to eq(queries_for(deck_check_path(small))) + end + end +``` + + (The bulk printings list `nonfoil`, so each proxy uses `finish: "nonfoil"`. A 500-row page renders every row; record the example's runtime in `verification.md`.) +- [ ] Run → PASS (the check adds no statement), and run `bin/rspec spec/models/deck/page_spec.rb`: its examples at `:173-200` pin `Deck::Page#zones` at 3 and 6 statements, which is AC-5.1's "`Deck::Page#zones` is as before". **Mutation proof:** temporarily make `split_lines` call `Catalog::Entry.find(it.row.entry.id)` per card, see the example FAIL, revert. Record it in `verification.md`. Commit `test(decks): the check runs no statement of its own (#258)`. + +## Phase 4: Design system and browser proof + +**Implements:** NFR Accessibility and design; decisions 6–8 · **Satisfies:** AC-1.8 (browser), 6.1–6.4, 7.4 +**Files:** `app/assets/stylesheets/collector/additions.css`, `docs/design-system/components/DeckCheck.md`, `DeckPage.md`, `docs/design-system/README.md`, `spec/system/deck_checks_spec.rb` +**Interfaces:** Consumes the `decks/_check` markup: `c-check`, `c-check__title|summary|list|row|status|text`, `--pass|--fault|--plain`. + +### T4.1 The `DeckCheck` pattern + +- [ ] **Test first (red).** Write T4.2's file (below) and run `bin/rspec spec/system/deck_checks_spec.rb` before any CSS exists: the phone example must **FAIL** (the 60-character text and zone name are unwrapped and clipped with no `c-check` rule). That failure is the red step for this task's CSS. +- [ ] Invoke the `collector-design-system` skill; read `docs/design-system/README.md`, `DeckPage.md`, `Badge.md`, `StageList.md`. Reuse first: `c-section__title` for the heading; `icons/check` and `icons/alert` at the 12 px badge-icon size. The gap is the Complexity note above. +- [ ] Add to `additions.css` after the `c-deck` block (tokens only; text sizes are the type scale's: body 15/22, data 13/18; the 12 px icon takes the 2.5 stroke the README gives an icon no component sizes, as `.c-badge svg` does at `additions.css:634`): + +```css +/* DeckCheck (spec 079): the Check deck results, a full-width section between the deck page's head and its zones. A row is a + status word with its icon and a sentence; the word and the icon say pass or fault, so colour is never the only signal. */ +.c-check { margin-top:var(--space-6); display:flex; flex-direction:column; gap:var(--space-3); min-width:0; } +.c-check__title:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius-sm); } +.c-check__summary { margin:0; font:400 13px/18px var(--font-mono); color:var(--ink-muted); } +.c-check__list { list-style:none; margin:0; padding:0; background:var(--surface-raised); border:1px solid var(--line); border-radius:var(--radius-md); } +.c-check__row { display:grid; grid-template-columns:minmax(0, 1fr); gap:var(--space-1); padding:var(--space-3) var(--space-4); border-top:1px solid var(--line); overflow-wrap:anywhere; } +.c-check__row:first-child { border-top:0; } +.c-check__status { display:inline-flex; align-items:center; gap:var(--space-2); font:600 13px/18px var(--font-sans); color:var(--ink); } +.c-check__status svg { width:12px; height:12px; flex:none; stroke-width:2.5; } +.c-check__row--pass .c-check__status svg { color:var(--success); } +.c-check__row--fault .c-check__status svg { color:var(--warning); } +.c-check__text { font:400 15px/22px var(--font-sans); color:var(--ink); } +@container page (min-width: 640px) { + .c-check__row { grid-template-columns:11rem minmax(0, 1fr); align-items:baseline; gap:var(--space-4); } + .c-check__row--plain .c-check__text { grid-column:1 / -1; } +} +``` + + Adjust a value only to match an existing token (confirm `--radius-sm` exists in `tokens.css`); re-run T4.2 after any change. +- [ ] Write `docs/design-system/components/DeckCheck.md` (shape of `DeckShape.md`: what it is, a markup sample, the classes, the pass/fault/plain states, colour never alone, focus on the heading, phone behaviour, the 12 px icon at stroke 2.5). Edit `DeckPage.md` (the "As built" paragraph gets the head's Check deck link and the results section between head and `c-deck`; keep "no `form`") and `README.md` (an App additions line, and `DeckCheck`'s 12 px icon added to the Iconography list beside the badge and legality icons). Commit `feat(design): the DeckCheck pattern for the check results (#258)`. + +### T4.2 Browser proof + +- [ ] **Test** `spec/system/deck_checks_spec.rb` (type :system; helpers `system_sign_in_as`, `wait_for_turbo_idle`, `open_in_viewport_frame`, `within_narrow_frame`, `page_fits_sideways?`, `tab_to_text`; `set_theme` defined locally as in `spec/system/deck_page_spec.rb:32`): + +```ruby +require "rails_helper" + +RSpec.describe "Deck checks", type: :system do + let!(:user) { system_sign_in_as(create(:user)) } + let(:deck) { create(:deck, account: user.account, name: "Izzet Tempo", format: "standard") } + + def set_theme(name) = page.execute_script("document.documentElement.dataset.theme = #{name.to_json}") + + def printing(name, type_line: "Basic Land — Plains") + entry = create(:catalog_entry, identity: create(:catalog_identity, name:), name:) + create(:mtg_printing, entry:, faces: [ { "name" => name, "type_line" => type_line, "image_uris" => {} } ]) + entry + end + + def clipped(selector) = page.evaluate_script("[...document.querySelectorAll(#{selector.to_json})].filter(el => el.scrollWidth > el.clientWidth + 1).length") + + it "moves keyboard focus to the results heading (AC-6.3)", :aggregate_failures do + visit deck_path(deck) + expect(tab_to_text("Check deck")).to be(true) # the link is reachable by Tab, and Enter is pressed on it + page.send_keys(:enter) + expect(page).to have_css("#deck-check-title:focus", text: "Deck check") + expect(page.evaluate_script("document.getElementById('deck-check-title').tabIndex")).to eq(-1) + end + + it "asks for the check again on Back instead of restoring a Turbo snapshot (AC-1.8)", :aggregate_failures do + visit deck_path(deck) + click_link "Check deck" + expect(page).to have_text("Main: 0 cards; Standard needs at least 60.") + page.execute_script("window.__sameDocument = true") # Turbo visits keep the document; a full page load would drop this + create_lot_in_zone(deck, "main", entry: printing("Plains"), quantity: 60) + click_link "Edit deck" + wait_for_turbo_idle + page.go_back + expect(page).to have_text("Main: 60 cards; Standard needs at least 60.") + expect(page.evaluate_script("window.__sameDocument")).to be(true) # still the same document, so Turbo did the Back + end + + it "puts the results before the zones in reading order (AC-6.3)" do + visit deck_check_path(deck) + follows = "document.getElementById('deck-check-title').compareDocumentPosition(document.querySelector('.c-zone')) & Node.DOCUMENT_POSITION_FOLLOWING" + expect(page.evaluate_script("!!(#{follows})")).to be(true) + end + + it "fits a phone, in both themes, with long names (AC-6.1, 6.4)", :aggregate_failures do + long = "N" * 60 + create_lot_in_zone(deck, "main", entry: printing(long, type_line: "Instant"), quantity: 5) + deck.zone("main").update_columns(name: "Z" * 60) # a zone's shown name is a copy, so a long one is a fixture, not a rename flow + visit deck_path(deck) + expect(open_in_viewport_frame(deck_check_path(deck), viewport: 360, ready: "#deck-check-title")).to eq(360) + within_narrow_frame do + %w[light dark].each do |theme| + set_theme(theme) + expect([ page_fits_sideways?, clipped(".c-check__text, .c-check__status, .c-item__actions .c-btn") ]).to eq([ true, 0 ]) + end + end + end + + it "says pass or fault in words with an icon on every row, never by colour alone (AC-6.2)", :aggregate_failures do + create_lot_in_zone(deck, "main", entry: printing("Plains"), quantity: 58) + visit deck_check_path(deck) + rows = all(".c-check__row--fault .c-check__status") + expect(rows.map(&:text)).to all(be_in([ "Short", "Over", "Over the limit", "Owned and proxy" ])) + expect(page).to have_css(".c-check__status svg", count: page.all(".c-check__status").size) + end +end +``` + + (The narrow-frame example's card uses an Instant type line so the copy limit applies and the finding "NNN…: 5 copies; the limit is 4." is the long text under test; the other examples use basic lands so a count finding is the only finding. The Back example follows the repo's own pattern, `spec/system/collection_spec.rb:22-33` and `back_navigation_spec.rb`: Turbo navigations, then Back, with a marker on `window` showing the document is the same one, so the Back was Turbo's and the snapshot cache was in play.) +- [ ] **Mutation proofs (record each in `verification.md`):** (a) remove `overflow-wrap:anywhere` from `.c-check__row`, confirm the phone example fails, restore (memory `headless-firefox-narrow-frame.md`); (b) remove the `turbo-cache-control` line from `decks/show.html.erb`, confirm the Back example fails (Turbo restores the 0-card snapshot), restore; (c) remove `autofocus` from the heading, confirm the focus example fails, restore. Run `bin/rspec spec/system/deck_checks_spec.rb spec/system/deck_page_spec.rb` → PASS. Commit `test(decks): the check's focus, Back and phone width in the browser (#258)`. + +### T4.3 Visual evidence + +- [ ] Per memories `pr-screenshots-workflow.md` and `pr-visual-evidence-required.md` (headless Firefox, magick/ffmpeg; set `data-theme` for light shots; pause 0.6 s after a theme switch): seed a Standard deck with findings (a short Main, a 5-of, a split), a Vintage deck with a restricted card, and a Casual deck. Capture the results at desktop and 360 px in light and dark, the plain page's head with the new link at 360 px, and a GIF of deck page → Check deck → results → remove a proxy → plain page. Save under `docs/specs/079-deck-checks/screenshots/`, embedded in the PR by SHA-pinned link. + +## Phase 5: Documents, verification and review + +**Implements:** Story 7 · **Satisfies:** AC-5.3, 5.4, 7.1–7.6 +**Files:** `docs/architecture/collection-and-lots.md`, `CLAUDE.md`, `docs/roadmap.md`, `docs/specs/README.md`, `docs/specs/079-deck-checks/verification.md` + +- [ ] **T5.1** `collection-and-lots.md`: the deck checks' rules (decisions 1–10) with the spec as source and a pointer to `Catalog::DeckChecks`' comment (not a copy of the contract); spec 069's non-goal done in part; the new files in the area's list. `CLAUDE.md` Project Status gets one clause ("A collector checks a deck on demand from its page: deck size, copy limits and owned plus proxy of one card (`Catalog::DeckChecks`, `MTG::Collecting.deck_rules`, spec 079)"). `docs/roadmap.md` theme C lists #258 as shipped. Verified by reading, not RSpec (`.claude/rules/testing.md`). +- [ ] **T5.2** `verification.md` in the shape of spec 069's: an AC → proving-example table per story; Story 7 "verified by reading"; the supersession grep (`git grep -n -i "deck rules\|copy limit\|deck size" -- spec docs app ':!.kamal/secrets'`); the Scryfall facts (T0.2); the mutation proofs (T2.2's `MAIN_MINIMUM`, T3.2's per-row statement, T4.2's CSS, cache tag and `autofocus`); the screenshots; findings; deviations. +- [ ] **T5.3** `bin/ci` (RuboCop, YARD, comment ratchet, Brakeman, bundler-audit, importmap audit, the whole suite). A CI failure is reproduced with its seed first (memory `reproduce-ci-with-seed.md`). Cite the pushed SHA. AC-5.3's evidence is the unchanged schema, export spec (`FORMAT_VERSION == 4`) and backup guard. +- [ ] **T5.4** `sdd-review` Mode B on **Opus**, recorded under `## Implementation review` in `verification.md` with the verdict first; then `distill-feature` before the PR is marked ready; open the PR with the maintainer assigned and `gh pr view N --web` once CI is green (memory `open-green-prs-for-review.md`); merge only on their approval. + +## Spec coverage + +| AC | Task | AC | Task | +|---|---|---|---| +| 1.1, 1.9 | T3.1 | 4.1–4.4, 4.6, 4.7, 4.9, 4.10 | T1.1 | +| 4.8 | T3.1 (no write reaches lots, proxies or locations) | +| 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 | T3.1 | 4.5 | T2.2, T3.1 | +| 1.8 | T3.1 (the tag), T4.2 (Back) | 5.1 | T3.2 | +| 2.1–2.6, 2.8, 2.9 | T1.2, T2.2 | 5.2 | T3.1 | +| 2.7 | T1.1, T2.2, T3.1 | 5.3 | T5.3 | +| 2.10–2.13 | T1.2, T2.2 | 5.4 | T1.1, T3.1 | +| 3.1–3.3, 3.5, 3.6, 3.8–3.11, 3.13, 3.15 | T1.3, T2.2 | 5.5 | T1.1 | +| 3.4, 3.7, 3.12, 3.14, 3.16 | T2.1, T2.2 | 6.1–6.4 | T4.2 | +| | | 7.1, 7.3–7.6 | T4.1, T5.1, T5.3 | +| | | 7.2 | T0.1 (the index row, committed with the spec) | + +FR-1 → T1.1, T2.1; FR-2 → T1.1–T1.3, T2.1; FR-3 → T3.1. NFRs: performance T3.2; security T3.1; reliability T1.1, T2.1; accessibility T4.1–T4.2. + +## Verification (end to end) + +1. `bin/rspec spec/models/catalog/deck_checks_spec.rb spec/models/mtg spec/requests/deck_checks_spec.rb spec/system/deck_checks_spec.rb spec/deck_core_spec.rb` green; then `bin/ci` green. +2. `bin/dev`, sign in, open a Standard deck, press **Check deck**: results appear between head and zones, focus is on "Deck check", the summary reads as expected; remove a proxy: the plain page returns with no results; press Back: a fresh check. Repeat at phone width in both themes. +3. Casual deck: "Casual has no rules to check." then the owned-plus-proxy result. Another account's deck URL: 404. + +## Risks and notes + +- **Unverified facts (T0.2):** Scryfall's rules-text wording and that `name` is English for a non-English printing. A wording difference changes two regexes and the fixtures, nothing else. +- **Autofocus on a heading:** Turbo honours `autofocus` on a link visit (an `advance` visit uses `PageRenderer`; morph renders don't focus, and this isn't one) and the browser does on a full load; the repo has no heading precedent, so T4.2's first example is the proof. If it fails, fall back to the repo's own pattern (`sitting_focus_controller.js`: `tabindex="-1"` and a one-shot Stimulus controller). +- **Three head buttons at 360 px:** Edit deck, Check deck and More share `c-item__actions`; T4.2 and the screenshots are the proof. If they overflow, wrap rather than shrink. +- **A numbered card with an unknown number word** ("up to twelve") falls through to the ordinary limit. Only two real cards have the text and both are in the map. +- **Left out on purpose:** boot-time verification of `deck_rules` (zone keys against the format's zones). It isn't in the spec; a wrong key counts as no cards and is named from the registration, which T1.2 tests. Offered as a follow-up. diff --git a/docs/specs/079-deck-checks/screenshots/01-results-desktop-dark.jpg b/docs/specs/079-deck-checks/screenshots/01-results-desktop-dark.jpg new file mode 100644 index 00000000..1dc7fec6 Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/01-results-desktop-dark.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/01-results-desktop-light.jpg b/docs/specs/079-deck-checks/screenshots/01-results-desktop-light.jpg new file mode 100644 index 00000000..5efa16cf Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/01-results-desktop-light.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/02-results-phone-dark.jpg b/docs/specs/079-deck-checks/screenshots/02-results-phone-dark.jpg new file mode 100644 index 00000000..84a40902 Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/02-results-phone-dark.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/02-results-phone-light.jpg b/docs/specs/079-deck-checks/screenshots/02-results-phone-light.jpg new file mode 100644 index 00000000..032da8aa Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/02-results-phone-light.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/03-vintage-restricted-desktop-dark.jpg b/docs/specs/079-deck-checks/screenshots/03-vintage-restricted-desktop-dark.jpg new file mode 100644 index 00000000..0bfd3924 Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/03-vintage-restricted-desktop-dark.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/03-vintage-restricted-desktop-light.jpg b/docs/specs/079-deck-checks/screenshots/03-vintage-restricted-desktop-light.jpg new file mode 100644 index 00000000..e9bbbe06 Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/03-vintage-restricted-desktop-light.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/04-commander-short-phone-dark.jpg b/docs/specs/079-deck-checks/screenshots/04-commander-short-phone-dark.jpg new file mode 100644 index 00000000..920c3ef8 Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/04-commander-short-phone-dark.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/04-commander-short-phone-light.jpg b/docs/specs/079-deck-checks/screenshots/04-commander-short-phone-light.jpg new file mode 100644 index 00000000..b65da85c Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/04-commander-short-phone-light.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/05-casual-no-rules-phone-dark.jpg b/docs/specs/079-deck-checks/screenshots/05-casual-no-rules-phone-dark.jpg new file mode 100644 index 00000000..ada45b3b Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/05-casual-no-rules-phone-dark.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/05-casual-no-rules-phone-light.jpg b/docs/specs/079-deck-checks/screenshots/05-casual-no-rules-phone-light.jpg new file mode 100644 index 00000000..c1953f8b Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/05-casual-no-rules-phone-light.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/06-plain-page-head-phone-dark.jpg b/docs/specs/079-deck-checks/screenshots/06-plain-page-head-phone-dark.jpg new file mode 100644 index 00000000..1d50fa6e Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/06-plain-page-head-phone-dark.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/06-plain-page-head-phone-light.jpg b/docs/specs/079-deck-checks/screenshots/06-plain-page-head-phone-light.jpg new file mode 100644 index 00000000..5e5f3dac Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/06-plain-page-head-phone-light.jpg differ diff --git a/docs/specs/079-deck-checks/screenshots/07-walkthrough.gif b/docs/specs/079-deck-checks/screenshots/07-walkthrough.gif new file mode 100644 index 00000000..2b73ede8 Binary files /dev/null and b/docs/specs/079-deck-checks/screenshots/07-walkthrough.gif differ diff --git a/docs/specs/079-deck-checks/spec.md b/docs/specs/079-deck-checks/spec.md new file mode 100644 index 00000000..fd31ba5c --- /dev/null +++ b/docs/specs/079-deck-checks/spec.md @@ -0,0 +1,306 @@ +# Feature 079: Deck Checks (Deck Size, Copy Limits, and Owned Plus Proxy of One Card) + +**Status:** Approved (2026-11-12, the maintainer, v0.2.1) +**Version:** 0.2.1 +**Created:** 2026-11-12 +**Last Updated:** 2026-11-12 +**Depends on:** [spec 060](../060-deck-components/spec.md) (the deck page's components; decision 12, a deck's cards are owned plus proxy), [spec 062](../062-create-rename-remove-a-deck/spec.md) (decks, zones and the format registry `Catalog::Formats`; merged), [spec 066](../066-deck-page-owned-cards-by-zone/spec.md) (the deck page, its seam `Catalog::DeckCards` and the legality marker; merged), [spec 068](../068-deck-proxies/spec.md) (proxies in a zone; merged), [spec 069](../069-deck-shape-panel/spec.md) (the second seam, `Catalog::DeckShape`, and the counted-zones and statement-count precedents; merged), [the decks and locations issue design](../../superpowers/specs/2026-11-09-decks-and-locations-issues-design.md) (decisions 5, 6 and 8 and items 13, 15 and 16) +**Branch:** `079-deck-checks` +**Issue:** [#258](https://github.com/plainprogrammer/Collector/issues/258) (the 2.0 milestone, roadmap theme C) +**ADR:** none: ADR 0028 already puts a deck's collectible part behind the collecting vocabulary, and specs 066 and 069 added optional answers to that seam; this spec adds a third the same way (decision 1) +**Frozen:** the current rules are in [`collection-and-lots.md`](../../architecture/collection-and-lots.md) (whole spec); the results section's markup and CSS rule are `DeckCheck` in the [design system](../../design-system/README.md); see the [specs index](../README.md) + +--- + +## Changelog + +| Version | Date | Change | +|---------|------|--------| +| 0.2.1 | 2026-11-12 | Spec review round 2 (Opus, NEEDS REVISION, 0 blocking, 7 important, 14 minor; no ruling reopened) fixed: the size rule holds zone keys, a minimum and a maximum and the core owns every word (decision 1; one shape: minimum, maximum or exact); a size rule's zone is counted over the deck's zone rows and named from the registration when the deck has none; the limit question is asked for every counted printing, a printing with no record gets the format's ordinary limit, "none" is above every number, and the reason is the lowest limit's; owned-plus-proxy findings are ordered by their own zone; AC-1.2 is for a deck with rules; the mechanism of "not kept for Back" is Turbo's no-cache tag, and the outcome is stated as a fresh check; `main` has no form; the results heading is "Deck check"; the split's wording; the guard's files are named; AC-7.1 says "in part"; a mis-cited decision, the header's citation of "Left to each issue's spec", AC-2.11's fixture and AC-1.5 are corrected; multi-case criteria are one example per case; `CLAUDE.md`'s Project Status is a Story 7 obligation | +| 0.2.0 | 2026-11-12 | Spec review round 1 (Opus, NEEDS REVISION, 2 blocking, 11 important, 8 minor) fixed: the seam now receives the cards and names its contract (decision 1); a printing with no card data has one rule (decision 4, Error Scenarios, NFR); a request-and-staleness design that fits Turbo (the check is a GET, decision 7); the placement of the control and results (decision 8); which name a card has in a finding and the order of findings (decision 6); every result string (decision 6); the results' order and a no-rules deck (decision 10); a card of another collectible is left out of every check, owned plus proxy included (decision 4); the focus rule for a screen reader (AC-6.3); the statement-count criterion in spec 069's form (AC-5.1); zones the rules don't name (decision 3); the guard, comment and YARD obligations (Story 7); wrong citations of the issue design. The maintainer ruled (2026-11-12) that a card whose text states a number is held to that number in every format, Commander included, and that the button sits in the page head with the results above the zones | +| 0.1.0 | 2026-11-12 | First draft from issue #258, the issue design, specs 062, 066, 068 and 069, and the maintainer's answers of 2026-11-12 (the Check deck button; size and copy limits only; copies counted by card across printings and zones; owned plus proxy of one card in one zone, any printing) | + +## Problem Statement + +A collector building a deck can't tell whether it meets its format's basic rules. Spec 066 marks each card that isn't legal, but nothing judges the deck as a whole: is it big enough, are there too many of one card, did a proxy stay behind next to the owned copy it was meant to be replaced by. Proxies change only by hand (the issue design's decision 8), so a forgotten proxy never announces itself, and a collector at the table or the sleeve box finds out too late. + +This spec adds a **Check deck** button to the deck page. Pressing it judges the deck against its format's size and copy rules and flags every card that has both owned copies and proxies in one zone, and shows the findings on the page. The checks are advisory (they never block an edit) and on demand (nothing is judged, stored or shown until the collector asks). The rules for a game and its formats are that collectible's, kept in code behind the collectible extension; the core deck page names no game, format or zone. + +> **Terms.** A *card* in a zone is one owned copy or one proxy, so a row of Owned 3 and Proxy 1 is four cards (spec 060 decision 12). *The same card* is one catalog identity across all its printings, so Lightning Bolt from two sets is one card. A *check* is one question put to the deck (its size, its copy limits, its owned-plus-proxy cards); a *finding* is a check's answer that something is wrong; a check that finds nothing *passes*. A *rule's zones* are the zones the extension names for that rule (decision 1); a zone no rule names is *unnamed*. + +## Goals + +- A deck page offers **Check deck**, and pressing it shows, on that page, the summary line and a result for each check that applies: a pass line or one finding per fault, each saying what is wrong in numbers. +- A Magic deck is judged against its format's rules (the table in decision 3): the size of its Main zone (and its Commander zone for Commander) and of its Sideboard, and the copies of each card. +- Any deck, whatever its game or format, is checked for a card with both owned copies and proxies in one zone, so a forgotten proxy shows up. +- The rules for a format are code in the collectible extension; the core names no collectible, format or zone key but `considering` (`spec/deck_core_spec.rb`). +- The checks add nothing to the page until asked for, store nothing, and run no statement beyond the ones the deck page already runs. + +## Non-Goals + +- Colour identity, banned cards and restricted cards of any format but Vintage's limit of one, and a whole-deck "is this deck legal" verdict. Spec 066's per-card marker is the legality view; this spec doesn't repeat it and doesn't judge the number or kind of commanders. +- Collector-defined formats and zones (the issue design's item 16) and rules a collector can change. +- Checks that run unasked: on every page view, on save, in the background or in an export. Nothing is stored and nothing is exported (decision 9). +- Blocking, warning on or refusing any edit because of a finding. The checks are read-only advice. +- A change to the proxy, the zone or the lot behaviour: a finding names a proxy, it never changes one (the issue design's decision 8 stands). +- Rules for Considering, and Sideboard rules beyond decision 3's size. Considering is never counted. +- A "fix it" action beside a finding, and a deck-builder workflow (the issue design's item 15). +- A new look: the results reuse the design system's patterns. If the plan finds a pattern it lacks, the pattern is added the way `CLAUDE.md` says (`collector/additions.css` and a component document), as an addition and not a redesign. + +## Users and Context + +**Primary users:** a collector with a deck, owned or planned, who is about to build, sleeve or play it. +**Usage context:** at a desk or on a phone, on the deck page, after adding or moving cards, asking "is this deck ready?". +**User mental model:** "a 60-card deck, no more than four of anything but basic lands; a Commander deck is 100 cards and one of each". A proxy is a card they will have on the table; "owned and proxy of one card" means "I meant to swap this proxy for my copy". + +## Decisions + +Decisions 1 to 10 are this spec's calls inside the issue and the issue design's outline ("Advisory and on demand, shown on the deck page"; "Formats stay rules in code, in the collectible extension"). Those marked *(my call)* are not stated by the issue or the maintainer's rulings; Gate A confirms them. + +1. **A third optional answer on the collecting vocabulary, beside `formats`, `deck_card` and `deck_shape` (the seam).** A vocabulary may answer `deck_rules(format_key)`, the rules of one of its formats, or nil for a format it has no rules for. A core module `Catalog::DeckChecks` (added to `spec/deck_core_spec.rb`'s list; it names no collectible, format or zone key but `considering`) asks it with the deck's collectible type and format, runs the rules over the zones as `Deck::Page#zones` built them, and returns the results the core partial draws. **The contract**, written in `Catalog::DeckChecks`' comment (the one place it is written down; the area doc points to it): + - **Size rules.** The answer lists size rules in the order they are shown. Each holds the *zone keys* it counts (the extension names them; the core matches them to each zone row's key and never names one) and one of three shapes: a minimum only, a maximum only, or an exact size (minimum and maximum equal); a range with two different bounds is not offered. The extension supplies no words: decision 6's sentences, the format's shown name (`Catalog::Formats.name_for`) and the zones' shown names are the core's. The cards of a rule are counted over the deck's zone rows with those keys; a key the deck has no zone row for counts as no cards and is named from the format's registration (`Catalog::Formats.zones_for`), or by its key when the registration lacks it. A deck zone no rule names is unnamed. + - **The copy rule.** The answer names the zone keys the copy rule counts together and gives a way to ask, for one counted printing (a `Catalog::Entry` whose extension record may be nil, as `Catalog::DeckCards` is asked for an entry loaded by `Catalog::Entry.preload_extensions`), what its card's limit is: a number, with an optional reason word ("restricted to 1"), or none (never limited). For a printing with no extension record the extension answers the format's ordinary limit (decision 4). The core counts copies by catalog identity and asks for **every** counted printing of a card; *none* is above every number, so a card is unlimited only when every counted printing answers none, and otherwise it is held to the lowest number, with the reason given alongside that number (the first printing in page order on a tie). *The mixed case* (one printing of a basic land has a record, another has none) therefore gives the ordinary limit, the same accepted consequence as decision 4's. + - **Reads.** Asking reads the loaded extension records only: no statement per card, and none beyond the deck page's own (decision 7, AC-5.1). The core reads each row's `catalog_identity_id`, which the page's entries already carry. + - **The core owns** the counting, the results, their words (decision 6), the order of findings and the pass lines, and it shows the zones' own names (`Zone#name`), joined with " + ". + + A vocabulary without `deck_rules`, a format it returns nil for, a collectible no longer registered and a deck whose format is no longer registered have **no size or copy rules**: the page says so in one line (decision 6). *Alternative:* a hard-coded table in the core. Rejected: the core would name formats, which `spec/deck_core_spec.rb` forbids and issue #258 rules out. +2. **Owned plus proxy is a core check, run for every deck.** It needs no game rule: it reads each zone's rows through their catalog identity and the owned and proxy counts the page already has. It runs for Casual and for a deck whose format is no longer registered, because a proxy forgotten there is as forgotten. *(my call)* +3. **The rules, by format (Magic).** *(The maintainer chose size and copy limits only, on 2026-11-12.)* + + | Format | Size | Copies of one card | + |---|---|---| + | Standard, Pioneer, Modern, Legacy, Pauper | Main at least 60; Sideboard at most 15 | at most 4, Main and Sideboard together | + | Vintage | as above | at most 4, and at most 1 of a card the catalog lists as restricted in Vintage, Main and Sideboard together | + | Commander | Commander and Main together exactly 100 | at most 1, Commander and Main together | + | Casual | none | none | + + Basic lands (a type line with the supertype Basic, Snow-Covered ones included) are never limited. A card whose own rules text says a deck can have any number of cards with its name is never limited; a card whose text states a number ("up to seven", "up to nine") is held to that number **in every format, Commander included** (the maintainer's ruling, 2026-11-12). Considering is never counted, for size or for copies, and neither is any unnamed zone. Owned copies and proxies both count (spec 060 decision 12); a proxy is a card on the table. A card is *restricted in Vintage* when any counted printing of it lists it so. +4. **Cards are counted by catalog identity; the cards counted are the deck's collectible's.** The copies of "a card" are summed over every printing of it in the rule's zones, owned plus proxy, so four Lightning Bolts from two sets are four. A card of another collectible than the deck's (a lot isn't type-checked when it is placed in a zone, so a page can hold one) is **left out of every check** (size, copies and owned plus proxy) and is named in no finding, as spec 069 decision 3 does for the panel, so a size count can be smaller than the zone head's "N cards", which counts every row. A token or any other kind of card placed in a zone counts like any card. A printing with no card data (no extension record: the catalog hasn't been refreshed since it was added) is a card like any other: it counts for size and for copies by its identity, and it is never exempt and never restricted, because nothing says it is. The consequence is stated and accepted: before a refresh, a basic land with no record is judged like any card. *(The maintainer chose "by card, across printings and zones", 2026-11-12; the no-card-data rule is my call.)* +5. **The owned-plus-proxy rule.** A card is flagged in a zone when that zone holds at least one owned copy and at least one proxy of the card, in the same or in different printings, in any zone including Considering and an unnamed zone. Each zone is judged alone: owned in Main and a proxy in Considering is not a finding, because a proxy in Considering is a plan and not a leftover *(my call)*. A finding names the card, the zone's shown name and the split ("2 owned, 1 proxy"). *(The maintainer chose "same card in one zone, any printing", 2026-11-12.)* +6. **Findings are numbers, in words; these are the strings.** Every result has a **status word** and a sentence, and the word and an icon the design system already has (if the plan finds one) say pass or fault, so nothing depends on colour alone. "N cards" and "N copies" use `number_with_delimiter` and the singular for one. *(my call)* + + | Result | Status word | Sentence | + |---|---|---| + | Size below the minimum | Short | "Main: 58 cards; Standard needs at least 60." | + | Size above the maximum | Over | "Sideboard: 17 cards; Standard allows at most 15." | + | Size not exact | Short or Over | "Commander + Main: 99 cards; Commander needs exactly 100." | + | Size right | Pass | the same sentence with the true count: "Main: 60 cards; Standard needs at least 60." (one line per size rule) | + | A card over its limit | Over the limit | "Lightning Bolt: 5 copies; the limit is 4." or, with a reason, "Black Lotus: 2 copies; restricted to 1." | + | No card over its limit | Pass | "No card is over its limit." | + | Owned and proxy in one zone | Owned and proxy | "Lightning Bolt in Main: 2 owned, 1 proxy." | + | No such card | Pass | "No card has both owned copies and proxies in one zone." | + | No size or copy rules | none | "Casual has no rules to check." (the format's shown name, or its stored key when it is no longer registered) | + + A card is named by the English name Scryfall gives the card (`Catalog::Entry#name`, the same for every printing, a double-faced card's "A // B" included), never by the localised name a non-English printing shows on the deck page. Copy findings are ordered by the position, in the page's zone order, of the first counted zone that holds the card, then by name, then by identity id. Owned-plus-proxy findings are ordered by their own zone's position in the page's zone order, then by name, then by identity id. A size finding names its rule's zones by their shown names joined with " + ". The split says "N owned, N proxy" (the proxy count's word does not change with the number, as the deck page's head and zone heads write it). The results section's heading is "Deck check". +7. **The check is a read-only request for the deck page; nothing about it is stored.** Check deck is a **link**, styled as a button like "Edit deck", to the deck's check: a read-only request (a GET, so no form and no redirect) that shows the deck page with the results. Pressing it again checks again against the deck as it then is. The results are never stored, so **any later request of the deck page has none**: a redirect after an edit, a change of format or a visit to the plain deck page shows no results and shows the button again. The results page carries Turbo's `turbo-cache-control: no-cache` tag (as the collection and search pages do), so Turbo keeps no snapshot of it: Back to the results page is a fresh request of the check, which judges the deck as it then is and never shows results computed before an edit. The deck page without a press runs the statements it runs today, and the check runs none beyond them. *(The maintainer chose the button, 2026-11-12; the mechanics are my call.)* +8. **Where it sits.** The button is in the page head's actions beside Edit deck. The results are a full-width section between the head and the zones (so a phone reads them first), with a heading and the summary line; the zones and the shape panel are drawn as on the plain page. On the results page the button remains and checks again. *(The maintainer chose this placement, 2026-11-12.)* +9. **Nothing is stored and nothing is exported.** A result is computed when the check is requested and shown; it needs no migration, no job, no cache, no column and no change to the export, the import or the backup. The export format stays at version 4. +10. **The summary line and the order of results.** Above the results, one line says how many findings there are ("3 findings", "1 finding") or "No findings" when every check passed or has no rules. The results follow in this order: the size rules in the extension's order, then the copy check, then owned plus proxy. A deck with no size or copy rules shows the no-rules line and then owned plus proxy. *(my call)* + +## User Stories + +### Story 1: Check a deck on demand + +**As a** collector reading a deck +**I want** to press Check deck and see what is wrong with it +**So that** I can fix it before I build or play the deck. + +**Acceptance criteria:** + +- [ ] **AC-1.1** Given a deck page When the collector has not asked for the check Then the page shows the Check deck button in the head's actions and no result, summary line or finding, and runs the statements it ran before this feature. +- [ ] **AC-1.2** Given a deck whose format has size and copy rules When the collector presses Check deck Then the deck page is shown with the summary line and, in the order of decision 10, one result for each size rule, the copy check and the owned-plus-proxy check, between the head and the zones, with the zones still shown. +- [ ] **AC-1.3** Given results are shown When the collector removes a proxy from a row's menu and the deck page is shown again Then it has no results and shows the button, and pressing the button shows the results for the changed deck. +- [ ] **AC-1.4** Given results are shown When the collector presses Check deck again without changing the deck Then the same results are shown. +- [ ] **AC-1.5** Given a deck that breaks a rule When the collector presses Check deck Then the results page has every link and button the plain deck page has, enabled, and following one of them (for example Edit deck) works as it does from the plain page. +- [ ] **AC-1.6** Given another account's deck When the collector requests its check Then the response is a 404 and nothing of that deck is revealed, as for the deck page itself. +- [ ] **AC-1.7** Given the summary line When there are N findings across the checks Then it reads "N findings" ("1 finding" for one), and "No findings" for none. +- [ ] **AC-1.8** Given the results page When it is served Then its head carries the `turbo-cache-control` tag with the value `no-cache`, and the plain deck page does not (decision 7). +- [ ] **AC-1.9** Given the deck page, plain and with results When it is inspected Then the Check deck control is a link and `main` has no `form`. + +### Story 2: Deck size + +**As a** collector with a Magic deck +**I want** its size judged against its format +**So that** I know it has enough cards, and not too many in the sideboard or in Commander. + +**Acceptance criteria:** + +- [ ] **AC-2.1** Given a Standard deck with 58 cards in Main (owned and proxy together) When the collector checks it Then there is a Short finding with the sentence "Main: 58 cards; Standard needs at least 60." +- [ ] **AC-2.2** Given a Standard deck with exactly 60 cards in Main When it is checked Then the Main size result passes with the sentence "Main: 60 cards; Standard needs at least 60.", and 61 or more also passes. +- [ ] **AC-2.3** Given a Modern deck with 16 cards in Sideboard When it is checked Then there is an Over finding "Sideboard: 16 cards; Modern allows at most 15."; given 15, or none, the Sideboard size result passes. +- [ ] **AC-2.4** Given a Commander deck with 1 card in Commander and 98 in Main When it is checked Then there is a Short finding "Commander + Main: 99 cards; Commander needs exactly 100."; with 101 cards it is Over and says 101; with 100 it passes. +- [ ] **AC-2.5** Given a deck with cards in Considering When it is checked Then those cards are in no size count. +- [ ] **AC-2.6** Given a Main of 59 owned copies and 1 proxy When it is checked Then the proxy counts: Main has 60 cards and the Main size result passes. +- [ ] **AC-2.7** Given a Casual deck, or a deck whose format the instance no longer registers When it is checked Then there is no size or copy result, one line says the format has no rules to check (its shown name, or its stored key when it is not registered), and the owned-plus-proxy result is still shown after it. +- [ ] **AC-2.8** Given a Pauper, Pioneer, Legacy or Vintage deck with a 59-card Main When it is checked Then each shows the same finding with its own format's name. +- [ ] **AC-2.9** Given a Standard deck with no card in Main When it is checked Then the finding says Main has 0 cards. +- [ ] **AC-2.10** Given a card of another collectible in a zone the size rule counts When the deck is checked Then it is not in the count. +- [ ] **AC-2.11** Given a deck with a zone row whose key no rule names, holding cards When it is checked Then that zone is in no size or copy count. +- [ ] **AC-2.12** Given a Standard deck that has no Sideboard zone row (so the rule's key has no zone) When it is checked Then the Sideboard size result passes as 0 cards and names the zone "Sideboard" from the registration. +- [ ] **AC-2.13** Given a Commander deck When it is checked Then the one size result names its zones "Commander + Main" and the sentence is decision 6's. + +### Story 3: Copy limits + +**As a** collector with a Magic deck +**I want** the cards I have too many of named +**So that** I can take them out before I play. + +**Acceptance criteria:** + +- [ ] **AC-3.1** Given a Standard deck with 5 copies of one card in Main When it is checked Then there is an Over the limit finding "Lightning Bolt: 5 copies; the limit is 4." naming the card. +- [ ] **AC-3.2** Given 3 copies of a card in Main and 2 in Sideboard of a Modern deck When it is checked Then the finding says 5 copies, because the zones are counted together; with 4 across both the copy check passes. +- [ ] **AC-3.3** Given 4 copies of a card from one printing and 1 from another printing in one Legacy deck, or 3 owned and 2 proxy of one printing, When it is checked Then each finding says 5 copies: printings and owned plus proxy are counted together. +- [ ] **AC-3.4** Given a deck with any number of Plains, or Snow-Covered Island, in Main When it is checked Then neither is a finding. +- [ ] **AC-3.5** Given a card whose rules text says a deck can have any number of cards with its name, in 9 copies When the deck is checked Then it is not a finding. +- [ ] **AC-3.6** Given a Commander deck with 2 copies of one card, one in Commander and one in Main When it is checked Then the finding says 2 copies and the limit is 1; two basic lands there are no finding. +- [ ] **AC-3.7** Given a Vintage deck with 2 copies of a card the catalog lists as restricted in Vintage When it is checked Then the finding reads "Black Lotus: 2 copies; restricted to 1."; given 2 copies of a card that is not restricted it passes, and the same restricted card in a Legacy deck is limited to 4. +- [ ] **AC-3.8** Given copies in Considering When the deck is checked Then they are counted by no copy rule. +- [ ] **AC-3.9** Given several cards over their limits When the deck is checked Then each has its own finding, ordered as decision 6 says. +- [ ] **AC-3.10** Given a deck with no card over any limit When it is checked Then the copy check passes with "No card is over its limit." +- [ ] **AC-3.11** Given a card of another collectible in a zone of a Magic deck When it is checked Then no rule counts it and no finding names it. +- [ ] **AC-3.12** Given a card whose rules text says a deck can have up to seven cards with its name, in 8 copies in a Standard deck When the deck is checked Then there is a finding saying 8 copies and the limit is 7; and given 8 copies in a Commander deck the finding says 8 copies and the limit is 7; and given 7 copies in either it passes. +- [ ] **AC-3.13** Given a Japanese printing and an English printing of one card in a Standard deck, 3 and 2 copies When the deck is checked Then the finding names the card by its English name and says 5 copies. +- [ ] **AC-3.14** Given a printing with no card data (no extension record) in 5 copies in a Standard deck When the deck is checked Then it is a finding as any card is, and in a Commander deck a basic land with no card data in 2 copies is a finding too (decision 4). +- [ ] **AC-3.16** Given a basic land with two printings in a Standard deck, one with card data and one without, 5 copies in all When the deck is checked Then there is a finding at the limit of 4 (decision 1's mixed case). +- [ ] **AC-3.15** Given a card in 5 copies in Main of a Standard deck and in 5 more in Sideboard When the deck is checked Then there is one finding for the card, saying 10 copies. + +### Story 4: A card with owned copies and proxies in one zone + +**As a** collector who plans a deck with proxies +**I want** to see the cards that have both +**So that** I notice a proxy I forgot to swap for my own copy. + +**Acceptance criteria:** + +- [ ] **AC-4.1** Given a zone with 2 owned copies and 1 proxy of one printing When the deck is checked Then there is an Owned and proxy finding "Lightning Bolt in Main: 2 owned, 1 proxy." +- [ ] **AC-4.2** Given a zone with an owned copy of one printing and a proxy of another printing of the same card When the deck is checked Then it is flagged. +- [ ] **AC-4.3** Given an owned copy of a card in Main and its proxy in Considering When the deck is checked Then it is not flagged. +- [ ] **AC-4.4** Given a zone with only owned copies of a card, or only proxies of it When the deck is checked Then the card is not flagged. +- [ ] **AC-4.5** Given a Casual deck, or a deck whose format is not registered When it is checked Then the owned-plus-proxy result is shown. +- [ ] **AC-4.6** Given a card with owned copies and proxies in both Main and Sideboard When the deck is checked Then there is one finding per zone, each with that zone's split. +- [ ] **AC-4.7** Given a deck with no card in this state When it is checked Then the result passes with "No card has both owned copies and proxies in one zone." +- [ ] **AC-4.8** Given a finding When the collector has read it Then nothing has changed: the proxy, the lots and the zones are as before. +- [ ] **AC-4.9** Given a card of another collectible with owned copies and a proxy in one zone of a Magic deck When the deck is checked Then it is not flagged (decision 4). +- [ ] **AC-4.10** Given a card with owned copies and a proxy in a zone no rule names When the deck is checked Then it is flagged, naming that zone. + +### Story 5: What the checks read, and what they change + +**As a** self-hoster and a maintainer +**I want** the checks to cost nothing extra, keep to one account and add no data +**So that** a big deck doesn't slow the page and an upgrade needs nothing. + +**Acceptance criteria:** + +- [ ] **AC-5.1** Given decks of 5 rows and of 500 rows When each is checked Then the check request runs the same number of statements as the plain deck page of that deck (spec 069 AC-6.1's form), and `Deck::Page#zones` is as before. +- [ ] **AC-5.2** Given a visitor who is not signed in When the check is requested Then the response is the sign-in redirect the other deck pages give. +- [ ] **AC-5.3** Given the app after the feature When its schema, the export, the import and the backup are compared with before Then nothing has changed and the export format is still version 4. +- [ ] **AC-5.4** Given the core files of the checks (`app/models/catalog/deck_checks.rb` as a new pattern, and the check's controller and views, which the existing globs cover, so the guard's hand-counted total rises by them) When `spec/deck_core_spec.rb` reads them, comments included Then none names a collectible, a format or a zone key but `considering`, so no core comment uses a format's name as an example; and the guard's list and count include them. +- [ ] **AC-5.5** Given a collectible whose vocabulary has no `deck_rules` When a deck of it is checked Then the page says it has no rules to check, shows the owned-plus-proxy result, and nothing raises. + +### Story 6: The results are readable + +**As a** collector on a phone, in either theme +**I want** the results to be legible +**So that** I can use them where I use the deck. + +**Acceptance criteria:** + +- [ ] **AC-6.1** Given the results at 360px in the light and the dark theme When they are shown Then nothing overflows the page horizontally and no text is clipped. +- [ ] **AC-6.2** Given a result When it is a pass or a finding Then its status word says which (decision 6), so no result depends on colour alone. +- [ ] **AC-6.3** Given the results page When it loads Then the results section has the heading "Deck check", keyboard focus is on that heading, and the button and every result are reachable in reading order. +- [ ] **AC-6.4** Given a card name that is long, or a zone name that is long When it appears in a finding Then it wraps and nothing overlaps. + +### Story 7: The documents and the guards + +**As a** maintainer and an agent +**I want** the rules written where the next feature will look +**So that** nothing here is rediscovered from the code. + +**Acceptance criteria:** + +- [ ] **AC-7.1** Given the area doc `collection-and-lots.md` When the feature is distilled Then it states the checks' rules (decisions 1 to 10) with this spec as the source, and says that spec 069's non-goal "deck rules" is done in part by this spec (size and copy limits; "is this deck legal as a whole" stays out, Non-Goals). +- [ ] **AC-7.2** Given the specs index When the spec is written Then it has a row for 079 naming the area docs it changes and `docs/design-system/components/DeckPage.md`, and notes that it supersedes nothing. +- [ ] **AC-7.3** Given `Catalog::DeckChecks` and `MTG::Collecting.deck_rules` When they are written Then each public class, module and method has a summary comment, `@param` and `@return` where `.claude/rules/code-documentation.md` asks for them, the contract of decision 1 is `Catalog::DeckChecks`' comment, the comment ratchet and the YARD build pass, and the area doc points to the comment. +- [ ] **AC-7.4** Given `docs/design-system/components/DeckPage.md` When the feature is distilled Then it describes the head's Check deck link, the results section and its place, and that the page remains form-free. +- [ ] **AC-7.5** Given the roadmap When the feature merges Then its theme C entry lists #258 as shipped. +- [ ] **AC-7.6** Given `CLAUDE.md`'s Project Status paragraph When the feature is distilled Then it carries one clause for the deck checks (`Catalog::DeckChecks`, `MTG::Collecting.deck_rules`, spec 079), as earlier deck specs added theirs, and restates no rule of the area doc. + +## Functional Requirements + +### FR-1: The seam + +**Must:** +- Ask the collecting vocabulary for a format's rules through an optional `deck_rules` answer (decision 1); treat a missing method, a nil answer and an unregistered type as "no rules" and never raise for them. +- Run the rules, and the owned-plus-proxy check for every deck, over the zones `Deck::Page` already built. +- Keep the core free of any collectible, format and zone key but `considering`. + +**Must not:** +- Name a format or a zone in the core, read a record by an id from the request, or run a statement per row. + +### FR-2: The checks + +**Must:** +- Judge size, copy limits and owned plus proxy by decisions 3 to 5, counting owned copies and proxies together, by catalog identity, and leaving out Considering, unnamed zones for the size and copy rules, and cards of another collectible. +- Return, for each check, either a pass with the line that says what passed, or one finding per fault with the words decision 6 gives, in decision 10's order. + +**Must not:** +- Change any lot, proxy, zone or deck, or store a result. + +### FR-3: The request and the page + +**Must:** +- Show the Check deck link in the page head and, for the check request, the summary line and the results between the head and the zones (decisions 7, 8 and 10). +- Show no result on a request of the deck page that is not the check. + +**Must not:** +- Disable or hide any of the page's actions because of a finding, or add a form to the page. + +## Non-Functional Requirements + +### Performance + +- The deck page without a press runs the statements it runs today; the check request runs none beyond them, however many zones, rows and lots the deck has (AC-5.1). The identity of a card and the rule data of a printing come from the entries and extension records the page already loaded. + +### Security + +- The check is a request for one deck of the signed-in account: another account's deck is a 404 and the identities read are catalog data (global, no `account_id`). No `account_id` or deck id is taken from a form field. Names of cards and zones shown in a finding are escaped as everywhere. + +### Reliability + +- A missing rule, an unregistered format, a collectible no longer registered or a printing without card data never raises and never shows a partial result: it gives the "no rules to check" line, or counts the printing by its identity as decision 4 says. + +### Accessibility and design + +- The results use the design system's tokens and `c-*` classes only; any new pattern gets a component document (`CLAUDE.md`). Nothing depends on colour alone; the page reads in both themes at 360px; focus goes to the results heading on load. + +## Error Scenarios + +| Scenario | Expected Behavior | +|----------|-------------------| +| The deck's format isn't registered (a format removed from the instance) | No size or copy result; one line says the stored key has no rules to check; the owned-plus-proxy result is shown | +| The collectible's vocabulary has no `deck_rules`, or returns nil for the format | The same line, naming the format's shown name | +| A printing has no card data (not refreshed, no extension record) | It counts for size and for copies by its identity, is never exempt and never restricted (decision 4); the card's name in a finding is its English name (decision 6) | +| A card of another collectible in a Magic deck's zone | Left out of every check and named in no finding (decision 4) | +| The deck has no card anywhere | A format with a minimum finds 0 cards in the named zone; the other results pass | +| A zone no rule names holds cards (an unregistered format's zones, or a zone the format no longer lists) | Not counted for size or copies; judged for owned plus proxy | +| A collector requests the check of a deck that was deleted meanwhile | The usual not-found response, as for the deck page | +| The deck changes after the check was shown | Any later request of the deck page has no results (decision 7), and Back does not show the old ones | + +## Interactions and Supersession + +- **Testing form:** an acceptance criterion that names several cases ("or", "with 101 it is Over") is one example per case in an `:aggregate_failures` group, so a failure names its case. +- **Supersedes:** nothing. Spec 069 left "deck rules" as a non-goal ("the 2.0 milestone, issue #258"); this spec is part of that work (size and copy limits) and changes no sentence of spec 069 or of spec 066. +- **Area docs it will change** (at distillation): [`collection-and-lots.md`](../../architecture/collection-and-lots.md) (decks and the deck page) and [`DeckPage.md`](../../design-system/components/DeckPage.md) (the head's link and the results section); the design system also if a new pattern is added. The specs index gets its row now. +- **Leaves alone:** spec 066's per-card marker, spec 068's proxy rules, spec 069's panel and the export format. + +## Open Questions + +None. Decisions marked *(my call)* are for Gate A: decision 2 (the owned-plus-proxy check runs for every deck), the no-card-data rule of decision 4, decision 5's per-zone judging, decision 6's strings and ordering, and decision 10's summary line. + +## Out of Scope (Future Considerations) + +- Colour identity, banned and restricted cards beyond Vintage's one, the number of commanders and partner rules. +- A check that runs on every view, on save or in the background, or one that warns while editing. +- Collector-defined formats and rules (the issue design's item 16), and rules for other collectibles (they register `deck_rules` when they exist). +- A "fix it" action beside a finding, such as "Use my copy" (spec 070 already offers that from the row's menu). +- Storing results, a history of checks or including them in the export. diff --git a/docs/specs/079-deck-checks/verification.md b/docs/specs/079-deck-checks/verification.md new file mode 100644 index 00000000..db19a0f2 --- /dev/null +++ b/docs/specs/079-deck-checks/verification.md @@ -0,0 +1,259 @@ +# Verification: Feature 079, Deck Checks + +**Spec:** [spec.md](spec.md) (v0.2.1) · **Plan:** [plan.md](plan.md) (present, retirement owed after the merge) · **Issue:** [#258](https://github.com/plainprogrammer/Collector/issues/258) + +Each acceptance criterion with the spec example (or the document check) that proves it. A path is a spec file; a quoted name is the example's title. Documents are verified by reading, never by RSpec (`.claude/rules/testing.md`). The feature's spec files were run together on 2026-11-12 (`bin/rspec spec/models/catalog/deck_checks_spec.rb spec/models/mtg/deck_rules_spec.rb spec/models/mtg/deck_checks_for_magic_spec.rb spec/requests/deck_checks_spec.rb spec/system/deck_checks_spec.rb spec/deck_core_spec.rb spec/models/deck/page_spec.rb`): 81 examples, 0 failures. The eleven magic examples added afterwards for the ACs below that had lacked a proof with the spec's own numbers (`848c6546`) were run with `bin/rspec spec/models/mtg spec/models/catalog spec/deck_core_spec.rb`: 806 examples, 0 failures. After the implementation review's follow-ups (`56b26fba`: two core examples and two magic examples), `bin/rspec spec/models/catalog spec/models/mtg spec/deck_core_spec.rb`: 810 examples, 0 failures; after `178a00ec`, `bin/rspec spec/deck_core_spec.rb spec/requests/deck_checks_spec.rb spec/requests/deck_pages_spec.rb`: 61 examples, 0 failures. `bin/ci` is T5.3's and is not recorded here. + +Short names used below: **core** `spec/models/catalog/deck_checks_spec.rb`; **rules** `spec/models/mtg/deck_rules_spec.rb`; **magic** `spec/models/mtg/deck_checks_for_magic_spec.rb`; **request** `spec/requests/deck_checks_spec.rb`; **system** `spec/system/deck_checks_spec.rb`. + +## Acceptance criteria + +### Story 1: Check a deck on demand + +| AC | Proved by | +|---|---| +| AC-1.1 | request "shows the button and no results on the plain deck page (AC-1.1, 1.9)" (the link, no heading, no form, no cache tag). "Runs the statements it ran before": `app/models/deck/page.rb`, `DecksController` and `spec/models/deck/page_spec.rb` are unchanged on the branch and the page spec is green; request "runs the plain deck page's statements however many rows the deck has" compares the check with the plain page, not the plain page with `main`'s, so the "before" half rests on the unchanged code | +| AC-1.2 | request "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)" (four results in decision 10's order; `main`'s children are `c-item__head`, `c-deckcheck`, `c-deck`) | +| AC-1.3 | request "shows no results on the next deck page request after the deck changes (AC-1.3)" (the proxy removed by its `DELETE`, the redirect followed, then the check again). The removal is the request the row menu's page sends, not a browser click | +| AC-1.4 | request "shows the same results again and every link of the plain page (AC-1.4, 1.5)" | +| AC-1.5 | same example (every `href` of the plain page's `main` present, no `disabled` or `aria-disabled`, Edit deck answers 200) | +| AC-1.6 | request "is a 404 for another account's deck, a zone and a bad id (AC-1.6)" (and the other deck's name absent from the body) | +| AC-1.7 | request "words no findings, one finding and several (AC-1.7)"; core "says the format has no rules and still runs owned-plus-proxy" ("1 finding") and "treats a missing method, a nil answer and an unregistered type alike (AC-5.5)" ("No findings") | +| AC-1.8 | request "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)" (the tag on the results page) and "shows the button and no results on the plain deck page (AC-1.1, 1.9)" (none on the plain page); system "asks for the check again on Back instead of restoring a Turbo snapshot (AC-1.8)" (the outcome) | +| AC-1.9 | request "shows the button and no results on the plain deck page (AC-1.1, 1.9)" and "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)" (Check deck is an `a.c-btn--secondary`; `main form` is nil on both pages) | + +### Story 2: Deck size + +| AC | Proved by | +|---|---| +| AC-2.1 | magic "judges Main and Sideboard in each constructed format's own name" (Standard, 58: "Main: 58 cards; Standard needs at least 60."); request "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)"; core "judges a minimum with the true count in every sentence" | +| AC-2.2 | magic "counts a proxy toward Main and passes at 60 and at 15" (60 passes, Standard); the 61 case: core "judges a minimum with the true count in every sentence" (a registered test format, not Magic's); request "words no findings, one finding and several (AC-1.7)" (60 Plains in Standard: "No findings") | +| AC-2.3 | magic "judges a Modern Sideboard Over at 16 and passes it at 15 and at none (AC-2.3)" (three Modern decks: "Over" "Sideboard: 16 cards; Modern allows at most 15.", "Pass" at 15 and at 0); also magic "judges Main and Sideboard in each constructed format's own name" (Modern among the six, Sideboard 16) | +| AC-2.4 | magic "judges Commander's two zones together" (99 Short, 101 Over) and "passes Commander at exactly 100 while cards in Considering count nowhere (AC-2.4, 2.5)" | +| AC-2.5 | magic "passes Commander at exactly 100 while cards in Considering count nowhere (AC-2.4, 2.5)"; core "counts nothing in Considering or an unnamed zone, and passes at exactly the exact size" and "counts nothing in Considering even when a rule names it, and still names it in the subject (decision 3)" (a rule naming Considering: "Main + Considering: 3 cards; tight needs at least 5.", its 10 cards not counted) | +| AC-2.6 | magic "counts a proxy toward Main and passes at 60 and at 15" (59 owned and 1 proxy); core "judges a minimum with the true count in every sentence" | +| AC-2.7 | magic "says Casual and an unregistered format have no rules but still flag a forgotten proxy"; request "gives a deck with no rules the no-rules line and the owned-plus-proxy result (AC-2.7)"; core "says the format has no rules and still runs owned-plus-proxy" | +| AC-2.8 | magic "is Short at a 59-card Main in Pauper, Pioneer, Legacy and Vintage, each in its own name (AC-2.8)" ("Short" "Main: 59 cards; Pauper needs at least 60.", and so for Pioneer, Legacy and Vintage) | +| AC-2.9 | magic "leaves out another collectible's cards" ("Main: 0 cards; Standard needs at least 60."); system "asks for the check again on Back instead of restoring a Turbo snapshot (AC-1.8)" (an empty Standard deck reads "Main: 0 cards") | +| AC-2.10 | magic "leaves out another collectible's cards"; core "leaves out another collectible's cards and zones no rule names" | +| AC-2.11 | magic "flags owned and proxy in Considering while no size or copy count includes it (AC-2.11)" (a Standard deck's Considering, a zone row Magic's rules don't name, holds 5 owned Lightning Bolt and 1 proxy: Main 0 Short, Sideboard 0 Pass, "No card is over its limit.", and "Lightning Bolt in Considering: 5 owned, 1 proxy."); core "leaves out another collectible's cards and zones no rule names" (a `stash` zone's 7 cards in no size count) and "passes at the limit and counts no copy in an unnamed zone" (copies); core "counts nothing in Considering even when a rule names it, and still names it in the subject (decision 3)" and "counts no copy in Considering even when the rule names it (decision 3)" (Considering is counted by no rule even when the vocabulary names it, `56b26fba`) | +| AC-2.12 | magic "names a Sideboard the deck has no zone row for from the registration (AC-2.12)"; core "names a rule's zones in its key order and a zone the deck lacks by its key" (the fallback to the key) | +| AC-2.13 | magic "judges Commander's two zones together" ("Commander + Main: …"); core "names a rule's zones in its key order and a zone the deck lacks by its key" | + +### Story 3: Copy limits + +| AC | Proved by | +|---|---| +| AC-3.1 | magic "names a Standard deck's 5 copies of one card in Main over the limit of 4 (AC-3.1)" (one printing, 5 owned in Main: "Over the limit" "Lightning Bolt: 5 copies; the limit is 4."; `56b26fba`, mutation 8); also magic "adds a card's copies across printings, zones, owned and proxy"; core "names a card over its limit across zones, printings and owned plus proxy"; request "words no findings, one finding and several (AC-1.7)" (5 Shock in Main is a finding) | +| AC-3.2 | magic "counts a Modern deck's Main and Sideboard together: 3 + 2 is over, 3 + 1 passes (AC-3.2)" ("Lightning Bolt: 5 copies; the limit is 4."; at 3 + 1 "No card is over its limit."); core "passes at the limit across two named zones (4 of 3 + 1)" | +| AC-3.3 | magic "counts a Legacy deck's 4 of one printing and 1 of another as 5 copies (AC-3.3)" and "counts a Legacy deck's 3 owned and 2 proxy of one printing as 5 copies (AC-3.3)" (each "Lightning Bolt: 5 copies; the limit is 4."); also magic "adds a card's copies across printings, zones, owned and proxy" and core "names a card over its limit across zones, printings and owned plus proxy" | +| AC-3.4 | magic "exempts basic lands and any-number cards, and holds Commander to one of the rest" (20 Plains in Standard; 2 Snow-Covered Island in Commander); rules "is none for a basic land, Snow-Covered ones included" | +| AC-3.5 | magic "exempts basic lands and any-number cards, and holds Commander to one of the rest" (9 Relentless Rats); rules "is none for any-number text, in every format" | +| AC-3.6 | magic "exempts basic lands and any-number cards, and holds Commander to one of the rest" (Krenko in Commander and Main: "2 copies; the limit is 1."; two Snow-Covered Islands no finding) | +| AC-3.7 | magic "restricts a card to one in Vintage only" (Vintage "Black Lotus: 2 copies; restricted to 1."; Legacy passes at 2); rules "is 1, restricted, in Vintage only" (Legacy's limit is 4) and "is 4 in constructed formats and 1 in Commander" (an unrestricted card in Vintage is 4) | +| AC-3.8 | core "passes at the limit and counts no copy in an unnamed zone" (9 copies in Considering not counted) and "counts no copy in Considering even when the rule names it (decision 3)" | +| AC-3.9 | core "orders findings by first zone, then name, then identity; leaves out another collectible" and "orders two findings of one zone by name, then by identity when the names match" | +| AC-3.10 | magic "exempts basic lands and any-number cards, and holds Commander to one of the rest" and "restricts a card to one in Vintage only" ("No card is over its limit."); request "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)" | +| AC-3.11 | magic "leaves out another collectible's cards" (70 copies of another collectible's card, "No card is over its limit."); core "orders findings by first zone, then name, then identity; leaves out another collectible" | +| AC-3.12 | magic "holds a numbered card to its number in every format, and passes at that number" (8 in Standard and in Commander: "the limit is 7"; 7 passes); rules "is the stated number for up-to text, in every format" | +| AC-3.13 | magic "names a card by its English name when its first row in page order is a Japanese printing" | +| AC-3.14 | magic "holds 5 copies of a printing with no card data in Standard to the ordinary limit (AC-3.14)" ("Lightning Bolt: 5 copies; the limit is 4.") and "holds a Commander deck's basic land with no card data to one copy (AC-3.14)" (a record-less Plains in Commander and in Main: "Plains: 2 copies; the limit is 1."); the mixed case: magic "counts a basic land's two printings, one with no card data, at the ordinary limit (AC-3.14, 3.16)"; rule level: rules "is the ordinary limit for a printing with no card data, a basic land included" | +| AC-3.15 | magic "gives a card in 5 copies in Main and 5 in Sideboard exactly one finding of 10 (AC-3.15)" (a Standard deck, two printings of one card; the Over the limit lines equal exactly `[ "Lightning Bolt: 10 copies; the limit is 4." ]`) | +| AC-3.16 | magic "counts a basic land's two printings, one with no card data, at the ordinary limit (AC-3.14, 3.16)"; core "treats nil as above every number: unlimited only when every printing says nil" | + +### Story 4: A card with owned copies and proxies in one zone + +| AC | Proved by | +|---|---| +| AC-4.1 | core "flags a zone that holds both, across printings, naming the split"; magic "says Casual and an unregistered format have no rules but still flag a forgotten proxy"; request "shows no results on the next deck page request after the deck changes (AC-1.3)" | +| AC-4.2 | core "flags a zone that holds both, across printings, naming the split" (owned of one entry, proxy of another of the same identity) | +| AC-4.3 | core "does not flag owned only, proxy only, owned in one zone with a proxy in another, or another collectible" and "judges each zone alone, includes zones no rule names, and skips Considering's lone proxy" | +| AC-4.4 | core "does not flag owned only, proxy only, owned in one zone with a proxy in another, or another collectible" | +| AC-4.5 | magic "says Casual and an unregistered format have no rules but still flag a forgotten proxy" (Casual's finding) and "says a deck whose format is no longer registered has no rules and still flags its owned and proxy card (AC-4.5)" (a Standard deck changed to `retired`: the lines equal exactly `[ nil, "retired has no rules to check." ]` and `[ "Owned and proxy", "Lightning Bolt in Main: 2 owned, 1 proxy." ]`; `56b26fba`, mutation 8); request "gives a deck with no rules the no-rules line and the owned-plus-proxy result (AC-2.7)"; core "says the format has no rules and still runs owned-plus-proxy" (a type and format the instance doesn't register: the finding is shown) | +| AC-4.6 | magic "flags owned and proxy in Main and in Sideboard once each, with that zone's split (AC-4.6)" (the Owned and proxy lines equal exactly "Lightning Bolt in Main: 2 owned, 1 proxy." and "Lightning Bolt in Sideboard: 1 owned, 3 proxy."); core "judges each zone alone, includes zones no rule names, and skips Considering's lone proxy" | +| AC-4.7 | core "does not flag owned only, proxy only, owned in one zone with a proxy in another, or another collectible"; request "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)" | +| AC-4.8 | request "changes nothing: no write reaches lots, proxies or locations (AC-4.8)" | +| AC-4.9 | magic "does not flag another collectible's owned copy and proxy in a Magic deck's Main (AC-4.9)" (a Standard deck: "No card has both owned copies and proxies in one zone."). `Deck::Proxy` refuses an entry of another collectible, so the example saves that proxy with `validate: false` to reach the check's own guard; core "does not flag owned only, proxy only, owned in one zone with a proxy in another, or another collectible" | +| AC-4.10 | core "judges each zone alone, includes zones no rule names, and skips Considering's lone proxy" ("Lightning Bolt in Stash: 2 owned, 3 proxy.") | + +### Story 5: What the checks read, and what they change + +| AC | Proved by | +|---|---| +| AC-5.1 | request "runs the plain deck page's statements however many rows the deck has" (5 and 500 rows; 13 statements each; mutation proof 2). `Deck::Page#zones` as before: `app/models/deck/page.rb` and `spec/models/deck/page_spec.rb` unchanged on the branch, the page spec green | +| AC-5.2 | request "redirects a visitor who is not signed in (AC-5.2)" | +| AC-5.3 | Evidence below ("Export, import and backup unchanged") | +| AC-5.4 | `spec/deck_core_spec.rb` (green): `app/models/catalog/deck_checks.rb` added as a pattern, the controller and `_check` under the existing globs, the count raised by 3 to 68; `app/models/mtg/deck_rules.rb` sits outside the core | +| AC-5.5 | core "treats a missing method, a nil answer and an unregistered type alike (AC-5.5)" | + +### Story 6: The results are readable + +| AC | Proved by | +|---|---| +| AC-6.1 | system "fits a phone, in both themes, with a long card name (AC-6.1, 6.4)" (a 360px viewport frame: no sideways scroll, no clipped status, sentence or head button; mutation proofs 3 and 6) | +| AC-6.2 | system "says pass or fault in words with an icon on every row, never by colour alone (AC-6.2)" (mutation proof 6); request "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)" (each row's text starts with its word) | +| AC-6.3 | system "moves keyboard focus to the results heading (AC-6.3)" (Tab reaches Check deck, Enter, the heading has focus; mutation proof 5) and "puts the results before the zones in reading order (AC-6.3)" | +| AC-6.4 | system "fits a phone, in both themes, with a long card name (AC-6.1, 6.4)" and "wraps a long zone name inside the results at phone width, in both themes (AC-6.4)" (mutation proof 3). The zone-name example measures the results section only (Deviations, c) | + +### Story 7: The documents (verified by reading) + +| AC | Checked | +|---|---| +| AC-7.1 | `docs/architecture/collection-and-lots.md` (commit `32b86d92`): the Check deck paragraph, the seam paragraph pointing to `Catalog::DeckChecks`' comment for the contract, the bullets (what is counted, owned plus proxy, Magic's rules, the order), the words table, the paragraph saying spec 069's non-goal "deck rules" is done in part; the head's actions, the fourth vocabulary answer, the deck guard's count (54 corrected to 68, 071's and 073's files were missing), the statement-count invariant, four Named constants rows, a decision, the accepted-limits lines, Sources and the issues list. Each statement carries its spec 079 decision or AC. Grepped before for "deck rules", "no rules", "Check deck", "Non-Goal": no statement had been made stale but the guard's count | +| AC-7.2 | `docs/specs/README.md` row 079 (written with the spec, its Supersedes cell reworded after the implementation review): names collection and lots and `DeckPage.md`, "Nothing. It does in part (size and copy limits) the 'deck rules' that 069 left as a non-goal" | +| AC-7.3 | Comments read: `Catalog::DeckChecks` (the contract in its header, which since `56b26fba` says the core never counts Considering for a size or copy rule even when a rule names it; `@param`/`@return` on `.report`), `MTG::DeckRules` (`.for`, `.limit_for`), `MTG::Collecting.deck_rules`, `Decks::ChecksController`. `bin/yard doc --no-cache` builds with 0 warnings (run 2026-11-12 after T5.1). The comment ratchet runs in T5.3's `bin/ci`; it was not run here | +| AC-7.4 | `docs/design-system/components/DeckPage.md` ("Spec 079 (#258) adds 'Check deck'…": the link, the `DeckCheck` section between head and `c-deck`, still no `form`, the no-cache tag; since `178a00ec` also why the link turns prefetch off) and the new `DeckCheck.md` | +| AC-7.5 | `docs/roadmap.md` theme C, item 14n: #258 "Built 2026-11-12, done when its pull request merges", as the theme's other built items are marked before their merge | +| AC-7.6 | `CLAUDE.md` Project Status: "A collector checks a deck on demand from its page, … (`Catalog::DeckChecks`, `MTG::Collecting.deck_rules`, `Decks::ChecksController`, spec 079)"; and the collection-and-lots guard sentence names the `Catalog::DeckChecks` seam. No rule of the area doc restated | + +**ACs without a full proof:** none. AC-2.3, 2.8, 2.11, 3.2, 3.3, 3.14, 3.15, 4.6 and 4.9 are proved on Magic decks with the formats and numbers their wording names (`848c6546`), and AC-3.1 and AC-4.5 since `56b26fba`; AC-4.9's proxy of another collectible is a fixture that skips `Deck::Proxy`'s validation, since the app refuses to make one. + +## Supersession grep + +The spec supersedes nothing. `git grep -n -i "deck rules\|copy limit\|deck size" -- spec docs app ':!.kamal/secrets'`, run after T5.1: + +- Changed (this branch, all new text): `app/models/catalog/deck_checks.rb:1`, `app/models/mtg/collecting.rb:72`, `app/models/mtg/deck_rules.rb:1`; `docs/architecture/collection-and-lots.md` (the deck checks paragraph, the decision, the issues line, Sources); `docs/roadmap.md:50` (14n); spec 079's own `spec.md` and `plan.md`. +- Left, because they are records of shipped specs or name something else: `docs/specs/060-deck-components/spec.md:49`, `066…/spec.md:245`, `068…/spec.md:318`, `069…/spec.md:43` and `:276` (non-goals and futures that point to #258; shipped specs are not edited, `.claude/rules/documentation.md`; the area doc and the index record that 079 does them in part); spec 057's title, its frozen line, its `verification.md`, specs 060's and 062's "Depends on", `docs/specs/README.md:75`, roadmap 14a, `docs/architecture/README.md:21`, the distillation log rows in `catalog-core.md:229` and `collection-and-lots.md`, the issues design `:108` and `spec/system/design_system_deck_rules_spec.rb` ("deck rules" there is spec 057's design rules, not this feature); `docs/specs/076-select-label-gap/verification.md:32` (a record of a run); the issues design `:180` (the source of this spec); `docs/specs/README.md:97` (079's own row). + +## Export, import and backup unchanged (AC-5.3) + +Run 2026-11-12 on `32b86d92`: + +- `bin/rspec spec/models/collection_export_spec.rb spec/backup_guards_spec.rb`: 58 examples, 0 failures. +- The export-format specs (`ls spec | grep -i export`: `collection_export_core_spec.rb`, `export_format_document_spec.rb`, `export_format_examples_spec.rb`, and `spec/models/export_format/`): `bin/rspec spec/collection_export_core_spec.rb spec/export_format_document_spec.rb spec/export_format_examples_spec.rb spec/models/export_format`: 120 examples, 0 failures. +- `CollectionExport::FORMAT_VERSION` is still `4` (`app/models/collection_export.rb:12`). +- `git diff main --stat -- db` prints nothing (no migration, no schema change). +- `git diff main --stat` over `app/models/collection_export*`, `app/models/collection_import*`, `app/models/export_format`, `lib/collector/backup*` and `docs/export-format` prints nothing. + +## Scryfall facts + +Plan T0.2, checked 2026-11-12 with curl against api.scryfall.com (User-Agent `Collector-dev`, Accept `application/json`): + +- `cards/named?exact=Relentless+Rats` → type_line "Creature — Rat"; oracle_text: + `'This creature gets +1/+1 for each other creature on the battlefield named Relentless Rats.\nA deck can have any number of cards named Relentless Rats.'` +- `cards/named?exact=Seven+Dwarves` → "Creature — Dwarf"; oracle_text: + `'This creature gets +1/+1 for each other creature named Seven Dwarves you control.\nA deck can have up to seven cards named Seven Dwarves.'` +- `cards/named?exact=Nazg%C3%BBl` → "Creature — Wraith Knight"; oracle_text: + `'Deathtouch\nWhen this creature enters, the Ring tempts you.\nWhenever the Ring tempts you, put a +1/+1 counter on each Wraith you control.\nA deck can have up to nine cards named Nazgûl.'` +- Japanese printings of Lightning Bolt (`q=lang:ja name:"Lightning Bolt" -name:"//"`, unique=prints): sets msc, tle, fca each `name` "Lightning Bolt", `printed_name` "稲妻", `lang` "ja", the same `oracle_id` 4457ed35-7c10-48c8-9776-456485fdf070; set sld has `printed_name` null. +- Consequence: `MTG::DeckRules::ANY_NUMBER` (`/a deck can have any number of cards named/i`) and `STATED_NUMBER` (`/a deck can have up to (\w+) cards named/i`) match the real wording; `Catalog::Entry#name` (Scryfall `name`) is the English name for a non-English printing (decision 6 holds). + +## Mutation proofs + +Each was made by hand, run, and reverted before its commit. + +1. `MTG::DeckRules::MAIN_MINIMUM` 60 → 61 failed 3 examples of magic (lines 23, 32, 138): "judges Main and Sideboard in each constructed format's own name", "counts a proxy toward Main and passes at 60 and at 15", "leaves out another collectible's cards". +2. A per-card `Catalog::Entry.find` inside `split_lines` failed request "runs the plain deck page's statements however many rows the deck has": small check vs plain 18 vs 13, big 513 vs 13, big vs small 513 vs 18. Unmutated, the check and the plain page each run 13 statements for the 5-row and the 500-row deck; the example runs in 0.64–0.71 s. +3. Removing `overflow-wrap:anywhere` from `.c-deckcheck__row` failed system "fits a phone, in both themes, with a long card name (AC-6.1, 6.4)" (got `[false, 1]`) and "wraps a long zone name inside the results at phone width, in both themes (AC-6.4)" (got `[1, false]`). +4. Removing the `turbo-cache-control` line failed system "asks for the check again on Back instead of restoring a Turbo snapshot (AC-1.8)" (the stale "Main: 0 cards"). +5. Removing `autofocus` failed system "moves keyboard focus to the results heading (AC-6.3)". Turbo focuses the heading on a link visit, so the plan's Stimulus fallback was not needed. +6. Breaking the markup (every `c-deckcheck__` class renamed `c-deckcheckX__`) showed two examples passing vacuously: "says pass or fault in words with an icon on every row, never by colour alone (AC-6.2)" passed, and so did the clipped-text part of the card-name phone example. Commit `62e1bc82` added existence assertions; both then failed against the broken markup (`spec/system/deck_checks_spec.rb:44` and `:72`). +7. For the examples of `848c6546`, each mutation made in the working tree, the magic file run, and the file restored with `git checkout --` (`git status` then clean for it): + - `MTG::DeckRules::MAIN_MINIMUM` 60 → 61 failed 5: the three of mutation 1, "is Short at a 59-card Main in Pauper, Pioneer, Legacy and Vintage, each in its own name (AC-2.8)" and "flags owned and proxy in Considering while no size or copy count includes it (AC-2.11)". + - `SIDEBOARD_MAXIMUM` 15 → 16 failed 5: "judges a Modern Sideboard Over at 16 and passes it at 15 and at none (AC-2.3)", "judges Main and Sideboard in each constructed format's own name", "counts a proxy toward Main and passes at 60 and at 15", "names a Sideboard the deck has no zone row for from the registration (AC-2.12)", and the AC-2.11 example. + - `CONSTRUCTED_COPIES` 4 → 5 failed 8: "counts a Modern deck's Main and Sideboard together: 3 + 2 is over, 3 + 1 passes (AC-3.2)", "counts a Legacy deck's 4 of one printing and 1 of another as 5 copies (AC-3.3)", "counts a Legacy deck's 3 owned and 2 proxy of one printing as 5 copies (AC-3.3)", "holds 5 copies of a printing with no card data in Standard to the ordinary limit (AC-3.14)", "gives a card in 5 copies in Main and 5 in Sideboard exactly one finding of 10 (AC-3.15)", "adds a card's copies across printings, zones, owned and proxy", "names a card by its English name when its first row in page order is a Japanese printing", "counts a basic land's two printings, one with no card data, at the ordinary limit (AC-3.14, 3.16)". + - `COMMANDER_COPIES` 1 → 2 failed 2: "holds a Commander deck's basic land with no card data to one copy (AC-3.14)" and "exempts basic lands and any-number cards, and holds Commander to one of the rest". + - The constructed copy rule's zone keys with `considering` added failed 1 at `848c6546`: the AC-2.11 example. That failure showed the core counted whatever zones a rule named, so the implementation review asked the core to enforce decision 3 itself. Since `56b26fba` the core never counts Considering for a size or copy rule, even when a rule names it, so this mutation no longer fails an example: re-run after that commit (the key added to `MTG::DeckRules`' constructed copy rule, `bin/rspec spec/models/mtg/deck_checks_for_magic_spec.rb`), it stayed green, 26 examples, 0 failures, and the file was restored with `git checkout --`. What proves the rule now is the two core examples "counts nothing in Considering even when a rule names it, and still names it in the subject (decision 3)" and "counts no copy in Considering even when the rule names it (decision 3)", written red before the change: `expected: ["Short", "Main + Considering: 3 cards; tight needs at least 5."]` `got: ["Pass", "Main + Considering: 13 cards; tight needs at least 5."]`, and `expected: ["Pass", "No card is over its limit."]` `got: ["Over the limit", "Lightning Bolt: 6 copies; the limit is 4."]`. + - `copy_lines` grouping by `it.row.entry.id` instead of `catalog_identity_id` (one finding per printing) failed 6: the AC-3.15 example, the first AC-3.3 example, "adds a card's copies across printings, zones, owned and proxy", "names a card by its English name when its first row in page order is a Japanese printing", "exempts basic lands and any-number cards, and holds Commander to one of the rest", "counts a basic land's two printings, one with no card data, at the ordinary limit (AC-3.14, 3.16)". + - `split_lines` grouping by `[ 0, catalog_identity_id ]` (the zone's position ignored) failed 1: "flags owned and proxy in Main and in Sideboard once each, with that zone's split (AC-4.6)". + - `counted_rows` without its `collectible_type == deck_type` filter failed 2: "does not flag another collectible's owned copy and proxy in a Magic deck's Main (AC-4.9)" and "leaves out another collectible's cards". +8. For the two magic examples of `56b26fba`, each mutation made in the working tree, the one example run, and the file restored exactly: + - `MTG::DeckRules::CONSTRUCTED_COPIES` 4 → 5 failed "names a Standard deck's 5 copies of one card in Main over the limit of 4 (AC-3.1)" (the lines did not include the finding); restored with `git checkout --`. + - `Catalog::DeckChecks.report` building `Report.new(lines: rules ? lines + split_lines(counted) : lines)` (no owned-plus-proxy check for a deck with no rules) failed "says a deck whose format is no longer registered has no rules and still flags its owned and proxy card (AC-4.5)" (got only `[nil, "retired has no rules to check."]`); the file held uncommitted work, so it was restored from a copy and compared byte for byte with `cmp`. + +**Characterisations** (examples that passed on arrival, because the behaviour was already built): + +- T2.2's 13 magic examples (`eb7156f4`). Proof of failing: mutations 1 and 7 fail 8 of them (lines 23, 32, 59, 70, 79, 93, 130, 138). The other 5 ("judges Commander's two zones together", "says Casual and an unregistered format have no rules but still flag a forgotten proxy", "passes Commander at exactly 100 while cards in Considering count nowhere (AC-2.4, 2.5)", "holds a numbered card to its number in every format, and passes at that number", "restricts a card to one in Vintage only") have no recorded mutation of their own. +- The 11 magic examples of `848c6546`. Proof: mutation 7, which fails each of them. +- The 2 magic examples of `56b26fba` (AC-3.1, AC-4.5). Proof: mutation 8. +- T3.2's statement-count example (`81c5cf96`). Proof: mutation 2. +- T4.2's system examples (`d0acb71d`): focus (mutation 5), Back (mutation 4), status words (mutation 6, after `62e1bc82`), the phone-width examples (mutations 3 and 6). The reading-order example ("puts the results before the zones in reading order (AC-6.3)") has no recorded mutation proof. + +## Screenshots + +In [`screenshots/`](screenshots/). Environment: headless Firefox, factory data with placeholder printings and no card art; desktop in a 1400px window, phone in a 360px viewport frame, the phone JPEGs cropped to 360px to drop Firefox's 15px classic scrollbar. Twelve JPEGs (six pairs, `-light` and `-dark`) and one GIF; the brief named 13 JPEGs, the folder holds 12. + +- `01-results-desktop-{light,dark}.jpg`: a Standard deck (Izzet Tempo) checked at desktop width: "4 findings", Short (Main 58), Over (Sideboard 16), Over the limit (Lightning Bolt 5), Owned and proxy (Shock in Main), above the zones and beside the shape panel; the heading's focus ring is visible. +- `02-results-phone-{light,dark}.jpg`: the same check at 360px: the three head actions on one line, the results in one column, the shape fold and the zones below. +- `03-vintage-restricted-desktop-{light,dark}.jpg`: a Vintage deck: Main and Sideboard pass, "Black Lotus: 2 copies; restricted to 1.", owned plus proxy passes. +- `04-commander-short-phone-{light,dark}.jpg`: a Commander deck at 360px: "Commander + Main: 92 cards; Commander needs exactly 100.", "Sol Ring: 2 copies; the limit is 1.", owned plus proxy passes. +- `05-casual-no-rules-phone-{light,dark}.jpg`: a Casual deck at 360px: "Casual has no rules to check." as a plain row, then "Llanowar Elves in Main: 3 owned, 1 proxy." +- `06-plain-page-head-phone-{light,dark}.jpg`: the plain deck page at 360px: Edit deck, Check deck and "…" fit one line; no results section. +- `07-walkthrough.gif`: a desktop walkthrough that starts on the plain Izzet Tempo page. Only its first frame was inspected for this record. + +## Findings (recorded, not fixed here) + +1. The focus ring on the "Deck check" heading spans the full content width on a direct visit (the heading is a block) and sits close to the text (shot 01); a mouse click on Check deck shows no ring (the browser's behaviour for script focus after a click). Cosmetic, left. +2. Shot 05: the owned-plus-proxy sentence wraps at 360px leaving "proxy." alone on its last line. Cosmetic. +3. A zone name of 60 unbroken characters overflows the zone head and the shape fold on the plain deck page. Pre-existing, and no registered format has such a name (zone names are copied from the registration); recorded in the area doc's accepted limits. +4. Unverified claims: Scryfall's wording is verified (above); Turbo's autofocus is proved by example (mutation 5); Firefox's bfcache under WebDriver is untested (the Back example proves Turbo's snapshot path only). +5. The 500-row statement-count example is not tagged `:timing`, so it runs in every suite (about 0.7 s). + +## Deviations from the plan + +a. T1.1's example "orders findings by zone, then name, then identity" expected a list without the no-rules line although it registers no rules; commit `b2ee0f10` expects `drop(1)` of the lines (a plan bug; the behaviour is as specified). +b. The component's classes were renamed from the plan's `c-check` to `c-deckcheck` (`4ca7014d`): `c-check` is `Form`'s existing checkbox-row class (5 views). Batch D's first version scoped the rule `.c-deckpage > .c-check` (`8d2fba5a`), replaced by the rename. The plan's text (T3.1's partial, T4.1's CSS, T4.2's specs) still shows `c-check`; the commits are the truth. +c. Batch D had added a second CSS block making zone heads and the shape fold wrap a 60-character zone name; it was dropped (`452cc148`) because it changed existing components outside the feature for a name no registered format has. AC-6.4's zone-name proof now measures the results section only, so such a zone name on the plain page still overflows (Findings, 3). +d. The vacuous-example fix (`62e1bc82`, mutation proof 6). +e. T4.3's screenshots came from a throwaway system spec rather than a dev server (the repo's recipe), deleted before the commit (`ec3260d4`). + +## Commits + +`git log --oneline c3589b30..HEAD` at this record's commit (newest first; this record's own commit follows): + +``` +178a00ec docs(decks): say why Check deck turns prefetch off (#258) +56b26fba fix(decks): the core never counts Considering for a size or copy rule (#258) +4144d1fe docs(decks): verification record names the new proofs (#258) +848c6546 test(decks): prove the Magic copy, size and split ACs with the spec's own numbers (#258) +03e52d79 docs(decks): verification record for the deck checks (#258) +32b86d92 docs(decks): document the deck checks in the area doc, CLAUDE.md and the roadmap (#258) +ec3260d4 docs(decks): screenshots and walkthrough for the deck checks (#258) +62e1bc82 test(decks): make the browser examples fail when their selectors match nothing (#258) +452cc148 fix(design): leave zone heads alone; test the results section's own width (#258) +4ca7014d fix(design): rename the DeckCheck classes to c-deckcheck, off Form's c-check (#258) +d0acb71d test(decks): the check's focus, Back and phone width in the browser (#258) +8d2fba5a feat(design): the DeckCheck pattern for the check results (#258) +81c5cf96 test(decks): the check runs no statement of its own (#258) +4c5097e6 feat(decks): Check deck on the deck page (#258) +eb7156f4 test(decks): Magic deck checks end to end through the seam (#258) +7988bb6d feat(decks): Magic's deck rules for the deck checks (#258) +afd799f3 feat(decks): copy-limit results for the deck checks (#258) +5362d866 feat(decks): size results for the deck checks (#258) +b2ee0f10 feat(decks): add Catalog::DeckChecks and its owned-plus-proxy check (#258) +``` + +## Implementation review + +SPEC-ALIGNED (Opus, Mode B, round 1, HEAD 4144d1fe) + +The reviewer found no behaviour drift and no missing AC: all of AC-1.1 to AC-7.6 Covered. It re-ran the feature's 69 model, request and guard examples and its 35 browser examples, all green. `bin/ci` ran green on `4144d1fe`: 4610 examples, 0 failures, 1 pending that predates the feature (the art-agreement spec, which needs `COLLECTOR_ART_AGREEMENT`). + +Its four follow-ups, and how each was resolved: + +1. The header contract of `Catalog::DeckChecks` said Considering is counted by no size or copy rule, but the core filtered by each rule's zone keys only, so it held only because Magic's rules never name Considering. Fixed in `56b26fba`: the core leaves Considering out of every size and copy count even when a rule names it (decision 3), the owned-plus-proxy check still judges it (decision 5), the comment says exactly that, and two core examples prove it (red before the change; mutation 7). +2. Why Check deck turns Turbo's prefetch off was written only in the plan, which retires after the merge. Fixed in `178a00ec`: a comment above the link in `app/views/decks/show.html.erb`, a sentence in the Check deck paragraph of `collection-and-lots.md` and one in `DeckPage.md`, each citing spec 079 decision 7. +3. This record: AC-3.1 had no example asserting its exact sentence, and AC-4.5's unregistered-format case asserted only the no-rules line, so "ACs without a full proof: none" was not true. Closed by the two magic examples of `56b26fba` (mutation 8) and the rows above; mutation 7's Considering entry is rewritten to say what it now shows. +4. The index's Supersedes cell for 079 now says it does the "deck rules" of 069's non-goal in part (size and copy limits) and supersedes nothing. + +The reviewer did not re-run the supersession grep, YARD or the comment ratchet (`bin/ci` did), and did not open the walkthrough GIF. + +## Distillation + +Distilled on 2026-11-12 by the `distill-feature` skill, state 2, before the pull request is opened. + +- **Area doc:** [`collection-and-lots.md`](../../architecture/collection-and-lots.md), written in T5.1 (`32b86d92`, `178a00ec`) and checked against the finished code (`Catalog::DeckChecks`, `MTG::DeckRules`, `MTG::Collecting.deck_rules`, `Decks::ChecksController`, `decks/_check`, `decks/show`). It now states: + - Check deck: a link to `GET /decks/:id/check` after Edit deck, no `form`, the 404 and sign-in rules, the results section between the head and the zones with focus on "Deck check", prefetch off and why, nothing stored, `turbo-cache-control: no-cache` on the results page only, the statement count (decisions 7, 8, 9; AC-1.x, AC-5.1–AC-5.3, AC-6.3). + - The seam: `deck_rules` as the vocabulary's fourth answer, the core `Catalog::DeckChecks` with the contract pointed to in its comment and not restated, the no-rules cases (decision 1, FR-1, AC-5.5). + - What is counted: owned plus proxy, by catalog identity, Considering and unnamed zones never (even when a rule names Considering, added in this distillation after `56b26fba`), another collectible's cards never, a printing with no card data like any card, the lowest limit and the mixed case (decisions 1, 3, 4). + - Owned plus proxy for every deck, each zone alone (decisions 2, 5). + - Magic's rules and their Named constants (`MTG::DeckRules`; decision 3). + - The words, the order and the summary line (decisions 6, 10). + - The glossary's check, finding and pass (spec 079 Terms; added in this distillation). + - The deck guard's 68 files, a decision (on demand, advice, rules in the extension), an accepted limit (a 60-character zone name with no space), Sources (spec 079, #258), and that spec 069's "deck rules" non-goal is done in part (AC-7.1). + - Corrected in this distillation: the deck page's collectible part is three optional answers after `formats`, not two. +- **Design system:** the results section's markup and CSS rule are `DeckCheck` (`docs/design-system/components/DeckCheck.md`, `.c-deckcheck` in `additions.css`), and the head's link is in `DeckPage.md`; both were written in T4.1 and T5.1 and agree with the final CSS (the `c-deckcheck` rename of `4ca7014d`). They are not area docs. +- **ADR:** none proposed. ADR 0028 already puts a deck's collectible part behind the collecting vocabulary; specs 066 and 069 added optional answers to it and this spec adds a third the same way (the spec's header). +- **CLAUDE.md:** its Project Status clause for the checks (AC-7.6) restates no rule of the area doc; unchanged. +- **Index:** 079 is Frozen by the area doc, with `DeckCheck` in the design system; Plan is "retirement owed": the plan stays on the branch and is retired after the pull request merges (state 1 of the skill). Spec 079 supersedes nothing, so no other row changed. +- **Distillation log:** the row names "spec 079's pull request" until the pull request has a number; state 1 replaces it with the link after the merge. diff --git a/docs/specs/README.md b/docs/specs/README.md index ad907e59..d8671d3f 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -94,3 +94,4 @@ A plan is deleted from the tree in a commit of its own after its feature's pull | [073 "Free to use" and "In decks" on the Card Page](073-card-page-deck-stats/spec.md) | Frozen, by [collection and lots](../architecture/collection-and-lots.md) and [catalog core](../architecture/catalog-core.md) (the card page's stats and "In decks" section; the visitor rule and the card page's order); the card page's markup is `ItemPage` in the [design system](../design-system/README.md) | [Collection and lots](../architecture/collection-and-lots.md), [catalog core](../architecture/catalog-core.md), [design system](../design-system/README.md) (`ItemPage`, `ItemPagePhone`, `SingleStat`) | retired in `1bdb1c62a908b76131f1eb2a94f769b419a78689`; `git show 1bdb1c62a908b76131f1eb2a94f769b419a78689^:docs/specs/073-card-page-deck-stats/plan.md` | In part 004 FR-9 (the single stat; no "In decks" or "Free to use"), its Non-Goal and Out of Scope entries and AC-8.1's order; 071 AC-1.4 and its "where #50 sits" note (fulfilled); fulfils the Non-Goals that name #50 (029, 032, 033, 057, 070) and 057 decisions 7 and 8 (the strip and the section) | | | [077 The Deck Shape Panel Stays Tidy at Extreme Counts](077-deck-shape-extreme-counts/spec.md) | Frozen, by [collection and lots](../architecture/collection-and-lots.md) (the panel's paragraph and Named constants); the panel's markup is `DeckShape` in the [design system](../design-system/README.md) | [Design system](../design-system/README.md) (`DeckShape`), [collection and lots](../architecture/collection-and-lots.md) (the panel's paragraph, Named constants) | retired in `a54b85eeb63a0ac540bd38b88747c79a0c2d47b4`; `git show a54b85eeb63a0ac540bd38b88747c79a0c2d47b4^:docs/specs/077-deck-shape-extreme-counts/plan.md` | In part 060 decision 2, AC-2.2, AC-2.4, AC-2.5 and AC-2.6 (the visible numbers, the types' alignment) and 069 decision 14, its four-digit-count Error Scenarios row and Findings 1 to 4 ([#320](https://github.com/plainprogrammer/Collector/issues/320)) | In part by [078](078-mana-bar-label-fit/spec.md) (decision 2's documented limit: the mana-label band, [#330](https://github.com/plainprogrammer/Collector/issues/330)) | | [078 A Mana Bar Label Fits Its Segment or Isn't Drawn](078-mana-bar-label-fit/spec.md) | Frozen, by [collection and lots](../architecture/collection-and-lots.md) (the panel's paragraph); the panel's markup and CSS rule are `DeckShape` in the [design system](../design-system/README.md) | [Design system](../design-system/README.md) (`DeckShape`), [collection and lots](../architecture/collection-and-lots.md) (the panel's paragraph) | retired in `b275dc7e20ef03355d825eec48569c2eb7ce8df0`; `git show b275dc7e20ef03355d825eec48569c2eb7ce8df0^:docs/specs/078-mana-bar-label-fit/plan.md` | In part 077 decision 2 (the mana-label band as a documented limit, ruled A on 2026-11-11), its Goal 1's qualification, Open Questions, Error Scenarios row for the band, AC-5.1 and AC-5.3, and its Supersedes row's exception for 060 AC-2.4 ([#330](https://github.com/plainprogrammer/Collector/issues/330)); 060 decision 4 and 069 decision 9 are kept | | +| [079 Deck Checks (Deck Size, Copy Limits, and Owned Plus Proxy of One Card)](079-deck-checks/spec.md) | Frozen, by [collection and lots](../architecture/collection-and-lots.md) (the checks' rules); the results section's markup and CSS rule are `DeckCheck` in the [design system](../design-system/README.md) | [Collection and lots](../architecture/collection-and-lots.md) (decks and the deck page), [DeckPage](../design-system/components/DeckPage.md) | retirement owed | Nothing. It does in part (size and copy limits) the "deck rules" that 069 left as a non-goal | | diff --git a/spec/deck_core_spec.rb b/spec/deck_core_spec.rb index bf05bba7..edfba1c7 100644 --- a/spec/deck_core_spec.rb +++ b/spec/deck_core_spec.rb @@ -17,6 +17,7 @@ %w[ app/controllers/decks_controller.rb app/controllers/decks/*.rb app/views/decks/**/*.erb app/models/deck.rb app/models/deck/*.rb app/models/location/placement.rb app/models/catalog/formats.rb app/models/catalog/deck_cards.rb app/models/catalog/deck_shape.rb + app/models/catalog/deck_checks.rb app/helpers/decks_helper.rb app/models/collection_import/decks.rb app/models/collection_import/deck_changes.rb app/views/collections/import_previews/_decks.html.erb app/views/icons/_decks.html.erb app/models/location/choices.rb app/helpers/location_choices_helper.rb @@ -36,7 +37,7 @@ def naming?(text) = text.match?(game_names) || text.match?(format_and_zone_keys) def relative(files) = files.map { |file| file.relative_path_from(Rails.root).to_s } it "covers every file of the feature" do - expect(files.size).to eq(20 + 6 + 1 + 1 + 4 + 1 + 15 + 2 + 6 + 5 + 1 + 3) # spec 069 adds the seam `app/models/catalog/deck_shape.rb` and the page's frame `app/views/decks/_shape.html.erb` (the views glob already covers it); spec 064 brought the list to 20; spec 065 adds the choices model, its helper and the four picker views; spec 067 adds the selections controller (the glob already covers it); spec 066 adds Deck::Page and the deck page's four views (the `app/models/deck/*.rb` and `app/views/decks/**/*.erb` globs already cover them) and the seam `app/models/catalog/deck_cards.rb`; spec 068 adds 15: `deck/proxy.rb` and the proxies controller (the globs already cover them), the six proxy views under `app/views/decks`, `decks/proxies/removals_controller.rb`, `deck/proxy/addition.rb` and `cap_exceeded.rb`, `finds_deck.rb`, the import's `proxies.rb` and `proxy_rows.rb`, and the preview's `_proxies.html.erb`; spec 070 adds 6: the replacements and copies controllers, UseCopies and AddCopy, and the two new views (the globs already cover them); spec 071 adds 5: the controller, its view, `Lot::FreeCopies` and the helper (new patterns) and `deck/addition.rb` (the `app/models/deck/*.rb` glob already covers it); spec 073 adds 1: `deck/card_uses.rb`, covered by the `app/models/deck/*.rb` glob; spec 073 adds 3 here: `catalog/card_overview.rb` and the card page's `_stats` and `_decks` partials (new patterns; the page's `show.html.erb` is not listed, because its `scryfall` link variable names a source) + expect(files.size).to eq(20 + 6 + 1 + 1 + 4 + 1 + 15 + 2 + 6 + 5 + 1 + 3 + 1 + 2) # spec 069 adds the seam `app/models/catalog/deck_shape.rb` and the page's frame `app/views/decks/_shape.html.erb` (the views glob already covers it); spec 064 brought the list to 20; spec 065 adds the choices model, its helper and the four picker views; spec 067 adds the selections controller (the glob already covers it); spec 066 adds Deck::Page and the deck page's four views (the `app/models/deck/*.rb` and `app/views/decks/**/*.erb` globs already cover them) and the seam `app/models/catalog/deck_cards.rb`; spec 068 adds 15: `deck/proxy.rb` and the proxies controller (the globs already cover them), the six proxy views under `app/views/decks`, `decks/proxies/removals_controller.rb`, `deck/proxy/addition.rb` and `cap_exceeded.rb`, `finds_deck.rb`, the import's `proxies.rb` and `proxy_rows.rb`, and the preview's `_proxies.html.erb`; spec 070 adds 6: the replacements and copies controllers, UseCopies and AddCopy, and the two new views (the globs already cover them); spec 071 adds 5: the controller, its view, `Lot::FreeCopies` and the helper (new patterns) and `deck/addition.rb` (the `app/models/deck/*.rb` glob already covers it); spec 073 adds 1: `deck/card_uses.rb`, covered by the `app/models/deck/*.rb` glob; spec 073 adds 3 here: `catalog/card_overview.rb` and the card page's `_stats` and `_decks` partials (new patterns; the page's `show.html.erb` is not listed, because its `scryfall` link variable names a source); spec 079 adds the seam app/models/catalog/deck_checks.rb (a new pattern); spec 079 adds the check's controller and the results partial (the globs already cover them) end it "names every file it lists, each of which exists" do diff --git a/spec/models/catalog/deck_checks_spec.rb b/spec/models/catalog/deck_checks_spec.rb new file mode 100644 index 00000000..dfd0f351 --- /dev/null +++ b/spec/models/catalog/deck_checks_spec.rb @@ -0,0 +1,201 @@ +require "rails_helper" + +RSpec.describe Catalog::DeckChecks, type: :model do + def zone(key, name, *rows) + location = Struct.new(:zone, :name).new(key, name) + Deck::Page::Zone.new(location:, groups: rows.empty? ? [] : [ Deck::Page::Group.new(name: nil, rows:) ]) + end + + def card(name, id, type: "game") + build_stubbed(:catalog_entry, identity: build_stubbed(:catalog_identity, id:, name:), name:, collectible_type: type) + end + + def row(entry, owned: 0, proxy: 0) + lines = proxy.zero? ? [] : [ Deck::Page::Line.new(id: 1, finish: "nonfoil", quantity: proxy) ] + Deck::Page::Row.new(entry:, owned:, lines:, answer: Catalog::DeckCards::NONE) + end + + def size(keys, min: nil, max: nil) = Catalog::DeckChecks::SizeRule.new(zone_keys: keys, min:, max:) + def limit(count, reason = nil) = ->(_entry) { Catalog::DeckChecks::Limit.new(count:, reason:) } + def copies(keys, limit_for) = Catalog::DeckChecks::CopyRule.new(zone_keys: keys, limit_for:) + + def register(sizes: [], copies: nil) + rules = Catalog::DeckChecks::Rules.new(sizes:, copies:) + stub_const("GameVocabulary", Module.new { define_singleton_method(:deck_rules) { |_key| rules } }) + Catalog.collecting["game"] = "GameVocabulary" + end + + def report(*zones) = described_class.report("game", "tight", zones) + def lines(report) = report.lines.map { [ it.status, it.sentence ] } + + after { Catalog.collecting.delete("game") } + + describe ".report with no rules" do + it "says the format has no rules and still runs owned-plus-proxy", :aggregate_failures do + result = described_class.report("game", "tight", [ zone("main", "Main", row(card("Bolt", 1), owned: 2, proxy: 1)) ]) + expect(lines(result)).to eq([ [ nil, "tight has no rules to check." ], [ "Owned and proxy", "Bolt in Main: 2 owned, 1 proxy." ] ]) + expect([ result.lines.first.fault, result.summary ]).to eq([ false, "1 finding" ]) + end + + it "treats a missing method, a nil answer and an unregistered type alike (AC-5.5)", :aggregate_failures do + stub_const("SilentVocabulary", Module.new) + stub_const("NilVocabulary", Module.new { def self.deck_rules(_key) = nil }) + expected = [ [ nil, "tight has no rules to check." ], [ "Pass", "No card has both owned copies and proxies in one zone." ] ] + %w[SilentVocabulary NilVocabulary].each do |vocabulary| + Catalog.collecting["game"] = vocabulary + expect(lines(described_class.report("game", "tight", []))).to eq(expected) + end + expect(described_class.report("gone", "tight", []).summary).to eq("No findings") + end + end + + describe "owned-plus-proxy results" do + let(:bolt) { card("Lightning Bolt", 1) } + + it "flags a zone that holds both, across printings, naming the split", :aggregate_failures do + result = report(zone("main", "Main", row(bolt, owned: 2), row(card("Lightning Bolt", 1), proxy: 1))) + expect(lines(result)).to include([ "Owned and proxy", "Lightning Bolt in Main: 2 owned, 1 proxy." ]) + expect(result.faults).to eq(1) + end + + it "judges each zone alone, includes zones no rule names, and skips Considering's lone proxy", :aggregate_failures do + zones = [ zone("main", "Main", row(bolt, owned: 1), row(bolt, proxy: 1)), zone("stash", "Stash", row(bolt, owned: 2, proxy: 3)), + zone("considering", "Considering", row(bolt, proxy: 1)) ] + sentences = lines(report(*zones)).map(&:last) + expect(sentences).to include("Lightning Bolt in Main: 1 owned, 1 proxy.", "Lightning Bolt in Stash: 2 owned, 3 proxy.") + expect(sentences).not_to include(a_string_matching(/Considering/)) + end + + it "does not flag owned only, proxy only, owned in one zone with a proxy in another, or another collectible", :aggregate_failures do + stray = card("Other", 9, type: "stranger") + zones = [ zone("main", "Main", row(bolt, owned: 2)), zone("considering", "Considering", row(bolt, proxy: 1), row(stray, owned: 1, proxy: 1)) ] + expect(lines(report(*zones)).last).to eq([ "Pass", "No card has both owned copies and proxies in one zone." ]) + end + + it "delimits the split's numbers as the deck page's heads do", :aggregate_failures do + result = report(zone("main", "Main", row(bolt, owned: 1_234, proxy: 5_678))) + expect(lines(result)).to include([ "Owned and proxy", "Lightning Bolt in Main: 1,234 owned, 5,678 proxy." ]) + end + + it "orders findings by zone, then name, then identity", :aggregate_failures do + zones = [ zone("a", "Alpha", row(card("Zed", 1), owned: 1, proxy: 1), row(card("Abe", 2), owned: 1, proxy: 1)), zone("b", "Beta", row(card("Abe", 2), owned: 1, proxy: 1)) ] + expect(lines(report(*zones)).drop(1).map(&:last)).to eq([ "Abe in Alpha: 1 owned, 1 proxy.", "Zed in Alpha: 1 owned, 1 proxy.", "Abe in Beta: 1 owned, 1 proxy." ]) + end + end + + describe "size results" do + it "judges a minimum with the true count in every sentence", :aggregate_failures do + register(sizes: [ size(%w[main], min: 60) ]) + bolt = card("Bolt", 1) + expect(lines(report(zone("main", "Main", row(bolt, owned: 58))))).to include([ "Short", "Main: 58 cards; tight needs at least 60." ]) + expect(lines(report(zone("main", "Main", row(bolt, owned: 59, proxy: 1))))).to include([ "Pass", "Main: 60 cards; tight needs at least 60." ]) + expect(lines(report(zone("main", "Main", row(bolt, owned: 61))))).to include([ "Pass", "Main: 61 cards; tight needs at least 60." ]) + end + + it "names a rule's zones in its key order and a zone the deck lacks by its key", :aggregate_failures do + register(sizes: [ size(%w[bench], max: 15), size(%w[lead main], min: 100, max: 100) ]) + zones = [ zone("main", "Main", row(card("A", 1), owned: 98)), zone("lead", "Lead", row(card("B", 2), owned: 1)) ] + expect(lines(report(*zones))).to start_with([ "Pass", "bench: 0 cards; tight allows at most 15." ], [ "Short", "Lead + Main: 99 cards; tight needs exactly 100." ]) + end + + it "says Over past a maximum and past an exact size, and the singular for one card", :aggregate_failures do + register(sizes: [ size(%w[bench], max: 15), size(%w[main], min: 1, max: 1) ]) + zones = [ zone("bench", "Bench", row(card("A", 1), owned: 16)), zone("main", "Main", row(card("B", 2), owned: 2)) ] + expect(lines(report(*zones))).to start_with([ "Over", "Bench: 16 cards; tight allows at most 15." ], [ "Over", "Main: 2 cards; tight needs exactly 1." ]) + expect(lines(report(zone("main", "Main", row(card("B", 2), owned: 1))))).to include([ "Pass", "Main: 1 card; tight needs exactly 1." ]) + end + + it "counts nothing in Considering or an unnamed zone, and passes at exactly the exact size", :aggregate_failures do + register(sizes: [ size(%w[main], min: 100, max: 100) ]) + zones = [ zone("main", "Main", row(card("A", 1), owned: 100)), zone("considering", "Considering", row(card("B", 2), owned: 30)) ] + expect(lines(report(*zones)).first).to eq([ "Pass", "Main: 100 cards; tight needs exactly 100." ]) + end + + it "counts nothing in Considering even when a rule names it, and still names it in the subject (decision 3)" do + register(sizes: [ size(%W[main #{Catalog::Formats::CONSIDERING}], min: 5) ]) + zones = [ zone("main", "Main", row(card("A", 1), owned: 3)), zone(Catalog::Formats::CONSIDERING, "Considering", row(card("B", 2), owned: 10)) ] + expect(lines(report(*zones)).first).to eq([ "Short", "Main + Considering: 3 cards; tight needs at least 5." ]) + end + + it "separates thousands in a count" do + register(sizes: [ size(%w[main], min: 60) ]) + expect(lines(report(zone("main", "Main", row(card("A", 1), owned: 1_234)))).first.last).to eq("Main: 1,234 cards; tight needs at least 60.") + end + + it "leaves out another collectible's cards and zones no rule names", :aggregate_failures do + register(sizes: [ size(%w[main], min: 1) ]) + zones = [ zone("main", "Main", row(card("Other", 9, type: "stranger"), owned: 5)), zone("stash", "Stash", row(card("A", 1), owned: 7)) ] + expect(lines(report(*zones)).first).to eq([ "Short", "Main: 0 cards; tight needs at least 1." ]) + end + end + + describe "copy results" do + let(:bolt) { card("Lightning Bolt", 1) } + + it "names a card over its limit across zones, printings and owned plus proxy", :aggregate_failures do + register(copies: copies(%w[main bench], limit(4))) + zones = [ zone("main", "Main", row(bolt, owned: 3), row(card("Lightning Bolt", 1), proxy: 1)), zone("bench", "Bench", row(bolt, owned: 1)) ] + expect(lines(report(*zones))).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + end + + it "passes at the limit and counts no copy in an unnamed zone", :aggregate_failures do + register(copies: copies(%w[main], limit(4))) + zones = [ zone("main", "Main", row(bolt, owned: 4)), zone("considering", "Considering", row(bolt, owned: 9)) ] + expect(lines(report(*zones))).to include([ "Pass", "No card is over its limit." ]) + end + + it "counts no copy in Considering even when the rule names it (decision 3)" do + register(copies: copies(%W[main #{Catalog::Formats::CONSIDERING}], limit(4))) + zones = [ zone("main", "Main", row(bolt, owned: 3)), zone(Catalog::Formats::CONSIDERING, "Considering", row(bolt, owned: 3)) ] + expect(lines(report(*zones)).first).to eq([ "Pass", "No card is over its limit." ]) + end + + it "passes at the limit across two named zones (4 of 3 + 1)" do + register(copies: copies(%w[main bench], limit(4))) + zones = [ zone("main", "Main", row(bolt, owned: 3)), zone("bench", "Bench", row(bolt, owned: 1)) ] + expect(lines(report(*zones))).to include([ "Pass", "No card is over its limit." ]) + end + + it "breaks a tie on the lowest limit by the first printing in page order", :aggregate_failures do + first = card("Black Lotus", 2) + second = card("Black Lotus", 2) + answer = ->(entry) { Catalog::DeckChecks::Limit.new(count: 1, reason: entry.equal?(first) ? "restricted to 1" : "banned past 1") } + register(copies: copies(%w[main], answer)) + expect(lines(report(zone("main", "Main", row(second, owned: 1), row(first, owned: 1))))).to include([ "Over the limit", "Black Lotus: 2 copies; banned past 1." ]) + end + + it "orders two findings of one zone by name, then by identity when the names match", :aggregate_failures do + register(copies: copies(%w[main], limit(0))) + zones = [ zone("main", "Main", row(card("Zed", 1), owned: 1), row(card("Abe", 5), owned: 2), row(card("Abe", 2), owned: 1)) ] + expect(lines(report(*zones)).first(3).map(&:last)).to eq([ "Abe: 1 copy; the limit is 0.", "Abe: 2 copies; the limit is 0.", "Zed: 1 copy; the limit is 0." ]) + end + + it "holds a card to the lowest limit its printings answer, with that reason", :aggregate_failures do + one = card("Black Lotus", 2) + two = card("Black Lotus", 2) + answer = ->(entry) { entry.equal?(one) ? Catalog::DeckChecks::Limit.new(count: 4, reason: nil) : Catalog::DeckChecks::Limit.new(count: 1, reason: "restricted to 1") } + register(copies: copies(%w[main], answer)) + expect(lines(report(zone("main", "Main", row(one, owned: 1), row(two, owned: 1))))).to include([ "Over the limit", "Black Lotus: 2 copies; restricted to 1." ]) + end + + it "treats nil as above every number: unlimited only when every printing says nil", :aggregate_failures do + free = card("Plains", 3) + mixed = card("Plains", 3) + register(copies: copies(%w[main], ->(entry) { Catalog::DeckChecks::Limit.new(count: 4, reason: nil) unless entry.equal?(free) })) + expect(lines(report(zone("main", "Main", row(free, owned: 20))))).to include([ "Pass", "No card is over its limit." ]) + expect(lines(report(zone("main", "Main", row(free, owned: 3), row(mixed, owned: 2))))).to include([ "Over the limit", "Plains: 5 copies; the limit is 4." ]) + end + + it "orders findings by first zone, then name, then identity; leaves out another collectible", :aggregate_failures do + register(copies: copies(%w[main bench], limit(1))) + zones = [ zone("main", "Main", row(card("Zed", 1), owned: 2)), zone("bench", "Bench", row(card("Abe", 2), owned: 2), row(card("Other", 3, type: "stranger"), owned: 9)) ] + expect(lines(report(*zones)).map(&:last)).to start_with("Zed: 2 copies; the limit is 1.", "Abe: 2 copies; the limit is 1.") + expect(lines(report(*zones)).map(&:last)).not_to include(a_string_matching(/Other/)) + end + + it "says the singular for one copy" do + register(copies: copies(%w[main], limit(0))) + expect(lines(report(zone("main", "Main", row(bolt, owned: 1))))).to include([ "Over the limit", "Lightning Bolt: 1 copy; the limit is 0." ]) + end + end +end diff --git a/spec/models/mtg/deck_checks_for_magic_spec.rb b/spec/models/mtg/deck_checks_for_magic_spec.rb new file mode 100644 index 00000000..1e9656c4 --- /dev/null +++ b/spec/models/mtg/deck_checks_for_magic_spec.rb @@ -0,0 +1,260 @@ +require "rails_helper" + +RSpec.describe "Catalog::DeckChecks for a Magic deck", type: :model do + let(:account) { create(:user).account } + + def deck_of(format) = create(:deck, account:, format:, name: "Deck #{format}") + + def card(name, type_line: "Instant", text: nil, legalities: {}, identity: create(:catalog_identity, name:), **attributes) + entry = create(:catalog_entry, identity:, name:, **attributes) + create(:mtg_printing, entry:, legalities:, faces: [ { "name" => name, "type_line" => type_line, "oracle_text" => text, "image_uris" => {} } ]) + entry + end + + def land(name = "Plains") = card(name, type_line: "Basic Land — #{name}") + def own(deck, zone, entry, quantity) = create_lot_in_zone(deck, zone, entry:, quantity:) + def proxy(deck, zone, entry, quantity = 1) = create(:deck_proxy, zone: deck.zone(zone), account:, entry:, finish: "nonfoil", quantity:) + + def lines(deck) + page = Deck::Page.new(Deck.includes(:location).find_by!(location_id: deck.location_id)) + Catalog::DeckChecks.report("mtg", deck.format, page.zones).lines.map { [ it.status, it.sentence ] } + end + + it "judges Main and Sideboard in each constructed format's own name", :aggregate_failures do + %w[standard pioneer modern legacy vintage pauper].each do |key| + deck = deck_of(key) + own(deck, "main", land, 58) + own(deck, "sideboard", land("Island"), 16) + expect(lines(deck)).to include([ "Short", "Main: 58 cards; #{key.titleize} needs at least 60." ], [ "Over", "Sideboard: 16 cards; #{key.titleize} allows at most 15." ]) + end + end + + it "counts a proxy toward Main and passes at 60 and at 15", :aggregate_failures do + deck = deck_of("standard") + own(deck, "main", land, 59) + proxy(deck, "main", land("Island")) + own(deck, "sideboard", land("Swamp"), 15) + expect(lines(deck)).to include([ "Pass", "Main: 60 cards; Standard needs at least 60." ], [ "Pass", "Sideboard: 15 cards; Standard allows at most 15." ]) + end + + it "judges Commander's two zones together", :aggregate_failures do + deck = deck_of("commander") + own(deck, "commander", card("Krenko"), 1) + own(deck, "main", land, 98) + expect(lines(deck)).to include([ "Short", "Commander + Main: 99 cards; Commander needs exactly 100." ]) + own(deck, "main", land("Island"), 2) + expect(lines(deck)).to include([ "Over", "Commander + Main: 101 cards; Commander needs exactly 100." ]) + end + + it "says Casual and an unregistered format have no rules but still flag a forgotten proxy", :aggregate_failures do + deck = deck_of("casual") + bolt = card("Lightning Bolt") + own(deck, "main", bolt, 2) + proxy(deck, "main", bolt) + expect(lines(deck)).to eq([ [ nil, "Casual has no rules to check." ], [ "Owned and proxy", "Lightning Bolt in Main: 2 owned, 1 proxy." ] ]) + deck.update!(format: "retired") + expect(lines(deck).first).to eq([ nil, "retired has no rules to check." ]) + end + + it "adds a card's copies across printings, zones, owned and proxy", :aggregate_failures do + deck = deck_of("standard") + bolt = create(:catalog_identity, name: "Lightning Bolt") + one = card("Lightning Bolt", identity: bolt) + two = card("Lightning Bolt", identity: bolt, set: create(:catalog_set)) + own(deck, "main", one, 3) + own(deck, "sideboard", two, 1) + proxy(deck, "main", two) + expect(lines(deck)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + end + + it "names a card by its English name when its first row in page order is a Japanese printing", :aggregate_failures do + deck = deck_of("standard") # Main is read before Sideboard, and the Japanese printing is the only row of Main + bolt = create(:catalog_identity, name: "Lightning Bolt") + own(deck, "main", card("Lightning Bolt", identity: bolt, language: "ja", localized_name: "稲妻"), 3) + own(deck, "sideboard", card("Lightning Bolt", identity: bolt, set: create(:catalog_set)), 2) + expect(lines(deck)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + expect(lines(deck).map(&:last)).not_to include(a_string_matching(/稲妻/)) + end + + it "names a Sideboard the deck has no zone row for from the registration (AC-2.12)" do + deck = deck_of("standard") + deck.zone("sideboard").destroy! + expect(lines(deck)).to include([ "Pass", "Sideboard: 0 cards; Standard allows at most 15." ]) + end + + it "passes Commander at exactly 100 while cards in Considering count nowhere (AC-2.4, 2.5)" do + commander = deck_of("commander") + own(commander, "commander", card("Krenko"), 1) + own(commander, "main", land, 99) + own(commander, "considering", land("Island"), 30) + expect(lines(commander)).to include([ "Pass", "Commander + Main: 100 cards; Commander needs exactly 100." ]) + end + + it "exempts basic lands and any-number cards, and holds Commander to one of the rest", :aggregate_failures do + standard = deck_of("standard") + own(standard, "main", land, 20) + rats = card("Relentless Rats", type_line: "Creature — Rat", text: "A deck can have any number of cards named Relentless Rats.") + own(standard, "main", rats, 9) + expect(lines(standard)).to include([ "Pass", "No card is over its limit." ]) + commander = deck_of("commander") + krenko = create(:catalog_identity, name: "Krenko") + own(commander, "commander", card("Krenko", identity: krenko), 1) + own(commander, "main", card("Krenko", identity: krenko, set: create(:catalog_set)), 1) + own(commander, "main", card("Snow-Covered Island", type_line: "Basic Snow Land — Island"), 2) + expect(lines(commander)).to include([ "Over the limit", "Krenko: 2 copies; the limit is 1." ]) + expect(lines(commander).map(&:last)).not_to include(a_string_matching(/Snow-Covered/)) + end + + it "holds a numbered card to its number in every format, and passes at that number", :aggregate_failures do + text = "A deck can have up to seven cards named Seven Dwarves." + %w[standard commander].each do |key| + over = deck_of(key) + own(over, "main", card("Seven Dwarves", type_line: "Creature — Dwarf", text:), 8) + expect(lines(over)).to include([ "Over the limit", "Seven Dwarves: 8 copies; the limit is 7." ]) + exact = create(:deck, account:, format: key, name: "Exact #{key}") + own(exact, "main", card("Seven Dwarves", type_line: "Creature — Dwarf", text:), 7) + expect(lines(exact)).to include([ "Pass", "No card is over its limit." ]) + end + end + + it "restricts a card to one in Vintage only", :aggregate_failures do + legalities = { "vintage" => "restricted" } + vintage = deck_of("vintage") + own(vintage, "main", card("Black Lotus", type_line: "Artifact", legalities:), 2) + expect(lines(vintage)).to include([ "Over the limit", "Black Lotus: 2 copies; restricted to 1." ]) + legacy = deck_of("legacy") + own(legacy, "main", card("Black Lotus", type_line: "Artifact", legalities:), 2) + expect(lines(legacy)).to include([ "Pass", "No card is over its limit." ]) + end + + it "counts a basic land's two printings, one with no card data, at the ordinary limit (AC-3.14, 3.16)", :aggregate_failures do + deck = deck_of("standard") + identity = create(:catalog_identity, name: "Plains") + own(deck, "main", card("Plains", type_line: "Basic Land — Plains", identity:), 1) + own(deck, "main", create(:catalog_entry, identity:, name: "Plains", set: create(:catalog_set)), 4) + expect(lines(deck)).to include([ "Over the limit", "Plains: 5 copies; the limit is 4." ]) + end + + it "leaves out another collectible's cards", :aggregate_failures do + deck = deck_of("standard") + own(deck, "main", create(:catalog_entry, collectible_type: "stranger", name: "Odd"), 70) + expect(lines(deck)).to include([ "Short", "Main: 0 cards; Standard needs at least 60." ], [ "Pass", "No card is over its limit." ]) + end + + it "is Short at a 59-card Main in Pauper, Pioneer, Legacy and Vintage, each in its own name (AC-2.8)", :aggregate_failures do + %w[pauper pioneer legacy vintage].each do |key| + deck = deck_of(key) + own(deck, "main", land, 59) + expect(lines(deck)).to include([ "Short", "Main: 59 cards; #{key.titleize} needs at least 60." ]) + end + end + + it "judges a Modern Sideboard Over at 16 and passes it at 15 and at none (AC-2.3)", :aggregate_failures do + empty = deck_of("modern") + expect(lines(empty)).to include([ "Pass", "Sideboard: 0 cards; Modern allows at most 15." ]) + over = create(:deck, account:, format: "modern", name: "Over modern") + own(over, "sideboard", land, 16) + expect(lines(over)).to include([ "Over", "Sideboard: 16 cards; Modern allows at most 15." ]) + exact = create(:deck, account:, format: "modern", name: "Exact modern") + own(exact, "sideboard", land, 15) + expect(lines(exact)).to include([ "Pass", "Sideboard: 15 cards; Modern allows at most 15." ]) + end + + it "counts a Modern deck's Main and Sideboard together: 3 + 2 is over, 3 + 1 passes (AC-3.2)", :aggregate_failures do + over = deck_of("modern") + bolt = card("Lightning Bolt") + own(over, "main", bolt, 3) + own(over, "sideboard", bolt, 2) + expect(lines(over)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + at_limit = create(:deck, account:, format: "modern", name: "At the limit") + own(at_limit, "main", bolt, 3) + own(at_limit, "sideboard", bolt, 1) + expect(lines(at_limit)).to include([ "Pass", "No card is over its limit." ]) + end + + it "counts a Legacy deck's 4 of one printing and 1 of another as 5 copies (AC-3.3)" do + deck = deck_of("legacy") + bolt = create(:catalog_identity, name: "Lightning Bolt") + own(deck, "main", card("Lightning Bolt", identity: bolt), 4) + own(deck, "main", card("Lightning Bolt", identity: bolt, set: create(:catalog_set)), 1) + expect(lines(deck)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + end + + it "counts a Legacy deck's 3 owned and 2 proxy of one printing as 5 copies (AC-3.3)" do + deck = deck_of("legacy") + bolt = card("Lightning Bolt") + own(deck, "main", bolt, 3) + proxy(deck, "main", bolt, 2) + expect(lines(deck)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + end + + it "holds 5 copies of a printing with no card data in Standard to the ordinary limit (AC-3.14)" do + deck = deck_of("standard") + own(deck, "main", create(:catalog_entry, name: "Lightning Bolt"), 5) + expect(lines(deck)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + end + + it "holds a Commander deck's basic land with no card data to one copy (AC-3.14)" do + deck = deck_of("commander") + plains = create(:catalog_entry, name: "Plains") + own(deck, "commander", plains, 1) + own(deck, "main", plains, 1) + expect(lines(deck)).to include([ "Over the limit", "Plains: 2 copies; the limit is 1." ]) + end + + it "gives a card in 5 copies in Main and 5 in Sideboard exactly one finding of 10 (AC-3.15)", :aggregate_failures do + deck = deck_of("standard") + bolt = create(:catalog_identity, name: "Lightning Bolt") + own(deck, "main", card("Lightning Bolt", identity: bolt), 5) + own(deck, "sideboard", card("Lightning Bolt", identity: bolt, set: create(:catalog_set)), 5) + over = lines(deck).select { it.first == "Over the limit" } + expect(over).to eq([ [ "Over the limit", "Lightning Bolt: 10 copies; the limit is 4." ] ]) + end + + it "flags owned and proxy in Considering while no size or copy count includes it (AC-2.11)", :aggregate_failures do + deck = deck_of("standard") + bolt = card("Lightning Bolt") + own(deck, "considering", bolt, 5) + proxy(deck, "considering", bolt) + expect(lines(deck)).to include( + [ "Short", "Main: 0 cards; Standard needs at least 60." ], [ "Pass", "Sideboard: 0 cards; Standard allows at most 15." ], + [ "Pass", "No card is over its limit." ], [ "Owned and proxy", "Lightning Bolt in Considering: 5 owned, 1 proxy." ] + ) + end + + it "flags owned and proxy in Main and in Sideboard once each, with that zone's split (AC-4.6)" do + deck = deck_of("standard") + bolt = card("Lightning Bolt") + own(deck, "main", bolt, 2) + proxy(deck, "main", bolt) + own(deck, "sideboard", bolt, 1) + proxy(deck, "sideboard", bolt, 3) + expect(lines(deck).select { it.first == "Owned and proxy" }).to eq( + [ [ "Owned and proxy", "Lightning Bolt in Main: 2 owned, 1 proxy." ], [ "Owned and proxy", "Lightning Bolt in Sideboard: 1 owned, 3 proxy." ] ] + ) + end + + it "names a Standard deck's 5 copies of one card in Main over the limit of 4 (AC-3.1)" do + deck = deck_of("standard") + own(deck, "main", card("Lightning Bolt"), 5) + expect(lines(deck)).to include([ "Over the limit", "Lightning Bolt: 5 copies; the limit is 4." ]) + end + + it "says a deck whose format is no longer registered has no rules and still flags its owned and proxy card (AC-4.5)" do + deck = deck_of("standard") + bolt = card("Lightning Bolt") + own(deck, "main", bolt, 2) + proxy(deck, "main", bolt) + deck.update!(format: "retired") + expect(lines(deck)).to eq([ [ nil, "retired has no rules to check." ], [ "Owned and proxy", "Lightning Bolt in Main: 2 owned, 1 proxy." ] ]) + end + + it "does not flag another collectible's owned copy and proxy in a Magic deck's Main (AC-4.9)" do + deck = deck_of("standard") + odd = create(:catalog_entry, collectible_type: "stranger", name: "Odd") + own(deck, "main", odd, 2) + # Deck::Proxy refuses another collectible's entry, so the fixture skips that validation to reach the check's own guard. + build(:deck_proxy, zone: deck.zone("main"), account:, entry: odd, finish: "nonfoil", quantity: 1).save!(validate: false) + expect(lines(deck)).to include([ "Pass", "No card has both owned copies and proxies in one zone." ]) + end +end diff --git a/spec/models/mtg/deck_rules_spec.rb b/spec/models/mtg/deck_rules_spec.rb new file mode 100644 index 00000000..bc691e7f --- /dev/null +++ b/spec/models/mtg/deck_rules_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" + +RSpec.describe MTG::DeckRules, type: :model do + def entry(name = "Card", type_line: "Instant", text: nil, legalities: {}, record: true) + entry = create(:catalog_entry, name:) + create(:mtg_printing, entry:, legalities:, faces: [ { "name" => name, "type_line" => type_line, "oracle_text" => text, "image_uris" => {} } ]) if record + Catalog::Entry.preload_extensions([ entry ]).first + end + + def limit(format, entry) = described_class.for(format).copies.limit_for.call(entry)&.then { [ it.count, it.reason ] } + def shape(rule) = [ rule.zone_keys, rule.min, rule.max ] + + describe ".for" do + it "has size and copy rules for each constructed format", :aggregate_failures do + %w[standard pioneer modern legacy vintage pauper].each do |key| + rules = described_class.for(key) + expect(rules.sizes.map { shape(it) }).to eq([ [ %w[main], 60, nil ], [ %w[sideboard], nil, 15 ] ]) + expect(rules.copies.zone_keys).to eq(%w[main sideboard]) + end + end + + it "has Commander's exact size, a singleton copy rule, and none for casual or an unknown key", :aggregate_failures do + commander = described_class.for("commander") + expect(commander.sizes.map { shape(it) }).to eq([ [ %w[commander main], 100, 100 ] ]) + expect(commander.copies.zone_keys).to eq(%w[commander main]) + expect([ described_class.for("casual"), described_class.for("retired"), MTG::Collecting.deck_rules("casual") ]).to all(be_nil) + end + end + + describe "a card's limit" do + it "is 4 in constructed formats and 1 in Commander", :aggregate_failures do + bolt = entry("Lightning Bolt") + expect([ limit("standard", bolt), limit("pauper", bolt), limit("vintage", bolt), limit("commander", bolt) ]).to eq([ [ 4, nil ], [ 4, nil ], [ 4, nil ], [ 1, nil ] ]) + end + + it "is none for a basic land, Snow-Covered ones included", :aggregate_failures do + plains = entry("Plains", type_line: "Basic Land — Plains") + snow = entry("Snow-Covered Island", type_line: "Basic Snow Land — Island") + expect([ limit("standard", plains), limit("commander", snow) ]).to all(be_nil) + end + + it "is none for any-number text, in every format", :aggregate_failures do + rats = entry("Relentless Rats", type_line: "Creature — Rat", text: "Relentless Rats gets +1/+1 for each other creature you control named Relentless Rats.\nA deck can have any number of cards named Relentless Rats.") + expect([ limit("modern", rats), limit("commander", rats) ]).to all(be_nil) + end + + it "is the stated number for up-to text, in every format", :aggregate_failures do + dwarves = entry("Seven Dwarves", type_line: "Creature — Dwarf", text: "Seven Dwarves gets +1/+1 for each other creature named Seven Dwarves you control.\nA deck can have up to seven cards named Seven Dwarves.") + expect([ limit("standard", dwarves), limit("commander", dwarves) ]).to eq([ [ 7, nil ], [ 7, nil ] ]) + end + + it "is 1, restricted, in Vintage only", :aggregate_failures do + lotus = entry("Black Lotus", type_line: "Artifact", legalities: { "vintage" => "restricted", "legacy" => "banned" }) + expect([ limit("vintage", lotus), limit("legacy", lotus) ]).to eq([ [ 1, "restricted to 1" ], [ 4, nil ] ]) + end + + it "is the ordinary limit for a printing with no card data, a basic land included", :aggregate_failures do + bare = entry("Plains", record: false) + expect([ limit("standard", bare), limit("commander", bare) ]).to eq([ [ 4, nil ], [ 1, nil ] ]) + end + end +end diff --git a/spec/requests/deck_checks_spec.rb b/spec/requests/deck_checks_spec.rb new file mode 100644 index 00000000..e0ee3d3a --- /dev/null +++ b/spec/requests/deck_checks_spec.rb @@ -0,0 +1,149 @@ +require "rails_helper" + +RSpec.describe "Deck checks", type: :request do + let(:user) { create(:user) } + let(:account) { user.account } + let(:deck) { create(:deck, account:, name: "Izzet Tempo", format: "standard") } + + def html = Nokogiri::HTML5(response.body) + + def printing(name: "Lightning Bolt", type_line: "Instant") + entry = create(:catalog_entry, identity: create(:catalog_identity, name:), name:) + create(:mtg_printing, entry:, faces: [ { "name" => name, "type_line" => type_line, "mana_cost" => "{R}", "image_uris" => {} } ]) + entry + end + + def plains = printing(name: "Plains", type_line: "Basic Land — Plains") + def own(zone, entry, quantity: 1) = create_lot_in_zone(deck, zone, entry:, quantity:) + def proxy_in(zone, entry) = create(:deck_proxy, zone: deck.zone(zone), account:, entry:, finish: "nonfoil", quantity: 1) + def rows = html.css(".c-deckcheck__row").map { it.text.squish } + + context "when signed in" do + before { sign_in_as(user) } + + it "shows the button and no results on the plain deck page (AC-1.1, 1.9)", :aggregate_failures do + get deck_path(deck) + expect(html.at_css(".c-item__actions > a.c-btn--secondary[href='#{deck_check_path(deck)}']").text).to eq("Check deck") + expect(html.at_css("#deck-check-title")).to be_nil + expect(html.at_css("main form")).to be_nil + expect(response.body).not_to include('name="turbo-cache-control"') + end + + it "shows the summary and the results between the head and the zones (AC-1.2, 1.7, 1.8, 1.9)", :aggregate_failures do + own("main", plains, quantity: 58) + get deck_check_path(deck) + expect(response).to have_http_status(:ok) + expect([ html.at_css("#deck-check-title").text, html.at_css(".c-deckcheck__summary").text ]).to eq([ "Deck check", "1 finding" ]) + expect(rows).to eq([ "Short Main: 58 cards; Standard needs at least 60.", "Pass Sideboard: 0 cards; Standard allows at most 15.", + "Pass No card is over its limit.", "Pass No card has both owned copies and proxies in one zone." ]) + expect(html.css("main > *").first(3).map { it["class"].to_s.split.first }).to eq(%w[c-item__head c-deckcheck c-deck]) + expect(response.body).to include('') + expect(html.at_css("main form")).to be_nil + end + + it "shows the same results again and every link of the plain page (AC-1.4, 1.5)", :aggregate_failures do + own("main", plains, quantity: 58) + get deck_path(deck) + plain = html.css("main a[href]").map { it["href"] } + get deck_check_path(deck) + first = rows + get deck_check_path(deck) + expect(rows).to eq(first) + expect(html.css("main a[href]").map { it["href"] }).to include(*plain) + expect(html.css("main a, main button").map { it["disabled"] || it["aria-disabled"] }.compact).to be_empty + get edit_deck_path(deck) # following one of them works as from the plain page + expect(response).to have_http_status(:ok) + end + + it "changes nothing: no write reaches lots, proxies or locations (AC-4.8)", :aggregate_failures do + entry = printing + own("main", entry) + proxy_in("main", entry) + before = [ Lot.sum(:quantity), Deck::Proxy.sum(:quantity), Location.count ] + writes = [ Lot, Deck::Proxy, Location ].flat_map { |model| write_statements_to(model.table_name) { get deck_check_path(deck) } } + expect(writes).to be_empty + expect([ Lot.sum(:quantity), Deck::Proxy.sum(:quantity), Location.count ]).to eq(before) + end + + it "shows no results on the next deck page request after the deck changes (AC-1.3)", :aggregate_failures do + entry = printing + own("main", entry) + proxy = proxy_in("main", entry) + get deck_check_path(deck) + expect(rows).to include("Owned and proxy Lightning Bolt in Main: 1 owned, 1 proxy.") + delete deck_proxy_path(deck, proxy) + follow_redirect! + expect(html.at_css("#deck-check-title")).to be_nil + expect(html.at_css("a[href='#{deck_check_path(deck)}']")).to be_present + get deck_check_path(deck) # pressing the button again judges the changed deck + expect(rows).not_to include(a_string_matching(/Owned and proxy/)) + end + + it "words no findings, one finding and several (AC-1.7)", :aggregate_failures do + own("main", plains, quantity: 60) + get deck_check_path(deck) + expect(html.at_css(".c-deckcheck__summary").text).to eq("No findings") + own("sideboard", plains, quantity: 16) + get deck_check_path(deck) + expect(html.at_css(".c-deckcheck__summary").text).to eq("1 finding") + own("main", printing(name: "Shock"), quantity: 5) + get deck_check_path(deck) + expect(html.at_css(".c-deckcheck__summary").text).to eq("2 findings") + end + + it "gives a deck with no rules the no-rules line and the owned-plus-proxy result (AC-2.7)" do + get deck_check_path(create(:deck, account:, name: "Pile", format: "casual")) + expect(rows).to eq([ "Casual has no rules to check.", "Pass No card has both owned copies and proxies in one zone." ]) + end + + it "is a 404 for another account's deck, a zone and a bad id (AC-1.6)", :aggregate_failures do + theirs = "Rival #{SecureRandom.hex(4)}" # built at run time: the test 404 page prints the spec's own source lines (docs/lessons 2026-11-11) + other = create(:deck, account: create(:user).account, name: theirs) + [ other.to_param, deck.zone("main").id, 0, "abc" ].each do |id| + get deck_check_path(id) + expect(response).to have_http_status(:not_found), "#{id.inspect} was not a 404" + expect(response.body).not_to include(theirs) + end + end + end + + it "redirects a visitor who is not signed in (AC-5.2)" do + get deck_check_path(deck) + expect(response).to redirect_to(new_session_path) + end + + describe "statements" do + before { sign_in_as(user) } + + # `count_queries` is `spec/support/query_counting.rb`'s (cached, SCHEMA and TRANSACTION statements are not counted). + def queries_for(path) + count = count_queries { get path } + expect(response).to have_http_status(:ok) + count + end + + # +rows+ printings of one identity, bulk-inserted and owned in the deck's Main zone, ten of them also as proxies. + def fill(deck, rows, label) + set = create(:catalog_set, code: label) + identity = create(:catalog_identity, name: "Bulk #{label}") + now = Time.current + Catalog::Entry.insert_all(Array.new(rows) { |i| { collectible_type: "mtg", external_key: "#{label}-#{i}", catalog_set_id: set.id, catalog_identity_id: identity.id, number: i.to_s, language: "en", name: "Card #{i}", kind: "card", content_digest: "d", created_at: now, updated_at: now } }) + ids = Catalog::Entry.where(catalog_set_id: set.id).pluck(:id) + MTG::Printing.insert_all(ids.map { |id| { catalog_entry_id: id, rarity: "common", finishes: %w[nonfoil], layout: "normal", scryfall_uri: "https://example.test/#{id}", faces: [], created_at: now, updated_at: now } }) + location = deck.zone("main") + Lot.insert_all(ids.map { |id| { account_id: account.id, catalog_entry_id: id, quantity: 2, finish: "nonfoil", price_paid_cents: nil, location_id: location.id, lot_key: Lot.key_for("nonfoil", nil, nil, location.id), created_at: now, updated_at: now } }) + ids.first(10).each { |id| create(:deck_proxy, zone: location, account:, entry: Catalog::Entry.find(id), finish: "nonfoil", quantity: 1) } + end + + it "runs the plain deck page's statements however many rows the deck has", :aggregate_failures do + small = create(:deck, account:, name: "Small", format: "standard") + big = create(:deck, account:, name: "Big", format: "standard") + fill(small, 5, "small") + fill(big, 500, "big") + [ small, big ].each { |deck| get deck_path(deck) } # warm the page once + expect(queries_for(deck_check_path(small))).to eq(queries_for(deck_path(small))) + expect(queries_for(deck_check_path(big))).to eq(queries_for(deck_path(big))) + expect(queries_for(deck_check_path(big))).to eq(queries_for(deck_check_path(small))) + end + end +end diff --git a/spec/system/deck_checks_spec.rb b/spec/system/deck_checks_spec.rb new file mode 100644 index 00000000..d355ee9e --- /dev/null +++ b/spec/system/deck_checks_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +RSpec.describe "Deck checks", type: :system do + let!(:user) { system_sign_in_as(create(:user)) } + let(:deck) { create(:deck, account: user.account, name: "Izzet Tempo", format: "standard") } + + def set_theme(name) = page.execute_script("document.documentElement.dataset.theme = #{name.to_json}") + + def printing(name, type_line: "Basic Land — Plains") + entry = create(:catalog_entry, identity: create(:catalog_identity, name:), name:) + create(:mtg_printing, entry:, faces: [ { "name" => name, "type_line" => type_line, "image_uris" => {} } ]) + entry + end + + def clipped(selector) = page.evaluate_script("[...document.querySelectorAll(#{selector.to_json})].filter(el => el.scrollWidth > el.clientWidth + 1).length") + + it "moves keyboard focus to the results heading (AC-6.3)", :aggregate_failures do + visit deck_path(deck) + expect(tab_to_text("Check deck")).to be(true) # the link is reachable by Tab, and Enter is pressed on it + page.send_keys(:enter) + expect(page).to have_css("#deck-check-title:focus", text: "Deck check") + expect(page.evaluate_script("document.getElementById('deck-check-title').tabIndex")).to eq(-1) + end + + it "asks for the check again on Back instead of restoring a Turbo snapshot (AC-1.8)", :aggregate_failures do + visit deck_path(deck) + click_link "Check deck" + expect(page).to have_text("Main: 0 cards; Standard needs at least 60.") + page.execute_script("window.__sameDocument = true") # Turbo visits keep the document; a full page load would drop this + create_lot_in_zone(deck, "main", entry: printing("Plains"), quantity: 60) + click_link "Edit deck" + wait_for_turbo_idle + page.go_back + expect(page).to have_text("Main: 60 cards; Standard needs at least 60.") + expect(page.evaluate_script("window.__sameDocument")).to be(true) # still the same document, so Turbo did the Back + end + + it "puts the results before the zones in reading order (AC-6.3)" do + visit deck_check_path(deck) + follows = "document.getElementById('deck-check-title').compareDocumentPosition(document.querySelector('.c-zone')) & Node.DOCUMENT_POSITION_FOLLOWING" + expect(page.evaluate_script("!!(#{follows})")).to be(true) + end + + it "fits a phone, in both themes, with a long card name (AC-6.1, 6.4)", :aggregate_failures do + create_lot_in_zone(deck, "main", entry: printing("N" * 60, type_line: "Instant"), quantity: 5) + visit deck_path(deck) + expect(open_in_viewport_frame(deck_check_path(deck), viewport: 360, ready: "#deck-check-title")).to eq(360) + within_narrow_frame do + expect(page).to have_css(".c-deckcheck__text", text: "N" * 60) # the clipped count below measures real elements + expect(page).to have_css(".c-deckcheck__status", minimum: 1) + %w[light dark].each do |theme| + set_theme(theme) + expect([ page_fits_sideways?, clipped(".c-deckcheck__text, .c-deckcheck__status, .c-item__actions .c-btn") ]).to eq([ true, 0 ]) + end + end + end + + it "wraps a long zone name inside the results at phone width, in both themes (AC-6.4)", :aggregate_failures do + deck.zone("main").update_columns(name: "Z" * 60) # a zone's shown name is a copy, so a long one is a fixture, not a rename flow + visit deck_path(deck) + expect(open_in_viewport_frame(deck_check_path(deck), viewport: 360, ready: "#deck-check-title")).to eq(360) + fits = "(s => s.scrollWidth <= s.clientWidth)(document.querySelector('.c-deckcheck'))" + within_narrow_frame do + expect(page).to have_css(".c-deckcheck__text", text: "Z" * 60) # the size finding names the zone + %w[light dark].each do |theme| + set_theme(theme) + expect([ clipped(".c-deckcheck__text, .c-deckcheck__status"), page.evaluate_script(fits) ]).to eq([ 0, true ]) + end + end + end + + it "says pass or fault in words with an icon on every row, never by colour alone (AC-6.2)", :aggregate_failures do + create_lot_in_zone(deck, "main", entry: printing("Plains"), quantity: 58) + visit deck_check_path(deck) + expect(page).to have_css(".c-deckcheck__row--fault .c-deckcheck__status", minimum: 1) # an empty set would pass the rest + words = all(".c-deckcheck__row--fault .c-deckcheck__status").map(&:text) + expect(words).to all(be_in([ "Short", "Over", "Over the limit", "Owned and proxy" ])) + expect(page).to have_css(".c-deckcheck__status svg", minimum: 1) + expect(all(".c-deckcheck__status svg").size).to eq(all(".c-deckcheck__status").size) + end +end diff --git a/spec/system/deck_page_spec.rb b/spec/system/deck_page_spec.rb index 73f7aee1..06f88aef 100644 --- a/spec/system/deck_page_spec.rb +++ b/spec/system/deck_page_spec.rb @@ -256,7 +256,7 @@ def head_boxes within_narrow_frame do set_theme(theme) expect(page_fits_sideways?).to be(true) - expect(head_boxes).to eq({ "cut" => false, "title" => true, "buttons" => [ true, true ] }) + expect(head_boxes).to eq({ "cut" => false, "title" => true, "buttons" => [ true, true, true ] }) end end end