diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb85029..e908191 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,26 @@ on: branches: [main] jobs: + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.8.x" + + - name: Set up Python + run: uv python install + + # The suite drives the app through Textual's headless test pilot, so it + # needs no terminal. Tests that shell out to the `openspec` CLI skip + # themselves when it is not installed. + - name: Run tests + run: uv run --group dev pytest -q + build-check: name: Build & import check runs-on: ubuntu-latest diff --git a/README.md b/README.md index ba13e3c..465b1da 100644 --- a/README.md +++ b/README.md @@ -48,75 +48,58 @@ newer (installed automatically). - **Live refresh** — open views re-read from disk as things change on disk; press `p` to pause. -### Keys +Press `?` at any time for the key bindings available on the current screen. -Press `?` at any time for the bindings available on the current screen. +## Screenshots -| Key | Does | -| --- | --- | -| `?` | Show key bindings for the current screen | -| `Ctrl+P` | Command palette (actions, jump to change/spec, theme) | -| `/` | Filter the current listing by name (`escape` clears) | -| `enter` | Open the selected change or spec | -| `k` / `d` | Kanban / dashboard | -| `n` | New change | -| `e` | Edit the current artifact in `$EDITOR` | -| `r` | Refresh now | -| `p` | Pause or resume live refresh | -| `t` | Toggle the table of contents (change detail) | -| `q` | Quit | +Screenshots show an example project, not this repository. -### Theming +**Dashboard** — changes in an aligned table with stage, progress and task +counts, over headline counts per stage. -`openspec-ui` ships no palette of its own — all colour comes from the active -Textual theme, so it follows your terminal. Switch themes in-session from the -command palette (`Ctrl+P` → *Theme*). Nothing is written to disk: the app -persists no state, theme included. +![The dashboard: changes in an aligned table with stage, progress and task +counts, over headline counts per stage](docs/images/dashboard.png) -## Design +**Kanban** — `k`. Every column heading and card border carries its stage's +colour, so the shape of the pipeline reads before any of the text does. -`openspec-ui` is a **thin client**: it owns no state. Every read comes from -`openspec ... --json` or direct file reads under `openspec/`; every write is -delegated to the `openspec` CLI or to `$EDITOR`. This keeps the view layer -replaceable (a future SvelteKit web UI could reuse the same data contract). +![Kanban: four stage columns with colour-coded headings and cards carrying a +stage-coloured left border](docs/images/kanban.png) -## Status +**Change detail** — `enter` on a change. Artifacts render as scrollable +markdown with a table of contents; code blocks and ASCII diagrams keep their +monospace alignment. -Alpha. Built with [Textual](https://textual.textualize.io/). Supports OpenSpec -CLI `1.7.x` (`@fission-ai/openspec`). +![A change's design document with a navigable table of contents on the left and +an ASCII diagram preserved in the body](docs/images/change-detail.png) -## Releases +**Tasks** — the `tasks.md` checklist, one section per heading with its +done/total count. Finished sections start collapsed so the eye lands on what is +left. Read-only: edits go through `$EDITOR`. -Releases are **fully automated**. Maintainers never edit the version field by -hand. +![The tasks pane: sections with completion counts, completed sections collapsed +and remaining work expanded](docs/images/tasks.png) -1. Conventional commits land on `main` (via PRs). -2. On each push to `main`, CI runs [`commitizen`](https://commitizen-tools.github.io/commitizen/) - (`cz bump --yes --changelog`), which determines the next semver from the - commit log, updates `version` in `pyproject.toml`, updates `CHANGELOG.md`, - and creates/pushes a `v` tag. -3. The tag push triggers a second CI job that builds the wheel+sdist and - publishes them to [PyPI](https://pypi.org/project/openspec-ui/) using - **trusted publishing (OIDC)** — no stored API tokens. -4. `feat:`/`fix:`/breaking commits release; pure `chore:`/`docs:` commits do - not (commitizen determines there is no increment). +**Spec browser** — `enter` on a spec. Requirements expand to reveal their +scenarios, which is the only way a spec with thirty of them stays readable. -### Trusted publisher setup (one-time, manual) +![A spec's requirements listed with scenario counts, one expanded to show its +WHEN/THEN scenarios](docs/images/spec-browser.png) -Before the publish job works, register a trusted publisher on -[pypi.org](https://pypi.org) once: +**Command palette** — `Ctrl+P`. Fuzzy-matched actions, plus every change and +spec by name. -- **PyPI project:** `openspec-ui` (create it if it does not yet exist). -- **GitHub repo:** `mrn-dk/openspec-ui`. -- **Workflow filename:** `.github/workflows/release.yml`. -- **Environment name:** `pypi` (the publish job uses `environment: pypi`). +![The command palette filtered to billing-related commands](docs/images/command-palette.png) -This is a web step on pypi.org that cannot be automated from within the repo. -Until it is done, the publish job will fail with an OIDC error; the build -artifacts still exist in the workflow run and can be uploaded manually. +**Filter** — `/`. Narrows the changes and specs by name; `escape` clears it. -The first release also needs a seeded `v0.1.0` tag (commitizen bumps -*relative* to an existing tag); after that, `cz bump` always has a reference. +![The dashboard with a filter narrowing the list to changes whose names contain +"add"](docs/images/filtering.png) + +**Light theme** — all colour comes from the active Textual theme, so it follows +your terminal. + +![The same dashboard under a light theme](docs/images/dashboard-light.png) ## Contributing @@ -133,7 +116,3 @@ Git history uses **Conventional Commits** and **conventional branch names**: For changes managed through OpenSpec, see `openspec/config.yaml` for the project context and per-operation guidance that the openspec skills follow. - -## License - -MIT diff --git a/docs/images/change-detail.png b/docs/images/change-detail.png new file mode 100644 index 0000000..f73b1df Binary files /dev/null and b/docs/images/change-detail.png differ diff --git a/docs/images/command-palette.png b/docs/images/command-palette.png new file mode 100644 index 0000000..6bc78f1 Binary files /dev/null and b/docs/images/command-palette.png differ diff --git a/docs/images/dashboard-light.png b/docs/images/dashboard-light.png new file mode 100644 index 0000000..703a311 Binary files /dev/null and b/docs/images/dashboard-light.png differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 0000000..7ae8a1b Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/docs/images/filtering.png b/docs/images/filtering.png new file mode 100644 index 0000000..167205d Binary files /dev/null and b/docs/images/filtering.png differ diff --git a/docs/images/kanban.png b/docs/images/kanban.png new file mode 100644 index 0000000..d7ab2a2 Binary files /dev/null and b/docs/images/kanban.png differ diff --git a/docs/images/spec-browser.png b/docs/images/spec-browser.png new file mode 100644 index 0000000..fc0dbed Binary files /dev/null and b/docs/images/spec-browser.png differ diff --git a/docs/images/tasks.png b/docs/images/tasks.png new file mode 100644 index 0000000..29bcf83 Binary files /dev/null and b/docs/images/tasks.png differ diff --git a/openspec_ui/screens/dashboard.py b/openspec_ui/screens/dashboard.py index 707d98b..41b9add 100644 --- a/openspec_ui/screens/dashboard.py +++ b/openspec_ui/screens/dashboard.py @@ -88,7 +88,7 @@ def __init__(self) -> None: # -- compose -------------------------------------------------------- def compose(self) -> None: - yield Header(show_clock=False) + yield Header(show_clock=False, icon="") yield Static("", id="title") yield FilterBar(id="filter") yield Horizontal( @@ -329,12 +329,16 @@ def _render_specs(self, specs) -> None: self._show_specs_empty("No specs yet.") return self._show_specs_empty(None) + # Pad names to the longest so the counts line up in a column, matching + # the changes table above rather than sitting ragged beneath it. + name_width = max(len(spec.name) for spec in specs) for spec in specs: noun = "requirement" if spec.requirement_count == 1 else "requirements" specs_list.append( ListItem( Label( - f"{spec.name} [$text-muted]{spec.requirement_count} {noun}[/]" + f"{spec.name:<{name_width}} " + f"[$text-muted]{spec.requirement_count:>3} {noun}[/]" ), name=spec.name, ) diff --git a/openspec_ui/screens/detail.py b/openspec_ui/screens/detail.py index 0b1792d..ec28396 100644 --- a/openspec_ui/screens/detail.py +++ b/openspec_ui/screens/detail.py @@ -195,7 +195,7 @@ def action_toggle_toc(self) -> None: self._apply_toc() def compose(self) -> None: - yield Header(show_clock=False) + yield Header(show_clock=False, icon="") yield Static("", id="title") yield TabbedContent(id="tabs") yield StatusBar() diff --git a/openspec_ui/screens/kanban.py b/openspec_ui/screens/kanban.py index 8c489e0..0af7006 100644 --- a/openspec_ui/screens/kanban.py +++ b/openspec_ui/screens/kanban.py @@ -118,7 +118,7 @@ def __init__(self) -> None: self._filter = "" def compose(self) -> None: - yield Header(show_clock=False) + yield Header(show_clock=False, icon="") yield Static("Kanban — change pipeline", id="title") yield FilterBar(id="filter") with Horizontal(id="board"): diff --git a/openspec_ui/screens/spec_detail.py b/openspec_ui/screens/spec_detail.py index c2a73aa..695e4db 100644 --- a/openspec_ui/screens/spec_detail.py +++ b/openspec_ui/screens/spec_detail.py @@ -29,7 +29,7 @@ def __init__(self, spec_name: str) -> None: self.spec_name = spec_name def compose(self): - yield Header(show_clock=False) + yield Header(show_clock=False, icon="") yield Static("", id="title") yield VerticalScroll(id="spec-body") yield StatusBar() diff --git a/pyproject.toml b/pyproject.toml index f41c735..5de0950 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,16 @@ version_scheme = "semver" tag_format = "v$version" update_changelog_on_bump = true changelog_incremental = true + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", +] + +[tool.pytest.ini_options] +# Tests drive the Textual app through its async test pilot, so every test +# coroutine is a real async test; auto mode saves marking each one. +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..21b5f89 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,189 @@ +"""Shared fixtures and helpers for the openspec-ui test suite. + +Tests drive the real `OpenSpecUIApp` under Textual's headless test pilot. Data +comes from a fake client by default so assertions do not depend on whatever +happens to be in this repo's own `openspec/` root; the handful of tests that +exercise the real CLI are marked `integration` and skip when it is absent. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from openspec_ui.app import OpenSpecUIApp +from openspec_ui.client import Change, OpenSpecClient, Requirement, Spec, SpecDetail + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +# -------------------------------------------------------------------------- +# fake client +# -------------------------------------------------------------------------- + + +class FakeClient(OpenSpecClient): + """An OpenSpecClient that serves fixed data and never shells out. + + Every read is overridden, so a test that accidentally reaches an + un-overridden method fails loudly rather than silently invoking the CLI. + """ + + def __init__( + self, + changes: list[Change] | None = None, + specs: list[Spec] | None = None, + artifacts: dict[str, list[Path]] | None = None, + spec_detail: SpecDetail | None = None, + root: Path = REPO_ROOT, + ) -> None: + super().__init__(openspec_cmd="openspec-must-not-be-called") + self.changes = changes if changes is not None else default_changes() + self.specs = specs if specs is not None else default_specs() + self.artifacts = artifacts if artifacts is not None else {} + self.spec_detail = spec_detail + self._root = root + self._dirty = False + + # -- reads --------------------------------------------------------- + + def root(self) -> Path: + return self._root + + def list_changes(self) -> list[Change]: + return list(self.changes) + + def list_specs(self) -> list[Spec]: + return list(self.specs) + + def artifact_paths(self, change_name: str) -> dict[str, list[Path]]: + return { + key: list(self.artifacts.get(key, [])) + for key in ("proposal", "design", "tasks", "specs") + } + + def show_spec(self, name: str) -> SpecDetail: + if self.spec_detail is not None: + return self.spec_detail + return default_spec_detail(name) + + # -- live refresh -------------------------------------------------- + + def has_changed(self) -> bool: + if self._dirty: + self._dirty = False + return True + return False + + def mark_dirty(self) -> None: + """Make the next poll tick report a change.""" + self._dirty = True + + def reset_change_cache(self) -> None: + self._dirty = False + + +class FailingClient(OpenSpecClient): + """A client whose every read raises, for exercising error paths.""" + + def __init__(self) -> None: + super().__init__(openspec_cmd="openspec-does-not-exist") + + +def default_changes() -> list[Change]: + return [ + Change("add-widgets", 0, 0, None, None), # Draft + Change("rebuild-core", 3, 8, None, None), # In Progress + Change("ship-release", 5, 5, None, None), # Completed + ] + + +def default_specs() -> list[Spec]: + return [Spec("core-engine", 4), Spec("release-pipeline", 1)] + + +def default_spec_detail(name: str = "core-engine") -> SpecDetail: + return SpecDetail( + name=name, + title=name, + overview="What this capability is for, at enough length to be a sentence.", + requirements=[ + Requirement( + text="The system SHALL do the first thing.", + scenarios=[ + "- **WHEN** a thing happens\n- **THEN** the first outcome follows", + "- **WHEN** another thing happens\n- **THEN** so does that", + ], + ), + Requirement( + text="The system SHALL do the second thing.", + scenarios=["- **WHEN** asked\n- **THEN** it obliges"], + ), + ], + ) + + +# -------------------------------------------------------------------------- +# fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture +def fake_client() -> FakeClient: + return FakeClient() + + +@pytest.fixture +def make_app(): + """Build an app around a given client.""" + + def _make(client: OpenSpecClient | None = None, root: Path = REPO_ROOT): + return OpenSpecUIApp(client=client or FakeClient(), root=root) + + return _make + + +@pytest.fixture +def app(make_app, fake_client): + return make_app(fake_client) + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- + + +async def settle(app, pilot, rounds: int = 6) -> None: + """Let background reads land and the screen repaint. + + Reads run in thread workers, so a single pause is not enough: the worker + has to finish and its `call_from_thread` render has to be processed. + """ + for _ in range(rounds): + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + + +def screen_lines(app) -> list[str]: + """The current screen flattened to plain text, one string per row.""" + return [ + "".join(segment.text for segment in strip._segments).rstrip() + for strip in app.screen._compositor.render_strips() + ] + + +def screen_text(app) -> str: + return "\n".join(screen_lines(app)) + + +def has_openspec_cli() -> bool: + return shutil.which("openspec") is not None + + +requires_openspec = pytest.mark.skipif( + not has_openspec_cli(), + reason="requires the `openspec` CLI on PATH", +) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..c69abef --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,97 @@ +"""Client-level behaviour: the tasks.md parser and polling cost.""" + +from __future__ import annotations + +import subprocess + +import pytest + +from conftest import requires_openspec +from openspec_ui.client import OpenSpecClient, parse_tasks + + +class TestParseTasks: + def test_no_checkboxes_yields_nothing(self): + """The caller uses "no sections" as the signal to fall back to markdown.""" + assert parse_tasks("# Title\n\nJust prose, no checklist.\n") == [] + + def test_flat_list_is_kept_under_an_untitled_section(self): + sections = parse_tasks("- [x] first\n- [ ] second\n") + assert [(s.title, s.completed, s.total) for s in sections] == [("", 1, 2)] + + def test_sections_carry_their_own_counts(self): + sections = parse_tasks( + "## 1. One\n- [x] a\n\n## 2. Two\n- [ ] b\n- [x] c\n" + ) + assert [(s.title, s.label) for s in sections] == [ + ("1. One", "1/1"), + ("2. Two", "1/2"), + ] + + def test_headings_without_tasks_are_dropped(self): + sections = parse_tasks("## Empty\n\nprose\n\n## Real\n- [ ] x\n") + assert [s.title for s in sections] == ["Real"] + + def test_checkboxes_inside_fences_are_ignored(self): + """Task files often show example markup in a fenced block.""" + sections = parse_tasks( + "## S\n- [x] real task\n\n```\n- [ ] example in a fence\n```\n" + ) + assert [(s.title, s.label) for s in sections] == [("S", "1/1")] + assert sections[0].tasks[0].text == "real task" + + @pytest.mark.parametrize("mark,expected", [("x", True), ("X", True), (" ", False)]) + def test_checkbox_marks(self, mark, expected): + sections = parse_tasks(f"## S\n- [{mark}] item\n") + assert sections[0].tasks[0].done is expected + + @pytest.mark.parametrize("bullet", ["-", "*", "+"]) + def test_bullet_styles(self, bullet): + sections = parse_tasks(f"## S\n{bullet} [ ] item\n") + assert sections[0].total == 1 + + def test_task_text_is_preserved_verbatim(self): + sections = parse_tasks("## S\n- [ ] 1.1 Add `foo` to [bar] (baz)\n") + assert sections[0].tasks[0].text == "1.1 Add `foo` to [bar] (baz)" + + def test_empty_input(self): + assert parse_tasks("") == [] + + +class TestPollingCost: + """The mtime gate exists so idle polling does not spawn the CLI.""" + + @requires_openspec + def test_idle_ticks_spawn_no_subprocess(self, monkeypatch): + calls: list[list[str]] = [] + real_run = subprocess.run + + def spy(args, **kwargs): + calls.append(args) + return real_run(args, **kwargs) + + client = OpenSpecClient() + client.has_changed() # first call baselines, and may resolve the root + + monkeypatch.setattr(subprocess, "run", spy) + for _ in range(5): + client.has_changed() + + assert calls == [], f"idle polling shelled out: {calls}" + + @requires_openspec + def test_root_is_resolved_once(self, monkeypatch): + client = OpenSpecClient() + first = client.root() + + calls: list[list[str]] = [] + real_run = subprocess.run + + def spy(args, **kwargs): + calls.append(args) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", spy) + for _ in range(3): + assert client.root() == first + assert calls == [], f"root() re-resolved via subprocess: {calls}" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..a73eeb8 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,208 @@ +"""Dashboard: aligned table, row-key selection, stat row, size guards.""" + +from __future__ import annotations + +from textual.containers import Horizontal +from textual.widgets import DataTable, ListView, Static + +from conftest import FailingClient, FakeClient, screen_lines, settle +from openspec_ui.client import Change +from openspec_ui.screens.dashboard import ( + NARROW_WIDTH, + STAT_ROW_MIN_HEIGHT, + format_modified, +) +from openspec_ui.screens.detail import DetailScreen +from openspec_ui.screens.kanban import KanbanScreen +from openspec_ui.screens.new_change import NewChangeScreen +from openspec_ui.screens.spec_detail import SpecDetailScreen +from openspec_ui.widgets import EmptyState, Stat, StatusBar + + +async def test_changes_are_listed_in_pipeline_order(app): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + table = app.screen.query_one("#changes", DataTable) + names = [str(table.get_cell_at((row, 1))) for row in range(table.row_count)] + assert names == ["add-widgets", "rebuild-core", "ship-release"] + + +async def test_columns_align_across_rows_of_differing_name_length(app): + """The reason the table replaced a ListView of formatted strings.""" + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + # A fully complete bar is all █ and a task-less one is all ─, so match + # on any of the three glyphs and compare where each bar starts. + bars = [ + line + for line in screen_lines(app) + if any(glyph in line for glyph in ("█", "░", "───")) + ] + assert len(bars) == 3, bars + starts = {min(line.index(g) for g in "█░─" if g in line) for line in bars} + assert len(starts) == 1, f"progress column not aligned: {starts}" + + +async def test_enter_opens_the_change_under_the_cursor(app): + """Selection travels by row key, not by list index.""" + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + table = app.screen.query_one("#changes", DataTable) + table.move_cursor(row=table.row_count - 1) + await pilot.pause() + await pilot.press("enter") + await settle(app, pilot) + assert isinstance(app.screen, DetailScreen) + assert app.screen.change_name == "ship-release" + + +async def test_enter_on_the_specs_list_opens_a_spec(app): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + specs = app.screen.query_one("#specs", ListView) + specs.focus() + specs.index = 0 + await pilot.pause() + await pilot.press("enter") + await settle(app, pilot) + assert isinstance(app.screen, SpecDetailScreen) + assert app.screen.spec_name == "core-engine" + + +async def test_stat_row_counts_each_stage(app): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + values = { + name: app.screen.query_one(f"#stat-{name}", Stat) + .query_one("Digits") + .value + for name in ("specs", "draft", "active", "completed", "archived") + } + assert values == { + "specs": "2", + "draft": "1", + "active": "1", + "completed": "1", + "archived": "0", + } + + +async def test_navigation_bindings_keep_their_meanings(app): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + + await pilot.press("k") + await settle(app, pilot) + assert isinstance(app.screen, KanbanScreen) + await pilot.press("d") + await settle(app, pilot) + + was_live = app.live_refresh + await pilot.press("p") + await pilot.pause() + assert app.live_refresh is not was_live + await pilot.press("p") + + await pilot.press("n") + await settle(app, pilot) + assert isinstance(app.screen, NewChangeScreen) + await pilot.press("escape") + await settle(app, pilot) + + await pilot.press("r") + await settle(app, pilot) + assert app.screen.query_one("#changes", DataTable).row_count == 3 + + +class TestSizeGuards: + async def test_stat_row_hides_on_a_short_terminal(self, make_app): + app = make_app() + async with app.run_test(size=(100, STAT_ROW_MIN_HEIGHT - 4)) as pilot: + await settle(app, pilot) + assert not app.screen.query_one("#stats", Horizontal).display + + async def test_stat_row_shows_when_there_is_room(self, make_app): + app = make_app() + async with app.run_test(size=(100, STAT_ROW_MIN_HEIGHT + 10)) as pilot: + await settle(app, pilot) + assert app.screen.query_one("#stats", Horizontal).display + + async def test_narrow_terminal_drops_the_modified_column(self, make_app): + app = make_app() + async with app.run_test(size=(NARROW_WIDTH - 10, 30)) as pilot: + await settle(app, pilot) + assert app.screen._compact + headers = [ + str(column.label) + for column in app.screen.query_one("#changes", DataTable).columns.values() + ] + assert "Modified" not in headers + + async def test_nothing_overflows_eighty_columns(self, make_app): + app = make_app() + async with app.run_test(size=(80, 24)) as pilot: + await settle(app, pilot) + too_wide = [line for line in screen_lines(app) if len(line) > 80] + assert not too_wide, too_wide + + +class TestEmptyAndErrorStates: + async def test_empty_root_renders_actionable_placeholders(self, make_app): + app = make_app(FakeClient(changes=[], specs=[])) + async with app.run_test(size=(100, 34)) as pilot: + await settle(app, pilot) + visible = [e for e in app.screen.query(EmptyState) if e.display] + messages = [str(e.content) for e in visible] + assert len(visible) == 2, messages + assert any("create one" in m for m in messages) + + async def test_failed_read_reports_twice_and_keeps_the_title(self, make_app): + """A toast so it is noticed, an inline state so it stays readable.""" + app = make_app(FailingClient()) + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + assert list(app._notifications) + title = str(app.screen.query_one("#title", Static).content) + assert title == "OpenSpec Dashboard" + inline = app.screen.query_one("#changes_empty", EmptyState) + assert inline.display and "openspec" in str(inline.content) + + async def test_failed_read_leaves_the_status_bar_intact(self, make_app): + app = make_app(FailingClient()) + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + bar = app.screen.query_one(StatusBar) + live = str(bar.query_one("#status-live", Static).content) + assert "live" in live or "paused" in live + + +class TestFormatModified: + def test_absent(self): + assert format_modified(None) == "—" + + def test_unparseable_falls_back_to_the_date_prefix(self): + assert format_modified("not-a-timestamp") == "not-a-time" + + def test_recent(self): + from datetime import datetime, timedelta, timezone + + stamp = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat() + assert format_modified(stamp) == "3h" + + +async def test_cursor_stays_put_across_a_reload(make_app): + """A live refresh must not yank the cursor back to the top.""" + client = FakeClient() + app = make_app(client) + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + table = app.screen.query_one("#changes", DataTable) + table.move_cursor(row=2) + await pilot.pause() + before = app.screen._highlighted_change_name() + + client.changes = [*client.changes, Change("late-arrival", 0, 3, None, None)] + app.screen.reload() + await settle(app, pilot) + + assert app.screen._highlighted_change_name() == before diff --git a/tests/test_detail.py b/tests/test_detail.py new file mode 100644 index 0000000..9584d38 --- /dev/null +++ b/tests/test_detail.py @@ -0,0 +1,210 @@ +"""Change detail: markdown viewers, per-file tabs, TOC, tasks checklist.""" + +from __future__ import annotations + +import pytest +from textual.widgets import Collapsible, MarkdownViewer, TabbedContent, TabPane + +from conftest import FakeClient, screen_lines, settle +from openspec_ui.screens.detail import TOC_MIN_WIDTH, ArtifactPane, TasksPane +from openspec_ui.widgets import EmptyState + +ASCII_DOC = """# Diagram + +Some prose. + +``` ++--------+ +--------+ +| alpha | --> | beta | ++--------+ +--------+ +``` + +## Section two + +- a list item +""" + + +@pytest.fixture +def artifacts(tmp_path): + """A change with one proposal, two spec files and a tasks checklist.""" + proposal = tmp_path / "proposal.md" + proposal.write_text(ASCII_DOC) + spec_a = tmp_path / "alpha-spec.md" + spec_a.write_text(ASCII_DOC) + spec_b = tmp_path / "beta-spec.md" + spec_b.write_text("# Beta\n\nbeta content\n") + tasks = tmp_path / "tasks.md" + tasks.write_text( + "## 1. First\n- [x] 1.1 done thing\n- [ ] 1.2 pending thing\n" + "\n## 2. Second\n- [x] 2.1 also done\n" + ) + return { + "proposal": [proposal], + "design": [], + "tasks": [tasks], + "specs": [spec_a, spec_b], + } + + +@pytest.fixture +def detail_app(make_app, artifacts): + return make_app(FakeClient(artifacts=artifacts)) + + +async def open_detail(app, pilot, size_note=""): + await settle(app, pilot) + app.open_detail("rebuild-core") + await settle(app, pilot) + return app.screen + + +class TestMarkdownRendering: + async def test_single_file_artifact_gets_one_viewer(self, detail_app): + async with detail_app.run_test(size=(120, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + pane = screen.query_one("#pane-proposal", ArtifactPane) + assert len(list(pane.query(MarkdownViewer))) == 1 + + async def test_ascii_diagrams_stay_aligned(self, detail_app): + """Monospace preservation is a spec requirement, not a nicety.""" + async with detail_app.run_test(size=(120, 34)) as pilot: + await open_detail(detail_app, pilot) + box_lines = [ln for ln in screen_lines(detail_app) if "+--------+" in ln] + assert box_lines, "diagram not rendered" + assert len({ln.index("+--------+") for ln in box_lines}) == 1 + + async def test_multi_file_artifact_becomes_one_tab_per_file(self, detail_app): + async with detail_app.run_test(size=(120, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + pane = screen.query_one("#pane-specs", ArtifactPane) + nested = pane.query_one(TabbedContent) + labels = [str(tab._title) for tab in nested.query(TabPane)] + assert labels == ["alpha-spec.md", "beta-spec.md"] + assert len(list(pane.query(MarkdownViewer))) == 2 + + async def test_missing_artifact_renders_an_empty_state(self, detail_app): + async with detail_app.run_test(size=(120, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + pane = screen.query_one("#pane-design", ArtifactPane) + assert list(pane.query(EmptyState)) + + +class TestTableOfContents: + async def test_shown_on_a_wide_terminal(self, detail_app): + async with detail_app.run_test(size=(120, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + viewers = list(screen.query(MarkdownViewer)) + assert viewers and all(v.show_table_of_contents for v in viewers) + + async def test_suppressed_on_a_narrow_terminal(self, detail_app): + async with detail_app.run_test(size=(TOC_MIN_WIDTH - 20, 30)) as pilot: + screen = await open_detail(detail_app, pilot) + assert not any( + v.show_table_of_contents for v in screen.query(MarkdownViewer) + ) + + async def test_t_toggles_it(self, detail_app): + async with detail_app.run_test(size=(120, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + await pilot.press("t") + await pilot.pause() + assert not any( + v.show_table_of_contents for v in screen.query(MarkdownViewer) + ) + await pilot.press("t") + await pilot.pause() + assert all(v.show_table_of_contents for v in screen.query(MarkdownViewer)) + + async def test_lists_the_document_headings(self, detail_app): + async with detail_app.run_test(size=(120, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + viewer = screen.query_one("#pane-proposal", ArtifactPane).query_one( + MarkdownViewer + ) + headings = [entry[1] for entry in viewer.document.table_of_contents] + assert headings == ["Diagram", "Section two"] + + async def test_selecting_a_heading_scrolls_the_document(self, detail_app, tmp_path): + long_doc = tmp_path / "long.md" + long_doc.write_text( + "# Top\n\n" + ("filler paragraph\n\n" * 60) + "## Far Below\n\nend\n" + ) + async with detail_app.run_test(size=(120, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + viewer = screen.query_one("#pane-proposal", ArtifactPane).query_one( + MarkdownViewer + ) + await viewer.document.load(long_doc) + await pilot.pause() + viewer.scroll_to(y=0, animate=False) + await pilot.pause() + + assert viewer.document.goto_anchor("far-below") + await pilot.pause() + assert viewer.scroll_offset.y > 0 + + +class TestTasksPane: + async def test_sections_render_with_their_counts(self, detail_app): + async with detail_app.run_test(size=(110, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + screen.query_one("#tabs", TabbedContent).active = "pane-tasks" + await settle(detail_app, pilot) + pane = screen.query_one("#pane-tasks", TasksPane) + titles = [str(c.title) for c in pane.query(Collapsible)] + assert titles == ["1. First (1/2)", "2. Second (1/1)"] + + async def test_overall_progress_is_shown(self, detail_app): + async with detail_app.run_test(size=(110, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + screen.query_one("#tabs", TabbedContent).active = "pane-tasks" + await settle(detail_app, pilot) + pane = screen.query_one("#pane-tasks", TasksPane) + summary = str(pane.query_one(".tasks-summary").content) + assert "2/3" in summary and "67%" in summary + + async def test_items_carry_done_and_pending_indicators(self, detail_app): + async with detail_app.run_test(size=(110, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + screen.query_one("#tabs", TabbedContent).active = "pane-tasks" + await settle(detail_app, pilot) + pane = screen.query_one("#pane-tasks", TasksPane) + items = [str(i.content) for i in pane.query(".task-item")] + assert len(items) == 3 + assert any("✓" in i for i in items) and any("○" in i for i in items) + + async def test_pane_never_writes_to_disk(self, detail_app, artifacts): + """Task edits go through the CLI or $EDITOR — never this pane.""" + tasks_file = artifacts["tasks"][0] + before = tasks_file.read_bytes() + async with detail_app.run_test(size=(110, 34)) as pilot: + screen = await open_detail(detail_app, pilot) + screen.query_one("#tabs", TabbedContent).active = "pane-tasks" + await settle(detail_app, pilot) + for key in ("space", "enter", "x", "left", "right", "up", "down"): + await pilot.press(key) + await settle(detail_app, pilot) + assert tasks_file.read_bytes() == before + + async def test_falls_back_to_markdown_without_checkboxes(self, make_app, tmp_path): + prose = tmp_path / "tasks.md" + prose.write_text("# Tasks\n\nWe will do things. No checkboxes.\n") + app = make_app(FakeClient(artifacts={"tasks": [prose]})) + async with app.run_test(size=(110, 34)) as pilot: + screen = await open_detail(app, pilot) + screen.query_one("#tabs", TabbedContent).active = "pane-tasks" + await settle(app, pilot) + pane = screen.query_one("#pane-tasks", TasksPane) + assert list(pane.query(MarkdownViewer)) + assert not list(pane.query(Collapsible)) + + async def test_change_without_tasks_shows_an_empty_state(self, make_app): + app = make_app(FakeClient(artifacts={})) + async with app.run_test(size=(110, 34)) as pilot: + screen = await open_detail(app, pilot) + screen.query_one("#tabs", TabbedContent).active = "pane-tasks" + await settle(app, pilot) + pane = screen.query_one("#pane-tasks", TasksPane) + empty = pane.query(EmptyState) + assert empty and "No tasks" in str(empty.first().content) diff --git a/tests/test_interaction.py b/tests/test_interaction.py new file mode 100644 index 0000000..e21d542 --- /dev/null +++ b/tests/test_interaction.py @@ -0,0 +1,364 @@ +"""Cross-cutting interaction: workers, palette, filtering, help, notifications.""" + +from __future__ import annotations + +import threading +import time + +from textual.command import CommandPalette +from textual.containers import VerticalScroll +from textual.widgets import DataTable, HelpPanel, Input, OptionList, Static + +from conftest import FailingClient, FakeClient, settle +from openspec_ui.screens.detail import DetailScreen +from openspec_ui.screens.kanban import ChangeCard, KanbanScreen +from openspec_ui.screens.new_change import NewChangeScreen +from openspec_ui.screens.spec_detail import SpecDetailScreen +from openspec_ui.widgets import EmptyState, FilterBar, StatusBar + + +# -------------------------------------------------------------------------- +# workers +# -------------------------------------------------------------------------- + + +class SlowClient(FakeClient): + """Delays reads so the in-flight window is observable.""" + + def __init__(self, delay: float = 0.4) -> None: + super().__init__() + self.delay = delay + self.threads: list[str] = [] + + def list_changes(self): + self.threads.append(threading.current_thread().name) + time.sleep(self.delay) + return super().list_changes() + + +class TestNonBlockingReads: + async def test_ui_answers_keys_while_a_read_is_in_flight(self, make_app): + client = SlowClient(delay=0.5) + app = make_app(client) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + assert app.screen.query_one("#body", VerticalScroll).loading + + started = time.monotonic() + await pilot.press("p") + elapsed = time.monotonic() - started + + assert elapsed < 0.3, f"input blocked for {elapsed:.2f}s" + assert app.live_refresh is False + await app.workers.wait_for_complete() + + async def test_reads_run_off_the_ui_thread(self, make_app): + client = SlowClient(delay=0.1) + app = make_app(client) + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + assert client.threads + assert "MainThread" not in client.threads + + async def test_loading_flag_clears_on_success(self, make_app): + app = make_app(SlowClient(delay=0.1)) + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + assert not app.screen.query_one("#body", VerticalScroll).loading + + async def test_loading_flag_clears_on_failure(self, make_app): + app = make_app(FailingClient()) + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + assert not app.screen.query_one("#body", VerticalScroll).loading + + async def test_superseded_reads_do_not_render(self, make_app): + """Rapid refreshes collapse to the latest; no stale result lands.""" + app = make_app(SlowClient(delay=0.3)) + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + renders: list[int] = [] + screen = app.screen + original = screen._finish_load + + def counting(changes, specs): + renders.append(len(changes)) + return original(changes, specs) + + screen._finish_load = counting + for _ in range(5): + await pilot.press("r") + await app.workers.wait_for_complete() + await pilot.pause() + + assert len(renders) <= 1, f"{len(renders)} renders for 5 refreshes" + + +# -------------------------------------------------------------------------- +# notifications and status bar +# -------------------------------------------------------------------------- + + +class TestNotifications: + async def test_live_toggle_updates_every_mounted_status_bar(self, app): + def live_text() -> str: + return str( + app.screen.query_one(StatusBar) + .query_one("#status-live", Static) + .content + ) + + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + assert "live" in live_text() + await pilot.press("p") + await pilot.pause() + assert "paused" in live_text() + + await pilot.press("k") + await settle(app, pilot) + assert "paused" in live_text() + + async def test_status_bar_shows_the_resolved_root(self, app): + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + root_text = str( + app.screen.query_one(StatusBar) + .query_one("#status-root", Static) + .content + ) + assert str(app.root) in root_text + + +# -------------------------------------------------------------------------- +# command palette +# -------------------------------------------------------------------------- + + +def palette_options(app) -> list[str]: + option_list = app.screen.query_one(OptionList) + return [ + str(option_list.get_option_at_index(i).prompt) + for i in range(option_list.option_count) + ] + + +class TestCommandPalette: + async def test_opens_and_lists_commands_with_nothing_typed(self, app): + async with app.run_test(size=(100, 34)) as pilot: + await settle(app, pilot) + await pilot.press("ctrl+p") + await settle(app, pilot) + assert isinstance(app.screen, CommandPalette) + listed = " | ".join(palette_options(app)).lower() + for expected in ("dashboard", "kanban", "new change", "theme"): + assert expected in listed + + async def test_lists_no_duplicate_commands(self, app): + """Textual already provides Quit; two would read as a bug.""" + async with app.run_test(size=(100, 34)) as pilot: + await settle(app, pilot) + await pilot.press("ctrl+p") + await settle(app, pilot) + names = [opt.split("\n")[0] for opt in palette_options(app)] + assert len(names) == len(set(names)), names + + async def test_typing_narrows_the_list(self, app): + async with app.run_test(size=(100, 34)) as pilot: + await settle(app, pilot) + await pilot.press("ctrl+p") + await settle(app, pilot) + before = len(palette_options(app)) + for char in "kanb": + await pilot.press(char) + await settle(app, pilot) + after = palette_options(app) + assert 0 < len(after) < before + assert any("kanban" in option.lower() for option in after) + + async def test_jumps_to_a_change_by_name(self, app): + async with app.run_test(size=(100, 34)) as pilot: + await settle(app, pilot) + await pilot.press("ctrl+p") + await settle(app, pilot) + for char in "rebuild": + await pilot.press(char) + await settle(app, pilot) + await pilot.press("enter") + await settle(app, pilot) + assert isinstance(app.screen, DetailScreen) + assert app.screen.change_name == "rebuild-core" + + async def test_jumps_to_a_spec_by_name(self, app): + async with app.run_test(size=(100, 34)) as pilot: + await settle(app, pilot) + await pilot.press("ctrl+p") + await settle(app, pilot) + for char in "core-eng": + await pilot.press(char) + await settle(app, pilot) + await pilot.press("enter") + await settle(app, pilot) + assert isinstance(app.screen, SpecDetailScreen) + assert app.screen.spec_name == "core-engine" + + async def test_theme_changes_in_session(self, app): + async with app.run_test(size=(100, 34)) as pilot: + await settle(app, pilot) + app.theme = "nord" + await pilot.pause() + assert app.theme == "nord" + + +# -------------------------------------------------------------------------- +# filtering +# -------------------------------------------------------------------------- + + +async def type_filter(pilot, text: str) -> None: + await pilot.press("slash") + await pilot.pause() + for char in text: + await pilot.press(char) + for _ in range(3): + await pilot.pause() + + +class TestFiltering: + async def test_slash_reveals_the_bar_and_narrows_the_listing(self, app): + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + bar = app.screen.query_one("#filter", FilterBar) + assert not bar.display + + table = app.screen.query_one("#changes", DataTable) + before = table.row_count + await type_filter(pilot, "rebuild") + + assert bar.display + assert 0 < table.row_count < before + + async def test_filtered_rows_keep_stage_and_progress(self, app): + from conftest import screen_lines + + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + await type_filter(pilot, "rebuild") + row = [ln for ln in screen_lines(app) if "rebuild-core" in ln] + assert row and "In Progress" in row[0] and "3/8" in row[0] + + async def test_escape_clears_and_restores(self, app): + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + table = app.screen.query_one("#changes", DataTable) + before = table.row_count + await type_filter(pilot, "rebuild") + await pilot.press("escape") + await settle(app, pilot) + assert not app.screen.query_one("#filter", FilterBar).display + assert table.row_count == before + + async def test_no_match_renders_an_empty_state(self, app): + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + await type_filter(pilot, "zzzznope") + empty = app.screen.query_one("#changes_empty", EmptyState) + assert empty.display and "matches" in str(empty.content) + + async def test_filter_survives_a_reload(self, app): + """Live refresh must not resurrect filtered-out rows.""" + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + table = app.screen.query_one("#changes", DataTable) + await type_filter(pilot, "rebuild") + narrowed = table.row_count + app.screen.reload() + await settle(app, pilot) + assert table.row_count == narrowed + + async def test_relaunch_opens_unfiltered(self, make_app): + app = make_app() + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + await type_filter(pilot, "rebuild") + + fresh = make_app() + async with fresh.run_test(size=(110, 34)) as pilot: + await settle(fresh, pilot) + bar = fresh.screen.query_one("#filter", FilterBar) + assert not bar.display and not bar.value + + async def test_kanban_filters_its_cards(self, app): + async with app.run_test(size=(110, 30)) as pilot: + await settle(app, pilot) + await pilot.press("k") + await settle(app, pilot) + screen = app.screen + assert isinstance(screen, KanbanScreen) + + def card_count() -> int: + return sum( + len(list(column.query(ChangeCard))) + for column in screen._columns.values() + ) + + before = card_count() + await type_filter(pilot, "rebuild") + assert 0 < card_count() < before + + await pilot.press("escape") + await settle(app, pilot) + assert card_count() == before + + +# -------------------------------------------------------------------------- +# help panel +# -------------------------------------------------------------------------- + + +class TestHelpPanel: + async def test_question_mark_opens_help_with_screen_bindings(self, app): + from conftest import screen_text + + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + await pilot.press("question_mark") + await settle(app, pilot) + assert list(app.screen.query(HelpPanel)) + text = screen_text(app) + assert "Kanban" in text and "Filter" in text + + async def test_help_reflects_the_current_screen(self, app): + from conftest import screen_text + + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + await pilot.press("k") + await settle(app, pilot) + await pilot.press("question_mark") + await settle(app, pilot) + assert "Dashboard" in screen_text(app) + + async def test_question_mark_types_into_a_focused_filter(self, app): + """`?` is printable, so a focused Input must win.""" + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + await pilot.press("slash") + await pilot.pause() + await pilot.press("question_mark") + for _ in range(3): + await pilot.pause() + assert not list(app.screen.query(HelpPanel)) + assert app.screen.query_one("#filter", FilterBar).value == "?" + + async def test_question_mark_types_into_the_new_change_input(self, app): + async with app.run_test(size=(110, 34)) as pilot: + await settle(app, pilot) + await pilot.press("n") + await settle(app, pilot) + assert isinstance(app.screen, NewChangeScreen) + await pilot.press("question_mark") + for _ in range(3): + await pilot.pause() + assert not list(app.screen.query(HelpPanel)) + assert app.screen.query_one("#name", Input).value == "?" diff --git a/tests/test_kanban.py b/tests/test_kanban.py new file mode 100644 index 0000000..84f3b0f --- /dev/null +++ b/tests/test_kanban.py @@ -0,0 +1,129 @@ +"""Kanban: stage identity, card structure, live movement between columns.""" + +from __future__ import annotations + +from textual.widgets import DataTable, Label + +from conftest import FailingClient, FakeClient, settle +from openspec_ui.client import Change, ChangeStage +from openspec_ui.screens.kanban import ChangeCard, KanbanScreen +from openspec_ui.theme import STAGE_STYLE, stage_style +from openspec_ui.widgets import EmptyState + + +async def open_kanban(app, pilot) -> KanbanScreen: + await settle(app, pilot) + await pilot.press("k") + await settle(app, pilot) + assert isinstance(app.screen, KanbanScreen) + return app.screen + + +def cards_by_stage(screen: KanbanScreen) -> dict[str, list[str]]: + return { + stage: [card.change.name for card in column.query(ChangeCard)] + for stage, column in screen._columns.items() + } + + +async def test_changes_land_in_the_column_for_their_stage(app): + async with app.run_test(size=(110, 30)) as pilot: + screen = await open_kanban(app, pilot) + assert cards_by_stage(screen) == { + ChangeStage.DRAFT: ["add-widgets"], + ChangeStage.IN_PROGRESS: ["rebuild-core"], + ChangeStage.COMPLETED: ["ship-release"], + ChangeStage.ARCHIVED: [], + } + + +async def test_column_headings_and_cards_carry_their_stage_class(app): + """One mapping drives every place a stage is drawn.""" + async with app.run_test(size=(110, 30)) as pilot: + screen = await open_kanban(app, pilot) + for stage, column in screen._columns.items(): + heading = column.query_one(".column-title") + assert stage_style(stage).css_class in heading.classes + for card in column.query(ChangeCard): + assert stage_style(card.change.stage).css_class in card.classes + + +async def test_column_count_is_shown_and_survives_a_narrow_column(app): + async with app.run_test(size=(80, 24)) as pilot: + screen = await open_kanban(app, pilot) + counts = { + stage: str(column.query_one(".column-title-count", Label).content) + for stage, column in screen._columns.items() + } + assert counts[ChangeStage.DRAFT] == "1" + assert counts[ChangeStage.ARCHIVED] == "0" + + +async def test_dashboard_badge_uses_the_same_token_as_the_mapping(app): + async with app.run_test(size=(110, 30)) as pilot: + await settle(app, pilot) + table = app.screen.query_one("#changes", DataTable) + badge = table.get_cell_at((1, 0)) # the In Progress row + assert badge.spans, "stage badge carries no style" + assert badge.spans[0].style == f"${STAGE_STYLE[ChangeStage.IN_PROGRESS].token}" + + +async def test_card_moves_column_on_live_refresh(make_app): + """No keypress: the poll tick notices and the card relocates.""" + client = FakeClient(changes=[Change("alpha", 1, 4, None, None)]) + app = make_app(client) + async with app.run_test(size=(110, 30)) as pilot: + screen = await open_kanban(app, pilot) + assert cards_by_stage(screen)[ChangeStage.IN_PROGRESS] == ["alpha"] + + client.changes = [Change("alpha", 4, 4, None, None)] + client.mark_dirty() + for _ in range(15): + await pilot.pause(delay=0.2) + await app.workers.wait_for_complete() + if cards_by_stage(screen)[ChangeStage.COMPLETED]: + break + + assert cards_by_stage(screen)[ChangeStage.COMPLETED] == ["alpha"] + assert cards_by_stage(screen)[ChangeStage.IN_PROGRESS] == [] + + +async def test_empty_column_shows_a_placeholder(app): + async with app.run_test(size=(110, 30)) as pilot: + screen = await open_kanban(app, pilot) + archived = screen._columns[ChangeStage.ARCHIVED] + placeholder = archived.query_one(EmptyState) + assert placeholder.display + assert "nothing" in str(placeholder.content) + + +async def test_failed_read_reports_a_toast_and_an_inline_state(make_app): + app = make_app(FailingClient()) + async with app.run_test(size=(110, 30)) as pilot: + await settle(app, pilot) + await pilot.press("k") + await settle(app, pilot) + assert list(app._notifications) + title = app.screen.query_one("#title") + assert str(title.content) == "Kanban — change pipeline" + placeholders = [ + str(column.query_one(EmptyState).content) + for column in app.screen._columns.values() + ] + assert any("openspec" in text for text in placeholders) + + +async def test_cards_do_not_wrap_at_eighty_columns(app): + """A wrapped name or bar costs a row and misaligns the cards below it.""" + from conftest import screen_lines + + async with app.run_test(size=(80, 24)) as pilot: + await open_kanban(app, pilot) + lines = screen_lines(app) + card_rows = [i for i, line in enumerate(lines) if "add-widgets" in line] + assert card_rows, lines + # Each column's first card must start on the same row. + first_card_rows = { + i for i, line in enumerate(lines) if "█ " in line and "─" not in line + } + assert first_card_rows, lines diff --git a/tests/test_small_terminal.py b/tests/test_small_terminal.py new file mode 100644 index 0000000..2916a99 --- /dev/null +++ b/tests/test_small_terminal.py @@ -0,0 +1,103 @@ +"""Every screen must stay usable in an 80x24 terminal. + +80x24 is the floor a terminal app is expected to survive. These tests walk each +screen at that size and assert nothing overflows the width, nothing is pushed +off the bottom, and each screen still renders content between its chrome. +""" + +from __future__ import annotations + +import pytest +from textual.widgets import HelpPanel, TabbedContent + +from conftest import FakeClient, screen_lines, settle + +SIZE = (80, 24) + + +@pytest.fixture +def small_app(make_app, tmp_path): + proposal = tmp_path / "proposal.md" + proposal.write_text("# Proposal\n\nSome prose that wraps a little.\n") + tasks = tmp_path / "tasks.md" + tasks.write_text("## 1. Section\n- [x] 1.1 done\n- [ ] 1.2 pending\n") + return make_app( + FakeClient(artifacts={"proposal": [proposal], "tasks": [tasks]}) + ) + + +def assert_fits(app, label: str) -> None: + lines = screen_lines(app) + too_wide = [(i, len(line)) for i, line in enumerate(lines) if len(line) > SIZE[0]] + assert not too_wide, f"{label}: lines exceed {SIZE[0]} cells: {too_wide[:3]}" + assert len(lines) <= SIZE[1], f"{label}: {len(lines)} rows in a {SIZE[1]}-row view" + body = [line for line in lines[2:-2] if line.strip()] + assert body, f"{label}: nothing rendered between header and footer" + + +async def test_dashboard_fits(small_app): + async with small_app.run_test(size=SIZE) as pilot: + await settle(small_app, pilot) + assert_fits(small_app, "dashboard") + + +async def test_dashboard_with_filter_fits(small_app): + async with small_app.run_test(size=SIZE) as pilot: + await settle(small_app, pilot) + await pilot.press("slash") + await pilot.pause() + for char in "reb": + await pilot.press(char) + await settle(small_app, pilot) + assert_fits(small_app, "dashboard+filter") + + +async def test_kanban_fits(small_app): + async with small_app.run_test(size=SIZE) as pilot: + await settle(small_app, pilot) + await pilot.press("k") + await settle(small_app, pilot) + assert_fits(small_app, "kanban") + + +async def test_change_detail_fits(small_app): + async with small_app.run_test(size=SIZE) as pilot: + await settle(small_app, pilot) + small_app.open_detail("rebuild-core") + await settle(small_app, pilot) + assert_fits(small_app, "change detail") + + +async def test_tasks_pane_fits(small_app): + async with small_app.run_test(size=SIZE) as pilot: + await settle(small_app, pilot) + small_app.open_detail("rebuild-core") + await settle(small_app, pilot) + small_app.screen.query_one("#tabs", TabbedContent).active = "pane-tasks" + await settle(small_app, pilot) + assert_fits(small_app, "tasks pane") + + +async def test_spec_detail_fits(small_app): + async with small_app.run_test(size=SIZE) as pilot: + await settle(small_app, pilot) + small_app.open_spec("core-engine") + await settle(small_app, pilot) + assert_fits(small_app, "spec detail") + + +async def test_new_change_modal_fits(small_app): + async with small_app.run_test(size=SIZE) as pilot: + await settle(small_app, pilot) + await pilot.press("n") + await settle(small_app, pilot) + assert_fits(small_app, "new-change modal") + + +async def test_help_panel_fits(small_app): + async with small_app.run_test(size=SIZE) as pilot: + await settle(small_app, pilot) + await pilot.press("question_mark") + await settle(small_app, pilot) + assert list(small_app.screen.query(HelpPanel)) + assert_fits(small_app, "help panel") diff --git a/tests/test_spec_browser.py b/tests/test_spec_browser.py new file mode 100644 index 0000000..368d911 --- /dev/null +++ b/tests/test_spec_browser.py @@ -0,0 +1,105 @@ +"""Spec browser: overview, expandable requirements, empty and error states.""" + +from __future__ import annotations + +from textual.widgets import Collapsible + +from conftest import ( + FailingClient, + FakeClient, + default_spec_detail, + requires_openspec, + settle, +) +from openspec_ui.client import OpenSpecClient, SpecDetail +from openspec_ui.screens.spec_detail import SpecDetailScreen +from openspec_ui.widgets import EmptyState + + +async def open_spec(app, pilot, name: str = "core-engine"): + await settle(app, pilot) + app.open_spec(name) + await settle(app, pilot) + assert isinstance(app.screen, SpecDetailScreen) + return app.screen + + +async def test_requirements_are_listed(app): + async with app.run_test(size=(100, 34)) as pilot: + screen = await open_spec(app, pilot) + collapsibles = list(screen.query(Collapsible)) + assert len(collapsibles) == 2 + + +async def test_scenario_counts_precede_the_requirement_text(app): + """A long SHALL statement truncates; a trailing count would go with it.""" + async with app.run_test(size=(100, 34)) as pilot: + screen = await open_spec(app, pilot) + titles = [str(c.title) for c in screen.query(Collapsible)] + assert titles[0].startswith("1. (2 scenarios)") + assert titles[1].startswith("2. (1 scenario)") + + +async def test_scenarios_are_hidden_until_expanded(app): + """Content must live inside Contents, or collapsing hides nothing.""" + async with app.run_test(size=(100, 34)) as pilot: + screen = await open_spec(app, pilot) + first = screen.query(Collapsible).first() + contents = first.query_one(Collapsible.Contents) + assert contents.children, "requirement content is not inside Contents" + + assert not contents.display + first.collapsed = False + await pilot.pause() + assert contents.display + first.collapsed = True + await pilot.pause() + assert not contents.display + + +async def test_escape_returns_to_the_dashboard(app): + async with app.run_test(size=(100, 34)) as pilot: + await open_spec(app, pilot) + await pilot.press("escape") + await settle(app, pilot) + assert not isinstance(app.screen, SpecDetailScreen) + + +async def test_spec_without_requirements_renders_an_empty_state(make_app): + detail = SpecDetail( + name="bare", title="bare", overview="A capability with nothing in it yet.", + requirements=[], + ) + app = make_app(FakeClient(spec_detail=detail)) + async with app.run_test(size=(100, 34)) as pilot: + screen = await open_spec(app, pilot, "bare") + assert list(screen.query(EmptyState)) + assert not list(screen.query(Collapsible)) + + +async def test_failed_read_reports_a_toast_and_an_inline_error(make_app): + app = make_app(FailingClient()) + async with app.run_test(size=(100, 34)) as pilot: + await settle(app, pilot) + app.open_spec("whatever") + await settle(app, pilot) + assert list(app._notifications) + assert list(app.screen.query(EmptyState)) + + +@requires_openspec +def test_show_spec_parses_the_cli_contract(): + """Guards the shape this app depends on from `openspec show --type spec`.""" + client = OpenSpecClient() + detail = client.show_spec("openspec-tui") + assert detail.name == "openspec-tui" + assert len(detail.overview) > 20 + assert detail.requirements + first = detail.requirements[0] + assert first.text and "SHALL" in first.text + assert first.scenarios and all(isinstance(s, str) for s in first.scenarios) + + +def test_default_spec_detail_fixture_is_representative(): + detail = default_spec_detail() + assert detail.requirements[0].scenarios diff --git a/tests/test_theming.py b/tests/test_theming.py new file mode 100644 index 0000000..9621689 --- /dev/null +++ b/tests/test_theming.py @@ -0,0 +1,171 @@ +"""Theme-following: nothing hard-coded, nothing unreadable, on any theme. + +The app ships no palette of its own — every colour comes from the active +Textual theme. These tests walk each screen under a truecolor light theme, a +truecolor dark theme and an ANSI theme, and assert that: + +* no text is rendered in a colour too close to what it sits on, and +* no truecolor literal reaches the screen while an ANSI theme is active, which + is the signature of a hard-coded colour bypassing the theme. +""" + +from __future__ import annotations + +import pytest +from textual.command import CommandPalette + +from conftest import FakeClient, screen_lines, settle + +THEMES = ["textual-dark", "textual-light", "ansi-dark"] + +#: Below this ratio a foreground is not readable against its background. +MIN_CONTRAST = 1.35 + + +def _luminance(triplet) -> float: + def channel(value: int) -> float: + c = value / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (channel(v) for v in (triplet.red, triplet.green, triplet.blue)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def contrast(fg, bg) -> float: + a, b = _luminance(fg), _luminance(bg) + hi, lo = max(a, b), min(a, b) + return (hi + 0.05) / (lo + 0.05) + + +def audit(app, label: str) -> list[str]: + """Return readability and hard-coded-colour problems on the current screen. + + Textual's built-in command palette is skipped: it is framework chrome we + neither compose nor style, and its border half-blocks and search glyph are + deliberately low-contrast decoration. + """ + if isinstance(app.screen, CommandPalette): + return [] + + problems: list[str] = [] + for strip in app.screen._compositor.render_strips(): + for segment in strip._segments: + text = segment.text.strip() + if not text or segment.style is None: + continue + fg, bg = segment.style.color, segment.style.bgcolor + if fg is None or bg is None: + continue + fg_true = not fg.is_default and fg.triplet is not None + bg_true = not bg.is_default and bg.triplet is not None + if fg_true and bg_true: + ratio = contrast(fg.triplet, bg.triplet) + if ratio < MIN_CONTRAST: + problems.append(f"{label}: {text!r} contrast {ratio:.2f}") + elif not fg.is_default and not bg.is_default and fg == bg: + problems.append(f"{label}: {text!r} foreground equals background") + + # Under an ANSI theme every themed colour arrives as a palette + # entry, so a truecolor triplet can only be a literal of ours. + if app.native_ansi_color: + for color in (fg, bg): + if color is not None and not color.is_default and color.triplet: + problems.append(f"{label}: hard-coded colour {color.name}") + return problems + + +async def walk_screens(app, pilot, label: str) -> list[str]: + """Visit every screen in turn, auditing each.""" + problems: list[str] = [] + + async def visit(name: str) -> None: + await settle(app, pilot) + problems.extend(audit(app, f"{label}/{name}")) + + await visit("dashboard") + + await pilot.press("k") + await visit("kanban") + + await pilot.press("d") + await settle(app, pilot) + await pilot.press("enter") + await visit("change detail") + + await pilot.press("d") + await settle(app, pilot) + await pilot.press("n") + await visit("new-change modal") + await pilot.press("escape") + await settle(app, pilot) + + app.open_spec("core-engine") + await visit("spec detail") + await pilot.press("escape") + await settle(app, pilot) + + await pilot.press("slash") + await pilot.pause() + for char in "reb": + await pilot.press(char) + await visit("filter active") + + return problems + + +@pytest.mark.parametrize("theme", THEMES) +async def test_screens_are_readable_under_theme(make_app, tmp_path, theme): + tasks = tmp_path / "tasks.md" + tasks.write_text("## 1. Section\n- [x] 1.1 done\n- [ ] 1.2 pending\n") + client = FakeClient(artifacts={"tasks": [tasks]}) + app = make_app(client) + async with app.run_test(size=(100, 30)) as pilot: + app.theme = theme + await settle(app, pilot) + problems = await walk_screens(app, pilot, theme) + assert not problems, "\n".join(problems) + + +@pytest.mark.parametrize("theme", THEMES) +async def test_theme_switch_does_not_break_rendering(make_app, theme): + """Switching theme mid-session must repaint, not blank the screen.""" + app = make_app() + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + app.theme = theme + await settle(app, pilot) + body = [line for line in screen_lines(app)[2:-2] if line.strip()] + assert body, f"screen rendered empty under {theme}" + assert not audit(app, theme) + + +async def test_no_theme_preference_is_persisted(make_app): + """Theme is session-only — a fresh app starts on the default.""" + app = make_app() + async with app.run_test(size=(100, 30)) as pilot: + await settle(app, pilot) + default = app.theme + app.theme = "nord" + await pilot.pause() + assert app.theme == "nord" + + fresh = make_app() + async with fresh.run_test(size=(100, 30)) as pilot: + await settle(fresh, pilot) + assert fresh.theme == default + + +def test_stylesheet_has_no_colour_literals(): + """Colour belongs to the theme; the stylesheet may only name tokens.""" + import re + + from openspec_ui.app import OpenSpecUIApp + + from conftest import REPO_ROOT + + css = (REPO_ROOT / "openspec_ui" / OpenSpecUIApp.CSS_PATH).read_text() + # Strip comments and id selectors before looking for hex values. + css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) + css = re.sub(r"^\s*#[\w-]+", "", css, flags=re.M) + literals = re.findall(r"#[0-9a-fA-F]{3,8}\b", css) + assert not literals, f"hard-coded colours in stylesheet: {literals}" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..81876e3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,285 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "openspec-ui" +version = "0.3.0" +source = { editable = "." } +dependencies = [ + { name = "textual" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "textual", specifier = ">=1.0.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +]