mcward-cli 1.1.1__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/.gitignore +2 -0
  2. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/PKG-INFO +1 -1
  3. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/__init__.py +1 -2
  4. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/daemon.py +6 -2
  5. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/test.py +36 -2
  6. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/datapacks.py +3 -2
  7. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/environments.py +24 -29
  8. mcward_cli-1.2.0/src/mcward/cli/reporters/coverage.py +159 -0
  9. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/reporters/github.py +4 -2
  10. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/reporters/live.py +89 -17
  11. mcward_cli-1.2.0/src/mcward/cli/reports/__init__.py +87 -0
  12. mcward_cli-1.2.0/src/mcward/cli/reports/html.py +491 -0
  13. mcward_cli-1.2.0/src/mcward/cli/reports/junit.py +50 -0
  14. mcward_cli-1.2.0/src/mcward/cli/reports/lcov.py +56 -0
  15. mcward_cli-1.2.0/src/mcward/cli/ui.py +70 -0
  16. mcward_cli-1.2.0/tests/test_coverage_report.py +187 -0
  17. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_discovery.py +13 -0
  18. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_live.py +55 -5
  19. mcward_cli-1.2.0/tests/test_reports.py +284 -0
  20. mcward_cli-1.1.1/src/mcward/cli/ui.py +0 -56
  21. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/README.md +0 -0
  22. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/pyproject.toml +0 -0
  23. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/__init__.py +0 -0
  24. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/install.py +0 -0
  25. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/list.py +0 -0
  26. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/reporters/__init__.py +0 -0
  27. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_cli.py +0 -0
  28. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_environments.py +0 -0
  29. {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_github.py +0 -0
@@ -23,6 +23,8 @@
23
23
  bin/
24
24
  build/
25
25
  classes/
26
+ coverage.html
27
+ coverage.lcov
26
28
  coverage.xml
27
29
  hs_err_*.log
28
30
  out/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcward-cli
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: Command-line interface for Ward, a testing framework for Minecraft datapacks using mcfunction.
5
5
  Author-email: Aksiome <54895777+aksiome@users.noreply.github.com>
6
6
  License: MPL-2.0
@@ -47,8 +47,7 @@ cli.add_command(test)
47
47
 
48
48
  def main() -> None:
49
49
  try:
50
- # Click expands globs itself on Windows, which would consume -p patterns
51
- # discover_datapacks resolves them instead
50
+ # discover_datapacks expands -p globs itself; Click's Windows expansion would eat them
52
51
  cli(windows_expand_args=False)
53
52
  except KeyboardInterrupt:
54
53
  console.print("\n[yellow]Interrupted[/yellow]")
@@ -51,7 +51,7 @@ def stop(version: str | None, all: bool = False) -> None:
51
51
  print_warning(f"Version {env.version.name} is not running")
52
52
  return
53
53
 
54
- envs = [cast(RunningEnvironment, manager.get(v.name)) for v in manager.list_running()]
54
+ envs = _running_environments()
55
55
  if not envs:
56
56
  print_note("No versions running")
57
57
  return
@@ -78,7 +78,7 @@ def stop(version: str | None, all: bool = False) -> None:
78
78
  @click.command()
79
79
  def status() -> None:
80
80
  """Show status of running daemons."""
81
- envs = [cast(RunningEnvironment, manager.get(v.name)) for v in manager.list_running()]
81
+ envs = _running_environments()
82
82
  if not envs:
83
83
  print_note("No versions running")
84
84
  return
@@ -98,3 +98,7 @@ def status() -> None:
98
98
  table.add_row(env.version.name, str(env.process.pid), str(env.process.port), state)
99
99
 
100
100
  console.print("", table)
101
+
102
+
103
+ def _running_environments() -> list[RunningEnvironment]:
104
+ return [cast(RunningEnvironment, manager.get(v.name)) for v in manager.list_running()]
@@ -1,14 +1,16 @@
1
1
  """The test command."""
2
2
 
3
3
  import sys
4
+ from pathlib import Path
4
5
 
5
6
  import rich_click as click
6
7
 
7
- from mcward import WardError
8
+ from mcward import CoverageIgnores, WardError
8
9
 
9
10
  from ..datapacks import DEFAULT_PATTERNS, discover_datapacks
10
11
  from ..environments import manager, select_compatible, start_environments
11
12
  from ..reporters import github, live
13
+ from ..reports import parse_coverage_report, report_session
12
14
  from ..ui import console
13
15
 
14
16
 
@@ -33,11 +35,39 @@ from ..ui import console
33
35
  default="live",
34
36
  help="Result output: interactive live display, or GitHub Actions annotations",
35
37
  )
