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.
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/.gitignore +2 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/PKG-INFO +1 -1
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/__init__.py +1 -2
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/daemon.py +6 -2
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/test.py +36 -2
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/datapacks.py +3 -2
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/environments.py +24 -29
- mcward_cli-1.2.0/src/mcward/cli/reporters/coverage.py +159 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/reporters/github.py +4 -2
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/reporters/live.py +89 -17
- mcward_cli-1.2.0/src/mcward/cli/reports/__init__.py +87 -0
- mcward_cli-1.2.0/src/mcward/cli/reports/html.py +491 -0
- mcward_cli-1.2.0/src/mcward/cli/reports/junit.py +50 -0
- mcward_cli-1.2.0/src/mcward/cli/reports/lcov.py +56 -0
- mcward_cli-1.2.0/src/mcward/cli/ui.py +70 -0
- mcward_cli-1.2.0/tests/test_coverage_report.py +187 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_discovery.py +13 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_live.py +55 -5
- mcward_cli-1.2.0/tests/test_reports.py +284 -0
- mcward_cli-1.1.1/src/mcward/cli/ui.py +0 -56
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/README.md +0 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/pyproject.toml +0 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/__init__.py +0 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/install.py +0 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/commands/list.py +0 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/src/mcward/cli/reporters/__init__.py +0 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_cli.py +0 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_environments.py +0 -0
- {mcward_cli-1.1.1 → mcward_cli-1.2.0}/tests/test_github.py +0 -0
|
@@ -47,8 +47,7 @@ cli.add_command(test)
|
|
|
47
47
|
|
|
48
48
|
def main() -> None:
|
|
49
49
|
try:
|
|
50
|
-
#
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
if
|
|
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(
|
|
35
|
-
seen.add(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
if
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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) #
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
for
|
|
92
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|