mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
repo_contract: launcher gate (icon ink radius, compliant label, hidden debug variants) + hook_sync
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Flutter repos now fail the repo contract when a launcher icon's ink is not on the shared 340px circle (png_ink), the launcher label breaks the naming rule (capital, ASCII letters, <= 10 chars, <= 522 wide in GoogleSansFlex), or an applicationIdSuffix variant still carries the LAUNCHER category. hook_sync rewrites a repo's stale repo-contract `files:` line to the widened template. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017k9j5dbjenpfZj7dh2faPN
This commit is contained in:
parent
20b2cdbc04
commit
d1ca1e3d5b
@ -48,7 +48,7 @@ repos:
|
||||
entry: scripts/check_repo_contract.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$
|
||||
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$
|
||||
|
||||
- id: md-naming
|
||||
name: markdown naming convention
|
||||
|
||||
@ -93,6 +93,44 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
|
||||
degrades to a pass, and the gate defers to that workflow when run inside
|
||||
another GitHub Actions job.
|
||||
|
||||
## Flutter apps: the launcher rules
|
||||
|
||||
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
||||
`pubspec.yaml` whose `dependencies:` pull in the Flutter SDK and that has an
|
||||
`android/` dir beside it, at the root or nested (`app/`, screen-locker's
|
||||
`stronglift_replacement/workout_app`). Violations are prefixed `launcher:`
|
||||
(or `launcher [<app dir>]:` for a nested app).
|
||||
|
||||
1. **Launcher name** (`launcher_label.py`): `<application android:label>`
|
||||
of the main manifest matches `^[A-Z][A-Za-z]{0,9}$`, and so does the
|
||||
`android:label` of any activity or alias with a MAIN + LAUNCHER filter
|
||||
(it overrides the application label on the launcher). A literal,
|
||||
`@string/<name>` and `${placeholder}` are all resolved. A placeholder is
|
||||
checked at every `manifestPlaceholders` assignment in
|
||||
`android/app/build.gradle[.kts]`, except inside a variant that sets
|
||||
`applicationIdSuffix`: rule 3 hides that variant from the launcher.
|
||||
Anything unresolvable fails. The rules and messages mirror testsAndMisc
|
||||
`python_pkg/app_icons/labels.py`.
|
||||
2. **Desktop name**: every tracked `*.desktop` file in the repo has a
|
||||
`[Desktop Entry] Name=` that passes rule 1.
|
||||
3. **Hidden variants** (`android_manifest.py`): a buildType or flavor whose
|
||||
own gradle block sets `applicationIdSuffix` needs
|
||||
`android/app/src/<variant>/AndroidManifest.xml` removing the LAUNCHER
|
||||
category (`tools:node="remove"` on the `<category>`) from each launcher
|
||||
activity. MAIN must stay, so `am start -n` still works.
|
||||
4. **Icon size** (`png_ink.py`, stdlib only): `assets/icon/icon_foreground.png`,
|
||||
when present, is 8-bit RGBA 1024x1024. Its furthest inked pixel
|
||||
(alpha > 127) sits 340px ±1% from the ink-box centre, and that centre is
|
||||
within 2px of (512, 512). This is the shared generator's `fit.py`
|
||||
measurement. An app that cannot ship the PNG declares
|
||||
`[launcher_icon] skip = "<reason>"` in a `repo-contract.toml` beside its
|
||||
pubspec, and the reason is required.
|
||||
|
||||
The repo-contract hook's `files:` (template `pre-commit-snippet.yaml`) also
|
||||
matches every file these rules read. `install.py` never rewrites an existing
|
||||
hook, so roll a widened regex out with
|
||||
`python3 -m repo_contract.hook_sync [--apply] [REPO ...]` (dry run by default).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
||||
169
repo_contract/android_manifest.py
Normal file
169
repo_contract/android_manifest.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""AndroidManifest.xml reading, and rule 3: suffixed variants stay off the launcher.
|
||||
|
||||
A variant whose gradle block sets `applicationIdSuffix` installs beside the
|
||||
real app (sandbox, debug), so it must not put a second icon in the launcher.
|
||||
The statically checkable form is a variant manifest that removes the
|
||||
LAUNCHER category and keeps MAIN, so `adb shell am start -n` still opens it:
|
||||
|
||||
<activity android:name=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"
|
||||
tools:node="remove"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract import gradle_blocks
|
||||
from repo_contract.sources import read_text
|
||||
|
||||
ANDROID = "{http://schemas.android.com/apk/res/android}"
|
||||
TOOLS = "{http://schemas.android.com/tools}"
|
||||
TOOLS_DECL = 'xmlns:tools="http://schemas.android.com/tools"'
|
||||
LAUNCHER = "android.intent.category.LAUNCHER"
|
||||
MAIN_ACTION = "android.intent.action.MAIN"
|
||||
APP = Path("android/app")
|
||||
MAIN = APP / "src/main/AndroidManifest.xml"
|
||||
GRADLE = (APP / "build.gradle.kts", APP / "build.gradle")
|
||||
ACTIVITIES = ("activity", "activity-alias")
|
||||
REMOVED = ("remove", "removeAll")
|
||||
|
||||
|
||||
class ManifestError(ValueError):
|
||||
"""A manifest is missing or not valid XML (message is the full reason)."""
|
||||
|
||||
|
||||
def parse(app: Path, rel: Path) -> ET.Element:
|
||||
"""Root element of `app/rel`; ManifestError when missing or malformed."""
|
||||
text = read_text(app / rel)
|
||||
if not text:
|
||||
raise ManifestError(f"{rel} is missing or unreadable")
|
||||
try:
|
||||
return ET.fromstring(text)
|
||||
except ET.ParseError as exc:
|
||||
raise ManifestError(f"{rel} is not valid XML: {exc}") from exc
|
||||
|
||||
|
||||
def gradle_script(app: Path) -> tuple[Path | None, str]:
|
||||
"""(relative path, text) of the app module's build script, if any."""
|
||||
for rel in GRADLE:
|
||||
if (app / rel).is_file():
|
||||
return rel, read_text(app / rel)
|
||||
return None, ""
|
||||
|
||||
|
||||
def _short(name: str) -> str:
|
||||
"""`.MainActivity` / `com.x.MainActivity` -> `MainActivity`."""
|
||||
return name.rsplit(".", 1)[-1]
|
||||
|
||||
|
||||
def _categories(activity: ET.Element) -> list[tuple[ET.Element, ET.Element]]:
|
||||
"""(intent-filter, category) pairs declaring LAUNCHER in `activity`."""
|
||||
return [
|
||||
(flt, cat)
|
||||
for flt in activity.iter("intent-filter")
|
||||
for cat in flt.iter("category")
|
||||
if cat.get(f"{ANDROID}name") == LAUNCHER
|
||||
]
|
||||
|
||||
|
||||
def launcher_activities(root: ET.Element) -> list[str]:
|
||||
"""Names of the activities (and aliases) that show in the launcher."""
|
||||
return [
|
||||
act.get(f"{ANDROID}name", "")
|
||||
for kind in ACTIVITIES
|
||||
for act in root.iter(kind)
|
||||
if _categories(act)
|
||||
]
|
||||
|
||||
|
||||
def main_launchers(root: ET.Element) -> list[ET.Element]:
|
||||
"""Activities (and aliases) with an intent-filter holding MAIN + LAUNCHER."""
|
||||
return [
|
||||
act
|
||||
for kind in ACTIVITIES
|
||||
for act in root.iter(kind)
|
||||
if any(
|
||||
action.get(f"{ANDROID}name") == MAIN_ACTION
|
||||
for flt, _ in _categories(act)
|
||||
for action in flt.iter("action")
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _removes_main(activity: ET.Element) -> bool:
|
||||
"""Whether the activity, a MAIN intent-filter or MAIN itself is removed."""
|
||||
if activity.get(f"{TOOLS}node") in REMOVED:
|
||||
return True
|
||||
return any(
|
||||
node.get(f"{TOOLS}node") in REMOVED
|
||||
for flt in activity.iter("intent-filter")
|
||||
for action in flt.iter("action")
|
||||
if action.get(f"{ANDROID}name") == MAIN_ACTION
|
||||
for node in (flt, action)
|
||||
)
|
||||
|
||||
|
||||
def _hides(root: ET.Element, name: str, rel: Path, variant: str) -> list[str]:
|
||||
"""Problems hiding launcher activity `name` in variant manifest `root`."""
|
||||
matches = [
|
||||
act
|
||||
for kind in ACTIVITIES
|
||||
for act in root.iter(kind)
|
||||
if _short(act.get(f"{ANDROID}name", "")) == _short(name)
|
||||
]
|
||||
if any(_removes_main(act) for act in matches):
|
||||
return [
|
||||
(
|
||||
f"variant '{variant}': {rel} removes {MAIN_ACTION} from {name};"
|
||||
" keep MAIN so `am start -n` still works"
|
||||
)
|
||||
]
|
||||
hidden = any(
|
||||
cat.get(f"{TOOLS}node") == "remove"
|
||||
for act in matches
|
||||
for _, cat in _categories(act)
|
||||
)
|
||||
if hidden:
|
||||
return []
|
||||
return [
|
||||
(
|
||||
f"variant '{variant}': {rel} does not remove the LAUNCHER category from"
|
||||
f' {name} (tools:node="remove" on the <category>)'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def variant_problems(app: Path) -> list[str]:
|
||||
"""Rule 3 for one Flutter app directory."""
|
||||
_, gradle = gradle_script(app)
|
||||
variants = gradle_blocks.suffixed_variants(gradle)
|
||||
try:
|
||||
names = launcher_activities(parse(app, MAIN))
|
||||
except ManifestError:
|
||||
return [] # rule 1 already reports the main manifest
|
||||
if not names:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for variant in variants:
|
||||
rel = APP / "src" / variant / "AndroidManifest.xml"
|
||||
if not (app / rel).is_file():
|
||||
out.append(
|
||||
f"variant '{variant}' sets applicationIdSuffix but {rel} is missing:"
|
||||
f' hide it with <category android:name="{LAUNCHER}"'
|
||||
f' tools:node="remove"/> in {names[0]}\'s intent-filter'
|
||||
)
|
||||
continue
|
||||
try:
|
||||
root = parse(app, rel)
|
||||
except ManifestError as exc:
|
||||
out.append(str(exc))
|
||||
continue
|
||||
for name in names:
|
||||
out += _hides(root, name, rel, variant)
|
||||
return out
|
||||
@ -24,6 +24,7 @@ from repo_contract.contract import (
|
||||
script_token,
|
||||
verbose_in_script,
|
||||
)
|
||||
from repo_contract.launcher import problems as launcher_problems
|
||||
from repo_contract.stacks import detect_stacks, has_tests
|
||||
from repo_contract.web import problems as web_problems
|
||||
|
||||
@ -102,11 +103,12 @@ def _check_consistency(
|
||||
def violations(repo: Path) -> tuple[list[str], list[str]]:
|
||||
"""(violations, detected stacks) for `repo`. Raises Unreadable.
|
||||
|
||||
Website repos also owe the AgentReady gate (web.problems), reported
|
||||
whatever state the Commands section is in.
|
||||
Website repos also owe the AgentReady gate (web.problems), and Flutter
|
||||
apps the launcher rules (launcher.problems), reported whatever state the
|
||||
Commands section is in.
|
||||
"""
|
||||
found, stacks = _doc_violations(repo)
|
||||
return found + web_problems(repo), stacks
|
||||
return found + web_problems(repo) + launcher_problems(repo), stacks
|
||||
|
||||
|
||||
def _doc_violations(repo: Path) -> tuple[list[str], list[str]]:
|
||||
|
||||
184
repo_contract/gradle_blocks.py
Normal file
184
repo_contract/gradle_blocks.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""Just enough gradle (Kotlin DSL or Groovy) parsing for the launcher rules.
|
||||
|
||||
Not a gradle evaluator: comments are dropped, braces are matched outside
|
||||
string literals, and each `{ ... }` becomes a Block whose path names its
|
||||
enclosing headers (`create("sandbox")` -> `sandbox`, `release {` -> `release`).
|
||||
A variant block is one directly under `buildTypes` or `productFlavors`.
|
||||
Anything this cannot read as a string literal is reported as None so the
|
||||
caller fails closed instead of guessing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
VARIANT_PARENTS = ("buildTypes", "productFlavors")
|
||||
_NAMED = re.compile(
|
||||
r"""(?:create|getByName|register|maybeCreate|named)\s*(?:<[^>]*>)?\s*"""
|
||||
r"""\(\s*["']([^"']+)["']\s*\)\s*$"""
|
||||
)
|
||||
_IDENT = re.compile(r"([A-Za-z_]\w*)\s*$")
|
||||
_SUFFIX = re.compile(r"\bapplicationIdSuffix\b")
|
||||
_MAP = re.compile(
|
||||
r"manifestPlaceholders\s*(?:\+=|=|\.\s*putAll\s*\()\s*"
|
||||
r"(?:mutableMapOf|mapOf|hashMapOf)?\s*[\[(]([^\])]*)[\])]"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Block:
|
||||
"""One `{ ... }`: its header path, start offset, and its own text."""
|
||||
|
||||
path: tuple[str, ...]
|
||||
start: int
|
||||
body: str
|
||||
|
||||
|
||||
def _string_end(text: str, i: int) -> int:
|
||||
"""Index just past the string literal opening at `i` (EOF if unclosed)."""
|
||||
quote = '"""' if text.startswith('"""', i) else text[i]
|
||||
j = i + len(quote)
|
||||
while j < len(text):
|
||||
if text[j] == "\\":
|
||||
j += 2
|
||||
elif text.startswith(quote, j):
|
||||
return j + len(quote)
|
||||
else:
|
||||
j += 1
|
||||
return len(text)
|
||||
|
||||
|
||||
def strip_comments(text: str) -> str:
|
||||
"""`text` without // and /* */ comments; string literals untouched."""
|
||||
out, i = [], 0
|
||||
while i < len(text):
|
||||
if text[i] in "\"'":
|
||||
j = _string_end(text, i)
|
||||
out.append(text[i:j])
|
||||
i = j
|
||||
elif text.startswith("//", i):
|
||||
j = text.find("\n", i)
|
||||
i = len(text) if j < 0 else j
|
||||
elif text.startswith("/*", i):
|
||||
j = text.find("*/", i + 2)
|
||||
i = len(text) if j < 0 else j + 2
|
||||
else:
|
||||
out.append(text[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _header(own: list[str]) -> str:
|
||||
"""Block name from the text just before its `{`."""
|
||||
tail = re.split(r"[\n;]", "".join(own))[-1]
|
||||
named = _NAMED.search(tail) or _IDENT.search(tail)
|
||||
return named.group(1) if named else "?"
|
||||
|
||||
|
||||
def blocks(text: str) -> list[Block]:
|
||||
"""Every block of a gradle script (closing order) plus the top level last."""
|
||||
clean = strip_comments(text)
|
||||
stack: list[tuple[tuple[str, ...], int, list[str]]] = [((), 0, [])]
|
||||
out: list[Block] = []
|
||||
i = 0
|
||||
while i < len(clean):
|
||||
ch = clean[i]
|
||||
if ch in "\"'":
|
||||
j = _string_end(clean, i)
|
||||
stack[-1][2].append(clean[i:j])
|
||||
i = j
|
||||
continue
|
||||
if ch == "{":
|
||||
stack.append((stack[-1][0] + (_header(stack[-1][2]),), i, []))
|
||||
elif ch == "}" and len(stack) > 1:
|
||||
path, start, own = stack.pop()
|
||||
out.append(Block(path, start, "".join(own)))
|
||||
stack[-1][2].append("\n")
|
||||
elif ch != "}":
|
||||
stack[-1][2].append(ch)
|
||||
i += 1
|
||||
while stack:
|
||||
path, start, own = stack.pop()
|
||||
out.append(Block(path, start, "".join(own)))
|
||||
return out
|
||||
|
||||
|
||||
def variant(block: Block) -> str | None:
|
||||
"""The variant a block configures (`sandbox`, `debug`), else None."""
|
||||
if len(block.path) >= 2 and block.path[-2] in VARIANT_PARENTS:
|
||||
return block.path[-1]
|
||||
return None
|
||||
|
||||
|
||||
def where(block: Block) -> str:
|
||||
"""Human label for a block: `productFlavors.sandbox`, `defaultConfig`."""
|
||||
if variant(block):
|
||||
return ".".join(block.path[-2:])
|
||||
if block.path and block.path[-1] == "defaultConfig":
|
||||
return "defaultConfig"
|
||||
return ".".join(block.path) or "top level"
|
||||
|
||||
|
||||
def _suffixed(block: Block) -> bool:
|
||||
"""Whether `block` is a variant that sets applicationIdSuffix."""
|
||||
return variant(block) is not None and bool(_SUFFIX.search(block.body))
|
||||
|
||||
|
||||
def suffixed_variants(text: str) -> list[str]:
|
||||
"""Variants whose own block sets applicationIdSuffix."""
|
||||
names = {variant(b) or "" for b in blocks(text) if _suffixed(b)}
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def _literal(text: str) -> str | None:
|
||||
"""The string literal `text` starts with; None for anything else."""
|
||||
if not text or text[0] not in "\"'":
|
||||
return None
|
||||
end = text.find(text[0], 1)
|
||||
if end < 0 or "$" in text[1:end] or "\\" in text[1:end]:
|
||||
return None
|
||||
return text[1:end]
|
||||
|
||||
|
||||
def _values(body: str, key: str) -> list[tuple[int, str | None]]:
|
||||
"""(offset, literal or None) of every assignment to placeholder `key`."""
|
||||
k = re.escape(key)
|
||||
q = r"""(['"])""" + k + r"\1"
|
||||
direct = (
|
||||
rf"manifestPlaceholders\s*\[\s*{q}\s*\]\s*=\s*",
|
||||
rf"manifestPlaceholders\s*\.\s*put\s*\(\s*{q}\s*,\s*",
|
||||
rf"manifestPlaceholders\s*\.\s*{k}\s*=\s*",
|
||||
)
|
||||
found = [
|
||||
(m.start(), _literal(body[m.end() :]))
|
||||
for pattern in direct
|
||||
for m in re.finditer(pattern, body)
|
||||
]
|
||||
pair = re.compile(r"""(?<!\w)(['"]?)""" + k + r"\1\s*(?:to\b|:)\s*")
|
||||
for m in _MAP.finditer(body):
|
||||
found += [
|
||||
(m.start(), _literal(m.group(1)[p.end() :]))
|
||||
for p in pair.finditer(m.group(1))
|
||||
]
|
||||
return found
|
||||
|
||||
|
||||
def _first(item: tuple[int, str | None]) -> int:
|
||||
return item[0]
|
||||
|
||||
|
||||
def placeholder_values(
|
||||
text: str, key: str, *, unsuffixed_only: bool = False
|
||||
) -> list[tuple[str, str | None]]:
|
||||
"""(block label, literal or None) per assignment, by block start.
|
||||
|
||||
`unsuffixed_only` drops assignments inside a suffixed variant's block.
|
||||
"""
|
||||
out = []
|
||||
for block in sorted(blocks(text), key=lambda b: b.start):
|
||||
if unsuffixed_only and _suffixed(block):
|
||||
continue
|
||||
values = sorted(_values(block.body, key), key=_first)
|
||||
out += [(where(block), v) for _, v in values]
|
||||
return out
|
||||
74
repo_contract/hook_sync.py
Normal file
74
repo_contract/hook_sync.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Bring every repo's repo-contract hook `files:` line up to the template.
|
||||
|
||||
Usage: python3 -m repo_contract.hook_sync [--apply] [REPO ...]
|
||||
(default REPOs: the sweep roots, git repos directly under ~/src and ~).
|
||||
|
||||
Why this exists: `install.py` only checks that the hook id is present
|
||||
(`precommit.needs_hook`), so widening the template's `files:` regex (as for
|
||||
the Flutter launcher rules, 2026-10-23) never reaches a repo installed
|
||||
earlier. This rewrites that one line with `precommit.set_hook_line` and leaves
|
||||
the rest of the config, comments included, untouched.
|
||||
|
||||
Dry run by default: exit 1 when any repo is stale, 0 when all are current.
|
||||
With --apply, stale repos are rewritten (not committed) and it exits 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""): # pragma: no cover - plain-script import path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from repo_contract.bootstrap import TEMPLATES
|
||||
from repo_contract.precommit import hook_line, needs_hook, set_hook_line
|
||||
from repo_contract.sweep import find_repos
|
||||
|
||||
HOOK_ID = "repo-contract"
|
||||
SNIPPET = TEMPLATES / "pre-commit-snippet.yaml"
|
||||
KEY = "files"
|
||||
|
||||
|
||||
def template_line() -> str:
|
||||
"""The template's `files:` line, exactly as a hook block carries it."""
|
||||
lines = SNIPPET.read_text(encoding="utf-8").splitlines()
|
||||
return next(ln for ln in lines if ln.startswith(f" {KEY}:"))
|
||||
|
||||
|
||||
def status(repo: Path) -> str:
|
||||
"""`no hook`, `current` or `stale` for one repo."""
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
return "no hook"
|
||||
current = hook_line(repo, HOOK_ID, KEY) == template_line()
|
||||
return "current" if current else "stale"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, roots: list[Path] | None = None) -> int:
|
||||
"""CLI entry point; see the module docstring for exit codes."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("repos", nargs="*", type=Path)
|
||||
parser.add_argument("--apply", action="store_true", help="rewrite stale hooks")
|
||||
args = parser.parse_args(argv)
|
||||
home = Path.home()
|
||||
repos = args.repos or find_repos(roots or [home / "src", home])
|
||||
counts: Counter[str] = Counter()
|
||||
for repo in repos:
|
||||
state = status(repo)
|
||||
if state == "stale" and args.apply:
|
||||
set_hook_line(repo, HOOK_ID, KEY, template_line())
|
||||
state = "updated"
|
||||
counts[state] += 1
|
||||
label = "would update" if state == "stale" else state
|
||||
print(f"{label}: {repo}")
|
||||
print(
|
||||
f"hook-sync: {counts['stale']} stale, {counts['updated']} updated,"
|
||||
f" {counts['current']} current, {counts['no hook']} without the hook"
|
||||
)
|
||||
return 1 if counts["stale"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
33
repo_contract/label_widths.py
Normal file
33
repo_contract/label_widths.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Advance widths of the ASCII letters in the Pixel launcher's label font.
|
||||
|
||||
Measured from ``GoogleSansFlex-Regular.ttf`` pulled off kuhy's Pixel 6a
|
||||
(``/product/fonts``) at a 100px em, rounded. Only the numbers are kept, not
|
||||
the font, which is not redistributable. Kerning is ignored; on every label
|
||||
measured it moved the total by under 1%.
|
||||
|
||||
Copied verbatim from testsAndMisc `python_pkg/app_icons/_label_widths.py`
|
||||
(utils must not import another repo); keep the two in step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
ADVANCE: Final[dict[str, int]] = {
|
||||
"a": 54, "b": 60, "c": 54, "d": 60, "e": 56, "f": 38, "g": 60, "h": 56,
|
||||
"i": 23, "j": 23, "k": 50, "l": 21, "m": 88, "n": 56, "o": 60, "p": 60,
|
||||
"q": 60, "r": 38, "s": 47, "t": 36, "u": 56, "v": 51, "w": 78, "x": 49,
|
||||
"y": 51, "z": 48, "A": 67, "B": 61, "C": 74, "D": 70, "E": 55, "F": 53,
|
||||
"G": 79, "H": 70, "I": 24, "J": 53, "K": 63, "L": 51, "M": 87, "N": 71,
|
||||
"O": 82, "P": 58, "Q": 82, "R": 59, "S": 55, "T": 54, "U": 65, "V": 63,
|
||||
"W": 94, "X": 64, "Y": 60, "Z": 57,
|
||||
} # fmt: skip
|
||||
|
||||
# The widest label seen whole in the app drawer is WakeAlarm (522), and
|
||||
# FocusOwner (580) lost its last letters there (kuhy, 2026-10-23).
|
||||
MAX_WIDTH: Final[int] = 522
|
||||
|
||||
|
||||
def label_width(name: str) -> int:
|
||||
"""Return a label's width in font units; only call on ASCII letters."""
|
||||
return sum(ADVANCE[char] for char in name)
|
||||
157
repo_contract/launcher.py
Normal file
157
repo_contract/launcher.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""Launcher rules for every Flutter app in a repo (kuhy, 2026-10-23).
|
||||
|
||||
Applies to each tracked `pubspec.yaml` whose `dependencies:` include the
|
||||
Flutter SDK and that has an `android/` directory beside it, at the repo root
|
||||
or nested (screen-locker's workout_app):
|
||||
|
||||
1. the launcher name (`launcher_label.py`);
|
||||
2. every tracked `*.desktop` file's `[Desktop Entry] Name=` meets the same rule;
|
||||
3. a suffixed variant stays off the launcher (`android_manifest.py`);
|
||||
4. `assets/icon/icon_foreground.png` fills the family ink circle
|
||||
(`png_ink.py`), unless the app's `repo-contract.toml` declares
|
||||
|
||||
[launcher_icon]
|
||||
skip = "<why this app cannot ship the PNG foreground>"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import tomllib
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from repo_contract import android_manifest, launcher_label, png_ink
|
||||
from repo_contract.stacks import PRUNE
|
||||
|
||||
CONFIG = Path("repo-contract.toml")
|
||||
TABLE = "launcher_icon"
|
||||
ICON = Path("assets/icon/icon_foreground.png")
|
||||
_SDK = re.compile(r"^\s+sdk:\s*flutter\s*$")
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""`[launcher_icon]` exists but is malformed."""
|
||||
|
||||
|
||||
def _tracked(repo: Path, *patterns: str) -> list[PurePosixPath] | None:
|
||||
"""Tracked paths matching `patterns`; None when git cannot list them."""
|
||||
done = subprocess.run(
|
||||
["git", "-C", str(repo), "ls-files", "-z", "--", *patterns],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if done.returncode != 0:
|
||||
return None
|
||||
return [PurePosixPath(p) for p in done.stdout.split("\0") if p]
|
||||
|
||||
|
||||
def _needs_flutter(pubspec: Path) -> bool:
|
||||
"""Whether the top-level `dependencies:` block pulls in the Flutter SDK."""
|
||||
try:
|
||||
lines = pubspec.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return False
|
||||
inside = False
|
||||
for line in lines:
|
||||
code = line.split("#", 1)[0].rstrip() # comments never end a block
|
||||
if not code.strip():
|
||||
continue
|
||||
if not code[0].isspace():
|
||||
inside = code == "dependencies:"
|
||||
elif inside and _SDK.match(code):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def flutter_apps(repo: Path) -> list[Path]:
|
||||
"""Android-enabled Flutter app dirs in `repo` (root-only outside git)."""
|
||||
found = _tracked(repo, "pubspec.yaml", "*/pubspec.yaml")
|
||||
rels = [PurePosixPath("pubspec.yaml")] if found is None else found
|
||||
apps = []
|
||||
for rel in sorted(rels):
|
||||
if PRUNE.intersection(rel.parts):
|
||||
continue
|
||||
app = repo / rel.parent
|
||||
if (app / "android").is_dir() and _needs_flutter(app / "pubspec.yaml"):
|
||||
apps.append(app)
|
||||
return apps
|
||||
|
||||
|
||||
def _desktop_name(text: str) -> list[str]:
|
||||
"""`Name=` values of the `[Desktop Entry]` group (not actions/locales)."""
|
||||
names, inside = [], False
|
||||
for line in text.splitlines():
|
||||
if line.startswith("["):
|
||||
inside = line.strip() == "[Desktop Entry]"
|
||||
elif inside and line.startswith("Name="):
|
||||
names.append(line[len("Name=") :].strip())
|
||||
return names
|
||||
|
||||
|
||||
def desktop_problems(repo: Path) -> list[str]:
|
||||
"""Rule 2 over every tracked .desktop file in the repo."""
|
||||
out = []
|
||||
for rel in _tracked(repo, "*.desktop") or []:
|
||||
try:
|
||||
text = (repo / rel).read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
out.append(f"{rel}: unreadable")
|
||||
continue
|
||||
names = _desktop_name(text)
|
||||
if not names:
|
||||
out.append(f"{rel}: no Name= in [Desktop Entry]")
|
||||
for name in names:
|
||||
out += [f"{rel}: Name={p}" for p in launcher_label.label_problems(name)]
|
||||
return out
|
||||
|
||||
|
||||
def icon_skip(app: Path) -> str | None:
|
||||
"""The `[launcher_icon] skip` reason; None when the icon rule applies."""
|
||||
path = app / CONFIG
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise ConfigError(f"not valid TOML: {exc}") from exc
|
||||
if TABLE not in data:
|
||||
return None
|
||||
table = data[TABLE]
|
||||
if not isinstance(table, dict):
|
||||
raise ConfigError(f"'{TABLE}' must be a table: [{TABLE}]")
|
||||
unknown = sorted(set(table) - {"skip"})
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown key(s) in [{TABLE}]: {', '.join(unknown)}")
|
||||
reason = table.get("skip")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
raise ConfigError(f'[{TABLE}] skip = "<reason>" needs a non-empty reason')
|
||||
return reason
|
||||
|
||||
|
||||
def _icon(app: Path) -> list[str]:
|
||||
try:
|
||||
if icon_skip(app) is not None:
|
||||
return []
|
||||
except ConfigError as exc:
|
||||
return [f"{CONFIG}: {exc}"]
|
||||
if not (app / ICON).is_file():
|
||||
return []
|
||||
return png_ink.icon_problems(app / ICON, str(ICON))
|
||||
|
||||
|
||||
def problems(repo: Path) -> list[str]:
|
||||
"""Every launcher-rule violation in `repo`, prefixed `launcher[ [app]]: `."""
|
||||
apps = flutter_apps(repo)
|
||||
if not apps:
|
||||
return []
|
||||
out = []
|
||||
for app in apps:
|
||||
rel = app.relative_to(repo).as_posix()
|
||||
prefix = "launcher: " if rel == "." else f"launcher [{rel}]: "
|
||||
found = launcher_label.problems(app)
|
||||
found += android_manifest.variant_problems(app)
|
||||
found += _icon(app)
|
||||
out += [prefix + p for p in found]
|
||||
return out + [f"launcher: {p}" for p in desktop_problems(repo)]
|
||||
144
repo_contract/launcher_label.py
Normal file
144
repo_contract/launcher_label.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""Rule 1: the launcher name every Flutter app shows on the phone.
|
||||
|
||||
kuhy's rules (2026-10-23): a name starts with a capital, holds only ASCII
|
||||
letters (no spaces, underscores or punctuation) and has at most 10 characters,
|
||||
so the Pixel launcher never truncates it with an ellipsis. Its width in
|
||||
the launcher font is capped too (`label_widths.py`): FocusOwner, 10
|
||||
characters, was still cut. The same rules live
|
||||
in `~/src/testsAndMisc/python_pkg/app_icons/labels.py` (`label_problems`);
|
||||
the messages are mirrored here, not imported, because utils must not depend
|
||||
on another repo.
|
||||
|
||||
The name is `<application android:label>` of the main manifest, plus the
|
||||
`android:label` of any activity (or alias) whose intent-filter holds MAIN +
|
||||
LAUNCHER, because that one overrides the application label on the launcher.
|
||||
Each is resolved from a literal, `@string/<name>` (main res/values/strings.xml)
|
||||
or `${placeholder}`: every `manifestPlaceholders` assignment in
|
||||
android/app/build.gradle[.kts] outside a variant that sets applicationIdSuffix
|
||||
(rule 3 hides those from the launcher, so they may say "SANDBOX").
|
||||
Anything that cannot be resolved statically fails: never a silent pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract import gradle_blocks
|
||||
from repo_contract.android_manifest import (
|
||||
ANDROID,
|
||||
MAIN,
|
||||
ManifestError,
|
||||
gradle_script,
|
||||
main_launchers,
|
||||
parse,
|
||||
)
|
||||
from repo_contract.label_widths import MAX_WIDTH, label_width
|
||||
|
||||
MAX_LENGTH = 10
|
||||
STRINGS = Path("android/app/src/main/res/values/strings.xml")
|
||||
_ASCII_LETTERS = re.compile(r"[A-Za-z]+")
|
||||
_STRING_REF = re.compile(r"@string/(\w+)")
|
||||
_PLACEHOLDER = re.compile(r"\$\{(\w+)\}")
|
||||
_UNRESOLVED = "cannot resolve the launcher name"
|
||||
|
||||
|
||||
def label_problems(name: str) -> list[str]:
|
||||
"""Every launcher-name rule `name` breaks (app_icons labels.py wording)."""
|
||||
if not name:
|
||||
return ["label is empty"]
|
||||
problems: list[str] = []
|
||||
letters_only = _ASCII_LETTERS.fullmatch(name) is not None
|
||||
if not letters_only:
|
||||
problems.append(
|
||||
f"{name!r} must contain only ASCII letters (no spaces, _ or symbols)"
|
||||
)
|
||||
if not name[0].isupper():
|
||||
problems.append(f"{name!r} must start with a capital letter")
|
||||
# The width table only knows ASCII letters; other names already failed.
|
||||
if letters_only and label_width(name) > MAX_WIDTH:
|
||||
problems.append(
|
||||
f"{name!r} is {label_width(name)} wide in the launcher font; "
|
||||
f"at most {MAX_WIDTH} fit"
|
||||
)
|
||||
if len(name) > MAX_LENGTH:
|
||||
problems.append(
|
||||
f"{name!r} has {len(name)} characters; at most {MAX_LENGTH} fit"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def _string(app: Path, attr: str, name: str) -> list[str]:
|
||||
try:
|
||||
root = parse(app, STRINGS)
|
||||
except ManifestError:
|
||||
return [f"{attr}: {STRINGS} is missing or unreadable; {_UNRESOLVED}"]
|
||||
for node in root.iter("string"):
|
||||
if node.get("name") == name:
|
||||
value = "".join(node.itertext())
|
||||
return [f"{attr} = {p}" for p in label_problems(value)]
|
||||
return [f'{attr}: no <string name="{name}"> in {STRINGS}; {_UNRESOLVED}']
|
||||
|
||||
|
||||
def _placeholder(app: Path, attr: str, key: str) -> list[str]:
|
||||
rel, gradle = gradle_script(app)
|
||||
if rel is None:
|
||||
return [f"{attr}: no android/app/build.gradle(.kts); {_UNRESOLVED}"]
|
||||
values = gradle_blocks.placeholder_values(gradle, key, unsuffixed_only=True)
|
||||
if not values:
|
||||
scope = (
|
||||
" for an unsuffixed variant"
|
||||
if gradle_blocks.placeholder_values(gradle, key)
|
||||
else ""
|
||||
)
|
||||
return [
|
||||
(
|
||||
f'{attr}: no manifestPlaceholders["{key}"] assignment{scope}'
|
||||
f" in {rel}; {_UNRESOLVED}"
|
||||
)
|
||||
]
|
||||
out = []
|
||||
for where, value in values:
|
||||
if value is None:
|
||||
out.append(
|
||||
f"{attr}: the value set in {where} is not a string literal;"
|
||||
f" {_UNRESOLVED}"
|
||||
)
|
||||
else:
|
||||
out += [f"{attr} in {where} = {p}" for p in label_problems(value)]
|
||||
return out
|
||||
|
||||
|
||||
def _resolve(app: Path, attr: str, label: str) -> list[str]:
|
||||
"""Problems with one android:label value; `attr` prefixes each message."""
|
||||
if string := _STRING_REF.fullmatch(label):
|
||||
return _string(app, f"{attr} {label}", string.group(1))
|
||||
if placeholder := _PLACEHOLDER.fullmatch(label):
|
||||
return _placeholder(app, f"{attr} {label}", placeholder.group(1))
|
||||
if label.startswith("@") or "${" in label:
|
||||
return [
|
||||
(
|
||||
f"{attr} {label!r} cannot be resolved statically:"
|
||||
" use a literal, @string/<name> or ${<placeholder>}"
|
||||
)
|
||||
]
|
||||
return [f"{attr} {p}" for p in label_problems(label)]
|
||||
|
||||
|
||||
def problems(app: Path) -> list[str]:
|
||||
"""Rule 1 for one Flutter app directory."""
|
||||
try:
|
||||
root = parse(app, MAIN)
|
||||
except ManifestError as exc:
|
||||
return [f"{exc}; {_UNRESOLVED}"]
|
||||
application = root.find("application")
|
||||
label = None if application is None else application.get(f"{ANDROID}label")
|
||||
if label is None:
|
||||
return [f"{MAIN}: <application> has no android:label; {_UNRESOLVED}"]
|
||||
out = _resolve(app, "android:label", label)
|
||||
for activity in main_launchers(root):
|
||||
own = activity.get(f"{ANDROID}label")
|
||||
if own is not None:
|
||||
name = activity.get(f"{ANDROID}name", "?")
|
||||
out += _resolve(app, f"<activity {name}> android:label", own)
|
||||
return out
|
||||
154
repo_contract/png_ink.py
Normal file
154
repo_contract/png_ink.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""Rule 4: every launcher glyph fills the same circle as the shared generator's.
|
||||
|
||||
The shared icon generator (`~/src/testsAndMisc/python_pkg/app_icons`, `fit.py`)
|
||||
scales each glyph so its furthest inked pixel (alpha > 127) sits exactly
|
||||
`style.INK_RADIUS` = 340px from the centre of its ink box, on a 1024px canvas,
|
||||
with that centre on the canvas centre. This module re-measures a committed
|
||||
`assets/icon/icon_foreground.png` the same way, so a hand-edited or stale
|
||||
foreground cannot drift back to a different size.
|
||||
|
||||
Stdlib only (zlib + struct): the per-repo CI runner installs nothing, so Pillow
|
||||
is not an option. Only the generator's own output format is decoded (8-bit
|
||||
RGBA, non-interlaced, 1024x1024); anything else fails closed with a message.
|
||||
Only the alpha channel is unfiltered: PNG filters work byte-wise with a
|
||||
one-pixel offset, so alpha bytes depend only on other alpha bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
CANVAS = 1024
|
||||
INK_RADIUS = 340 # app_icons style.INK_RADIUS
|
||||
RADIUS_TOLERANCE = CANVAS * 0.01
|
||||
CENTRE_TOLERANCE = 2.0
|
||||
INK_ALPHA = 127 # app_icons fit._INK_ALPHA: above this is ink
|
||||
_SIG = b"\x89PNG\r\n\x1a\n"
|
||||
_INKED = bytes(1 if v > INK_ALPHA else 0 for v in range(256))
|
||||
_FIX = "regenerate it with ~/src/testsAndMisc/python_pkg/app_icons"
|
||||
|
||||
|
||||
class PngError(ValueError):
|
||||
"""The PNG is not one this check can measure."""
|
||||
|
||||
|
||||
def _chunks(data: bytes) -> tuple[tuple[int, ...], bytes]:
|
||||
"""(IHDR fields, concatenated IDAT) of a PNG byte string."""
|
||||
if not data.startswith(_SIG):
|
||||
raise PngError("is not a PNG")
|
||||
pos, header, idat = len(_SIG), None, []
|
||||
while pos + 8 <= len(data):
|
||||
(size,) = struct.unpack_from(">I", data, pos)
|
||||
kind, body = data[pos + 4 : pos + 8], data[pos + 8 : pos + 8 + size]
|
||||
pos += 12 + size
|
||||
if kind == b"IHDR":
|
||||
header = struct.unpack(">IIBBBBB", body)
|
||||
elif kind == b"IDAT":
|
||||
idat.append(body)
|
||||
elif kind == b"IEND":
|
||||
break
|
||||
if header is None:
|
||||
raise PngError("has no IHDR chunk")
|
||||
return header, b"".join(idat)
|
||||
|
||||
|
||||
def _paeth(a: int, b: int, c: int) -> int:
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
if pa <= pb and pa <= pc:
|
||||
return a
|
||||
return b if pb <= pc else c
|
||||
|
||||
|
||||
def _unfilter(kind: int, cur: bytes, prior: bytes) -> bytes:
|
||||
"""Undo one row's PNG filter on a single channel (neighbour = 1 byte)."""
|
||||
if kind == 0:
|
||||
return cur
|
||||
if kind == 2:
|
||||
return bytes((x + p) & 255 for x, p in zip(cur, prior))
|
||||
if kind not in (1, 3, 4):
|
||||
raise PngError(f"has an invalid PNG filter type {kind}")
|
||||
out = bytearray(cur)
|
||||
for i in range(len(out)):
|
||||
a = out[i - 1] if i else 0
|
||||
if kind == 1:
|
||||
pred = a
|
||||
elif kind == 3:
|
||||
pred = (a + prior[i]) // 2
|
||||
else:
|
||||
pred = _paeth(a, prior[i], prior[i - 1] if i else 0)
|
||||
out[i] = (out[i] + pred) & 255
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def alpha_rows(data: bytes) -> list[bytes]:
|
||||
"""The alpha channel of a 1024x1024 8-bit RGBA PNG, one bytes per row."""
|
||||
(width, height, depth, colour, _, _, interlace), idat = _chunks(data)
|
||||
if (width, height) != (CANVAS, CANVAS):
|
||||
raise PngError(f"is {width}x{height}; the family canvas is {CANVAS}x{CANVAS}")
|
||||
if depth != 8 or colour != 6 or interlace:
|
||||
raise PngError(
|
||||
"must be 8-bit RGBA, non-interlaced "
|
||||
f"(bit depth {depth}, colour type {colour}, interlace {interlace})"
|
||||
)
|
||||
try:
|
||||
raw = zlib.decompress(idat)
|
||||
except zlib.error as exc:
|
||||
raise PngError(f"has corrupt image data: {exc}") from exc
|
||||
stride = width * 4 + 1
|
||||
if len(raw) != stride * height:
|
||||
raise PngError("has truncated image data")
|
||||
rows, prior = [], bytes(width)
|
||||
for y in range(height):
|
||||
line = raw[y * stride : (y + 1) * stride]
|
||||
prior = _unfilter(line[0], line[4::4], prior)
|
||||
rows.append(prior)
|
||||
return rows
|
||||
|
||||
|
||||
def ink_geometry(path: Path) -> tuple[float, float, float]:
|
||||
"""(radius, cx, cy) exactly as app_icons `fit.ink_geometry` measures it.
|
||||
|
||||
The furthest inked pixel of a row is always its first or last one, so
|
||||
only those two are measured per row.
|
||||
"""
|
||||
spans = []
|
||||
for y, row in enumerate(alpha_rows(path.read_bytes())):
|
||||
mask = row.translate(_INKED)
|
||||
first = mask.find(1)
|
||||
if first >= 0:
|
||||
spans.append((y, first, mask.rfind(1)))
|
||||
if not spans:
|
||||
raise PngError(f"has no inked pixel (alpha > {INK_ALPHA})")
|
||||
cx = (min(s[1] for s in spans) + max(s[2] for s in spans) + 1) / 2
|
||||
cy = (spans[0][0] + spans[-1][0] + 1) / 2
|
||||
radius = max(
|
||||
math.hypot(x + 0.5 - cx, y + 0.5 - cy) for y, *ends in spans for x in ends
|
||||
)
|
||||
return radius, cx, cy
|
||||
|
||||
|
||||
def icon_problems(path: Path, rel: str) -> list[str]:
|
||||
"""Violations of the family ink circle for the foreground at `path`."""
|
||||
try:
|
||||
radius, cx, cy = ink_geometry(path)
|
||||
except PngError as exc:
|
||||
return [f"{rel} {exc}"]
|
||||
except OSError as exc:
|
||||
return [f"{rel} is unreadable: {exc}"]
|
||||
out = []
|
||||
if abs(radius - INK_RADIUS) > RADIUS_TOLERANCE:
|
||||
out.append(
|
||||
f"{rel}: ink radius {radius:.1f}px, the family circle is {INK_RADIUS}px"
|
||||
f" ±{RADIUS_TOLERANCE:.1f}px: {_FIX}"
|
||||
)
|
||||
off = math.hypot(cx - CANVAS / 2, cy - CANVAS / 2)
|
||||
if off > CENTRE_TOLERANCE:
|
||||
out.append(
|
||||
f"{rel}: ink centre ({cx:.1f}, {cy:.1f}) is {off:.1f}px from"
|
||||
f" ({CANVAS // 2}, {CANVAS // 2}); at most {CENTRE_TOLERANCE:.0f}px"
|
||||
)
|
||||
return out
|
||||
@ -1,9 +1,11 @@
|
||||
# Add under `repos: - repo: local: hooks:` in a repo's .pre-commit-config.yaml,
|
||||
# after copying check_repo_contract.shim.sh to scripts/check_repo_contract.sh.
|
||||
# pass_filenames is false: the contract is about the repo, not the staged files.
|
||||
# files: the Commands section, plus everything the Flutter launcher rules read
|
||||
# (launcher.py). Existing repos pick up a change via repo_contract/hook_sync.py.
|
||||
- id: repo-contract
|
||||
name: repo contract (CLAUDE.md Commands + test_changed)
|
||||
entry: scripts/check_repo_contract.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$
|
||||
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$
|
||||
|
||||
75
repo_contract/tests/flutter_app.py
Normal file
75
repo_contract/tests/flutter_app.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Builders for minimal Flutter app fixtures: pubspec, manifests, gradle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .conftest import write
|
||||
|
||||
PUBSPEC = "name: demo\ndependencies:\n flutter:\n sdk: flutter\n"
|
||||
NS = 'xmlns:android="http://schemas.android.com/apk/res/android"'
|
||||
TOOLS = 'xmlns:tools="http://schemas.android.com/tools"'
|
||||
ACTIVITY = """ <activity android:name="{name}"{extra}>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"{main}/>
|
||||
<category android:name="android.intent.category.LAUNCHER"{tag}/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
"""
|
||||
GRADLE = """android {
|
||||
defaultConfig {
|
||||
applicationId = "com.kuhy.demo"
|
||||
}
|
||||
}
|
||||
"""
|
||||
SANDBOX_GRADLE = """android {
|
||||
productFlavors {
|
||||
create("daily") { dimension = "store" }
|
||||
create("sandbox") { applicationIdSuffix = ".sandbox" }
|
||||
}
|
||||
}
|
||||
"""
|
||||
MAIN = Path("android/app/src/main/AndroidManifest.xml")
|
||||
REMOVE = ' tools:node="remove"'
|
||||
|
||||
|
||||
def manifest(
|
||||
label: str | None = "Demo",
|
||||
activity: str = ".MainActivity",
|
||||
activity_label: str | None = None,
|
||||
) -> str:
|
||||
"""The main AndroidManifest.xml with one LAUNCHER activity."""
|
||||
attr = "" if label is None else f' android:label="{label}"'
|
||||
extra = "" if activity_label is None else f' android:label="{activity_label}"'
|
||||
body = ACTIVITY.format(name=activity, main="", tag="", extra=extra)
|
||||
return f"<manifest {NS}>\n <application{attr}>\n{body} </application>\n</manifest>\n"
|
||||
|
||||
|
||||
def hide(activity: str = ".MainActivity", *, main: str = "", tag: str = REMOVE) -> str:
|
||||
"""A variant manifest that removes LAUNCHER (by default) from `activity`."""
|
||||
body = ACTIVITY.format(name=activity, main=main, tag=tag, extra="")
|
||||
return f"<manifest {NS} {TOOLS}>\n <application>\n{body} </application>\n</manifest>\n"
|
||||
|
||||
|
||||
def flutter_app(
|
||||
root: Path,
|
||||
rel: str = ".",
|
||||
*,
|
||||
label: str | None = "Demo",
|
||||
gradle: str | None = GRADLE,
|
||||
kts: bool = True,
|
||||
) -> Path:
|
||||
"""A minimal Android-enabled Flutter app at `root/rel`; returns its dir."""
|
||||
app = root / rel
|
||||
write(app, "pubspec.yaml", PUBSPEC)
|
||||
write(app, MAIN.as_posix(), manifest(label))
|
||||
if gradle is not None:
|
||||
name = "build.gradle.kts" if kts else "build.gradle"
|
||||
write(app, f"android/app/{name}", gradle)
|
||||
return app
|
||||
|
||||
|
||||
def track(repo: Path) -> None:
|
||||
"""Stage everything so `git ls-files` sees it."""
|
||||
subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True)
|
||||
92
repo_contract/tests/pngkit.py
Normal file
92
repo_contract/tests/pngkit.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Tiny RGBA PNG encoder for icon fixtures (stdlib only, like the gate)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
SIG = b"\x89PNG\r\n\x1a\n"
|
||||
SIZE = 1024
|
||||
INK = bytes((0xB8, 0x86, 0x2E, 0xFF))
|
||||
FAINT = bytes((0xB8, 0x86, 0x2E, 0x7F)) # alpha 127: fringe, not ink
|
||||
CLEAR = bytes(4)
|
||||
|
||||
|
||||
def disk_rows(
|
||||
radius: float, cx: float = 512.0, cy: float = 512.0, size: int = SIZE
|
||||
) -> list[bytes]:
|
||||
"""RGBA rows of a solid disk; pixel centres within `radius` are inked."""
|
||||
rows = []
|
||||
for y in range(size):
|
||||
dy = y + 0.5 - cy
|
||||
if abs(dy) >= radius:
|
||||
rows.append(CLEAR * size)
|
||||
continue
|
||||
half = math.sqrt(radius * radius - dy * dy)
|
||||
x0 = max(0, math.ceil(cx - half - 0.5))
|
||||
x1 = min(size, math.floor(cx + half - 0.5) + 1)
|
||||
rows.append(CLEAR * x0 + INK * (x1 - x0) + CLEAR * (size - x1))
|
||||
return rows
|
||||
|
||||
|
||||
def _paeth(a: int, b: int, c: int) -> int:
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
if pa <= pb and pa <= pc:
|
||||
return a
|
||||
return b if pb <= pc else c
|
||||
|
||||
|
||||
def _filter(kind: int, row: bytes, prior: bytes) -> bytes:
|
||||
"""Apply PNG filter `kind` (0-4, 4 bytes per pixel) to one raw row."""
|
||||
if kind == 0:
|
||||
return row
|
||||
out = bytearray(len(row))
|
||||
for i, value in enumerate(row):
|
||||
a = row[i - 4] if i >= 4 else 0
|
||||
b = prior[i]
|
||||
c = prior[i - 4] if i >= 4 else 0
|
||||
pred = (0, a, b, (a + b) // 2, _paeth(a, b, c))[kind]
|
||||
out[i] = (value - pred) & 255
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def chunk(kind: bytes, body: bytes) -> bytes:
|
||||
"""One PNG chunk: length, type, body, CRC."""
|
||||
crc = struct.pack(">I", zlib.crc32(kind + body))
|
||||
return struct.pack(">I", len(body)) + kind + body + crc
|
||||
|
||||
|
||||
def encode(
|
||||
rows: list[bytes],
|
||||
*,
|
||||
filters: Callable[[int], int] | None = None,
|
||||
size: int = SIZE,
|
||||
depth: int = 8,
|
||||
colour: int = 6,
|
||||
interlace: int = 0,
|
||||
iend: bool = True,
|
||||
) -> bytes:
|
||||
"""PNG bytes for `rows`; `filters(y)` picks each row's filter type."""
|
||||
prior = bytes(len(rows[0]))
|
||||
raw = bytearray()
|
||||
for y, row in enumerate(rows):
|
||||
kind = filters(y) if filters else 0
|
||||
raw.append(kind)
|
||||
raw += _filter(kind, row, prior)
|
||||
prior = row
|
||||
ihdr = struct.pack(">IIBBBBB", size, size, depth, colour, 0, 0, interlace)
|
||||
body = chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(bytes(raw), 1))
|
||||
return SIG + body + (chunk(b"IEND", b"") if iend else b"")
|
||||
|
||||
|
||||
def write_icon(
|
||||
path: Path, radius: float = 340, cx: float = 512, cy: float = 512
|
||||
) -> Path:
|
||||
"""Write a disk icon foreground to `path` (parents created)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(encode(disk_rows(radius, cx, cy)))
|
||||
return path
|
||||
136
repo_contract/tests/test_gradle_blocks.py
Normal file
136
repo_contract/tests/test_gradle_blocks.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Brace-matched gradle blocks: suffix variants and manifest placeholders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from repo_contract import gradle_blocks as gb
|
||||
|
||||
KTS = """
|
||||
// a { comment } with braces
|
||||
android {
|
||||
namespace = "com.kuhy.demo" /* { block comment } */
|
||||
defaultConfig {
|
||||
applicationId = "com.kuhy.demo"
|
||||
manifestPlaceholders["appLabel"] = "Demo"
|
||||
versionName = "${flutter.versionName}"
|
||||
}
|
||||
flavorDimensions += "store"
|
||||
productFlavors {
|
||||
create("daily") {
|
||||
dimension = "store"
|
||||
manifestPlaceholders["appLabel"] =
|
||||
"Daily"
|
||||
}
|
||||
create("sandbox") {
|
||||
applicationIdSuffix = ".sandbox"
|
||||
manifestPlaceholders["appLabel"] = "Demo SANDBOX"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
GROOVY = """
|
||||
android {
|
||||
defaultConfig {
|
||||
manifestPlaceholders = [appLabel: 'Demo', other: "x"]
|
||||
applicationIdSuffix ".everything"
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
manifestPlaceholders.appLabel = "Debug"
|
||||
}
|
||||
profile {
|
||||
manifestPlaceholders.put("appLabel", labelFor("profile"))
|
||||
}
|
||||
}
|
||||
productFlavors {
|
||||
beta {
|
||||
manifestPlaceholders += mapOf("appLabel" to "Beta")
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_comments_dropped_strings_kept() -> None:
|
||||
text = "a = \"// not a comment\" // gone\nb = '/* kept */' /* gone */ c"
|
||||
assert gb.strip_comments(text) == "a = \"// not a comment\" \nb = '/* kept */' c"
|
||||
|
||||
|
||||
def test_escaped_and_triple_quoted_strings() -> None:
|
||||
text = 'x = "a\\"{" \ny = """{ "quoted" }""" // c\nz { }'
|
||||
paths = [b.path for b in gb.blocks(text)]
|
||||
assert ("z",) in paths and len(paths) == 2
|
||||
|
||||
|
||||
def test_block_paths() -> None:
|
||||
paths = {b.path for b in gb.blocks(KTS)}
|
||||
assert ("android", "productFlavors", "sandbox") in paths
|
||||
assert ("android", "buildTypes", "debug") in paths
|
||||
assert ("android", "buildTypes", "release") in paths
|
||||
assert ("android", "defaultConfig") in paths
|
||||
|
||||
|
||||
def test_braces_inside_strings_ignored() -> None:
|
||||
blocks = gb.blocks('a { b = "${x}" }\n')
|
||||
assert [b.path for b in blocks] == [("a",), ()]
|
||||
|
||||
|
||||
def test_unbalanced_braces_tolerated() -> None:
|
||||
assert [b.path for b in gb.blocks("}\na {\n b {\n")] == [
|
||||
("a", "b"),
|
||||
("a",),
|
||||
(),
|
||||
]
|
||||
|
||||
|
||||
def test_unnamed_header() -> None:
|
||||
assert gb.blocks("{ }")[0].path == ("?",)
|
||||
|
||||
|
||||
def test_suffixed_variants_kts() -> None:
|
||||
assert gb.suffixed_variants(KTS) == ["debug", "sandbox"]
|
||||
|
||||
|
||||
def test_suffixed_variants_groovy_ignores_default_config() -> None:
|
||||
assert gb.suffixed_variants(GROOVY) == ["debug"]
|
||||
|
||||
|
||||
def test_placeholders_kts_every_block() -> None:
|
||||
assert gb.placeholder_values(KTS, "appLabel") == [
|
||||
("defaultConfig", "Demo"),
|
||||
("productFlavors.daily", "Daily"),
|
||||
("productFlavors.sandbox", "Demo SANDBOX"),
|
||||
]
|
||||
|
||||
|
||||
def test_placeholders_groovy_forms() -> None:
|
||||
assert gb.placeholder_values(GROOVY, "appLabel") == [
|
||||
("defaultConfig", "Demo"),
|
||||
("buildTypes.debug", "Debug"),
|
||||
("buildTypes.profile", None),
|
||||
("productFlavors.beta", "Beta"),
|
||||
]
|
||||
|
||||
|
||||
def test_placeholder_templates_and_variables_are_not_literals() -> None:
|
||||
text = 'android {\n manifestPlaceholders["l"] = "A$b"\n}\nmanifestPlaceholders["l"] = name\n'
|
||||
assert gb.placeholder_values(text, "l") == [("top level", None), ("android", None)]
|
||||
|
||||
|
||||
def test_placeholder_unterminated_string_is_not_literal() -> None:
|
||||
assert gb.placeholder_values('manifestPlaceholders["l"] = "abc', "l") == [
|
||||
("top level", None)
|
||||
]
|
||||
|
||||
|
||||
def test_placeholder_absent() -> None:
|
||||
assert gb.placeholder_values(KTS, "missing") == []
|
||||
118
repo_contract/tests/test_hook_sync.py
Normal file
118
repo_contract/tests/test_hook_sync.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""The widened repo-contract hook `files:` and the sync that rolls it out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from repo_contract import hook_sync
|
||||
from repo_contract.bootstrap import TEMPLATES
|
||||
|
||||
from .conftest import write
|
||||
|
||||
OLD = r" files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$"
|
||||
CONFIG = """repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: repo-contract
|
||||
name: repo contract
|
||||
entry: scripts/check_repo_contract.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
{files}
|
||||
- id: other
|
||||
entry: x
|
||||
"""
|
||||
|
||||
|
||||
def template_regex() -> re.Pattern[str]:
|
||||
hooks = yaml.safe_load((TEMPLATES / "pre-commit-snippet.yaml").read_text())
|
||||
return re.compile(hooks[0]["files"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"CLAUDE.md",
|
||||
"AGENTS.md",
|
||||
"scripts/test_changed.sh",
|
||||
"android/app/src/main/AndroidManifest.xml",
|
||||
"app/android/app/src/sandbox/AndroidManifest.xml",
|
||||
"android/app/build.gradle",
|
||||
"android/app/build.gradle.kts",
|
||||
"android/app/src/main/res/values/strings.xml",
|
||||
"linux/todo.desktop",
|
||||
"todo.desktop",
|
||||
"assets/icon/icon_foreground.png",
|
||||
"app/assets/icon/icon.png",
|
||||
"repo-contract.toml",
|
||||
"focus-owner/repo-contract.toml",
|
||||
"pubspec.yaml",
|
||||
],
|
||||
)
|
||||
def test_template_fires_on_launcher_files(path: str) -> None:
|
||||
assert template_regex().search(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["lib/main.dart", "docs/CLAUDE.md", "assets/icon/sub/x.png", "x.desktop.bak"],
|
||||
)
|
||||
def test_template_ignores_other_files(path: str) -> None:
|
||||
assert not template_regex().search(path)
|
||||
|
||||
|
||||
def config(repo: Path, files: str = OLD) -> Path:
|
||||
return write(repo, ".pre-commit-config.yaml", CONFIG.format(files=files))
|
||||
|
||||
|
||||
def test_status(tmp_path: Path) -> None:
|
||||
assert hook_sync.status(tmp_path) == "no hook"
|
||||
config(tmp_path)
|
||||
assert hook_sync.status(tmp_path) == "stale"
|
||||
config(tmp_path, hook_sync.template_line())
|
||||
assert hook_sync.status(tmp_path) == "current"
|
||||
|
||||
|
||||
def test_dry_run_changes_nothing(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
before = config(tmp_path).read_text()
|
||||
assert hook_sync.main([str(tmp_path)]) == 1
|
||||
assert (tmp_path / ".pre-commit-config.yaml").read_text() == before
|
||||
out = capsys.readouterr().out
|
||||
assert f"would update: {tmp_path}" in out
|
||||
assert "1 stale, 0 updated, 0 current, 0 without the hook" in out
|
||||
|
||||
|
||||
def test_apply_rewrites_only_the_files_line(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
path = config(tmp_path)
|
||||
assert hook_sync.main(["--apply", str(tmp_path)]) == 0
|
||||
assert path.read_text() == CONFIG.format(files=hook_sync.template_line())
|
||||
assert f"updated: {tmp_path}" in capsys.readouterr().out
|
||||
assert hook_sync.main(["--apply", str(tmp_path)]) == 0
|
||||
assert "0 stale, 0 updated, 1 current" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_hook_without_files_line_gets_one(tmp_path: Path) -> None:
|
||||
config(tmp_path, " always_run: false")
|
||||
assert hook_sync.status(tmp_path) == "stale"
|
||||
hook_sync.main(["--apply", str(tmp_path)])
|
||||
assert hook_sync.status(tmp_path) == "current"
|
||||
|
||||
|
||||
def test_default_roots_are_the_sweep_roots(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
(tmp_path / "a" / ".git").mkdir(parents=True)
|
||||
(tmp_path / "b" / ".git").mkdir(parents=True)
|
||||
config(tmp_path / "a", hook_sync.template_line())
|
||||
assert hook_sync.main([], roots=[tmp_path]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert f"current: {tmp_path / 'a'}" in out
|
||||
assert f"no hook: {tmp_path / 'b'}" in out
|
||||
68
repo_contract/tests/test_label_widths.py
Normal file
68
repo_contract/tests/test_label_widths.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Rule 1 width cap: the rendered name fits the Pixel app drawer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from repo_contract import launcher
|
||||
from repo_contract import launcher_label as ll
|
||||
from repo_contract.label_widths import ADVANCE, MAX_WIDTH, label_width
|
||||
|
||||
from .conftest import write
|
||||
from .flutter_app import MAIN, flutter_app, manifest, track
|
||||
|
||||
TOO_WIDE = "'FocusOwner' is 580 wide in the launcher font; at most 522 fit"
|
||||
|
||||
|
||||
def test_table_covers_every_ascii_letter() -> None:
|
||||
assert sorted(ADVANCE) == sorted(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "width"), [("WakeAlarm", 522), ("FocusOwner", 580)])
|
||||
def test_label_width(name: str, width: int) -> None:
|
||||
assert label_width(name) == width
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["WakeAlarm", "Focuser", "Homer"])
|
||||
def test_fitting_names_pass(name: str) -> None:
|
||||
assert label_width(name) <= MAX_WIDTH
|
||||
assert ll.label_problems(name) == []
|
||||
|
||||
|
||||
def test_focusowner_is_too_wide() -> None:
|
||||
assert ll.label_problems("FocusOwner") == [TOO_WIDE]
|
||||
|
||||
|
||||
def test_width_sits_between_capital_and_length_rules() -> None:
|
||||
assert ll.label_problems("wwwwwwwwwww") == [
|
||||
"'wwwwwwwwwww' must start with a capital letter",
|
||||
"'wwwwwwwwwww' is 858 wide in the launcher font; at most 522 fit",
|
||||
"'wwwwwwwwwww' has 11 characters; at most 10 fit",
|
||||
]
|
||||
|
||||
|
||||
def test_width_skipped_for_non_letters() -> None:
|
||||
got = ll.label_problems("Wide Wide W")
|
||||
assert not any("wide in the launcher font" in p for p in got)
|
||||
|
||||
|
||||
def test_app_label_too_wide(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path, label="FocusOwner")
|
||||
assert ll.problems(app) == [f"android:label {TOO_WIDE}"]
|
||||
|
||||
|
||||
def test_activity_label_too_wide(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
write(app, MAIN.as_posix(), manifest(activity_label="FocusOwner"))
|
||||
assert ll.problems(app) == [f"<activity .MainActivity> android:label {TOO_WIDE}"]
|
||||
|
||||
|
||||
def test_desktop_name_too_wide(repo: Path) -> None:
|
||||
flutter_app(repo)
|
||||
write(repo, "linux/app.desktop", "[Desktop Entry]\nName=FocusOwner\n")
|
||||
track(repo)
|
||||
assert launcher.problems(repo) == [f"launcher: linux/app.desktop: Name={TOO_WIDE}"]
|
||||
182
repo_contract/tests/test_launcher.py
Normal file
182
repo_contract/tests/test_launcher.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""Which repos owe the launcher rules, rule 2 (.desktop), and rule 4 wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from repo_contract import check, launcher
|
||||
|
||||
from .conftest import good_repo, write
|
||||
from .flutter_app import flutter_app, track
|
||||
from .pngkit import write_icon
|
||||
|
||||
ICON = "assets/icon/icon_foreground.png"
|
||||
DESKTOP = "[Desktop Entry]\nType=Application\nName={name}\nName[pl]=Zadania pl\n"
|
||||
|
||||
|
||||
def test_root_app_found(repo: Path) -> None:
|
||||
flutter_app(repo)
|
||||
track(repo)
|
||||
assert launcher.flutter_apps(repo) == [repo]
|
||||
assert launcher.problems(repo) == []
|
||||
|
||||
|
||||
def test_nested_app_found_and_prefixed(repo: Path) -> None:
|
||||
flutter_app(repo, "tools/workout_app", label="workout_app")
|
||||
track(repo)
|
||||
got = launcher.problems(repo)
|
||||
assert got[0].startswith(
|
||||
"launcher [tools/workout_app]: android:label 'workout_app'"
|
||||
)
|
||||
assert len(got) == 3
|
||||
|
||||
|
||||
def test_untracked_and_pruned_pubspecs_ignored(repo: Path) -> None:
|
||||
flutter_app(repo, "build/stale", label="x")
|
||||
flutter_app(repo, ".utils/vendored", label="x")
|
||||
flutter_app(repo, "deleted", label="x")
|
||||
track(repo)
|
||||
(repo / "deleted/pubspec.yaml").unlink()
|
||||
flutter_app(repo, "untracked", label="x")
|
||||
assert launcher.flutter_apps(repo) == []
|
||||
|
||||
|
||||
def test_not_flutter_or_no_android_skipped(repo: Path) -> None:
|
||||
write(repo, "dart_pkg/pubspec.yaml", "name: p\ndependencies:\n path: 1.9.0\n")
|
||||
write(repo, "dart_pkg/android/x", "")
|
||||
write(
|
||||
repo,
|
||||
"web_only/pubspec.yaml",
|
||||
"name: w\ndependencies:\n flutter:\n sdk: flutter\n",
|
||||
)
|
||||
write(
|
||||
repo,
|
||||
"dev_only/pubspec.yaml",
|
||||
"dev_dependencies:\n flutter:\n sdk: flutter\n",
|
||||
)
|
||||
write(repo, "dev_only/android/x", "")
|
||||
track(repo)
|
||||
assert launcher.flutter_apps(repo) == []
|
||||
|
||||
|
||||
def test_pubspec_comments_do_not_hide_an_app(repo: Path) -> None:
|
||||
body = "dependencies: # exact pins\n# column-0 note\n\n flutter:\n sdk: flutter # sdk\n"
|
||||
write(repo, "a/pubspec.yaml", body)
|
||||
write(repo, "a/android/x", "")
|
||||
track(repo)
|
||||
assert launcher.flutter_apps(repo) == [repo / "a"]
|
||||
|
||||
|
||||
def test_not_a_git_repo_checks_root_only(tmp_path: Path) -> None:
|
||||
flutter_app(tmp_path, label="demo")
|
||||
flutter_app(tmp_path, "nested", label="demo")
|
||||
assert launcher.flutter_apps(tmp_path) == [tmp_path]
|
||||
|
||||
|
||||
def test_no_flutter_app_no_rules(repo: Path) -> None:
|
||||
write(repo, "x.desktop", DESKTOP.format(name="bad name"))
|
||||
track(repo)
|
||||
assert launcher.problems(repo) == []
|
||||
|
||||
|
||||
def test_desktop_names_checked(repo: Path) -> None:
|
||||
flutter_app(repo)
|
||||
write(repo, "linux/good.desktop", DESKTOP.format(name="Todo"))
|
||||
write(repo, "linux/bad.desktop", DESKTOP.format(name="todo app"))
|
||||
write(
|
||||
repo,
|
||||
"linux/action.desktop",
|
||||
DESKTOP.format(name="Todo") + "[Desktop Action new]\nName=New window\n",
|
||||
)
|
||||
write(repo, "linux/none.desktop", "[Desktop Entry]\nType=Application\n")
|
||||
track(repo)
|
||||
write(repo, "linux/untracked.desktop", DESKTOP.format(name="bad name"))
|
||||
assert launcher.problems(repo) == [
|
||||
(
|
||||
"launcher: linux/bad.desktop: Name='todo app' must contain only ASCII"
|
||||
" letters (no spaces, _ or symbols)"
|
||||
),
|
||||
"launcher: linux/bad.desktop: Name='todo app' must start with a capital letter",
|
||||
"launcher: linux/none.desktop: no Name= in [Desktop Entry]",
|
||||
]
|
||||
|
||||
|
||||
def test_unreadable_desktop_file(repo: Path) -> None:
|
||||
flutter_app(repo)
|
||||
write(repo, "a.desktop", "x")
|
||||
track(repo)
|
||||
(repo / "a.desktop").write_bytes(b"\xff\xfe")
|
||||
assert launcher.problems(repo) == ["launcher: a.desktop: unreadable"]
|
||||
|
||||
|
||||
def test_icon_checked_when_present(repo: Path) -> None:
|
||||
app = flutter_app(repo)
|
||||
write_icon(app / ICON, 250)
|
||||
track(repo)
|
||||
got = launcher.problems(repo)
|
||||
assert len(got) == 1 and got[0].startswith(f"launcher: {ICON}: ink radius 2")
|
||||
|
||||
|
||||
def test_no_icon_no_icon_rule(repo: Path) -> None:
|
||||
flutter_app(repo)
|
||||
track(repo)
|
||||
assert launcher.problems(repo) == []
|
||||
|
||||
|
||||
def test_icon_skip_with_reason(repo: Path) -> None:
|
||||
app = flutter_app(repo, "focus-owner")
|
||||
write_icon(app / ICON, 250)
|
||||
write(app, "repo-contract.toml", '[launcher_icon]\nskip = "vector drawables"\n')
|
||||
track(repo)
|
||||
assert launcher.problems(repo) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "message"),
|
||||
[
|
||||
(
|
||||
'[launcher_icon]\nskip = ""\n',
|
||||
'[launcher_icon] skip = "<reason>" needs a non-empty reason',
|
||||
),
|
||||
(
|
||||
"[launcher_icon]\nskip = true\n",
|
||||
'[launcher_icon] skip = "<reason>" needs a non-empty reason',
|
||||
),
|
||||
(
|
||||
"[launcher_icon]\n",
|
||||
'[launcher_icon] skip = "<reason>" needs a non-empty reason',
|
||||
),
|
||||
(
|
||||
'[launcher_icon]\nskip = "x"\nwhy = 1\n',
|
||||
"unknown key(s) in [launcher_icon]: why",
|
||||
),
|
||||
("launcher_icon = 1\n", "'launcher_icon' must be a table: [launcher_icon]"),
|
||||
("[launcher_icon\n", "not valid TOML"),
|
||||
],
|
||||
)
|
||||
def test_icon_skip_malformed(repo: Path, body: str, message: str) -> None:
|
||||
app = flutter_app(repo)
|
||||
write_icon(app / ICON, 250)
|
||||
write(app, "repo-contract.toml", body)
|
||||
track(repo)
|
||||
got = launcher.problems(repo)
|
||||
assert len(got) == 1
|
||||
assert got[0].startswith(f"launcher: repo-contract.toml: {message}")
|
||||
|
||||
|
||||
def test_other_tables_do_not_skip(repo: Path) -> None:
|
||||
app = flutter_app(repo)
|
||||
write_icon(app / ICON, 250)
|
||||
write(app, "repo-contract.toml", '[agent_ready]\nskip = "not a site"\n')
|
||||
track(repo)
|
||||
assert len(launcher.problems(repo)) == 1
|
||||
|
||||
|
||||
def test_check_reports_launcher_problems(repo: Path) -> None:
|
||||
good_repo(repo)
|
||||
flutter_app(repo, label="demo")
|
||||
track(repo)
|
||||
found, _ = check.violations(repo)
|
||||
assert found == ["launcher: android:label 'demo' must start with a capital letter"]
|
||||
179
repo_contract/tests/test_launcher_label.py
Normal file
179
repo_contract/tests/test_launcher_label.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""Rule 1: the resolved android:label is a short capitalised ASCII word."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from repo_contract import launcher_label as ll
|
||||
|
||||
from .conftest import write
|
||||
from .flutter_app import MAIN, flutter_app, manifest
|
||||
|
||||
STRINGS = Path("android/app/src/main/res/values/strings.xml")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("PunchMe", []),
|
||||
("DailyLimit", []),
|
||||
("", ["label is empty"]),
|
||||
("punchme", ["'punchme' must start with a capital letter"]),
|
||||
(
|
||||
"Umbrella?",
|
||||
["'Umbrella?' must contain only ASCII letters (no spaces, _ or symbols)"],
|
||||
),
|
||||
(
|
||||
"focus_owner",
|
||||
[
|
||||
"'focus_owner' must contain only ASCII letters (no spaces, _ or symbols)",
|
||||
"'focus_owner' must start with a capital letter",
|
||||
"'focus_owner' has 11 characters; at most 10 fit",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Zażółć",
|
||||
["'Zażółć' must contain only ASCII letters (no spaces, _ or symbols)"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_label_problems_mirror_app_icons(name: str, expected: list[str]) -> None:
|
||||
assert ll.label_problems(name) == expected
|
||||
|
||||
|
||||
def test_literal_label(tmp_path: Path) -> None:
|
||||
assert ll.problems(flutter_app(tmp_path, label="Demo")) == []
|
||||
assert ll.problems(flutter_app(tmp_path, label="demo")) == [
|
||||
"android:label 'demo' must start with a capital letter"
|
||||
]
|
||||
|
||||
|
||||
def test_string_resource(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path, label="@string/app_name")
|
||||
write(
|
||||
app,
|
||||
STRINGS.as_posix(),
|
||||
'<resources><string name="app_name">My App</string></resources>',
|
||||
)
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
"android:label @string/app_name = 'My App' must contain only ASCII letters"
|
||||
" (no spaces, _ or symbols)"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_string_resource_missing(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path, label="@string/app_name")
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
f"android:label @string/app_name: {STRINGS} is missing or unreadable;"
|
||||
" cannot resolve the launcher name"
|
||||
)
|
||||
]
|
||||
write(
|
||||
app,
|
||||
STRINGS.as_posix(),
|
||||
'<resources><string name="other">X</string></resources>',
|
||||
)
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
f'android:label @string/app_name: no <string name="app_name"> in {STRINGS};'
|
||||
" cannot resolve the launcher name"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_placeholder_every_assignment_checked(tmp_path: Path) -> None:
|
||||
gradle = """android {
|
||||
defaultConfig { manifestPlaceholders["appLabel"] = "Workout" }
|
||||
productFlavors {
|
||||
create("daily") { manifestPlaceholders["appLabel"] = "workout_app" }
|
||||
create("sandbox") { manifestPlaceholders["appLabel"] = "Workout SANDBOX" }
|
||||
}
|
||||
}
|
||||
"""
|
||||
app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle)
|
||||
got = ll.problems(app)
|
||||
assert got[0] == (
|
||||
"android:label ${appLabel} in productFlavors.daily = 'workout_app'"
|
||||
" must contain only ASCII letters (no spaces, _ or symbols)"
|
||||
)
|
||||
assert len(got) == 5
|
||||
assert all("defaultConfig" not in p for p in got)
|
||||
|
||||
|
||||
def test_placeholder_groovy_passes(tmp_path: Path) -> None:
|
||||
gradle = "android { defaultConfig { manifestPlaceholders = [appLabel: 'Demo'] } }\n"
|
||||
app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle, kts=False)
|
||||
assert ll.problems(app) == []
|
||||
|
||||
|
||||
def test_placeholder_not_literal(tmp_path: Path) -> None:
|
||||
gradle = 'android { defaultConfig { manifestPlaceholders["appLabel"] = name } }\n'
|
||||
app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle)
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
"android:label ${appLabel}: the value set in defaultConfig is not a string"
|
||||
" literal; cannot resolve the launcher name"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_placeholder_unassigned(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path, label="${appLabel}")
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
'android:label ${appLabel}: no manifestPlaceholders["appLabel"] assignment'
|
||||
" in android/app/build.gradle.kts; cannot resolve the launcher name"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_placeholder_without_gradle_file(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path, label="${appLabel}", gradle=None)
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
"android:label ${appLabel}: no android/app/build.gradle(.kts);"
|
||||
" cannot resolve the launcher name"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label", ["Demo ${suffix}", "@string/a/b", "@android:string/x"]
|
||||
)
|
||||
def test_unresolvable_forms(tmp_path: Path, label: str) -> None:
|
||||
assert ll.problems(flutter_app(tmp_path, label=label)) == [
|
||||
(
|
||||
f"android:label {label!r} cannot be resolved statically:"
|
||||
" use a literal, @string/<name> or ${<placeholder>}"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_no_label(tmp_path: Path) -> None:
|
||||
assert ll.problems(flutter_app(tmp_path, label=None)) == [
|
||||
f"{MAIN}: <application> has no android:label; cannot resolve the launcher name"
|
||||
]
|
||||
|
||||
|
||||
def test_missing_or_broken_manifest(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
(app / MAIN).unlink()
|
||||
assert ll.problems(app) == [
|
||||
f"{MAIN} is missing or unreadable; cannot resolve the launcher name"
|
||||
]
|
||||
write(app, MAIN.as_posix(), manifest()[:-20])
|
||||
got = ll.problems(app)
|
||||
assert len(got) == 1 and got[0].startswith(f"{MAIN} is not valid XML:")
|
||||
|
||||
|
||||
def test_manifest_without_application(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
write(app, MAIN.as_posix(), "<manifest/>")
|
||||
assert ll.problems(app) == [
|
||||
f"{MAIN}: <application> has no android:label; cannot resolve the launcher name"
|
||||
]
|
||||
137
repo_contract/tests/test_launcher_label_scope.py
Normal file
137
repo_contract/tests/test_launcher_label_scope.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""Rule 1 scope: suffixed variants are exempt; launcher activity labels count."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract import launcher_label as ll
|
||||
|
||||
from .conftest import write
|
||||
from .flutter_app import MAIN, NS, flutter_app, manifest
|
||||
|
||||
WORKOUT = """android {
|
||||
defaultConfig { manifestPlaceholders["appLabel"] = "Workout" }
|
||||
productFlavors {
|
||||
create("daily") { manifestPlaceholders["appLabel"] = "Workout" }
|
||||
create("sandbox") {
|
||||
applicationIdSuffix = ".sandbox"
|
||||
manifestPlaceholders["appLabel"] = "Workout SANDBOX"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
STRINGS = "android/app/src/main/res/values/strings.xml"
|
||||
|
||||
|
||||
def test_suffixed_flavor_label_is_exempt(tmp_path: Path) -> None:
|
||||
"""screen-locker's workout_app: the hidden sandbox may say SANDBOX."""
|
||||
app = flutter_app(tmp_path, label="${appLabel}", gradle=WORKOUT)
|
||||
assert ll.problems(app) == []
|
||||
|
||||
|
||||
def test_unsuffixed_flavor_still_checked(tmp_path: Path) -> None:
|
||||
gradle = WORKOUT.replace(
|
||||
'create("daily") { manifestPlaceholders["appLabel"] = "Workout" }',
|
||||
'create("daily") { manifestPlaceholders["appLabel"] = "workout" }',
|
||||
)
|
||||
app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle)
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
"android:label ${appLabel} in productFlavors.daily = 'workout'"
|
||||
" must start with a capital letter"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_suffixed_buildtype_label_is_exempt(tmp_path: Path) -> None:
|
||||
gradle = """android {
|
||||
defaultConfig { manifestPlaceholders["appLabel"] = "Demo" }
|
||||
buildTypes { debug { applicationIdSuffix ".debug"; manifestPlaceholders["appLabel"] = "Demo dbg" } }
|
||||
}
|
||||
"""
|
||||
app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle, kts=False)
|
||||
assert ll.problems(app) == []
|
||||
|
||||
|
||||
def test_only_suffixed_assignments_is_unresolvable(tmp_path: Path) -> None:
|
||||
gradle = """android { productFlavors { create("sandbox") {
|
||||
applicationIdSuffix = ".sandbox"
|
||||
manifestPlaceholders["appLabel"] = "Sandbox"
|
||||
} } }
|
||||
"""
|
||||
app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle)
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
'android:label ${appLabel}: no manifestPlaceholders["appLabel"] assignment'
|
||||
" for an unsuffixed variant in android/app/build.gradle.kts;"
|
||||
" cannot resolve the launcher name"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_launcher_activity_label_checked(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
write(app, MAIN.as_posix(), manifest(activity_label="punch me"))
|
||||
assert ll.problems(app) == [
|
||||
(
|
||||
"<activity .MainActivity> android:label 'punch me' must contain only ASCII"
|
||||
" letters (no spaces, _ or symbols)"
|
||||
),
|
||||
(
|
||||
"<activity .MainActivity> android:label 'punch me' must start with a"
|
||||
" capital letter"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_launcher_activity_label_good(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
write(app, MAIN.as_posix(), manifest(activity_label="PunchMe"))
|
||||
assert ll.problems(app) == []
|
||||
|
||||
|
||||
def test_launcher_activity_label_resolved_like_the_app_label(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
write(app, MAIN.as_posix(), manifest(activity_label="@string/launcher"))
|
||||
write(
|
||||
app, STRINGS, '<resources><string name="launcher">My App</string></resources>'
|
||||
)
|
||||
got = ll.problems(app)
|
||||
assert got[0].startswith(
|
||||
"<activity .MainActivity> android:label @string/launcher = 'My App'"
|
||||
)
|
||||
|
||||
|
||||
def test_activity_label_also_checked_without_app_label_issue(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
write(app, MAIN.as_posix(), manifest(label="demo", activity_label="Demo"))
|
||||
assert ll.problems(app) == ["android:label 'demo' must start with a capital letter"]
|
||||
|
||||
|
||||
def test_non_launcher_activity_label_ignored(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
body = manifest().replace(
|
||||
" </application>",
|
||||
' <activity android:name=".Settings" android:label="settings page"/>\n'
|
||||
' <activity android:name=".Share" android:label="share it">\n'
|
||||
" <intent-filter>\n"
|
||||
' <category android:name="android.intent.category.LAUNCHER"/>\n'
|
||||
" </intent-filter>\n"
|
||||
" </activity>\n"
|
||||
" </application>",
|
||||
)
|
||||
write(app, MAIN.as_posix(), body)
|
||||
assert ll.problems(app) == []
|
||||
|
||||
|
||||
def test_activity_alias_label_checked(tmp_path: Path) -> None:
|
||||
app = flutter_app(tmp_path)
|
||||
body = f"""<manifest {NS}><application android:label="Demo">
|
||||
<activity-alias android:name=".Alias" android:label="alias">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/></intent-filter>
|
||||
</activity-alias></application></manifest>"""
|
||||
write(app, MAIN.as_posix(), body)
|
||||
assert ll.problems(app) == [
|
||||
"<activity .Alias> android:label 'alias' must start with a capital letter"
|
||||
]
|
||||
133
repo_contract/tests/test_launcher_variants.py
Normal file
133
repo_contract/tests/test_launcher_variants.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""Rule 3: an applicationIdSuffix variant hides LAUNCHER but keeps MAIN."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract import android_manifest as am
|
||||
|
||||
from .conftest import write
|
||||
from .flutter_app import MAIN, NS, REMOVE, SANDBOX_GRADLE, flutter_app, hide, manifest
|
||||
|
||||
SANDBOX = "android/app/src/sandbox/AndroidManifest.xml"
|
||||
|
||||
|
||||
def sandbox_app(root: Path) -> Path:
|
||||
return flutter_app(root, gradle=SANDBOX_GRADLE)
|
||||
|
||||
|
||||
def test_no_suffix_variant_passes(tmp_path: Path) -> None:
|
||||
assert am.variant_problems(flutter_app(tmp_path)) == []
|
||||
|
||||
|
||||
def test_no_gradle_passes(tmp_path: Path) -> None:
|
||||
assert am.variant_problems(flutter_app(tmp_path, gradle=None)) == []
|
||||
|
||||
|
||||
def test_hidden_variant_passes(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(app, SANDBOX, hide())
|
||||
assert am.variant_problems(app) == []
|
||||
|
||||
|
||||
def test_fully_qualified_activity_name_matches(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(app, SANDBOX, hide("com.kuhy.demo.MainActivity"))
|
||||
assert am.variant_problems(app) == []
|
||||
|
||||
|
||||
def test_missing_variant_manifest_fails(tmp_path: Path) -> None:
|
||||
got = am.variant_problems(sandbox_app(tmp_path))
|
||||
assert got == [
|
||||
(
|
||||
f"variant 'sandbox' sets applicationIdSuffix but {SANDBOX} is missing:"
|
||||
' hide it with <category android:name="android.intent.category.LAUNCHER"'
|
||||
' tools:node="remove"/> in .MainActivity\'s intent-filter'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_variant_manifest_without_remove_fails(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(app, SANDBOX, hide(tag=""))
|
||||
assert am.variant_problems(app) == [
|
||||
(
|
||||
f"variant 'sandbox': {SANDBOX} does not remove the LAUNCHER category from"
|
||||
' .MainActivity (tools:node="remove" on the <category>)'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_remove_on_other_activity_fails(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(app, SANDBOX, hide(".OtherActivity"))
|
||||
assert len(am.variant_problems(app)) == 1
|
||||
|
||||
|
||||
def test_removing_main_fails(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(app, SANDBOX, hide(main=REMOVE))
|
||||
assert am.variant_problems(app) == [
|
||||
(
|
||||
f"variant 'sandbox': {SANDBOX} removes android.intent.action.MAIN from"
|
||||
" .MainActivity; keep MAIN so `am start -n` still works"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_removing_whole_activity_fails(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
body = hide().replace(
|
||||
'<activity android:name=".MainActivity">',
|
||||
'<activity android:name=".MainActivity" tools:node="remove">',
|
||||
)
|
||||
write(app, SANDBOX, body)
|
||||
assert "removes android.intent.action.MAIN" in am.variant_problems(app)[0]
|
||||
|
||||
|
||||
def test_removing_main_intent_filter_fails(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(
|
||||
app,
|
||||
SANDBOX,
|
||||
hide(tag="").replace("<intent-filter>", f"<intent-filter{REMOVE}>"),
|
||||
)
|
||||
assert "removes android.intent.action.MAIN" in am.variant_problems(app)[0]
|
||||
|
||||
|
||||
def test_invalid_variant_manifest_fails(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(app, SANDBOX, hide().replace(f" {am.TOOLS_DECL}", ""))
|
||||
got = am.variant_problems(app)
|
||||
assert len(got) == 1 and got[0].startswith(f"{SANDBOX} is not valid XML:")
|
||||
|
||||
|
||||
def test_main_manifest_without_launcher_needs_nothing(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(app, MAIN.as_posix(), f"<manifest {NS}><application/></manifest>")
|
||||
assert am.variant_problems(app) == []
|
||||
|
||||
|
||||
def test_unreadable_main_manifest_needs_nothing(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
write(app, MAIN.as_posix(), "<manifest")
|
||||
assert am.variant_problems(app) == []
|
||||
|
||||
|
||||
def test_activity_alias_is_a_launcher_too(tmp_path: Path) -> None:
|
||||
app = sandbox_app(tmp_path)
|
||||
body = (
|
||||
manifest()
|
||||
.replace("<activity ", "<activity-alias ")
|
||||
.replace("</activity>", "</activity-alias>")
|
||||
)
|
||||
write(app, MAIN.as_posix(), body)
|
||||
got = am.variant_problems(app)
|
||||
assert len(got) == 1 and "is missing" in got[0]
|
||||
|
||||
|
||||
def test_groovy_buildtype_suffix(tmp_path: Path) -> None:
|
||||
gradle = "android { buildTypes { debug { applicationIdSuffix '.debug' } } }\n"
|
||||
app = flutter_app(tmp_path, gradle=gradle, kts=False)
|
||||
write(app, "android/app/src/debug/AndroidManifest.xml", hide())
|
||||
assert am.variant_problems(app) == []
|
||||
130
repo_contract/tests/test_png_ink.py
Normal file
130
repo_contract/tests/test_png_ink.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""The stdlib PNG reader and the family ink-circle rule (rule 4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from repo_contract import png_ink
|
||||
|
||||
from .pngkit import CLEAR, FAINT, SIG, SIZE, chunk, disk_rows, encode, write_icon
|
||||
|
||||
|
||||
def geometry(tmp_path: Path, data: bytes) -> tuple[float, float, float]:
|
||||
path = tmp_path / "icon.png"
|
||||
path.write_bytes(data)
|
||||
return png_ink.ink_geometry(path)
|
||||
|
||||
|
||||
def test_family_disk_measures_340_centred(tmp_path: Path) -> None:
|
||||
radius, cx, cy = png_ink.ink_geometry(write_icon(tmp_path / "i.png"))
|
||||
assert (cx, cy) == (512, 512)
|
||||
assert 338 < radius <= 340
|
||||
|
||||
|
||||
def test_family_icon_passes(tmp_path: Path) -> None:
|
||||
assert png_ink.icon_problems(write_icon(tmp_path / "i.png"), "i.png") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("radius", [380, 300])
|
||||
def test_too_big_or_too_small_fails(tmp_path: Path, radius: int) -> None:
|
||||
got = png_ink.icon_problems(write_icon(tmp_path / "i.png", radius), "i.png")
|
||||
assert len(got) == 1
|
||||
assert got[0].startswith("i.png: ink radius ")
|
||||
assert "340px" in got[0] and "app_icons" in got[0]
|
||||
|
||||
|
||||
def test_within_one_percent_passes(tmp_path: Path) -> None:
|
||||
assert png_ink.icon_problems(write_icon(tmp_path / "i.png", 349), "i.png") == []
|
||||
|
||||
|
||||
def test_off_centre_fails(tmp_path: Path) -> None:
|
||||
path = write_icon(tmp_path / "i.png", 340, cx=520)
|
||||
got = png_ink.icon_problems(path, "i.png")
|
||||
assert got == [
|
||||
"i.png: ink centre (520.0, 512.0) is 8.0px from (512, 512); at most 2px"
|
||||
]
|
||||
|
||||
|
||||
def test_two_px_off_centre_passes(tmp_path: Path) -> None:
|
||||
path = write_icon(tmp_path / "i.png", 340, cx=514)
|
||||
assert png_ink.icon_problems(path, "i.png") == []
|
||||
|
||||
|
||||
def test_every_filter_type_decodes_identically(tmp_path: Path) -> None:
|
||||
rows = disk_rows(100, 500, 520)
|
||||
plain = geometry(tmp_path, encode(rows))
|
||||
mixed = encode(rows, filters=lambda y: y % 5 if 400 <= y < 640 else 0)
|
||||
assert geometry(tmp_path, mixed) == plain
|
||||
|
||||
|
||||
def test_faint_fringe_is_not_ink(tmp_path: Path) -> None:
|
||||
rows = disk_rows(100)
|
||||
rows[0] = FAINT * SIZE
|
||||
assert geometry(tmp_path, encode(rows)) == geometry(
|
||||
tmp_path, encode(disk_rows(100))
|
||||
)
|
||||
|
||||
|
||||
def test_missing_iend_still_decodes(tmp_path: Path) -> None:
|
||||
assert geometry(tmp_path, encode(disk_rows(50), iend=False))[0] > 0
|
||||
|
||||
|
||||
def test_ancillary_chunks_skipped(tmp_path: Path) -> None:
|
||||
data = encode(disk_rows(50))
|
||||
with_text = data[:33] + chunk(b"tEXt", b"k\0v") + data[33:]
|
||||
assert geometry(tmp_path, with_text) == geometry(tmp_path, data)
|
||||
|
||||
|
||||
def _ihdr_only(ihdr: bytes) -> bytes:
|
||||
return SIG + chunk(b"IHDR", ihdr) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def _header(size: int = SIZE, depth: int = 8, colour: int = 6, il: int = 0) -> bytes:
|
||||
return struct.pack(">IIBBBBB", size, size, depth, colour, 0, 0, il)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "message"),
|
||||
[
|
||||
(b"GIF89a", "is not a PNG"),
|
||||
(SIG + chunk(b"IEND", b""), "has no IHDR chunk"),
|
||||
(_ihdr_only(_header(size=512)), "is 512x512; the family canvas is 1024x1024"),
|
||||
(_ihdr_only(_header(colour=2)), "must be 8-bit RGBA, non-interlaced"),
|
||||
(_ihdr_only(_header(depth=16)), "must be 8-bit RGBA, non-interlaced"),
|
||||
(_ihdr_only(_header(il=1)), "must be 8-bit RGBA, non-interlaced"),
|
||||
(
|
||||
SIG + chunk(b"IHDR", _header()) + chunk(b"IDAT", b"not zlib"),
|
||||
"has corrupt image data",
|
||||
),
|
||||
(
|
||||
SIG + chunk(b"IHDR", _header()) + chunk(b"IDAT", zlib.compress(b"\0" * 9)),
|
||||
"has truncated image data",
|
||||
),
|
||||
(encode([CLEAR * SIZE] * SIZE), "has no inked pixel (alpha > 127)"),
|
||||
],
|
||||
)
|
||||
def test_undecodable_fails_closed(tmp_path: Path, data: bytes, message: str) -> None:
|
||||
path = tmp_path / "icon.png"
|
||||
path.write_bytes(data)
|
||||
got = png_ink.icon_problems(path, "icon.png")
|
||||
assert len(got) == 1
|
||||
assert got[0].startswith(f"icon.png {message}")
|
||||
|
||||
|
||||
def test_bad_filter_type_fails_closed(tmp_path: Path) -> None:
|
||||
raw = (b"\x07" + CLEAR * SIZE) * SIZE
|
||||
data = SIG + chunk(b"IHDR", _header()) + chunk(b"IDAT", zlib.compress(raw))
|
||||
path = tmp_path / "icon.png"
|
||||
path.write_bytes(data)
|
||||
assert png_ink.icon_problems(path, "icon.png") == [
|
||||
"icon.png has an invalid PNG filter type 7"
|
||||
]
|
||||
|
||||
|
||||
def test_unreadable_file_fails_closed(tmp_path: Path) -> None:
|
||||
got = png_ink.icon_problems(tmp_path / "missing.png", "missing.png")
|
||||
assert len(got) == 1 and got[0].startswith("missing.png is unreadable:")
|
||||
Loading…
Reference in New Issue
Block a user