38
+ @click.option(
39
+ "--coverage",
40
+ is_flag=True,
41
+ help="Record which function commands run and report line coverage",
42
+ )
43
+ @click.option(
44
+ "--coverage-report",
45
+ "coverage_reports",
46
+ multiple=True,
47
+ metavar="FORMAT[:PATH]",
48
+ help="Write coverage as 'lcov' or 'html', with an optional path (repeatable); "
49
+ "implies --coverage",
50
+ )
51
+ @click.option(
52
+ "--junit-xml",
53
+ type=click.Path(dir_okay=False, writable=True, path_type=Path),
54
+ default=None,
55
+ help="Write test results as JUnit XML",
56
+ )
57
+ @click.option(
58
+ "--verbose",
59
+ is_flag=True,
60
+ help="List every test and coverage row instead of collapsing large runs",
61
+ )
36
62
  @click.argument("selector", default="*:*")
37
63
  def test(
38
64
  versions: tuple[str, ...],
39
65
  packs: tuple[str, ...],
40
66
  reporter: str,
67
+ coverage: bool,
68
+ coverage_reports: tuple[str, ...],
69
+ junit_xml: Path | None,
70
+ verbose: bool,
41
71
  selector: str,
42
72
  ) -> None:
43
73
  """Run datapack tests."""
