diff --git a/README.md b/README.md index 49eb728..5e7259e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ Decks are saved in Arcana Land's [Tarot Deck format](https://github.com/arcanala - Access to a 3D printer to create jigs that will hold cards in your scanner. - A slab of dark material to place on top of the cards while scanning, such as black polyurethane foam (Optional) +## Usage + +``` +deckle init my-deck # create a project +deckle rectify scan.png # process a single scan image +deckle assign # name staged masters from labels (dry run) +deckle assign --apply +deckle unassign m2 w7 # back to staging/, labelled, to relabel and assign again +deckle status +deckle emit # write the output deck directory +``` + ## Technical Details Detecting a card in an image: diff --git a/src/deckle/assign.py b/src/deckle/assign.py index ebcadb9..6ab7177 100644 --- a/src/deckle/assign.py +++ b/src/deckle/assign.py @@ -3,15 +3,20 @@ from __future__ import annotations import os +import re import shutil -from dataclasses import dataclass -from pathlib import Path +from dataclasses import dataclass, replace +from pathlib import Path, PurePosixPath +from typing import Any import cv2 from .ids import CardId, check_custom_name, resolve_ref -from .project import CARD_BACKS_DIR, Project -from .staging import provenance_for, read_staging_index, write_staging_index +from .labels import ROTATE_MARK, Label, LabelError, format_label, parse_label, write_label +from .project import CARD_BACKS_DIR, PROJECT_FILE, Project +from .staging import MEASURED_KEYS, provenance_for, read_staging_index, write_staging_index + +_SLOT = re.compile(r"r[0-9]+c[0-9]+") class AssignError(RuntimeError): @@ -96,3 +101,92 @@ def assign( write_staging_index(project, index) return Assignment(ref=ref_out, source=source, dest=dest, replaced=replaced, rotated=rotate_180) + + +@dataclass(frozen=True) +class Unassignment: + ref: str + master: Path + restaged: Path + label: str + labelled: bool # False when the filesystem refused the label xattr + + +@dataclass(frozen=True) +class _Undo: + label: Label + master: Path + restaged: Path + row: dict[str, Any] + + +def _restaged_name(label: Label, record: dict[str, Any]) -> str: + """The name rectify gave the file, so a later assign finds its index row again.""" + scan, slot = record.get("source_scan"), record.get("slot") + if scan and slot and _SLOT.fullmatch(slot): + return f"{PurePosixPath(scan).stem}_{slot}.png" + return f"{label.target.replace(':', '.')}.png" + + +def unassign(project: Project, refs: list[str]) -> list[Unassignment]: + """Put masters back in staging/ as they were before assign: name, index row, label.""" + if project.degraded: + raise AssignError( + f"cannot unassign: {project.degraded_reason}. Fix or remove the state file first." + ) + + back_default = project.doc.get("project", {}).get("card_back_default") + undo: list[_Undo] = [] + problems: list[str] = [] + for text in refs: + try: + label = parse_label(text, card_back_default=back_default) + except LabelError as exc: + problems.append(str(exc)) + continue + if label.rotate_180: + problems.append(f"{text!r}: {ROTATE_MARK} has no meaning here; unassign restores it") + continue + record = project.cards.get(label.target) + if record is None: + problems.append(f"{label.target} has no record in {PROJECT_FILE}") + continue + master = project.root / record["master"] if record.get("master") else None + if master is None or not master.is_file(): + problems.append(f"{label.target}: its master {record.get('master')!r} is missing") + continue + rotated = bool(record.get("rotate_180")) + restaged = project.staging_dir / _restaged_name(label, record) + undo.append(_Undo(replace(label, rotate_180=rotated), master, restaged, record)) + + seen: dict[Path, str] = {} + for u in undo: + if u.restaged.exists(): + problems.append(f"{u.label.target}: {u.restaged.name} is already in staging/") + elif u.restaged in seen: + problems.append( + f"{u.label.target} and {seen[u.restaged]} both restage as {u.restaged.name}" + ) + seen[u.restaged] = u.label.target + if problems: + raise AssignError("nothing was unassigned:\n " + "\n ".join(problems)) + + index = read_staging_index(project) + out: list[Unassignment] = [] + for u in undo: + _move(u.master, u.restaged, rotate_180=u.label.rotate_180) + text = format_label(u.label) + try: + write_label(u.restaged, text) + labelled = True + except OSError: + labelled = False + if _SLOT.fullmatch(u.row.get("slot") or ""): + index[u.restaged.name] = { + k: u.row[k] for k in ("source_scan", "slot", *MEASURED_KEYS) if k in u.row + } + write_staging_index(project, index) + project.forget_card(u.label.target) + project.save() + out.append(Unassignment(u.label.target, u.master, u.restaged, text, labelled)) + return out diff --git a/src/deckle/cli.py b/src/deckle/cli.py index c6c9ed8..746d122 100644 --- a/src/deckle/cli.py +++ b/src/deckle/cli.py @@ -9,11 +9,12 @@ import numpy as np from . import build_id -from .assign import AssignError, assign +from .assign import AssignError, assign, unassign from .debug import write_bundle from .detect import Card, CardSpec, DetectionError, detect_report from .emit import EmitError, emit from .ids import IdError +from .labels import plan_staging from .project import Config, Project, ProjectError, find_project, init from .rectify import rectify_all from .render import report, status_report @@ -230,6 +231,17 @@ def _cmd_status(args) -> int: def _cmd_assign(args) -> int: + if args.file is None: + stray = [f for f, on in _FILE_ONLY_FLAGS.items() if on(args)] + if stray: + args.usage(f"{', '.join(stray)} need a file; without one, assign reads labels") + return _assign_from_labels( + Project.discover(args.project), apply=args.apply, force=args.force + ) + stray = [f for f, on in _LABEL_ONLY_FLAGS.items() if on(args)] + if stray: + args.usage(f"{', '.join(stray)} only apply without a file, when assign reads labels") + project = Project.discover(args.project) ref = args.id if ref and args.variant: @@ -242,6 +254,65 @@ def _cmd_assign(args) -> int: return 0 +def _cmd_unassign(args) -> int: + project = Project.discover(args.project) + for u in unassign(project, args.refs): + print(f"{u.ref} -> {u.restaged.relative_to(project.root)} (label {u.label})") + if not u.labelled: + print( + f"deckle: warning: could not write the label onto {u.restaged.name}; " + "label it by hand", + file=sys.stderr, + ) + return 0 + + +_FILE_ONLY_FLAGS = { + "--variant": lambda a: a.variant is not None, + "--card-back": lambda a: a.card_back is not None, + "--rotate-180": lambda a: a.rotate_180, +} +_LABEL_ONLY_FLAGS = {"--apply": lambda a: a.apply, "--force": lambda a: a.force} + + +def _assign_from_labels(project: Project, *, apply: bool, force: bool) -> int: + plan = plan_staging(project, force=force) + + for row in plan.rows: + print(row.describe(project.root)) + for w in plan.warnings: + print(f"warning: {w}") + for p in plan.problems: + print(f"problem: {p}") + print(f"staging: {plan.summary()}") + + if plan.problems: + if apply: + print("deckle: nothing was assigned; fix the problems above first", file=sys.stderr) + return 1 + if not apply: + return 0 + + for done, row in enumerate(plan.rows): + try: + assign( + project, + row.source, + row.label.ref, + card_back=row.label.card_back, + rotate_180=row.label.rotate_180, + ) + except (AssignError, ProjectError, IdError, OSError) as exc: + print(f"deckle: {row.source.name}: {exc}", file=sys.stderr) + print( + f"deckle: {done} of {len(plan.rows)} assigned before this; run again to continue", + file=sys.stderr, + ) + return 1 + print(f"assigned {len(plan.rows)} file(s)") + return 0 + + def _cmd_emit(args) -> int: result = emit(Project.discover(args.project), args.deck_dir) @@ -287,14 +358,31 @@ def _parser() -> argparse.ArgumentParser: _project_arg(s) s.set_defaults(func=_cmd_status) - a = sub.add_parser("assign", help="move a staged master into masters/ under a card ID") - a.add_argument("file", type=Path, help="the staged master to assign") + a = sub.add_parser( + "assign", + help="move staged masters into masters/ under card IDs", + description="With a file, assign it to the given card ID. Without one, read every " + "label in staging/, print the plan, and with --apply carry it out.", + ) + a.add_argument("file", type=Path, nargs="?", default=None, help="the staged master to assign") a.add_argument("id", nargs="?", default=None, help="card ID, e.g. minor_arcana.cups.king") _project_arg(a) a.add_argument("--variant", default=None, help="variant key, e.g. two_women") a.add_argument("--card-back", default=None, help="assign as a card back design instead") a.add_argument("--rotate-180", action="store_true", help="the card was placed upside down") - a.set_defaults(func=_cmd_assign) + a.add_argument("--apply", action="store_true", help="carry out the plan read from labels") + a.add_argument( + "--force", action="store_true", help="let the plan replace masters that already exist" + ) + a.set_defaults(func=_cmd_assign, usage=a.error) + + u = sub.add_parser( + "unassign", + help="put masters back in staging/, labelled, to relabel and assign again", + ) + u.add_argument("refs", nargs="+", metavar="ref", help="a card ID or label, e.g. m2:alt") + _project_arg(u) + u.set_defaults(func=_cmd_unassign) e = sub.add_parser("emit", help="generate the deck directory from masters/") _project_arg(e) diff --git a/src/deckle/labels.py b/src/deckle/labels.py new file mode 100644 index 0000000..509c205 --- /dev/null +++ b/src/deckle/labels.py @@ -0,0 +1,300 @@ +"""Card identity read from a staged file's comment xattr.""" + +from __future__ import annotations + +import errno +import os +import re +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +from .ids import ( + MAJOR, + MINOR, + CardId, + IdError, + check_custom_name, + parse_card_id, +) +from .project import CARD_BACKS_DIR, Project, ProjectError + +COMMENT_XATTR = "user.xdg.comment" + +BACK = "back" +ROTATE_MARK = "~" + +_SUITS = {"w": "wands", "c": "cups", "s": "swords", "p": "pentacles"} +_RANKS = { + "1": "ace", + "a": "ace", + "2": "two", + "3": "three", + "4": "four", + "5": "five", + "6": "six", + "7": "seven", + "8": "eight", + "9": "nine", + "10": "ten", + "t": "ten", + "p": "page", + "n": "knight", + "q": "queen", + "k": "king", +} +_MAJOR_SHORT = re.compile(r"m([0-9]{1,2})\Z") +_MINOR_SHORT = re.compile(r"([wcsp])(10|[1-9]|[atpnqk])\Z") +_RECTIFIED_STEM = re.compile(r"_r[0-9]+c[0-9]+\Z") + +_EXPECTED = ( + "expected m, (suit w c s p; rank 1-10, a, t, p, n, q, k), " + f"a canonical ID, or {BACK}; then an optional : and a trailing {ROTATE_MARK}" +) + +# Linux reports a missing attribute as ENODATA, the BSDs as ENOATTR. +_NO_LABEL = {getattr(errno, "ENOATTR", errno.ENODATA), errno.ENODATA, errno.ENOTSUP} + + +class LabelError(ValueError): + """A comment that is not a label.""" + + +@dataclass(frozen=True) +class Label: + """Either a card reference or a card back design, never both.""" + + card: CardId | None + variant: str | None + card_back: str | None + rotate_180: bool + + @property + def ref(self) -> str | None: + """The canonical `[:]`, or None for a card back.""" + if self.card is None: + return None + return str(self.card) if self.variant is None else f"{self.card}:{self.variant}" + + @property + def target(self) -> str: + return self.ref or f"{CARD_BACKS_DIR}.{self.card_back}" + + +def _card(text: str) -> CardId: + if text.startswith((f"{MAJOR}.", f"{MINOR}.")): + try: + return parse_card_id(text) + except IdError as exc: + raise LabelError(str(exc)) from None + if m := _MAJOR_SHORT.fullmatch(text): + return CardId("major", key=f"{int(m[1]):02d}") + if m := _MINOR_SHORT.fullmatch(text): + return CardId("minor", suit=_SUITS[m[1]], rank=_RANKS[m[2]]) + raise LabelError(f"{text!r} is not a card: {_EXPECTED}") + + +def _key(text: str, what: str) -> str: + try: + return check_custom_name(text, what) + except IdError as exc: + raise LabelError(str(exc)) from None + + +def parse_label(text: str, *, card_back_default: str | None) -> Label: + """Parse `card[:variant][~]` or `back[:design][~]`.""" + body = text.strip() + if not body: + raise LabelError("empty label") + + rotate = body.endswith(ROTATE_MARK) + if rotate: + body = body[: -len(ROTATE_MARK)] + if ROTATE_MARK in body: + raise LabelError(f"{text.strip()!r}: {ROTATE_MARK} goes at the end, and only there") + + head, sep, key = body.partition(":") + if sep and not key: + raise LabelError(f"{text.strip()!r}: empty key after ':'") + + if head == BACK: + if sep: + design = _key(key, "card back design key") + elif card_back_default: + design = _key(card_back_default, "card_back_default") + else: + raise LabelError( + f"{text.strip()!r}: no card_back_default is set, so give a design: {BACK}:" + ) + return Label(card=None, variant=None, card_back=design, rotate_180=rotate) + + card = _card(head) + variant = _key(key, "variant key") if sep else None + return Label(card=card, variant=variant, card_back=None, rotate_180=rotate) + + +def format_label(label: Label) -> str: + """The shortest text `parse_label` reads back as `label`, whatever the default back.""" + if label.card is None: + body = f"{BACK}:{label.card_back}" + else: + body = _short(label.card) or str(label.card) + if label.variant is not None: + body += f":{label.variant}" + return body + (ROTATE_MARK if label.rotate_180 else "") + + +def _short(card: CardId) -> str | None: + if card.kind == "major": + return f"m{int(card.key)}" if card.key and card.key.isdigit() else None + suit = next((k for k, v in _SUITS.items() if v == card.suit), None) + # the first spelling of each rank in the table: digits for ace to ten + rank = next((k for k, v in _RANKS.items() if v == card.rank), None) + return f"{suit}{rank}" if suit and rank else None + + +def write_label(path: Path, text: str) -> None: + os.setxattr(path, COMMENT_XATTR, text.encode("utf-8")) + + +def read_label(path: Path) -> str | None: + """The comment xattr, stripped, or None when there is none.""" + try: + raw = os.getxattr(path, COMMENT_XATTR) + except OSError as exc: + if exc.errno in _NO_LABEL: + return None + raise + text = raw.decode("utf-8", errors="replace").strip() + return text or None + + +@dataclass(frozen=True) +class PlannedFile: + source: Path + label: Label + dest: Path + replaces: Path | None + + def describe(self, root: Path) -> str: + line = f"{self.source.name} -> {self.label.target}" + if self.label.rotate_180: + line += " (rotated 180)" + if self.replaces is not None: + line += f" (replaces {self.replaces.relative_to(root)})" + return line + + +@dataclass +class StagingPlan: + files: int = 0 + unlabelled: int = 0 + rows: list[PlannedFile] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + problems: list[str] = field(default_factory=list) + + @property + def labelled(self) -> int: + return self.files - self.unlabelled + + def summary(self) -> str: + return ( + f"{self.files} files, {self.labelled} labelled, {self.unlabelled} unlabelled, " + f"{len(self.problems)} problem(s)" + ) + + +def staged_files(project: Project) -> list[Path]: + staging = project.staging_dir + if not staging.is_dir(): + return [] + return sorted(p for p in staging.glob("*.png") if p.is_file()) + + +def plan_staging(project: Project, *, force: bool) -> StagingPlan: + """Check every labelled file in staging/ without moving anything.""" + if project.degraded: + raise ProjectError( + f"cannot assign: {project.degraded_reason}. Fix or remove the state file first." + ) + + # Read the raw key: a bad edge_strategy elsewhere in [project] must not hide staging. + back_default = project.doc.get("project", {}).get("card_back_default") + roster = {str(c) for c in project.roster.cards()} + plan = StagingPlan() + + for path in staged_files(project): + plan.files += 1 + try: + text = read_label(path) + except OSError as exc: + plan.problems.append(f"{path.name}: cannot read its label: {exc.strerror or exc}") + continue + if text is None: + plan.unlabelled += 1 + continue + try: + label = parse_label(text, card_back_default=back_default) + except LabelError as exc: + plan.problems.append(f"{path.name}: {exc}") + continue + + if label.card is not None: + if str(label.card) not in roster: + plan.problems.append(f"{path.name}: {label.card} is not in the roster") + dest = project.master_path(label.card, label.variant) + else: + dest = project.back_path(label.card_back) # type: ignore[arg-type] + + # assign() only checks the record, and its move overwrites an unrecorded file. + replaces: Path | None = None + if dest.exists(): + replaces = dest + elif label.target in project.cards: + previous = project.cards[label.target].get("master") + replaces = project.root / previous if previous else dest + if replaces is not None and not force: + plan.problems.append( + f"{path.name}: {label.target} already has a master " + f"({replaces.relative_to(project.root)}); pass --force to replace it" + ) + + if not _RECTIFIED_STEM.search(path.stem): + plan.warnings.append( + f"{path.name}: not a rectified slot name, so its provenance will be thin" + ) + plan.rows.append(PlannedFile(path, label, dest, replaces)) + + _check_collisions(plan) + _check_defaults(project, plan) + return plan + + +def _check_collisions(plan: StagingPlan) -> None: + claims: dict[str, list[PlannedFile]] = defaultdict(list) + for row in plan.rows: + claims[row.label.target].append(row) + for target, rows in claims.items(): + if len(rows) < 2: + continue + names = ", ".join(r.source.name for r in rows) + msg = f"{target} is claimed by {len(rows)} files: {names}" + if rows[0].label.card is not None and rows[0].label.variant is None: + msg += "; give one of these a variant key" + plan.problems.append(msg) + + +def _check_defaults(project: Project, plan: StagingPlan) -> None: + """A card the plan gives a variant must end up with a bare master too.""" + bare = {str(m.card) for m in project.masters() if m.card is not None and m.variant is None} + bare |= {str(r.label.card) for r in plan.rows if r.label.card and r.label.variant is None} + variants: dict[str, list[str]] = defaultdict(list) + for r in plan.rows: + if r.label.card is not None and r.label.variant is not None: + variants[str(r.label.card)].append(r.source.name) + for card, names in variants.items(): + if card not in bare: + plan.problems.append( + f"{card} would have a variant ({', '.join(names)}) but no default master; " + f"label its default artwork with the bare card" + ) diff --git a/src/deckle/render.py b/src/deckle/render.py index 44cd7db..00cfb97 100644 --- a/src/deckle/render.py +++ b/src/deckle/render.py @@ -29,6 +29,7 @@ def report(cards: list[Card], scan: Path) -> None: def status_report(st: Status) -> None: + no_default = set(st.no_default) print(f"{st.root}") if st.config_problem: print(f" {st.config_problem}") @@ -42,15 +43,23 @@ def status_report(st: Status) -> None: for m in st.present: print(f" {m.ref}") else: - print(f" {len(st.present)} of {st.total} roster cards present") + print(f" {st.total - len(st.missing)} of {st.total} roster cards present") + if st.staging is not None: + print(f" staging: {st.staging.summary()} (deckle assign for detail)") if st.missing: print(f" missing ({len(st.missing)}):") for ref in st.missing: - print(f" {ref}") + note = " (has variants, but no default master)" if ref in no_default else "" + print(f" {ref}{note}") if st.unexpected: print(f" present but not in the roster ({len(st.unexpected)}) — the roster is wrong:") for ref in st.unexpected: print(f" {ref}") + if st.variants: + print(f" cards with variants ({len(st.variants)}):") + for card, refs in st.variants.items(): + note = " — missing its default master" if card in no_default else "" + print(f" {card} variants: {', '.join(refs)}{note}") if st.backs: print(f" card backs ({len(st.backs)}): {', '.join(st.backs)}") if st.strays: diff --git a/src/deckle/status.py b/src/deckle/status.py index 95a3ccd..0cee356 100644 --- a/src/deckle/status.py +++ b/src/deckle/status.py @@ -2,9 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass +from collections import defaultdict +from dataclasses import dataclass, field from pathlib import Path +from .labels import StagingPlan, plan_staging, staged_files from .project import Master, Project, ProjectError @@ -14,12 +16,20 @@ class Status: degraded_reason: str | None edge_strategy: str | None config_problem: str | None - present: list[Master] + present: list[Master] # bare masters only: a variant alone does not make a card present total: int | None missing: list[str] unexpected: list[str] backs: list[str] strays: list[Path] + variants: dict[str, list[str]] = field(default_factory=dict) # card ID -> variant refs + staging: StagingPlan | None = None + + @property + def no_default(self) -> list[str]: + """Cards with variant masters and no bare one.""" + bare = {str(m.card) for m in self.present} + return [c for c in self.variants if c not in bare] def status(project: Project) -> Status: @@ -35,7 +45,12 @@ def status(project: Project) -> Status: backs = [m.ref for m in masters if m.is_back] fronts = [m for m in masters if not m.is_back] - present_ids = {str(m.card) for m in fronts} + bare = [m for m in fronts if m.variant is None] + present_ids = {str(m.card) for m in bare} + variants: dict[str, list[str]] = defaultdict(list) + for m in fronts: + if m.variant is not None: + variants[str(m.card)].append(m.ref) if project.degraded: return Status( @@ -43,12 +58,13 @@ def status(project: Project) -> Status: degraded_reason=project.degraded_reason, edge_strategy=None, config_problem=None, - present=fronts, + present=bare, total=None, missing=[], unexpected=[], backs=backs, strays=project.strays(), + variants=dict(variants), ) wanted = [str(c) for c in project.roster.cards()] @@ -57,10 +73,12 @@ def status(project: Project) -> Status: degraded_reason=None, edge_strategy=strategy, config_problem=config_problem, - present=fronts, + present=bare, total=len(wanted), missing=[c for c in wanted if c not in present_ids], - unexpected=sorted(present_ids - set(wanted)), + unexpected=sorted({str(m.card) for m in fronts} - set(wanted)), backs=backs, strays=project.strays(), + variants=dict(variants), + staging=plan_staging(project, force=False) if staged_files(project) else None, ) diff --git a/tests/test_assign.py b/tests/test_assign.py index 93e00a8..4b6bd33 100644 --- a/tests/test_assign.py +++ b/tests/test_assign.py @@ -4,8 +4,9 @@ import numpy as np import pytest -from deckle.assign import AssignError, assign +from deckle.assign import AssignError, assign, unassign from deckle.ids import IdError +from deckle.labels import read_label from deckle.project import PROJECT_FILE, Project, init from deckle.staging import read_staging_index, write_staging_index @@ -172,3 +173,119 @@ def test_a_degraded_project_refuses_to_assign(tmp_path): with pytest.raises(AssignError, match="not valid TOML"): assign(Project.load(project.root), src, "major_arcana.03") assert src.exists() + + +# --- unassign --------------------------------------------------------------------------- + + +def label_of(path) -> str | None: + try: + return read_label(path) + except OSError: + return None + + +def assigned(project, name: str, ref: str, **kw): + """Stage an indexed png and assign it; returns the staged pixels.""" + path, img = stage_png(project, name) + scan, _, slot = name[: -len(".png")].rpartition("_") + index = read_staging_index(project) + index[name] = {"source_scan": f"{scan}.jpg", "slot": slot, "width_mm": 70.1, "dpi": 600.0} + write_staging_index(project, index) + assign(project, path, ref, **kw) + return img + + +def test_unassign_restores_the_staged_file(tmp_path): + project = make_project(tmp_path) + img = assigned(project, "scan_r1c0.png", "minor_arcana.wands.seven", rotate_180=True) + + (u,) = unassign(project, ["w7"]) + + staged = project.staging_dir / "scan_r1c0.png" + assert u.restaged == staged and u.ref == "minor_arcana.wands.seven" + assert not u.master.exists() + # back in the orientation rectify wrote, carrying the label that reassigns it as it was + assert np.array_equal(cv2.imread(str(staged), cv2.IMREAD_UNCHANGED), img) + assert u.label == "w7~" + if u.labelled: + assert label_of(staged) == "w7~" + assert read_staging_index(project)["scan_r1c0.png"] == { + "source_scan": "scan.jpg", + "slot": "r1c0", + "width_mm": 70.1, + "dpi": 600.0, + } + assert "minor_arcana.wands.seven" not in Project.load(project.root).cards + + +def test_unassign_then_assign_is_the_same_assignment(tmp_path): + project = make_project(tmp_path) + assigned(project, "scan_r0c1.png", "major_arcana.02:alt", rotate_180=True) + before = dict(Project.load(project.root).cards["major_arcana.02:alt"]) + + (u,) = unassign(project, ["major_arcana.02:alt"]) + assert u.label == "m2:alt~" + assign(project, u.restaged, "major_arcana.02:alt", rotate_180=True) + + after = Project.load(project.root).cards["major_arcana.02:alt"] + for key in ("source_scan", "slot", "width_mm", "dpi", "rotate_180", "master"): + assert after[key] == before[key] + + +def test_unassign_a_card_back(tmp_path): + project = make_project(tmp_path) + project.doc["project"]["card_back_default"] = "plain" + assigned(project, "scan_r0c0.png", None, card_back="plain") + (u,) = unassign(project, ["back"]) + assert (u.ref, u.label) == ("card_backs.plain", "back:plain") + assert not project.back_path("plain").exists() + + +def test_unassign_checks_every_ref_before_moving_any(tmp_path): + project = make_project(tmp_path) + assigned(project, "scan_r0c0.png", "minor_arcana.wands.seven") + master = project.masters_dir / "minor_arcana/wands/seven.png" + + with pytest.raises(AssignError) as exc: + unassign(project, ["w7", "w8", "M2", "w7~"]) + msg = str(exc.value) + assert msg.startswith("nothing was unassigned") + assert "minor_arcana.wands.eight has no record" in msg + assert "expected m" in msg + assert "has no meaning here" in msg + assert master.is_file() + assert "minor_arcana.wands.seven" in Project.load(project.root).cards + + +def test_unassign_refuses_to_overwrite_a_staged_file(tmp_path): + project = make_project(tmp_path) + assigned(project, "scan_r0c0.png", "minor_arcana.wands.seven") + stage(project, "scan_r0c0.png") + with pytest.raises(AssignError, match="already in staging"): + unassign(project, ["w7"]) + assert (project.masters_dir / "minor_arcana/wands/seven.png").is_file() + + +def test_unassign_refuses_a_record_whose_master_is_gone(tmp_path): + project = make_project(tmp_path) + assigned(project, "scan_r0c0.png", "minor_arcana.wands.seven") + (project.masters_dir / "minor_arcana/wands/seven.png").unlink() + with pytest.raises(AssignError, match="is missing"): + unassign(project, ["w7"]) + + +def test_thin_provenance_restages_under_the_card_name(tmp_path): + project = make_project(tmp_path) + src = stage(project, "hand-staged.png") + assign(project, src, "major_arcana.02:alt") + (u,) = unassign(project, ["m2:alt"]) + assert u.restaged.name == "major_arcana.02.alt.png" + assert read_staging_index(project) == {} + + +def test_unassign_refuses_a_degraded_project(tmp_path): + project = make_project(tmp_path) + (project.root / PROJECT_FILE).unlink() + with pytest.raises(AssignError, match="cannot unassign"): + unassign(Project.load(project.root), ["w7"]) diff --git a/tests/test_cli.py b/tests/test_cli.py index e407198..96e61c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,15 +1,20 @@ from __future__ import annotations +import errno import json +import os from pathlib import Path import cv2 +import numpy as np import pytest from test_detect import CARD_MM, DPI, TWO, Placed, jig_scene from deckle import build_id from deckle.cli import _parser, _settings, main from deckle.detect import CardSpec +from deckle.ids import parse_card_id +from deckle.labels import COMMENT_XATTR, read_label from deckle.project import PROJECT_FILE, Project, init from deckle.staging import read_staging_index from deckle.units import DEFAULT_DPI, DEFAULT_STRATEGY, EdgeStrategy @@ -255,6 +260,178 @@ def test_an_assign_refusal_exits_nonzero(tmp_path, capsys) -> None: assert staged.is_file() +# --- assign from labels ----------------------------------------------------------------- + + +def label(path: Path | str, text: str) -> None: + try: + os.setxattr(path, COMMENT_XATTR, text.encode()) + except OSError as exc: + if exc.errno == errno.ENOTSUP: + pytest.skip(f"{Path(path).parent} does not support user xattrs") + raise + + +@pytest.fixture +def staged(scan, tmp_path, monkeypatch, capsys) -> tuple[Project, list[Path]]: + """Two rectified, indexed files in a project's staging/, cwd at the project root.""" + project = make_project(tmp_path / "proj") + monkeypatch.chdir(project.root) + assert main(["rectify", str(scan)]) == 0 + files = [Path(p) for p in capsys.readouterr().out.split()] + return project, files + + +def test_a_dry_run_prints_the_plan_and_moves_nothing(staged, capsys) -> None: + project, (first, second) = staged + label(first, "m0") + label(second, "w7~") + assert main(["assign"]) == 0 + out = capsys.readouterr().out + assert out == ( + f"{first.name} -> major_arcana.00\n" + f"{second.name} -> minor_arcana.wands.seven (rotated 180)\n" + "staging: 2 files, 2 labelled, 0 unlabelled, 0 problem(s)\n" + ) + assert first.is_file() and second.is_file() + assert Project.load(project.root).cards == {} + + +def test_apply_with_a_problem_moves_nothing(staged, capsys) -> None: + project, (first, second) = staged + label(first, "m0") + label(second, "m0~") + assert main(["assign", "--apply"]) == 1 + captured = capsys.readouterr() + assert "problem: major_arcana.00 is claimed by 2 files" in captured.out + assert "nothing was assigned" in captured.err + assert first.is_file() and second.is_file() + assert not project.masters_dir.joinpath("major_arcana").exists() + + +def test_a_dry_run_with_problems_exits_1(staged, capsys) -> None: + _, (first, _) = staged + label(first, "M0") + assert main(["assign"]) == 1 + assert "staging: 2 files, 1 labelled, 1 unlabelled, 1 problem(s)" in capsys.readouterr().out + + +def test_apply_assigns_with_full_provenance(staged, scan, capsys) -> None: + project, (first, second) = staged + index = read_staging_index(project) + label(first, "m0") + label(second, "major_arcana.00:alt~") + assert main(["assign", "--apply"]) == 0 + out = capsys.readouterr().out + assert "assigned 2 file(s)" in out + assert not first.exists() and not second.exists() + assert read_staging_index(project) == {} + + cards = Project.load(project.root).cards + assert set(cards) == {"major_arcana.00", "major_arcana.00:alt"} + bare, alt = cards["major_arcana.00"], cards["major_arcana.00:alt"] + assert (bare["slot"], alt["slot"]) == ("r0c0", "r0c1") + assert (bare["rotate_180"], alt["rotate_180"]) == (False, True) + for row, name in ((bare, first.name), (alt, second.name)): + assert row["source_scan"] == scan.name + for key in ("width_mm", "height_mm", "aspect", "skew_deg", "edge_strategy", "dpi"): + assert row[key] == index[name][key] + assert alt["master"] == "masters/major_arcana/00.alt.png" + assert (project.root / alt["master"]).is_file() + + +def test_apply_force_replaces(staged, capsys) -> None: + project, (first, second) = staged + assert main(["assign", str(first), "major_arcana.00"]) == 0 + label(second, "m0") + capsys.readouterr() + + assert main(["assign", "--apply"]) == 1 + assert "already has a master" in capsys.readouterr().out + assert second.is_file() + + assert main(["assign", "--apply", "--force"]) == 0 + assert "(replaces masters/major_arcana/00.png)" in capsys.readouterr().out + assert not second.exists() + assert Project.load(project.root).cards["major_arcana.00"]["slot"] == "r0c1" + + +def test_label_mode_refuses_a_degraded_project(staged, capsys) -> None: + project, (first, _) = staged + label(first, "m0") + (project.root / PROJECT_FILE).unlink() + assert main(["assign", "--apply"]) == 1 + assert capsys.readouterr().err.startswith("deckle: cannot assign: ") + assert first.is_file() + + +def test_unassign_to_swap_a_default_and_its_variant(staged, capsys) -> None: + project, (first, second) = staged + label(first, "m2") + assert main(["assign", "--apply"]) == 0 + label(second, "m2:late") + assert main(["assign", "--apply"]) == 0 + capsys.readouterr() + + assert main(["unassign", "m2", "major_arcana.02:late"]) == 0 + assert capsys.readouterr().out == ( + f"major_arcana.02 -> staging/{first.name} (label m2)\n" + f"major_arcana.02:late -> staging/{second.name} (label m2:late)\n" + ) + assert read_label(first) == "m2" and read_label(second) == "m2:late" + assert Project.load(project.root).cards == {} + + label(first, "m2:early") + label(second, "m2") + assert main(["assign", "--apply"]) == 0 + cards = Project.load(project.root).cards + assert cards["major_arcana.02"]["slot"] == "r0c1" + assert cards["major_arcana.02:early"]["slot"] == "r0c0" + assert not project.master_path(parse_card_id("major_arcana.02"), "late").exists() + + +def test_unassign_to_add_a_forgotten_rotation(staged, capsys) -> None: + project, (first, _) = staged + pixels = cv2.imread(str(first), cv2.IMREAD_UNCHANGED) + label(first, "w7") + assert main(["assign", "--apply"]) == 0 + + assert main(["unassign", "w7"]) == 0 + label(first, "w7~") + assert main(["assign", "--apply"]) == 0 + + master = project.masters_dir / "minor_arcana/wands/seven.png" + assert np.array_equal( + cv2.imread(str(master), cv2.IMREAD_UNCHANGED), cv2.rotate(pixels, cv2.ROTATE_180) + ) + row = Project.load(project.root).cards["minor_arcana.wands.seven"] + assert (row["rotate_180"], row["slot"]) == (True, "r0c0") + + +def test_an_unassign_refusal_exits_nonzero(staged, capsys) -> None: + assert main(["unassign", "w7"]) == 1 + assert "nothing was unassigned" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "argv", + [ + ["assign", "--variant", "alt"], + ["assign", "--card-back", "plain"], + ["assign", "--rotate-180"], + ["assign", "--apply", "--rotate-180"], + ["assign", "x.png", "major_arcana.00", "--apply"], + ["assign", "x.png", "major_arcana.00", "--force"], + ["assign", "x.png", "--card-back", "plain", "--apply"], + ], +) +def test_flags_from_the_other_mode_are_usage_errors(argv, capsys) -> None: + with pytest.raises(SystemExit) as exc: + main(argv) + assert exc.value.code == 2 + assert "usage: deckle assign" in capsys.readouterr().err + + # --- debug bundles ---------------------------------------------------------------------- diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..b3bfb9f --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +import errno +import os + +import pytest + +from deckle.ids import canonical_78 +from deckle.labels import ( + COMMENT_XATTR, + Label, + LabelError, + format_label, + parse_label, + plan_staging, + read_label, +) +from deckle.project import PROJECT_FILE, Project, ProjectError, init +from deckle.staging import write_staging_index + + +def parse(text: str, default: str | None = None): + return parse_label(text, card_back_default=default) + + +# --- grammar ---------------------------------------------------------------------------- + +SUITS = {"w": "wands", "c": "cups", "s": "swords", "p": "pentacles"} +RANKS = { + "1": "ace", + "a": "ace", + "2": "two", + "3": "three", + "4": "four", + "5": "five", + "6": "six", + "7": "seven", + "8": "eight", + "9": "nine", + "10": "ten", + "t": "ten", + "p": "page", + "n": "knight", + "q": "queen", + "k": "king", +} + + +@pytest.mark.parametrize("suit", SUITS) +@pytest.mark.parametrize("rank", RANKS) +def test_every_minor_shorthand(suit, rank): + label = parse(f"{suit}{rank}") + assert label.ref == f"minor_arcana.{SUITS[suit]}.{RANKS[rank]}" + assert label.rotate_180 is False + + +@pytest.mark.parametrize( + "text, ref", [("m0", "major_arcana.00"), ("m2", "major_arcana.02"), ("m21", "major_arcana.21")] +) +def test_major_shorthand_is_zero_padded(text, ref): + assert parse(text).ref == ref + + +@pytest.mark.parametrize( + "text", + [ + "major_arcana.02", + "major_arcana.02:crowned", + "minor_arcana.swords.seven", + "minor_arcana.coins.sevn", # custom suit and rank: valid grammar, roster says no + ], +) +def test_longhand_passes_through(text): + assert parse(text).ref == text + + +def test_shorthand_with_a_variant(): + label = parse("m2:crowned") + assert (label.ref, label.variant, label.rotate_180) == ( + "major_arcana.02:crowned", + "crowned", + False, + ) + + +def test_rotation_mark_after_a_variant(): + label = parse("m2:crowned~") + assert (label.ref, label.rotate_180) == ("major_arcana.02:crowned", True) + + +def test_surrounding_whitespace_is_stripped(): + assert parse(" w7~\n").ref == "minor_arcana.wands.seven" + + +@pytest.mark.parametrize("text", ["m2~:crowned", "~m2", "m2~~", "w~7"]) +def test_rotation_mark_anywhere_but_the_end_is_refused(text): + with pytest.raises(LabelError, match="goes at the end"): + parse(text) + + +def test_backs(): + assert parse("back:alt").card_back == "alt" + assert parse("back:alt").target == "card_backs.alt" + assert parse("back", "classic").card_back == "classic" + label = parse("back~", "classic") + assert (label.card_back, label.rotate_180, label.card) == ("classic", True, None) + + +def test_bare_back_with_no_default_is_refused(): + with pytest.raises(LabelError, match="give a design: back:"): + parse("back") + + +@pytest.mark.parametrize( + "text, match", + [ + ("m2:Crowned", "not a custom name"), + ("m2:king", "reserved"), + ("back:2nd", "not a custom name"), + ("M14", "expected m"), + ("m14:", "empty key"), + ("back:", "empty key"), + ("m100", "expected m"), + ("w0", "expected m"), + ("x7", "expected m"), + ("major_arcana.2", "both digits"), + ("", "empty label"), + ], +) +def test_bad_labels(text, match): + with pytest.raises(LabelError, match=match): + parse(text) + + +@pytest.mark.parametrize("card", canonical_78(), ids=str) +def test_format_label_round_trips_every_card(card): + for variant in (None, "alt"): + for rotate in (False, True): + label = Label(card=card, variant=variant, card_back=None, rotate_180=rotate) + assert parse(format_label(label)) == label + + +@pytest.mark.parametrize( + "ref, text", + [ + ("major_arcana.00", "m0"), + ("major_arcana.14:crowned", "m14:crowned"), + ("minor_arcana.wands.ace", "w1"), + ("minor_arcana.cups.ten", "c10"), + ("minor_arcana.pentacles.page", "pp"), + ("major_arcana.the_fool", "major_arcana.the_fool"), + ("minor_arcana.coins.seven", "minor_arcana.coins.seven"), + ("minor_arcana.swords.sevn", "minor_arcana.swords.sevn"), + ], +) +def test_format_label_is_short_where_it_can_be(ref, text): + label = parse(ref) + assert format_label(label) == text + assert parse(text) == label + + +def test_format_label_names_the_back_design_even_when_it_is_the_default(): + label = parse("back~", "plain") + assert format_label(label) == "back:plain~" + + +# --- planning --------------------------------------------------------------------------- + + +def make_project(tmp_path) -> Project: + return init(tmp_path / "proj", "Test Deck") + + +def stage(project: Project, name: str, label: str | None = None): + path = project.staging_dir / name + path.write_bytes(b"pixels") + if label is not None: + try: + os.setxattr(path, COMMENT_XATTR, label.encode()) + except OSError as exc: + if exc.errno == errno.ENOTSUP: + pytest.skip(f"{path.parent} does not support user xattrs") + raise + return path + + +def touch_master(project: Project, relpath: str): + path = project.masters_dir / relpath + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"pixels") + return path + + +def test_read_label_without_the_attribute_is_none(tmp_path): + project = make_project(tmp_path) + assert read_label(stage(project, "a_r0c0.png")) is None + assert read_label(stage(project, "b_r0c0.png", " ")) is None + assert read_label(stage(project, "c_r0c0.png", " w7 ")) == "w7" + + +def test_a_clean_plan(tmp_path): + project = make_project(tmp_path) + stage(project, "s1_r0c0.png", "m2") + stage(project, "s1_r0c1.png", "m2:crowned~") + stage(project, "s1_r1c0.png", "pk") + stage(project, "s1_r1c1.png", "back:plain") + (project.staging_dir / "notes.txt").write_text("ignored") + + plan = plan_staging(project, force=False) + assert plan.problems == [] and plan.warnings == [] + assert (plan.files, plan.labelled, plan.unlabelled) == (4, 4, 0) + assert [(r.source.name, r.label.target, r.label.rotate_180) for r in plan.rows] == [ + ("s1_r0c0.png", "major_arcana.02", False), + ("s1_r0c1.png", "major_arcana.02:crowned", True), + ("s1_r1c0.png", "minor_arcana.pentacles.king", False), + ("s1_r1c1.png", "card_backs.plain", False), + ] + assert plan.rows[1].dest == project.masters_dir / "major_arcana/02.crowned.png" + assert ( + plan.rows[1].describe(project.root) + == "s1_r0c1.png -> major_arcana.02:crowned (rotated 180)" + ) + assert plan.summary() == "4 files, 4 labelled, 0 unlabelled, 0 problem(s)" + + +def test_unlabelled_files_are_counted_not_problems(tmp_path): + project = make_project(tmp_path) + stage(project, "s_r0c0.png", "w7") + stage(project, "s_r0c1.png") + stage(project, "s_r1c0.png", "") + plan = plan_staging(project, force=False) + assert (plan.files, plan.labelled, plan.unlabelled) == (3, 1, 2) + assert plan.problems == [] + assert [r.source.name for r in plan.rows] == ["s_r0c0.png"] + + +def test_an_empty_staging_is_an_empty_plan(tmp_path): + project = make_project(tmp_path) + (project.staging_dir).rmdir() + plan = plan_staging(project, force=False) + assert (plan.files, plan.rows, plan.problems) == (0, [], []) + + +def test_a_parse_error_names_the_file(tmp_path): + project = make_project(tmp_path) + stage(project, "s_r0c0.png", "M14") + plan = plan_staging(project, force=False) + assert plan.labelled == 1 and plan.rows == [] + assert len(plan.problems) == 1 + assert plan.problems[0].startswith("s_r0c0.png: ") + assert "expected m" in plan.problems[0] + + +@pytest.mark.parametrize("label", ["minor_arcana.swords.sevn", "major_arcana.23"]) +def test_a_card_outside_the_roster_is_a_problem(tmp_path, label): + project = make_project(tmp_path) + stage(project, "s_r0c0.png", label) + plan = plan_staging(project, force=False) + assert plan.problems == [f"s_r0c0.png: {label} is not in the roster"] + + +def test_an_extended_major_in_the_roster_is_fine(tmp_path): + project = make_project(tmp_path) + project.doc["roster"]["majors"].append("23") + project.save() + stage(project, "s_r0c0.png", "m23") + assert plan_staging(Project.load(project.root), force=False).problems == [] + + +def test_two_bare_labels_on_one_card(tmp_path): + project = make_project(tmp_path) + stage(project, "a_r0c1.png", "m14~") + stage(project, "b_r1c0.png", "major_arcana.14") + (problem,) = plan_staging(project, force=False).problems + assert "major_arcana.14" in problem + assert "a_r0c1.png" in problem and "b_r1c0.png" in problem + assert "give one of these a variant key" in problem + + +def test_a_duplicate_variant(tmp_path): + project = make_project(tmp_path) + touch_master(project, "major_arcana/02.png") + stage(project, "a_r0c0.png", "m2:crowned") + stage(project, "b_r0c0.png", "m2:crowned~") + (problem,) = plan_staging(project, force=False).problems + assert "major_arcana.02:crowned" in problem + assert "a_r0c0.png" in problem and "b_r0c0.png" in problem + assert "variant key" not in problem + + +def test_a_duplicate_back_design(tmp_path): + project = make_project(tmp_path) + project.doc["project"]["card_back_default"] = "plain" + project.save() + stage(project, "a_r0c0.png", "back") + stage(project, "b_r0c0.png", "back:plain") + (problem,) = plan_staging(Project.load(project.root), force=False).problems + assert "card_backs.plain" in problem and "a_r0c0.png" in problem and "b_r0c0.png" in problem + + +def test_a_variant_without_a_default_is_a_problem(tmp_path): + project = make_project(tmp_path) + stage(project, "a_r0c0.png", "m2:crowned") + (problem,) = plan_staging(project, force=False).problems + assert "major_arcana.02" in problem and "no default master" in problem + + +def test_a_variant_passes_when_the_default_is_already_a_master(tmp_path): + project = make_project(tmp_path) + touch_master(project, "major_arcana/02.png") + stage(project, "a_r0c0.png", "m2:crowned") + assert plan_staging(project, force=False).problems == [] + + +def test_a_variant_passes_when_the_default_is_in_the_same_plan(tmp_path): + project = make_project(tmp_path) + stage(project, "a_r0c0.png", "m2:crowned") + stage(project, "b_r0c0.png", "m2") + assert plan_staging(project, force=False).problems == [] + + +def test_a_variant_only_card_the_plan_does_not_touch_is_not_its_problem(tmp_path): + project = make_project(tmp_path) + touch_master(project, "major_arcana/02.crowned.png") + stage(project, "a_r0c0.png", "w7") + assert plan_staging(project, force=False).problems == [] + + +def test_replacing_a_recorded_master_is_a_problem(tmp_path): + project = make_project(tmp_path) + old = touch_master(project, "minor_arcana/wands/seven.png") + project.record_card( + "minor_arcana.wands.seven", {"master": "masters/minor_arcana/wands/seven.png"} + ) + project.save() + stage(project, "s_r0c0.png", "w7") + plan = plan_staging(Project.load(project.root), force=False) + (problem,) = plan.problems + assert "minor_arcana.wands.seven already has a master" in problem and "--force" in problem + assert plan.rows[0].replaces == old + + +def test_a_record_without_its_file_still_guards(tmp_path): + project = make_project(tmp_path) + project.record_card("minor_arcana.wands.seven", {"source_scan": "s.jpg"}) + project.save() + stage(project, "s_r0c0.png", "w7") + (problem,) = plan_staging(Project.load(project.root), force=False).problems + assert "already has a master" in problem + + +def test_an_unrecorded_file_at_the_destination_guards(tmp_path): + project = make_project(tmp_path) + old = touch_master(project, "card_backs/plain.png") + stage(project, "s_r0c0.png", "back:plain") + plan = plan_staging(project, force=False) + (problem,) = plan.problems + assert "card_backs.plain already has a master (masters/card_backs/plain.png)" in problem + assert plan.rows[0].replaces == old + + +def test_force_lifts_the_replace_problem(tmp_path): + project = make_project(tmp_path) + touch_master(project, "minor_arcana/wands/seven.png") + stage(project, "s_r0c0.png", "w7") + plan = plan_staging(project, force=True) + assert plan.problems == [] + assert plan.rows[0].describe(project.root) == ( + "s_r0c0.png -> minor_arcana.wands.seven (replaces masters/minor_arcana/wands/seven.png)" + ) + + +def test_a_non_rectified_name_warns_but_plans(tmp_path): + project = make_project(tmp_path) + stage(project, "seven of wands.png", "w7") + plan = plan_staging(project, force=False) + assert plan.problems == [] + assert [r.label.ref for r in plan.rows] == ["minor_arcana.wands.seven"] + (warning,) = plan.warnings + assert warning.startswith("seven of wands.png: ") and "provenance" in warning + + +def test_the_filename_is_never_a_label(tmp_path): + project = make_project(tmp_path) + stage(project, "w7.png") + plan = plan_staging(project, force=False) + assert (plan.unlabelled, plan.rows, plan.warnings) == (1, [], []) + + +def test_a_stale_index_row_is_ignored(tmp_path): + project = make_project(tmp_path) + stage(project, "s_r0c0.png", "w7") + write_staging_index(project, {"gone_r0c0.png": {"source_scan": "gone.jpg", "slot": "r0c0"}}) + plan = plan_staging(project, force=False) + assert plan.files == 1 and plan.problems == [] + + +def test_a_degraded_project_refuses(tmp_path): + project = make_project(tmp_path) + (project.root / PROJECT_FILE).unlink() + with pytest.raises(ProjectError, match="cannot assign"): + plan_staging(Project.load(project.root), force=False) diff --git a/tests/test_status.py b/tests/test_status.py index cd86720..5647768 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,6 +1,13 @@ from __future__ import annotations +import errno +import os + +import pytest + +from deckle.labels import COMMENT_XATTR from deckle.project import PROJECT_FILE, Project, init +from deckle.render import status_report from deckle.status import status from deckle.units import EdgeStrategy @@ -23,16 +30,92 @@ def test_empty_project_is_zero_of_78(tmp_path): assert st.config_problem is None -def test_variants_do_not_double_count(tmp_path): +def test_a_variant_is_listed_under_its_card_not_counted(tmp_path): project = make_project(tmp_path) touch_master(project, "major_arcana/06.png") touch_master(project, "major_arcana/06.two_women.png") st = status(project) - assert len(st.present) == 2 + assert [m.ref for m in st.present] == ["major_arcana.06"] + assert st.variants == {"major_arcana.06": ["major_arcana.06:two_women"]} + assert st.no_default == [] assert st.total == 78 assert "major_arcana.06" not in st.missing +def test_a_variant_alone_does_not_make_a_card_present(tmp_path, capsys): + project = make_project(tmp_path) + touch_master(project, "major_arcana/02.crowned.png") + touch_master(project, "major_arcana/03.png") + st = status(project) + assert [m.ref for m in st.present] == ["major_arcana.03"] + assert "major_arcana.02" in st.missing and len(st.missing) == 77 + assert st.no_default == ["major_arcana.02"] + + status_report(st) + out = capsys.readouterr().out + assert " 1 of 78 roster cards present\n" in out + assert " major_arcana.02 (has variants, but no default master)\n" in out + assert ( + " major_arcana.02 variants: major_arcana.02:crowned — missing its default master\n" + in out + ) + + +def test_present_counts_distinct_cards_with_a_bare_master(tmp_path, capsys): + project = make_project(tmp_path) + for rel in ("major_arcana/06.png", "major_arcana/06.a.png", "major_arcana/06.b.png"): + touch_master(project, rel) + status_report(status(project)) + out = capsys.readouterr().out + assert " 1 of 78 roster cards present\n" in out + assert " major_arcana.06 variants: major_arcana.06:a, major_arcana.06:b\n" in out + + +def label(path, text: str) -> None: + try: + os.setxattr(path, COMMENT_XATTR, text.encode()) + except OSError as exc: + if exc.errno == errno.ENOTSUP: + pytest.skip(f"{path.parent} does not support user xattrs") + raise + + +def test_the_staging_line_counts_the_plan(tmp_path, capsys): + project = make_project(tmp_path) + touch_master(project, "minor_arcana/wands/seven.png") + for name, text in [("a_r0c0.png", "m2"), ("a_r0c1.png", "w7"), ("a_r1c0.png", "M3")]: + (project.staging_dir / name).write_bytes(b"pixels") + label(project.staging_dir / name, text) + (project.staging_dir / "a_r1c1.png").write_bytes(b"pixels") + + st = status(project) + # the replace counts as a problem, as in a dry run without --force + assert (st.staging.files, st.staging.labelled, st.staging.unlabelled) == (4, 3, 1) + assert len(st.staging.problems) == 2 + status_report(st) + out = capsys.readouterr().out + assert ( + " staging: 4 files, 3 labelled, 1 unlabelled, 2 problem(s) (deckle assign for detail)\n" + in out + ) + + +def test_no_staged_pngs_means_no_staging_line(tmp_path, capsys): + project = make_project(tmp_path) + (project.staging_dir / "index.toml").write_text("") + st = status(project) + assert st.staging is None + status_report(st) + assert "staging:" not in capsys.readouterr().out + + +def test_a_degraded_project_skips_the_staging_line(tmp_path): + project = make_project(tmp_path) + (project.staging_dir / "a_r0c0.png").write_bytes(b"pixels") + (project.root / PROJECT_FILE).unlink() + assert status(Project.load(project.root)).staging is None + + def test_a_deleted_state_file_reports_no_total(tmp_path): project = make_project(tmp_path) for rel in ("major_arcana/03.png", "minor_arcana/cups/king.png", "card_backs/classic.png"):