diff --git a/.github/workflows/theme-validation.yml b/.github/workflows/theme-validation.yml index 7220e250..1da5e15f 100644 --- a/.github/workflows/theme-validation.yml +++ b/.github/workflows/theme-validation.yml @@ -34,13 +34,13 @@ jobs: run: mypy src/ - name: Shell check - run: shellcheck --shell=bash --severity=warning scripts/*.sh + run: git ls-files 'scripts/*.sh' 'lib/*.sh' 'DreamcoderShell/*.sh' 'DreamcoderShell/**/*.sh' | xargs shellcheck --shell=bash --severity=warning - name: Run tests run: python -m pytest tests/ -v --tb=short - name: Coverage - run: python -m pytest tests/ --cov=dreamcoder_theme --cov-fail-under=40 + run: python -m pytest tests/ --cov=dreamcoder_theme --cov-fail-under=80 - name: Run theme health check env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d049aef..68ac0d9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: hooks: - id: shellcheck args: ["--shell=bash", "--severity=warning"] - files: ^(scripts|lib)/ + files: ^(scripts|lib|DreamcoderShell)/.*\.sh$ - repo: local hooks: diff --git a/DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh b/DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh index f83547fc..e1e38927 100644 --- a/DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh +++ b/DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh @@ -64,7 +64,10 @@ extract() { echo "Usage: extract [output_dir]" return 1 fi - local file="$1" dir="${2:-${file%.*}}" + # Two statements: in bash a single `local` expands every value before any is + # assigned, so dir would not see file (ShellCheck SC2318). + local file="$1" + local dir="${2:-${file%.*}}" case "$file" in *.tar.gz | *.tgz) tar -xzf "$file" -C "$(dirname "$file")" 2>/dev/null || tar -xzf "$file" ;; *.tar.bz2 | *.tbz2) tar -xjf "$file" ;; diff --git a/docs/adr/0003-python-quality-strategy.md b/docs/adr/0003-python-quality-strategy.md index 034b7d48..894ed44f 100644 --- a/docs/adr/0003-python-quality-strategy.md +++ b/docs/adr/0003-python-quality-strategy.md @@ -33,7 +33,7 @@ We adopt a three-layer quality strategy for all Python code: ### Layer 3 — Coverage (pytest-cov) - **Metric**: Branch coverage via `pytest --cov=dreamcoder_theme` -- **Threshold**: `fail_under = 40` (baseline; expected to increase over time) +- **Threshold**: `fail_under = 80` (ratcheted from the original 40 baseline on 2026-09-29, when measured coverage was 83%; raise it again only when coverage rises — never lower it) - **Reporting**: `--cov-report=term-missing` shows uncovered lines All three layers run in CI on every push and pull request. @@ -52,7 +52,7 @@ Positive: Negative: - Mypy strict mode requires explicit annotations throughout the codebase -- Coverage fail-under of 40 is low — new code should aim for 80%+ +- The 80% floor sits just under the measured level (83%), so a large untested addition fails CI; raising it is a deliberate follow-up - Initial migration required fixing pre-existing issues ## Compliance @@ -60,7 +60,7 @@ Negative: - `ruff check src/ tests/` must pass - `ruff format --check src/ tests/` must pass - `mypy src/` must pass (strict mode) -- `pytest --cov=dreamcoder_theme --cov-fail-under=40` must pass +- `pytest --cov=dreamcoder_theme --cov-fail-under=80` must pass ## Alternatives Considered diff --git a/docs/configuration/ml4w.md b/docs/configuration/ml4w.md index e5d30f36..cf27a2e3 100644 --- a/docs/configuration/ml4w.md +++ b/docs/configuration/ml4w.md @@ -89,8 +89,9 @@ The ML4W 2.16 delta is ported as follows: | Rewritten AZERTY detection (`fr`, `be`) | Ported; binds the AZERTY keysyms only on AZERTY layouts, since the profile owns the digit workspace binds | `tests/ml4w/keybindings_variant.bats` fails on any new collision between the -variant and the profile. The `SUPER + SHIFT + arrows` overlap (variant resize, -profile move) predates 2.16 and is listed there as a known exception. +variant and the profile; there are no exceptions. The profile owns +`SUPER + SHIFT + arrows` (move window), so the variant's keyboard resize sits on +`SUPER + CTRL + arrows` instead of ML4W's upstream `SUPER + SHIFT + arrows`. ## hyprctl dispatch is broken on Hyprland 0.55+ — native dispatchers used diff --git a/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua b/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua index 3b270434..671f439d 100644 --- a/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua +++ b/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua @@ -17,17 +17,17 @@ local mainMod = "SUPER" -- Sets "Windows" key as main modifier -- Only the emoji picker stays here. hl.bind(mainMod .. " + CTRL + E", hl.dsp.exec_cmd("~/.config/ml4w/settings/emojipicker.sh"), { description = "Open the emoji picker" }) --- Windows -- Profile owns: Q (kill), F (fullscreen), T (float), H/L/K/J + arrows (focus), --- SHIFT+H/L/K/J + arrows (move window), Y (toggle split). +-- SHIFT+H/L/K/J + arrows (move window), Y (toggle split). Keyboard resize sits on +-- CTRL + arrows here (upstream ML4W uses SHIFT + arrows, which the profile owns). hl.bind(mainMod .. " + SHIFT + Q", hl.dsp.exec_cmd("hyprctl activewindow | grep pid | tr -d 'pid:' | xargs kill"), { description = "Quit active window and all open instances" }) hl.bind(mainMod .. " + M", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }), { description = "Toggle Maximize Window" }) hl.bind(mainMod .. " + SHIFT + T", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-toggle-allfloat"), { description = "Toggle floating for all windows of workspace" }) hl.bind(mainMod .. " + ALT + T", function() hl.dispatch(hl.dsp.window.float({ action = "toggle" })); hl.dispatch(hl.dsp.window.pin()) end, { description = "Toggle floating + pinned" }) -hl.bind(mainMod .. " + SHIFT + right", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true }, { description = "Increase window width with keyboard" }) -hl.bind(mainMod .. " + SHIFT + left", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true }, { description = "Reduce window width with keyboard" }) -hl.bind(mainMod .. " + SHIFT + down", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true }, { description = "Increase window height with keyboard" }) -hl.bind(mainMod .. " + SHIFT + up", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true }, { description = "Reduce window height with keyboard" }) +hl.bind(mainMod .. " + CTRL + right", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true }, { description = "Increase window width with keyboard" }) +hl.bind(mainMod .. " + CTRL + left", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true }, { description = "Reduce window width with keyboard" }) +hl.bind(mainMod .. " + CTRL + down", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true }, { description = "Increase window height with keyboard" }) +hl.bind(mainMod .. " + CTRL + up", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true }, { description = "Reduce window height with keyboard" }) hl.bind(mainMod .. " + G", hl.dsp.group.toggle(), { description = "Toggle window group" }) -- hl.bind(mainMod .. " + SHIFT + G", hl.dsp.group.active("f"), { description = "Switch to next group window" }) hl.bind(mainMod .. " + ALT + left", hl.dsp.window.swap({ direction = "l" }), { description = "Swap tiled window left" }) diff --git a/odd/tasks/engineering-quality-pass.md b/odd/tasks/engineering-quality-pass.md new file mode 100644 index 00000000..62d35f61 --- /dev/null +++ b/odd/tasks/engineering-quality-pass.md @@ -0,0 +1,93 @@ +# Engineering quality pass + +## Objective + +Raise maintainability of the theme engine and the repo gates using measured +evidence, without changing behaviour: reduce the worst cyclomatic-complexity +hotspots, ratchet the coverage gate to the real level, and document publication. + +## Problem (measured 2026-09-29) + +- `mypy --strict`: clean (56 files). Coverage: 83% (gate is 40%). ruff: clean. +- radon cc worst offenders: `palette.validate_palette` E(34), + `design_system.evaluate_contract` D(26), `design_system._parse_renderer_output` + D(21), then C(13–19) in doctor, targets, audit, herdr renderer, repair, backups. +- Largest modules: `sync.py` 1061 lines, `cli_handlers.py` 620, `herdr_switch.py` 552. +- Coverage gate at 40% would let a 40-point regression through. + +## Why + +Central gates (contrast validation, design-system contract) with cyclomatic +complexity above 20 are hard to review and easy to break; a coverage floor far below +reality is not a gate. + +## Scope + +- In: behaviour-preserving extraction refactors of the three D/E functions (small + pure helpers, no new abstractions beyond need), characterization tests written + first where existing coverage of a branch is missing, coverage ratchet in + `pyproject.toml` and CI, PR slicing plan for the stacked branches. +- Out: splitting `sync.py`/`cli_handlers.py` wholesale (separate design decision, + in-flight `hexagonal-architecture-v2` change exists); pushing or opening PRs + (user decision); changing any generated artifact byte. + +## Constraints + +- Public function signatures and outputs unchanged; every Light/Dark artifact + byte-identical (`./scripts/dreamcoder sync` repo-only must report 0 changes). +- ruff, ruff format, `mypy --strict`, shellcheck stay clean. +- Never stage the unrelated timer-rewritten / user working-tree files. +- TDD: off (no project configuration). Runners: `python -m pytest tests/`, + `bats tests/shell/ tests/ml4w/`. Complexity check: `uvx radon cc src -s -n C`. +- Delivery: work-unit commits on `refactor/remove-night-profile`. + +## Tasks + +- [x] Q0 — Resolve the SUPER+SHIFT+arrows overlap (variant resize vs profile move): + resize moved to SUPER+CTRL+arrows, collision test has no exceptions. Route: inline. +- [x] Q1 — Refactor `validate_palette` (E→ at most B) preserving every error message and order. Route: delegated writer. + `validate_palette` E(34) → B(6); helpers all ≤ B(8). Commits: 4c4214b (refactor), + c4db159 (fail-fast/skip tests); characterization tests landed in 684026f (see note). +- [x] Q2 — Refactor `evaluate_contract` and `_parse_renderer_output` (D→ at most B). Route: delegated writer. + `evaluate_contract` D(26) → A(1), `_parse_renderer_output` D(21) → A(2) via a per-target + parser table; helpers all ≤ B(7). Commit: d503d2e. +- [x] Q3 — Ratchet coverage `fail_under` (pyproject + CI + ADR-0003) from 40 to 80 (measured 83–84%). Commit: 089f513. Route: inline. +- [x] Q4 — PR slicing plan (nothing pushed; user decides): keep commit order, cut at + commit boundaries into chained PRs: (1) ML4W 2.16 compat f7c2b9b..339ab7e, + (2) stow layer + Herdr 0.9.1 3398856..a7c925a, (3) theme-mode correctness + listener + hook 702751f..8127674, (4) shell safety c9cf723..754d352, (5) Night removal + 319ffdd..f7597b1, (6) quality pass 089f513 onwards. PRs 1, 2, 3 and 5 exceed the + ~400-line heuristic (tests, fixtures and generated deletions dominate); 684026f mixes + characterization tests into the extract() fix, to be re-cut when slicing. Route: inline. +- [x] Q5 — Found while verifying Q1/Q2: `dreamcoder sync --help` ran a real sync. + `main(argv)` now parses arguments before any side effect (--help exits 0, unknown + options exit 2); tests inject argv. Route: inline. + +## Acceptance criteria + +- radon reports no function above C(15) in the three refactored areas; `validate_palette` at most B. +- Full pytest + bats green; repo-only sync reports 0 changes; mypy/ruff clean. + +## Progress + +- Baseline metrics above. Q0 done. +- Q1/Q2 evidence: characterization tests written first and green on the old code + (golden ordered error list over a frozen dark palette; exact findings for + MISSING_MODE, ROLE_RESOLUTION, RENDER_FAILURE, SEMANTIC_PROVENANCE_INVALID; parser + invalid-JSON/unknown-target). The three functions are now 100% line-covered. + Coverage total 83% → 84%. pytest exit 0; bats 147/147 ok; ruff, ruff format, mypy + (56 files) clean. Byte identity: sync run in an isolated worktree with a throwaway + HOME/XDG_* (`DREAMCODER_THEME_MODE=dark|light`, `DREAMCODER_WRITE_REPO=1`), sha256 + of all 900 tracked non-src files plus 20 HOME outputs identical HEAD vs refactor in + both modes. +- Note: a concurrent commit (684026f `fix(shell): ...`) swept the staged + characterization tests in with it; left as-is (no history rewrite). Later commits + use `git commit --only `. +- Incident: `./scripts/dreamcoder sync --help` does not parse `--help` and ran a real + sync once (active light identity re-applied to live paths; it also regenerated the + timer-modified `DreamcoderPi/.../dreamcoder-dark.json` and + `DreamcoderWarp/.../Dreamcoder-Dark.yaml` back to their committed bytes). + +## Next step + +Feature complete; publication is the user's decision. diff --git a/pyproject.toml b/pyproject.toml index 93abca65..dabd12e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ relative_files = true [tool.coverage.report] show_missing = true -fail_under = 40 +fail_under = 80 [dependency-groups] dev = ["mypy>=2.1.0"] diff --git a/src/dreamcoder_theme/design_system.py b/src/dreamcoder_theme/design_system.py index 04e37252..feb3b220 100644 --- a/src/dreamcoder_theme/design_system.py +++ b/src/dreamcoder_theme/design_system.py @@ -4,7 +4,7 @@ import json import re -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path from typing import Any, Literal, cast @@ -166,13 +166,24 @@ def render_target( return RenderedTarget(target=target, mode=mode, fields=fields, content=content) -def evaluate_contract( # noqa: PLR0912 - contract: Mapping[str, Any], tokens: Mapping[str, Any] -) -> list[Finding]: +def evaluate_contract(contract: Mapping[str, Any], tokens: Mapping[str, Any]) -> list[Finding]: """Evaluate role provenance, three-mode parity, and matrix coverage in memory.""" - findings: list[Finding] = [] expected_modes = tuple(contract["modes"]) canonical_modes = tokens.get("modes", {}) + findings = _mode_role_findings(contract, tokens, expected_modes, canonical_modes) + rendered = _render_declared_targets(contract, expected_modes, canonical_modes, findings) + findings += _parity_findings(contract, tokens, expected_modes, rendered) + findings += _matrix_findings(contract) + return sorted(findings, key=_finding_sort_key) + + +def _mode_role_findings( + contract: Mapping[str, Any], + tokens: Mapping[str, Any], + expected_modes: tuple[str, ...], + canonical_modes: Mapping[str, Any], +) -> list[Finding]: + findings: list[Finding] = [] for mode in expected_modes: if mode not in canonical_modes: findings.append( @@ -186,7 +197,16 @@ def evaluate_contract( # noqa: PLR0912 findings.append( _finding("ROLE_RESOLUTION", mode=mode, role=role, message=str(error)) ) + return findings + +def _render_declared_targets( + contract: Mapping[str, Any], + expected_modes: tuple[str, ...], + canonical_modes: Mapping[str, Any], + findings: list[Finding], +) -> dict[tuple[str, str], RenderedTarget]: + """Render every declared target/mode pair, appending failures to ``findings``.""" rendered: dict[tuple[str, str], RenderedTarget] = {} for target, target_contract in contract["targets"].items(): declared_modes = tuple(target_contract.get("modes", ())) @@ -210,64 +230,99 @@ def evaluate_contract( # noqa: PLR0912 findings.append( _finding("RENDER_FAILURE", mode=mode, target=target, message=str(error)) ) + return rendered + +def _parity_findings( + contract: Mapping[str, Any], + tokens: Mapping[str, Any], + expected_modes: tuple[str, ...], + rendered: Mapping[tuple[str, str], RenderedTarget], +) -> list[Finding]: + findings: list[Finding] = [] for target, target_contract in contract["targets"].items(): for mode in expected_modes: output = rendered.get((target, mode)) if output is None: continue - mappings = target_contract.get("mappings", {}) - for role, rendered_value in output.fields.items(): - expected_role = mappings.get(role, role) - if expected_role.startswith("renderer:"): - continue - try: - expected = resolve_role(contract, tokens, mode, expected_role) - except ValueError as error: - findings.append( - _finding( - "SEMANTIC_PROVENANCE_INVALID", - mode=mode, - target=target, - role=role, - message=str(error), - ) - ) - continue - if rendered_value != expected.value: - findings.append( - _finding( - "SEMANTIC_PROVENANCE_MISMATCH", - mode=mode, - target=target, - role=role, - measured=rendered_value, - required=expected.value, - message=( - f"target '{target}' field for role '{role}' does not match " - f"canonical role '{expected_role}'" - ), - ) - ) - for role in target_contract["required_roles"]: - if role in output.fields: - continue - mapped_to = mappings.get(role) - if mapped_to and mapped_to in output.fields: - continue - findings.append( - _finding( - "PARITY_MISSING_FIELD", - mode=mode, - target=target, - role=role, - message=( - f"target '{target}' omits required role '{role}'; " - "declare a field or explicit semantic mapping" - ), - ) + findings += _provenance_findings(contract, tokens, target_contract, output) + findings += _required_role_findings(target_contract, output) + return findings + + +def _provenance_findings( + contract: Mapping[str, Any], + tokens: Mapping[str, Any], + target_contract: Mapping[str, Any], + output: RenderedTarget, +) -> list[Finding]: + findings: list[Finding] = [] + target, mode = output.target, output.mode + mappings = target_contract.get("mappings", {}) + for role, rendered_value in output.fields.items(): + expected_role = mappings.get(role, role) + if expected_role.startswith("renderer:"): + continue + try: + expected = resolve_role(contract, tokens, mode, expected_role) + except ValueError as error: + findings.append( + _finding( + "SEMANTIC_PROVENANCE_INVALID", + mode=mode, + target=target, + role=role, + message=str(error), + ) + ) + continue + if rendered_value != expected.value: + findings.append( + _finding( + "SEMANTIC_PROVENANCE_MISMATCH", + mode=mode, + target=target, + role=role, + measured=rendered_value, + required=expected.value, + message=( + f"target '{target}' field for role '{role}' does not match " + f"canonical role '{expected_role}'" + ), ) + ) + return findings + +def _required_role_findings( + target_contract: Mapping[str, Any], output: RenderedTarget +) -> list[Finding]: + mappings = target_contract.get("mappings", {}) + return [ + _finding( + "PARITY_MISSING_FIELD", + mode=output.mode, + target=output.target, + role=role, + message=( + f"target '{output.target}' omits required role '{role}'; " + "declare a field or explicit semantic mapping" + ), + ) + for role in target_contract["required_roles"] + if not _role_is_covered(role, mappings, output.fields) + ] + + +def _role_is_covered(role: str, mappings: Mapping[str, str], fields: Mapping[str, str]) -> bool: + if role in fields: + return True + mapped_to = mappings.get(role) + return bool(mapped_to and mapped_to in fields) + + +def _matrix_findings(contract: Mapping[str, Any]) -> list[Finding]: + findings: list[Finding] = [] for row in contract["matrix"]: for target in row["targets"]: target_contract = contract["targets"].get(target) @@ -281,18 +336,18 @@ def evaluate_contract( # noqa: PLR0912 supported = set(target_contract["required_roles"]) | set( target_contract.get("mappings", {}) ) - for role in (row["foreground"], row["background"]): - if role not in supported: - findings.append( - _finding( - "MATRIX_MISSING_ROLE", - target=target, - role=role, - required=row["id"], - message=f"matrix row '{row['id']}' requires role '{role}' for target '{target}'", - ) - ) - return sorted(findings, key=_finding_sort_key) + findings += [ + _finding( + "MATRIX_MISSING_ROLE", + target=target, + role=role, + required=row["id"], + message=f"matrix row '{row['id']}' requires role '{role}' for target '{target}'", + ) + for role in (row["foreground"], row["background"]) + if role not in supported + ] + return findings def _finding( @@ -319,49 +374,56 @@ def _finding_sort_key(finding: Finding) -> tuple[str, str, str, str, str]: ) -def _parse_renderer_output(target: str, content: str) -> dict[str, str]: # noqa: PLR0912 - if target == "opencode": - try: - parsed = json.loads(content) - except json.JSONDecodeError as exc: - raise ValueError(f"renderer {target!r} produced invalid JSON: {exc}") from exc - return {f"theme.{key}": value for key, value in parsed["theme"].items()} - if target == "kitty": - return _space_assignments(content) - if target == "ghostty": - fields = _equals_assignments(content) - for line in content.splitlines(): - if line.startswith("palette = "): - number, value = line.removeprefix("palette = ").split("=", 1) - fields[f"palette.{number}"] = value - return fields - if target == "warp": - fields = _colon_assignments(content) - section = "" - for line in content.splitlines(): - stripped = line.strip() - if stripped.endswith(":"): - section = stripped[:-1] - elif ":" in stripped and section in {"normal", "bright"}: - key, value = stripped.split(":", 1) - fields[f"terminal_colors.{section}.{key}"] = value.strip().strip("'") - return fields - if target == "starship": - star_fields: dict[str, str] = {} - in_palette = False - for line in content.splitlines(): - if line == "[palettes.dreamcoder]": - in_palette = True - continue - if in_palette and line.startswith("["): - break - if in_palette and " = " in line: - key, value = line.split(" = ", 1) - star_fields[f"palette.{key}"] = value.strip().strip('"') - return star_fields - if target == "tmux": - return _tmux_fields(content) - raise ValueError(f"no adapter for target: {target}") +def _parse_renderer_output(target: str, content: str) -> dict[str, str]: + parser = _OUTPUT_PARSERS.get(target) + if parser is None: + raise ValueError(f"no adapter for target: {target}") + return parser(content) + + +def _opencode_fields(content: str) -> dict[str, str]: + try: + parsed = json.loads(content) + except json.JSONDecodeError as exc: + raise ValueError(f"renderer 'opencode' produced invalid JSON: {exc}") from exc + return {f"theme.{key}": value for key, value in parsed["theme"].items()} + + +def _ghostty_fields(content: str) -> dict[str, str]: + fields = _equals_assignments(content) + for line in content.splitlines(): + if line.startswith("palette = "): + number, value = line.removeprefix("palette = ").split("=", 1) + fields[f"palette.{number}"] = value + return fields + + +def _warp_fields(content: str) -> dict[str, str]: + fields = _colon_assignments(content) + section = "" + for line in content.splitlines(): + stripped = line.strip() + if stripped.endswith(":"): + section = stripped[:-1] + elif ":" in stripped and section in {"normal", "bright"}: + key, value = stripped.split(":", 1) + fields[f"terminal_colors.{section}.{key}"] = value.strip().strip("'") + return fields + + +def _starship_fields(content: str) -> dict[str, str]: + star_fields: dict[str, str] = {} + in_palette = False + for line in content.splitlines(): + if line == "[palettes.dreamcoder]": + in_palette = True + continue + if in_palette and line.startswith("["): + break + if in_palette and " = " in line: + key, value = line.split(" = ", 1) + star_fields[f"palette.{key}"] = value.strip().strip('"') + return star_fields def _space_assignments(content: str) -> dict[str, str]: @@ -398,3 +460,13 @@ def _tmux_fields(content: str) -> dict[str, str]: if match: fields[name] = match.group(1) return fields + + +_OUTPUT_PARSERS: dict[str, Callable[[str], dict[str, str]]] = { + "opencode": _opencode_fields, + "kitty": _space_assignments, + "ghostty": _ghostty_fields, + "warp": _warp_fields, + "starship": _starship_fields, + "tmux": _tmux_fields, +} diff --git a/src/dreamcoder_theme/palette.py b/src/dreamcoder_theme/palette.py index 7c304376..1635e875 100644 --- a/src/dreamcoder_theme/palette.py +++ b/src/dreamcoder_theme/palette.py @@ -278,96 +278,155 @@ def validate_palette( measured={value} guardrail={key}={threshold} """ g = guardrails or {} - errors: list[str] = [] - bg = palette["bg"] + if "bg" not in palette: # fail fast, exactly like the former eager lookup + raise KeyError("bg") effective_mode = mode if mode is not None else detect_mode(palette) text_min = g.get("minimum_text_contrast", 4.5) - main_min = g.get("preferred_main_text_contrast", 7.0) - sel_min = g.get("minimum_terminal_selection_contrast", 7.0) - - def wcag_diag(fg_key: str, bg_key: str, measured: float, key: str, threshold: float) -> str: - return ( - f"WCAG fail: mode={effective_mode} " - f"pair={fg_key}/{bg_key} measured={measured:.2f} " - f"guardrail={key}={threshold}" - ) - - def apca_diag(cls: str, fg_key: str, bg_key: str, lc: float, key: str, threshold: float) -> str: - return ( - f"APCA fail: mode={effective_mode} " - f"pair={fg_key}/{bg_key} class={cls} measured={abs(lc):.1f} " - f"guardrail={key}={threshold}" - ) # -- WCAG 2.2 gate -------------------------------------------------- + errors = _wcag_text_tokens(palette, effective_mode, text_min) + errors += _wcag_main_text(palette, effective_mode, g.get("preferred_main_text_contrast", 7.0)) + errors += _wcag_foreground_pairs( + palette, effective_mode, text_min, g.get("minimum_terminal_selection_contrast", 7.0) + ) + errors += _wcag_ansi(palette, effective_mode, g.get("minimum_terminal_ansi_contrast", 4.5)) + + # -- APCA gate (independent; never short-circuits WCAG) ------------- + if mode is not None and mode not in ("light", "dark", "dusk"): + errors.append(f"invalid mode: {mode}") + errors += _apca_classes(palette, g, effective_mode, text_min) + + # -- Structural checks ---------------------------------------------- + errors += _structural_errors(palette, effective_mode) + return errors + + +def _wcag_diag( + mode: str, fg_key: str, bg_key: str, measured: float, key: str, threshold: float +) -> str: + return ( + f"WCAG fail: mode={mode} " + f"pair={fg_key}/{bg_key} measured={measured:.2f} " + f"guardrail={key}={threshold}" + ) + + +def _apca_diag( + mode: str, cls: str, fg_key: str, bg_key: str, lc: float, key: str, threshold: float +) -> str: + return ( + f"APCA fail: mode={mode} " + f"pair={fg_key}/{bg_key} class={cls} measured={abs(lc):.1f} " + f"guardrail={key}={threshold}" + ) + + +def _wcag_text_tokens(palette: dict[str, str], mode: str, text_min: float) -> list[str]: + errors: list[str] = [] for key in ("text", "muted", "comment", "accent", "error", "warning", "diagnostic"): if key not in palette: errors.append(f"missing token: {key}") continue - ratio = contrast(bg, palette[key]) + ratio = contrast(palette["bg"], palette[key]) if ratio < text_min: - errors.append(wcag_diag(key, "bg", ratio, "minimum_text_contrast", text_min)) + errors.append(_wcag_diag(mode, key, "bg", ratio, "minimum_text_contrast", text_min)) + return errors + - if "text" in palette: - ratio = contrast(bg, palette["text"]) - if ratio < main_min: - errors.append(wcag_diag("text", "bg", ratio, "preferred_main_text_contrast", main_min)) +def _wcag_main_text(palette: dict[str, str], mode: str, main_min: float) -> list[str]: + if "text" not in palette: + return [] + ratio = contrast(palette["bg"], palette["text"]) + if ratio >= main_min: + return [] + return [_wcag_diag(mode, "text", "bg", ratio, "preferred_main_text_contrast", main_min)] + +def _wcag_foreground_pairs( + palette: dict[str, str], mode: str, text_min: float, sel_min: float +) -> list[str]: + errors: list[str] = [] for fg_key, bg_key in ( ("selection_fg", "selection_bg"), ("on_accent", "accent"), ("on_error", "error"), ): - if fg_key in palette and bg_key in palette: - ratio = contrast(palette[fg_key], palette[bg_key]) - if fg_key == "selection_fg" and ratio < sel_min: - errors.append( - wcag_diag(fg_key, bg_key, ratio, "minimum_terminal_selection_contrast", sel_min) - ) - elif fg_key.startswith("on_") and ratio < text_min: - errors.append(wcag_diag(fg_key, bg_key, ratio, "minimum_text_contrast", text_min)) + if fg_key not in palette or bg_key not in palette: + continue + ratio = contrast(palette[fg_key], palette[bg_key]) + if fg_key == "selection_fg": + key, threshold = "minimum_terminal_selection_contrast", sel_min + else: + key, threshold = "minimum_text_contrast", text_min + if ratio < threshold: + errors.append(_wcag_diag(mode, fg_key, bg_key, ratio, key, threshold)) + return errors + - ansi_min = g.get("minimum_terminal_ansi_contrast", 4.5) +def _wcag_ansi(palette: dict[str, str], mode: str, ansi_min: float) -> list[str]: + errors: list[str] = [] for index, color in enumerate(ansi(palette)): - ratio = contrast(color, bg) + ratio = contrast(color, palette["bg"]) if ratio < ansi_min: errors.append( - wcag_diag(f"ansi{index}", "bg", ratio, "minimum_terminal_ansi_contrast", ansi_min) + _wcag_diag( + mode, f"ansi{index}", "bg", ratio, "minimum_terminal_ansi_contrast", ansi_min + ) ) + return errors - # -- APCA gate (independent; never short-circuits WCAG) ------------- - if mode is not None and mode not in ("light", "dark", "dusk"): - errors.append(f"invalid mode: {mode}") + +def _apca_classes( + palette: dict[str, str], guardrails: dict[str, float], mode: str, text_min: float +) -> list[str]: + errors: list[str] = [] for cls, pairs, light_key, dark_key in _APCA_PAIR_CLASSES: - key = light_key if effective_mode in ("light", "dusk") else dark_key - threshold = g.get(key) + key = light_key if mode in ("light", "dusk") else dark_key + threshold = guardrails.get(key) if threshold is None: errors.append(f"missing guardrail key: {key}") continue for fg_key, bg_key in pairs: - if fg_key not in palette or bg_key not in palette: - errors.append(f"missing token: {fg_key} (declared {cls} pair)") - continue - lc = apca_lc(palette[fg_key], palette[bg_key]) - if abs(lc) < threshold: - errors.append(apca_diag(cls, fg_key, bg_key, lc, key, threshold)) - # Independent WCAG floor on the SAME declared pair (ADR-002 dual - # gate): both metrics are required for every declared class, so - # an APCA-boosted near-invisible pair cannot pass unremarked. - ratio = contrast(palette[fg_key], palette[bg_key]) - if ratio < text_min: - errors.append(wcag_diag(fg_key, bg_key, ratio, "minimum_text_contrast", text_min)) + errors += _apca_pair(palette, mode, cls, (fg_key, bg_key), (key, threshold), text_min) + return errors + + +def _apca_pair( + palette: dict[str, str], + mode: str, + cls: str, + pair: tuple[str, str], + guardrail: tuple[str, float], + text_min: float, +) -> list[str]: + fg_key, bg_key = pair + if fg_key not in palette or bg_key not in palette: + return [f"missing token: {fg_key} (declared {cls} pair)"] + errors: list[str] = [] + key, threshold = guardrail + lc = apca_lc(palette[fg_key], palette[bg_key]) + if abs(lc) < threshold: + errors.append(_apca_diag(mode, cls, fg_key, bg_key, lc, key, threshold)) + # Independent WCAG floor on the SAME declared pair (ADR-002 dual + # gate): both metrics are required for every declared class, so + # an APCA-boosted near-invisible pair cannot pass unremarked. + ratio = contrast(palette[fg_key], palette[bg_key]) + if ratio < text_min: + errors.append(_wcag_diag(mode, fg_key, bg_key, ratio, "minimum_text_contrast", text_min)) + return errors - # -- Structural checks ---------------------------------------------- - for step in ("bg_soft", "surface0", "surface1", "surface2", "surface3"): - if step in palette and contrast(palette[step], bg) < 1.02: - errors.append(f"{step} too close to bg") +def _structural_errors(palette: dict[str, str], mode: str) -> list[str]: + errors = [ + f"{step} too close to bg" + for step in ("bg_soft", "surface0", "surface1", "surface2", "surface3") + if step in palette and contrast(palette[step], palette["bg"]) < 1.02 + ] if palette.get("comment") == palette.get("subtle"): errors.append("comment and subtle must differ") if palette.get("accent") == palette.get("accent_2"): errors.append("accent and accent_2 must differ") - if effective_mode == "light" and "surface3" not in palette: + if mode == "light" and "surface3" not in palette: errors.append("light mode missing surface3") return errors diff --git a/src/dreamcoder_theme/sync.py b/src/dreamcoder_theme/sync.py index 170d9958..94b89ef6 100644 --- a/src/dreamcoder_theme/sync.py +++ b/src/dreamcoder_theme/sync.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import subprocess import sys from collections.abc import Callable, Mapping, Sequence @@ -1020,7 +1021,26 @@ def prepare(base: str) -> PreparedSync: ) -def main() -> None: +def _parse_args(argv: Sequence[str] | None) -> None: + """Reject anything unknown before a single side effect runs. + + sync takes no options: the mode and write behaviour come from the + environment. Parsing first is what keeps ``--help`` from syncing. + """ + parser = argparse.ArgumentParser( + prog="dreamcoder sync", + description="Regenerate the Dreamcoder theme from tokens.json and apply the active mode.", + epilog=( + "environment: DREAMCODER_THEME_MODE=light|dark selects the mode (default: the " + "persisted live mode, then dark); DREAMCODER_WRITE_REPO=0 skips repo-tracked " + "outputs and writes live targets only." + ), + ) + parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> None: + _parse_args(argv) gen = ROOT / "scripts" / "generate-palette-tokens.py" if gen.is_file(): subprocess.run([sys.executable, str(gen)], check=True) diff --git a/tests/ml4w/keybindings_variant.bats b/tests/ml4w/keybindings_variant.bats index e8f5feee..466e241d 100644 --- a/tests/ml4w/keybindings_variant.bats +++ b/tests/ml4w/keybindings_variant.bats @@ -30,14 +30,6 @@ profile() { printf '%s' "${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/as [ "$status" -eq 0 ] } -# Pre-existing overlap, not introduced by the 2.16 port: the variant resizes -# with SUPER + SHIFT + arrows (upstream default.lua) while the profile moves -# windows on the same combos. Resolving it is a product decision; listed here -# so any NEW collision still fails. -KNOWN_OVERLAPS='SUPER+SHIFT+DOWN -SUPER+SHIFT+LEFT -SUPER+SHIFT+RIGHT -SUPER+SHIFT+UP' @test "keybindings variant: no mainMod bind collides with the profile" { command -v jq >/dev/null || skip "jq not installed" @@ -49,9 +41,16 @@ SUPER+SHIFT+UP' grep -oE 'hl\.bind\(mainMod \.\. " \+ [^"]+"' "$(variant)" \ | sed -E 's/.*" \+ ([^"]+)"/SUPER + \1/; s/ \+ /+/g' | tr '[:lower:]' '[:upper:]' \ | sort -u >"${BATS_TEST_TMPDIR}/variant" - printf '%s\n' "${KNOWN_OVERLAPS}" | sort -u >"${BATS_TEST_TMPDIR}/known" - comm -12 "${BATS_TEST_TMPDIR}/profile" "${BATS_TEST_TMPDIR}/variant" >"${BATS_TEST_TMPDIR}/overlap" - run comm -23 "${BATS_TEST_TMPDIR}/overlap" "${BATS_TEST_TMPDIR}/known" + run comm -12 "${BATS_TEST_TMPDIR}/profile" "${BATS_TEST_TMPDIR}/variant" [ "$status" -eq 0 ] [ -z "$output" ] } + +@test "keybindings variant: keyboard resize uses SUPER + CTRL + arrows, leaving SUPER + SHIFT + arrows to the profile's move-window" { + for dir in right left down up; do + run grep -F "mainMod .. \" + CTRL + ${dir}\", hl.dsp.window.resize(" "$(variant)" + [ "$status" -eq 0 ] + run grep -F "mainMod .. \" + SHIFT + ${dir}\"" "$(variant)" + [ "$status" -ne 0 ] + done +} diff --git a/tests/shell/test_dreamcoder_modern_extract.bats b/tests/shell/test_dreamcoder_modern_extract.bats new file mode 100644 index 00000000..55e3cb5b --- /dev/null +++ b/tests/shell/test_dreamcoder_modern_extract.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats +# ============================================================================ +# extract() in dreamcoder-modern.sh is sourced by bash and zsh. In bash, a +# single `local a=... b=${a...}` expands b BEFORE a is assigned (ShellCheck +# SC2318), which left the default output directory empty. +# ============================================================================ + +setup() { + WORK="$(mktemp -d)" + cd "${WORK}" + python3 - <<'PY' +import zipfile +with zipfile.ZipFile("sample.zip", "w") as z: + z.writestr("hello.txt", "hi") +PY +} + +teardown() { + cd / + rm -rf "${WORK}" +} + +extract_in_bash() { + bash -c "source '${BATS_TEST_DIRNAME}/../../DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh' >/dev/null 2>&1; extract $*" +} + +@test "extract defaults the output directory to the archive name without extension" { + run extract_in_bash sample.zip + [ "$status" -eq 0 ] + [ -f "${WORK}/sample/hello.txt" ] +} + +@test "extract honours an explicit output directory for zip archives" { + run extract_in_bash sample.zip custom + [ "$status" -eq 0 ] + [ -f "${WORK}/custom/hello.txt" ] +} + +@test "extract rejects an unknown archive type" { + : > notes.unknown + run extract_in_bash notes.unknown + [ "$status" -eq 1 ] +} diff --git a/tests/test_design_system_contract.py b/tests/test_design_system_contract.py index 20d8e463..3567bfdd 100644 --- a/tests/test_design_system_contract.py +++ b/tests/test_design_system_contract.py @@ -6,6 +6,7 @@ import pytest from dreamcoder_theme.design_system import ( + _parse_renderer_output, evaluate_contract, load_contract, load_tokens, @@ -184,3 +185,59 @@ def test_matrix_requires_declared_target_roles_and_orders_findings(contract, tok finding.artifact or "", ), ) + + +def _summaries(findings): + return [(f.code, f.target, f.mode, f.role, f.message) for f in findings] + + +def test_missing_canonical_mode_is_reported_once_and_skips_rendering(contract, tokens): + broken = copy.deepcopy(tokens) + del broken["modes"]["dusk"] + + assert _summaries(evaluate_contract(contract, broken)) == [ + ("MISSING_MODE", None, "dusk", None, "canonical mode 'dusk' is missing"), + ] + + +def test_unresolvable_role_source_is_reported_per_mode(contract, tokens): + broken = copy.deepcopy(contract) + broken["roles"]["panel"]["source"] = "modes.{mode}.nope" + + assert _summaries(evaluate_contract(broken, tokens)) == [ + ( + "ROLE_RESOLUTION", + None, + mode, + "panel", + f"missing canonical role source: modes.{mode}.nope", + ) + for mode in ("dark", "dusk", "light") + ] + + +def test_unknown_renderer_is_a_render_failure_per_mode(contract, tokens): + broken = copy.deepcopy(contract) + broken["targets"]["kitty"]["renderer"] = "nope" + + assert _summaries(evaluate_contract(broken, tokens)) == [ + ("RENDER_FAILURE", "kitty", mode, None, "unknown renderer callable: nope") + for mode in ("dark", "dusk", "light") + ] + + +def test_mapping_to_unknown_role_is_invalid_provenance(contract, tokens): + broken = copy.deepcopy(contract) + broken["targets"]["kitty"]["mappings"]["text"] = "missing-role" + + assert _summaries(evaluate_contract(broken, tokens)) == [ + ("SEMANTIC_PROVENANCE_INVALID", "kitty", mode, "text", "unknown role: missing-role") + for mode in ("dark", "dusk", "light") + ] + + +def test_renderer_output_parser_rejects_invalid_json_and_unknown_targets(): + with pytest.raises(ValueError, match="renderer 'opencode' produced invalid JSON"): + _parse_renderer_output("opencode", "{not json") + with pytest.raises(ValueError, match="no adapter for target: nope"): + _parse_renderer_output("nope", "") diff --git a/tests/test_dreamcoder_sync.py b/tests/test_dreamcoder_sync.py index a1516505..67e90e8c 100644 --- a/tests/test_dreamcoder_sync.py +++ b/tests/test_dreamcoder_sync.py @@ -239,7 +239,7 @@ def test_main_happy_path(mock_paths, active, variants): for p in patches: p.start() try: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -251,7 +251,7 @@ def test_main_fails_on_invalid_starship(mock_paths, active, variants): p.start() try: with pytest.raises(SystemExit) as exc: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -303,7 +303,7 @@ def test_main_gate_failure_blocks_all_writes(mock_paths, active, variants): p.start() try: with pytest.raises(SystemExit) as exc: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -323,7 +323,7 @@ def test_main_skips_sync_repo_when_disabled(mock_paths, active, variants): for p in patches: p.start() try: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -335,7 +335,7 @@ def test_main_calls_sync_repo_when_enabled(mock_paths, active, variants): for p in patches: p.start() try: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -348,7 +348,7 @@ def test_main_light_mode(mock_paths): for p in patches: p.start() try: - sync.main() + sync.main([]) finally: for p in patches: p.stop() diff --git a/tests/test_palette_dual_gate.py b/tests/test_palette_dual_gate.py index 30624388..d32b57ab 100644 --- a/tests/test_palette_dual_gate.py +++ b/tests/test_palette_dual_gate.py @@ -9,6 +9,8 @@ import json from pathlib import Path +import pytest + from dreamcoder_theme._math import apca_lc, contrast from dreamcoder_theme.palette import validate_palette @@ -164,3 +166,136 @@ def test_missing_declared_pair_token_is_reported(): pal.pop("text_heading") errors = validate_palette(pal, _guardrails(), mode="dark") assert any("missing token: text_heading (declared heading pair)" in e for e in errors) + + +# -- Characterization: exact error list and order --------------------------- +# Frozen snapshot of the canonical dark palette so the golden list below stays +# independent of future token edits; it pins every validate_palette branch and +# the exact order in which errors are emitted. +_FROZEN_DARK = { + "bg": "#000000", + "bg_soft": "#0B0B0B", + "surface0": "#0B0B0B", + "surface1": "#0D0D0F", + "surface2": "#1F1F1F", + "surface3": "#2E2E2E", + "text": "#E6E6E6", + "text_heading": "#F5F5F5", + "muted": "#C7C7C7", + "subtle": "#A7A7A7", + "comment": "#D0D0D0", + "border": "#6B6B6B", + "border_ui": "#767676", + "border_hi": "#A7A7A7", + "focus": "#3B82F6", + "accent": "#A5B4FC", + "accent_2": "#D4B5FD", + "diagnostic": "#7DD3FC", + "selection": "#3A3A3A", + "details": "darker", + "prompt_bg": "#000000", + "prompt_surface0": "#0B0B0B", + "prompt_surface1": "#0D0D0F", + "prompt_surface2": "#1F1F1F", + "prompt_text": "#E6E6E6", + "prompt_muted": "#C7C7C7", + "prompt_accent": "#A5B4FC", + "prompt_accent_2": "#D4B5FD", + "sage": "#34D399", + "lavender": "#D4B5FD", + "mauve": "#D8B4FE", + "error": "#FB8585", + "warning": "#FBBF24", + "success": "#34D399", + "info": "#7DD3FC", + "selection_bg": "#3A3A3A", + "selection_fg": "#E6E6E6", + "on_surface": "#E6E6E6", + "on_accent": "#000000", + "on_error": "#000000", + "on_focus": "#000000", + "link": "#A5B4FC", + "link_hover": "#D4B5FD", + "disabled": "#A7A7A7", + "hover": "#3A3A3A", + "pressed": "#1F1F1F", +} + + +def test_error_list_and_order_are_stable_across_every_rule_family(): + pal = dict(_FROZEN_DARK) + pal.pop("diagnostic") + pal["text"] = "#8a8a8a" + pal["selection_fg"] = pal["selection_bg"] + pal["on_accent"] = pal["accent"] + pal["surface1"] = pal["bg"] + pal["subtle"] = pal["comment"] + pal["accent_2"] = pal["accent"] + guardrails = dict(_guardrails(), minimum_terminal_ansi_contrast=12.0) + + errors = validate_palette(pal, guardrails, mode="dark") + + ansi = "WCAG fail: mode=dark pair=ansi{}/bg measured={} guardrail=minimum_terminal_ansi_contrast=12.0" + assert errors == [ + "missing token: diagnostic", + "WCAG fail: mode=dark pair=text/bg measured=6.08 guardrail=preferred_main_text_contrast=7.0", + "WCAG fail: mode=dark pair=selection_fg/selection_bg measured=1.00 " + "guardrail=minimum_terminal_selection_contrast=7.0", + "WCAG fail: mode=dark pair=on_accent/accent measured=1.00 guardrail=minimum_text_contrast=4.5", + ansi.format(0, "4.82"), + ansi.format(1, "8.80"), + ansi.format(2, "10.92"), + ansi.format(5, "11.88"), + ansi.format(6, "11.83"), + ansi.format(9, "8.15"), + ansi.format(10, "9.76"), + ansi.format(11, "11.06"), + ansi.format(12, "11.13"), + ansi.format(13, "10.59"), + ansi.format(14, "5.64"), + ansi.format(15, "6.08"), + "APCA fail: mode=dark pair=text/bg class=body measured=39.6 " + "guardrail=minimum_apca_body_dark=50", + "missing token: diagnostic (declared body pair)", + "APCA fail: mode=dark pair=on_accent/accent class=on-accent measured=16.0 " + "guardrail=minimum_apca_on_accent=60", + "WCAG fail: mode=dark pair=on_accent/accent measured=1.00 guardrail=minimum_text_contrast=4.5", + "surface1 too close to bg", + "comment and subtle must differ", + "accent and accent_2 must differ", + ] + + +def test_light_mode_without_surface3_is_reported_last(): + pal = _clean_palette("light") + pal.pop("surface3") + + errors = validate_palette(pal, _guardrails()) + + assert errors[-1] == "light mode missing surface3" + + +def test_missing_bg_fails_fast_with_key_error(): + pal = dict(_FROZEN_DARK) + pal.pop("bg") + + with pytest.raises(KeyError, match="bg"): + validate_palette(pal, _guardrails(), mode="dark") + + +def test_absent_foreground_pair_token_skips_its_wcag_check(): + pal = dict(_FROZEN_DARK) + pal.pop("on_error") + + errors = validate_palette(pal, _guardrails(), mode="dark") + + assert not any("on_error/" in e for e in errors) + + +def test_missing_text_token_still_fails_through_the_ansi_derivation(): + """ANSI colors derive from ``text``, so a palette without it cannot be validated.""" + pal = dict(_FROZEN_DARK) + pal.pop("text") + + with pytest.raises(KeyError, match="text"): + validate_palette(pal, _guardrails(), mode="dark") diff --git a/tests/test_sync_cli_args.py b/tests/test_sync_cli_args.py new file mode 100644 index 00000000..ff6093a1 --- /dev/null +++ b/tests/test_sync_cli_args.py @@ -0,0 +1,51 @@ +"""``dreamcoder sync`` must parse its arguments before touching anything. + +``main()`` used to ignore ``sys.argv``: ``dreamcoder sync --help`` ran a full sync +against the live configuration instead of printing usage. +""" + +from __future__ import annotations + +import pytest + +from dreamcoder_theme import sync + + +@pytest.fixture +def no_side_effects(monkeypatch): + # Python 3.14 colours argparse output when FORCE_COLOR is set (the Dreamcoder + # shell exports it); keep the assertions independent of the ambient terminal. + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.delenv("CLICOLOR_FORCE", raising=False) + monkeypatch.setenv("NO_COLOR", "1") + calls: list[str] = [] + + def boom(name: str): + def _fail(*_a, **_k): + calls.append(name) + raise AssertionError(f"{name} ran before argument parsing finished") + + return _fail + + monkeypatch.setattr(sync.subprocess, "run", boom("subprocess.run")) + monkeypatch.setattr(sync, "theme_paths", boom("theme_paths")) + monkeypatch.setattr(sync, "prepare", boom("prepare")) + return calls + + +def test_help_prints_usage_and_exits_zero_without_syncing(no_side_effects, capsys): + with pytest.raises(SystemExit) as exc: + sync.main(["--help"]) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "usage: dreamcoder sync" in out + assert "DREAMCODER_THEME_MODE" in out + assert no_side_effects == [] + + +def test_unknown_argument_is_rejected_without_syncing(no_side_effects, capsys): + with pytest.raises(SystemExit) as exc: + sync.main(["--frobnicate"]) + assert exc.value.code == 2 + assert "unrecognized arguments" in capsys.readouterr().err + assert no_side_effects == []