@@ -56,11 +86,15 @@ def test(
56
86
  selected = versions or select_compatible(strictest.min_format, loosest.max_format)
57
87
  paths = [datapack.path for datapack in datapacks]
58
88
 
89
+ specs = [parse_coverage_report(value) for value in coverage_reports]
90
+ enabled = coverage or bool(specs)
59
91
  run = github.run if reporter == "github" else live.run
60
92
  try:
93
+ ignores = CoverageIgnores.load()
61
94
  envs = start_environments([manager.get(v) for v in selected])
62
95
  console.print()
63
- session = run(paths, envs, selector)
96
+ session = run(paths, envs, selector, coverage=enabled, verbose=verbose)
97
+ report_session(session, paths, specs, junit_xml, verbose, selector, ignores)
64
98
  if session.failed:
65
99
  sys.exit(1)
66
100
  except WardError as e:
@@ -1,5 +1,6 @@
1
1
  """Datapack discovery, pack.mcmeta parsing and resource-to-file resolution."""
2
2
 
3
+ import glob
3
4
  import json
4
5
  import os
5
6
  import zipfile
@@ -26,14 +27,14 @@ class DataPack:
26
27
 
27
28
  def discover_datapacks(patterns: Sequence[str]) -> list[DataPack]:
28
29
  """Find the datapacks (directories or zips) matching the glob patterns."""
29
- cwd = Path.cwd()
30
30
  paths = set()
31
31
 
32
32
  for pattern in patterns:
33
33
  if (p := Path(pattern)).exists():
34
34
  paths.add(p.resolve())
35
35
  else:
36
- paths.update(p.resolve() for p in cwd.glob(pattern))
36
+ matches = glob.glob(pattern, recursive=True, include_hidden=True)
37
+ paths.update(Path(m).resolve() for m in matches)
37
38
 
38
39
  return [parse_datapack(p) for p in paths if _is_datapack(p)]
39
40
 
@@ -5,7 +5,6 @@ from concurrent.futures import ThreadPoolExecutor
5
5
  from itertools import groupby
6
6
 
7
7
  import rich_click as click
8
- from questionary.prompts.common import Choice
9
8
  from rich.progress import Progress, SpinnerColumn, TextColumn
10
9
 
11
10
  from mcward import (
@@ -18,7 +17,7 @@ from mcward import (
18
17
  VersionNotFoundError,
19
18
  )
20
19
 
21
- from .ui import console, print_note, print_success, select
20
+ from .ui import Option, console, print_note, print_success, select
22
21
 
23
22
  manager = EnvironmentManager()
24
23
 
@@ -27,12 +26,12 @@ def curate_versions(versions: Iterable[Version]) -> list[Version]:
27
26
  """Keep all releases but only the newest snapshot of each (year, major) line."""
28
27
  seen = set()
29
28
  curated = []
30
- for v in versions:
31
- k = (v.year, v.major)
32
- if v.is_snapshot and k in seen:
29
+ for version in versions:
30
+ line = (version.year, version.major)
31
+ if version.is_snapshot and line in seen:
33
32
  continue
34
- curated.append(v)
35
- seen.add(k)
33
+ curated.append(version)
34
+ seen.add(line)
36
35
  return curated
37
36
 
38
37
 
@@ -45,18 +44,12 @@ def get_environment(version: str) -> Environment:
45
44
 
46
45
  def select_installed(message: str) -> str:
47
46
  """Prompt for an installed version, skipping the prompt when only one fits."""
48
- versions = manager.list_installed()
49
- if not versions:
50
- raise click.ClickException("No versions installed")
51
- return versions[0].name if len(versions) == 1 else select(message, [v.name for v in versions])
47
+ return _select_version(manager.list_installed(), message, "No versions installed")
52
48
 
53
49
 
54
50
  def select_running(message: str) -> str:
55
51
  """Prompt for a running version, skipping the prompt when only one fits."""
56
- versions = manager.list_running()
57
- if not versions:
58
- raise click.ClickException("No versions running")
59
- return versions[0].name if len(versions) == 1 else select(message, [v.name for v in versions])
52
+ return _select_version(manager.list_running(), message, "No versions running")
60
53
 
61
54
 
62
55
  def select_available(message: str) -> str:
@@ -65,18 +58,13 @@ def select_available(message: str) -> str:
65
58
  if not curated:
66
59
  raise click.ClickException("No versions available")
67
60
 
68
- choices = []
69
61
  snapshot = next((v for v in curated if v.is_snapshot), None)
70
62
  latest = next((v for v in curated if not v.is_snapshot), None)
71
- for v in curated:
72
- title = [("", v.name)]
73
- if v == latest:
74
- title.append(("dim", " (latest)"))
75
- elif v == snapshot:
76
- title.append(("dim", " (snapshot)"))
77
- choices.append(Choice(title))
78
-
79
- return select(message, choices)
63
+ options = []
64
+ for version in curated:
65
+ hint = "(latest)" if version == latest else "(snapshot)" if version == snapshot else ""
66
+ options.append(Option(version.name, hint))
67
+ return select(message, options)
80
68
 
81
69
 
82
70
  def select_compatible(min_format: int, max_format: int) -> list[str]:
@@ -92,10 +80,9 @@ def select_compatible(min_format: int, max_format: int) -> list[str]:
92
80
  return [versions[0].name]
93
81
 
94
82
  print_note("Pack format range supports multiple major versions")
95
-
96
- choices = [Choice(title=[("", "Use all versions "), ("dim", "(Recommended)")], value="all")]
97
- choices.extend(Choice(title=f"Use {v.name} only", value=v.name) for v in versions)
98
- name = select("Select version strategy:", choices)
83
+ options = [Option("Use all versions", "(Recommended)", value="all")]
84
+ options.extend(Option(f"Use {v.name} only", value=v.name) for v in versions)
85
+ name = select("Select version strategy:", options)
99
86
 
100
87
  return [v.name for v in versions if name == "all" or v.name == name]
101
88
 
@@ -112,6 +99,14 @@ def start_environments(envs: Sequence[Environment]) -> list[RunningEnvironment]:
112
99
  return list(executor.map(lambda env: _start_environment(env, progress), envs))
113
100
 
114
101
 
102
+ def _select_version(versions: list[Version], message: str, empty: str) -> str:
103
+ if not versions:
104
+ raise click.ClickException(empty)
105
+ if len(versions) == 1:
106
+ return versions[0].name
107
+ return select(message, [v.name for v in versions])
108
+
109
+
115
110
  def _start_environment(env: Environment, progress: Progress) -> RunningEnvironment:
116
111
  """Start a single environment, reporting steps to the shared progress."""
117
112
  if isinstance(env, UninstalledEnvironment):
@@ -0,0 +1,159 @@
1
+ """Coverage rendering shared by the terminal reporters."""
2
+
3
+ from collections.abc import Callable, Mapping, Sequence
4
+
5
+ from rich.text import Text
6
+
7
+ from mcward import (
8
+ CoverageReport,
9
+ CoverageTotals,
10
+ FunctionReport,
11
+ ResolvedCoverage,
12
+ ResourceReport,
13
+ Version,
14
+ )
15
+
16
+ _GAP_PATHS_SHOWN = 3
17
+
18
+
19
+ def render_coverage(resolved: Mapping[Version, ResolvedCoverage], verbose: bool = False) -> Text:
20
+ """The coverage summary, one section per version that reported it."""
21
+ text = Text()
22
+ for version, coverage in resolved.items():
23
+ reports = coverage.reports
24
+ if len(resolved) > 1:
25
+ text.append(f"{version.name}\n", "magenta")
26
+ _render_summary(text, reports)
27
+ _render_groups(text, reports)
28
+ if verbose:
29
+ _render_gaps(text, coverage.functions)
30
+ _render_uncalled(text, coverage.functions)
31
+ _render_condition_gaps(text, coverage.resources)
32
+ return text
33
+
34
+
35
+ def _render_summary(text: Text, reports: Sequence[CoverageReport]) -> None:
36
+ totals = CoverageTotals.of(reports)
37
+ text.append("Coverage: ", "bold")
38
+ text.append(_share(totals))
39
+ text.append(
40
+ f" ({totals.covered}/{totals.total}, {totals.touched}/{totals.files} files)\n", "dim"
41
+ )
42
+
43
+
44
+ def _render_groups(text: Text, reports: Sequence[CoverageReport]) -> None:
45
+ """One rollup row per group: combined share, covered count, files reached."""
46
+ key = _group_key(reports)
47
+ groups = sorted({key(report) for report in reports})
48
+ if len(groups) < 2:
49
+ return
50
+
51
+ rows = []
52
+ for group in groups:
53
+ totals = CoverageTotals.of(report for report in reports if key(report) == group)
54
+ counts = f"{totals.covered}/{totals.total}"
55
+ rows.append((group, _share(totals, 6), counts, f"{totals.touched}/{totals.files} files"))
56
+
57
+ name_width = max(len(row[0]) for row in rows)
58
+ count_width = max(len(row[2]) for row in rows)
59
+ for group, share, counts, files in rows:
60
+ text.append(f" {group:<{name_width}} {share}")
61
+ text.append(f" {counts:>{count_width}}", "dim")
62
+ text.append(f" {files}\n", "dim")
63
+
64
+
65
+ def _share(totals: CoverageTotals, width: int = 0) -> str:
66
+ return f"{totals.ratio:{width}.1%}" if totals.ratio is not None else f"{'—':>{width}}"
67
+
68
+
69
+ def _group_key(reports: Sequence[CoverageReport]) -> Callable[[CoverageReport], str]:
70
+ """Group by namespace, or by resource type when there is only one."""
71
+ if len({report.namespace for report in reports}) > 1:
72
+ return lambda report: report.namespace
73
+ return lambda report: report.kind
74
+
75
+
76
+ def _render_gaps(text: Text, reports: Sequence[FunctionReport]) -> None:
77
+ gaps = [r for r in reports if r.touched and r.covered < r.total]
78
+ gaps.sort(key=lambda r: (r.covered - r.total, r.name))
79
+
80
+ for report in gaps:
81
+ text.append(" ~ ", "yellow")
82
+ text.append(report.name)
83
+ text.append(f" {report.covered}/{report.total}", "dim")
84
+ if detail := _describe_gaps(report):
85
+ text.append(f" {detail}", "dim yellow")
86
+ text.append("\n")
87
+
88
+
89
+ def _render_condition_gaps(text: Text, resources: Sequence[ResourceReport]) -> None:
90
+ """An untouched file gets counts only: paths would drown big generated files."""
91
+ gaps = [r for r in resources if r.covered < r.total]
92
+ gaps.sort(key=lambda r: (r.covered - r.total, r.name))
93
+
94
+ for resource in gaps:
95
+ symbol, style = ("~", "yellow") if resource.touched else ("✗", "red")
96
+ text.append(f" {symbol} ", style)
97
+ text.append(resource.name)
98
+ text.append(f" ({resource.kind})", "dim")
99
+ text.append(f" {resource.covered}/{resource.total}", "dim")
100
+ if resource.touched and (detail := _describe_nodes(resource)):
101
+ text.append(f" {detail}", "dim yellow")
102
+ text.append("\n")
103
+
104
+
105
+ def _describe_nodes(resource: ResourceReport) -> str:
106
+ groups: dict[str, list[str]] = {"never": [], "blocked": [], "unreached": []}
107
+ for node in resource.counted:
108
+ if not node.evaluated:
109
+ groups["never"].append(node.path)
110
+ for run in resource.runs:
111
+ if run.blocked:
112
+ groups["blocked"].append(run.path)
113
+ elif not run.reached:
114
+ groups["unreached"].append(run.path)
115
+ return "; ".join(f"{label} {_some_paths(paths)}" for label, paths in groups.items() if paths)
116
+
117
+
118
+ def _some_paths(paths: list[str]) -> str:
119
+ shown = ", ".join(path or "(root)" for path in paths[:_GAP_PATHS_SHOWN])
120
+ extra = len(paths) - _GAP_PATHS_SHOWN
121
+ return f"{shown} +{extra} more" if extra > 0 else shown
122
+
123
+
124
+ def _render_uncalled(text: Text, reports: Sequence[FunctionReport]) -> None:
125
+ for report in reports:
126
+ if report.lines and not report.touched:
127
+ text.append(" ✗ ", "red")
128
+ text.append(report.name)
129
+ text.append(f" 0/{report.total}\n", "dim")
130
+
131
+
132
+ def _describe_gaps(report: FunctionReport) -> str:
133
+ if report.file is None:
134
+ return ""
135
+ unreached = [line.line for line in report.lines if not line.reached and line.line is not None]
136
+ guarded = [line.line for line in report.guarded if line.line is not None]
137
+ parts = []
138
+ if unreached:
139
+ parts.append(f"missing {_ranges(unreached)}")
140
+ if guarded:
141
+ parts.append(f"guarded {_ranges(guarded)}")
142
+ return "; ".join(parts)
143
+
144
+
145
+ def _ranges(lines: Sequence[int]) -> str:
146
+ """Consecutive line numbers folded into ranges: 5-6, 10."""
147
+
148
+ def fold(start: int, end: int) -> str:
149
+ return str(start) if start == end else f"{start}-{end}"
150
+
151
+ parts: list[str] = []
152
+ start = previous = lines[0]
153
+ for line in lines[1:]:
154
+ if line != previous + 1:
155
+ parts.append(fold(start, previous))
156
+ start = line
157
+ previous = line
158
+ parts.append(fold(start, previous))
159
+ return ", ".join(parts)
@@ -22,6 +22,8 @@ def run(
22
22
  datapacks: Sequence[Path],
23
23
  environments: Sequence[RunningEnvironment],
24
24
  selector: str = "*:*",
25
+ coverage: bool = False,
26
+ verbose: bool = False,
25
27
  resolve: FileResolver | None = None,
26
28
  ) -> TestSession:
27
29
  """Run tests, printing the final results view and GitHub annotations."""
@@ -30,8 +32,8 @@ def run(
30
32
  resolve = resolve or pack_resolver(datapacks)
31
33
 
32
34
  # Consume the stream without rendering; it always yields a starting frame
33
- *_, session = run_tests(datapacks, environments, selector=selector)
34
- console.print(render_session(session, resolve))
35
+ *_, session = run_tests(datapacks, environments, selector=selector, coverage=coverage)
36
+ console.print(render_session(session, resolve, verbose))
35
37
 
36
38
  for command in annotations(session, resolve):
37
39
  print(command, flush=True)
@@ -2,12 +2,14 @@
2
2
 
3
3
  from collections.abc import Sequence
4
4
  from pathlib import Path
5
+ from time import monotonic
5
6
 
6
7
  from rich.live import Live
7
8
  from rich.text import Text
8
9
 
9
10
  from mcward import (
10
11
  RunningEnvironment,
12
+ TestBatch,
11
13
  TestResult,
12
14
  TestSession,
13
15
  TestStatus,
@@ -38,6 +40,8 @@ def run(
38
40
  datapacks: Sequence[Path],
39
41
  environments: Sequence[RunningEnvironment],
40
42
  selector: str = "*:*",
43
+ coverage: bool = False,
44
+ verbose: bool = False,
41
45
  resolve: FileResolver | None = None,
42
46
  ) -> TestSession:
43
47
  """Run tests across running environments with a live Rich display."""
@@ -45,10 +49,16 @@ def run(
45
49
  console.print(render_header([p.name for p in datapacks], versions))
46
50
  resolve = resolve or pack_resolver(datapacks)
47
51
 
48
- session = TestSession(versions) # placeholder so the return is always bound
52
+ session = TestSession(versions) # rebound by every frame; bound even if none arrives
53
+ rendered = 0.0
49
54
  with Live(console=console, refresh_per_second=10) as live:
50
- for session in run_tests(datapacks, environments, selector=selector):
51
- live.update(render_session(session, resolve), refresh=False)
55
+ for session in run_tests(datapacks, environments, selector=selector, coverage=coverage):
56
+ # Events can outpace the display many times over; render at frame rate
57
+ if (now := monotonic()) - rendered < 0.1:
58
+ continue
59
+ rendered = now
60
+ live.update(render_session(session, resolve, verbose), refresh=False)
61
+ live.update(render_session(session, resolve, verbose), refresh=False)
52
62
  return session
53
63
 
54
64
 
@@ -58,10 +68,7 @@ def format_millis(millis: int) -> str:
58
68
 
59
69
 
60
70
  def describe_failure(outcome: VersionOutcome, file: str | None = None) -> str:
61
- """The failure message, with the position suffix when the test carries one.
62
-
63
- With a ``path:line`` relative to the project root when applicable.
64
- """
71
+ """The failure message, with its position (``file:line`` when the file is known) and tick."""
65
72
  if outcome.line is None:
66
73
  return outcome.error
67
74
  if file is None:
@@ -81,20 +88,34 @@ def render_header(datapacks: Sequence[str], versions: Sequence[Version]) -> Text
81
88
  return text
82
89
 
83
90
 
84
- def render_session(session: TestSession, resolve: FileResolver | None = None) -> Text:
91
+ def render_session(
92
+ session: TestSession,
93
+ resolve: FileResolver | None = None,
94
+ verbose: bool = False,
95
+ ) -> Text:
85
96
  """The full aggregated results view for the test phase."""
86
97
  text = Text()
98
+ batches = session.batches
99
+ summary = session.summary
87
100
  _render_diagnostics(text, session, resolve)
88
- for batch in session.batches:
89
- label = f"{batch.name} ({batch.dimension})" if batch.dimension else batch.name
90
- text.append(f"\n {label}\n", "dim")
91
- for result in batch.results:
92
- _render_result(text, result, resolve)
101
+ if not verbose and batches:
102
+ text.append("\n")
103
+ active = session.active_batches if not summary.done else set()
104
+ width = max(len(batch.name) for batch in batches)
105
+ counts = [_count(batch) for batch in batches]
106
+ count_width = max(map(len, counts))
107
+ for batch, count in zip(batches, counts, strict=True):
108
+ _render_batch(text, batch, active, width, f"{count:>{count_width}}", resolve)
109
+ else:
110
+ for batch in batches:
111
+ text.append(f"\n {_label(batch)}\n", "dim")
112
+ for result in batch.results:
113
+ _render_result(text, result, resolve)
93
114
 
94
115
  for version, message in session.aborted.items():
95
116
  text.append(f"✗ {version.name}: {message}\n", "red")
96
117
  text.append("\n")
97
- _render_summary(text, session.summary, session.versions)
118
+ _render_summary(text, summary, session.versions)
98
119
  return text
99
120
 
100
121
 
@@ -123,10 +144,57 @@ def _render_diagnostics(
123
144
  text.append(f" {diagnostic.message}\n", f"dim {style}")
124
145
 
125
146
 
147
+ def _render_batch(
148
+ text: Text,
149
+ batch: TestBatch,
150
+ active: set[tuple[str, str | None]],
151
+ width: int,
152
+ count: str,
153
+ resolve: FileResolver | None = None,
154
+ ) -> None:
155
+ """One line per batch: status symbol, name, dimension, aligned passed count."""
156
+ statuses = [result.status for result in batch.results]
157
+ if TestStatus.FAILED in statuses:
158
+ status: TestStatus | None = TestStatus.FAILED
159
+ elif None in statuses or (batch.name, batch.dimension) in active:
160
+ status = None
161
+ elif TestStatus.SKIPPED in statuses:
162
+ status = TestStatus.SKIPPED
163
+ else:
164
+ status = TestStatus.PASSED
165
+
166
+ symbol, style = _SYMBOL[status]
167
+ text.append(f"{_frame(symbol)} ", style)
168
+ text.append(f"{batch.name:<{width}}")
169
+ text.append(f" {count}")
170
+ if batch.dimension:
171
+ text.append(f" ({batch.dimension})", "dim")
172
+ text.append("\n")
173
+
174
+ for result in batch.results:
175
+ if result.status in (TestStatus.FAILED, TestStatus.SKIPPED):
176
+ _render_result(text, result, resolve)
177
+
178
+
179
+ def _label(batch: TestBatch) -> str:
180
+ return f"{batch.name} ({batch.dimension})" if batch.dimension else batch.name
181
+
182
+
183
+ def _count(batch: TestBatch) -> str:
184
+ """Passed over the announced batch size, so the denominator is stable mid-run."""
185
+ passed = sum(result.status is TestStatus.PASSED for result in batch.results)
186
+ return f"{passed}/{max(batch.total or 0, len(batch.results))}"
187
+
188
+
189
+ def _frame(symbol: str | tuple[str, ...]) -> str:
190
+ if isinstance(symbol, tuple):
191
+ return symbol[(int(console.get_time() * 16 % len(symbol)))]
192
+ return symbol
193
+
194
+
126
195
  def _render_result(text: Text, result: TestResult, resolve: FileResolver | None = None) -> None:
127
196
  symbol, symbol_style = _SYMBOL[result.status]
128
- if isinstance(symbol, tuple):
129
- symbol = symbol[(int(console.get_time() * 16 % len(symbol)))]
197
+ symbol = _frame(symbol)
130
198
 
131
199
  text.append(" ")
132
200
  text.append(f"{symbol} ", symbol_style)
@@ -176,7 +244,11 @@ def _render_failure(
176
244
 
177
245
  def _render_summary(text: Text, summary: TestSummary, versions: tuple[Version, ...]) -> None:
178
246
  if not summary.done:
179
- text.append(f"Tests: {summary.completed}/{summary.total} completed", "dim")
247
+ if summary.total:
248
+ text.append(f"Tests: {summary.completed}/{summary.total} completed", "dim")
249
+ else:
250
+ # Nothing has been announced yet: the server is still deploying and booting
251
+ text.append("Starting tests…", "dim")
180
252
  return
181
253
 
182
254
  if len(versions) > 1: