testsAndMisc/python_pkg/billsplit_coverage/checker.py
Krzysztof Rudnicki c600760e71 Move billsplit Flutter app into the monorepo
billsplit was a standalone Flutter app in ~/Downloads with one commit and no
git remote — that directory was the only copy of its history.

Three things the move needed beyond copying the tree:

- The root .gitignore silently swallowed 32 of its files (*.png, *.ico,
  *.manifest). Ignored files do not error on `git add`, they just vanish, so
  this would have committed an app that cannot build. Added a trailing block
  of ! overrides plus .binary-allowlist (which did not exist yet) — the
  documented in-repo exception for binaries a build genuinely needs. The
  ../testsAndMisc_binaries/ symlink scheme does not apply: those directories
  are gone, and a symlinked PNG breaks a fresh clone.

- billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads
  workflows at the repository root. Relocated with a working-directory and a
  billsplit/** path filter.

- The app's coverage gate moved to python_pkg/billsplit_coverage/ so every
  Python file stays under python_pkg/, and gained a test suite at 100% branch
  coverage as that location requires. billsplit/ now holds only Dart.

Also replaces the stock Flutter icon with a family icon from the shared
generator. The glyph is a filled silhouette because the family's 72px stroke
cannot carry a fine torn edge, and the split is an evenodd-cut perforation so
the holes stay transparent on the foreground and monochrome layers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:22:22 +02:00

131 lines
4.2 KiB
Python

"""Fail unless lcov reports 100% line coverage for every ``billsplit/lib`` file.
Two separate checks, because they catch different mistakes: a file present in
the report but with unhit lines, and a file missing from the report entirely.
The second is the one that catches a newly added Dart file nothing imports —
partial coverage is visible in the report, absence is not.
The default project root is resolved from this file's location rather than the
working directory, so the CI job can invoke the module from anywhere in the
repository.
"""
from __future__ import annotations
import argparse
import pathlib
import sys
from typing import Final
# python_pkg/billsplit_coverage/checker.py -> repo root -> billsplit/
_DEFAULT_PROJECT: Final[pathlib.Path] = (
pathlib.Path(__file__).resolve().parents[2] / "billsplit"
)
EXIT_INCOMPLETE: Final[int] = 1
EXIT_NO_REPORT: Final[int] = 2
def parse_lcov(lcov: pathlib.Path) -> dict[str, list[int]]:
"""Map each file in an lcov report to its uncovered line numbers.
Parameters:
lcov (pathlib.Path): Path to an ``lcov.info`` report.
Returns:
dict[str, list[int]]: Source path to the lines with a zero hit count. A
fully covered file maps to an empty list, which is what lets the caller
tell "covered completely" apart from "absent from the report".
"""
uncovered: dict[str, list[int]] = {}
current: str | None = None
for line in lcov.read_text(encoding="utf-8").splitlines():
if line.startswith("SF:"):
# lcov quotes the path verbatim; normalise Windows separators.
current = line[3:].replace("\\", "/")
uncovered.setdefault(current, [])
elif line.startswith("DA:") and current is not None:
number, hits = line[3:].split(",")
if int(hits) == 0:
uncovered[current].append(int(number))
return uncovered
def find_failures(
uncovered: dict[str, list[int]],
project: pathlib.Path,
) -> list[str]:
"""Describe every coverage gap, as lines ready to print.
Parameters:
uncovered (dict[str, list[int]]): Output of :func:`parse_lcov`.
project (pathlib.Path): Flutter project root holding ``lib/``.
Returns:
list[str]: One message per gap; empty when coverage is complete.
"""
failures = [
f"{path}: uncovered lines {misses}"
for path, misses in sorted(uncovered.items())
if misses
]
lib_files = {
str(path.relative_to(project)).replace("\\", "/")
for path in (project / "lib").rglob("*.dart")
}
failures.extend(
f"{path}: not executed by any test (0% coverage)"
for path in sorted(lib_files - set(uncovered))
)
return failures
def check(project: pathlib.Path) -> int:
"""Run the gate for one Flutter project and report to stdout/stderr.
Parameters:
project (pathlib.Path): Flutter project root, i.e. the directory holding
``lib/`` and ``coverage/lcov.info``.
Returns:
int: 0 when coverage is complete, :data:`EXIT_INCOMPLETE` when it is not,
:data:`EXIT_NO_REPORT` when the lcov report is missing.
"""
lcov = project / "coverage" / "lcov.info"
if not lcov.exists():
sys.stderr.write(f"{lcov} missing — run: flutter test --coverage\n")
return EXIT_NO_REPORT
uncovered = parse_lcov(lcov)
failures = find_failures(uncovered, project)
if failures:
sys.stderr.write("COVERAGE < 100%:\n")
for failure in failures:
sys.stderr.write(f" {failure}\n")
return EXIT_INCOMPLETE
sys.stdout.write(f"100% line coverage across {len(uncovered)} lib files.\n")
return 0
def main(argv: list[str] | None = None) -> int:
"""Parse arguments and run the gate.
Parameters:
argv (list[str] | None): Argument list, or None to read ``sys.argv``.
Returns:
int: Process exit status.
"""
parser = argparse.ArgumentParser(
description="Fail unless billsplit has 100% line coverage.",
)
parser.add_argument(
"--project",
type=pathlib.Path,
default=_DEFAULT_PROJECT,
help="Flutter project root (defaults to the billsplit/ app in this repo)",
)
args = parser.parse_args(argv)
return check(args.project)