mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:47:20 +01:00
Compare commits
6 Commits
focus-proj
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae56037e41 | |||
| eef7af4c86 | |||
| 9daa376254 | |||
| 17e300af0a | |||
| afcc1a47c7 | |||
| 0527bc680d |
@ -4,13 +4,13 @@ description: >-
|
||||
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
|
||||
dialog on desktop, a download on web, plus a versioned export envelope.
|
||||
publish_to: 'none'
|
||||
version: 0.2.1
|
||||
version: 0.2.2
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
dependencies:
|
||||
file_picker: 13.3.2
|
||||
file_picker: 13.3.3
|
||||
file_selector: 1.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
@ -199,6 +199,7 @@ their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day =
|
||||
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
||||
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
||||
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
||||
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
|
||||
|
||||
## Adding a gate
|
||||
|
||||
|
||||
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
|
||||
|
||||
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
|
||||
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
|
||||
itself does not change; this pins that the gate reaches pay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import credit_units, earners_for
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
|
||||
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
|
||||
|
||||
|
||||
def at(hour: int, minute: int) -> float:
|
||||
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_fp(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||
) -> ModuleType:
|
||||
"""The real focus_project on redirected state (HOME, data dir, key)."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
|
||||
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
|
||||
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
|
||||
monkeypatch.syspath_prepend(str(_SIBLING))
|
||||
fp = importlib.import_module("focus_project")
|
||||
fp.create_project(
|
||||
"hr",
|
||||
name="HR",
|
||||
start=DAY,
|
||||
end=DAY,
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=120,
|
||||
unlock_minutes=30,
|
||||
)
|
||||
return fp
|
||||
|
||||
|
||||
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
|
||||
log = tmp_path / "data" / "activity.jsonl"
|
||||
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
|
||||
harvest = importlib.import_module("focus_project.harvest")
|
||||
assert harvest.harvest(now=at(23, 0)) == 0
|
||||
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
|
||||
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
|
||||
|
||||
|
||||
def row(source: str, kind: str, ts: float) -> dict[str, object]:
|
||||
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
|
||||
|
||||
|
||||
TIMER = (
|
||||
row("timer", "start", at(9, 0)),
|
||||
row("timer", "checkin", at(9, 5)),
|
||||
row("timer", "stop", at(9, 10)),
|
||||
)
|
||||
|
||||
|
||||
def test_timer_only_day_pays_zero(
|
||||
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
assert date(2026, 11, 8) == real_fp.BACKING_FROM
|
||||
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
|
||||
|
||||
|
||||
def test_backed_timer_day_still_pays(
|
||||
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
beat = row("tutor", "heartbeat", at(9, 5))
|
||||
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10
|
||||
@ -36,6 +36,10 @@ uncovers today. A NEW project starting today may replace gates and set any targe
|
||||
new obligation, not a reduction (but see the floor).
|
||||
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
|
||||
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
|
||||
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
|
||||
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
|
||||
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
|
||||
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
|
||||
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
||||
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
||||
|
||||
|
||||
@ -31,10 +31,12 @@ from focus_project._update import Update, apply_update, update_project
|
||||
from focus_project.activity import record_activity
|
||||
from focus_project.checks import register_source, run_checks
|
||||
from focus_project.evidence import Evidence
|
||||
from focus_project.minutes import minutes_for
|
||||
from focus_project.minutes import minutes_for, unbacked_minutes_for
|
||||
from focus_project.sources.activity import BACKING_FROM
|
||||
from focus_project.status import status
|
||||
|
||||
__all__ = [
|
||||
"BACKING_FROM",
|
||||
"Evidence",
|
||||
"Limits",
|
||||
"Milestone",
|
||||
@ -59,5 +61,6 @@ __all__ = [
|
||||
"run_checks",
|
||||
"stands_down",
|
||||
"status",
|
||||
"unbacked_minutes_for",
|
||||
"update_project",
|
||||
]
|
||||
|
||||
@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
||||
from focus_project import _api, _interview, evidence
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project._kind import INTERVIEW
|
||||
from focus_project.sources import activity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -30,6 +31,7 @@ if TYPE_CHECKING:
|
||||
_LOG = logging.getLogger(__name__)
|
||||
State = Literal["pending", "done", "missed", "late_done", "unknown"]
|
||||
PHONE_LEAD = timedelta(hours=12)
|
||||
MOCK_SESSION_S = 90 * 60
|
||||
_RED = frozenset({"missed", "unknown"})
|
||||
|
||||
|
||||
@ -134,6 +136,21 @@ def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]:
|
||||
return judge
|
||||
|
||||
|
||||
def _mock(mid: str, rows: Rows, project: Project) -> Completion:
|
||||
"""Done at the first score row that follows a >= 90 min focus session.
|
||||
|
||||
The session (contiguous beats, :func:`activity.sessions`) must END at or
|
||||
before the score row; a score with no such session completes nothing.
|
||||
"""
|
||||
ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S]
|
||||
for row in rows:
|
||||
if (row.milestone, row.kind) == (mid, "score") and any(
|
||||
end <= row.ms / 1000 for end in ends
|
||||
):
|
||||
return row.at, False
|
||||
return None, False
|
||||
|
||||
|
||||
def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion:
|
||||
"""Done at the answer row; an answer with no row was given at creation."""
|
||||
if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers):
|
||||
@ -164,7 +181,7 @@ def _phone(mid: str, rows: Rows, _project: Project) -> Completion:
|
||||
|
||||
_JUDGES = {
|
||||
"rules": _by_evidence("file"),
|
||||
"mock": _by_evidence("score"),
|
||||
"mock": _mock,
|
||||
"trip_ask": _trip_ask,
|
||||
"slot": _slot,
|
||||
"phone": _phone,
|
||||
|
||||
@ -8,6 +8,10 @@ minutes (floor). Time not yet lived is never credited: every interval is
|
||||
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||
(logged): fail closed.
|
||||
|
||||
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
|
||||
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
||||
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -15,9 +19,10 @@ from __future__ import annotations
|
||||
from datetime import date # noqa: TC003
|
||||
import logging
|
||||
|
||||
from focus_project import _api, _clock, carry
|
||||
from focus_project import _api, _clock, carry, sources
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project._own import union_seconds
|
||||
from focus_project.sources import activity
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
@ -38,4 +43,26 @@ def minutes_for(day: date, now: float | None = None) -> int:
|
||||
return carry.balance(day, moment).counted(project.daily_on(day))
|
||||
|
||||
|
||||
__all__ = ["minutes_for", "union_seconds"]
|
||||
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
|
||||
"""Whole minutes of timer time on ``day`` that the gate did not count.
|
||||
|
||||
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
|
||||
or with an unreadable registry (logged).
|
||||
"""
|
||||
try:
|
||||
project = _api.active(day)
|
||||
if project is None:
|
||||
return 0
|
||||
lo, hi = sources.day_bounds(day)
|
||||
bounds = (lo, min(hi, _clock.now() if now is None else now))
|
||||
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
|
||||
counted = sources.clip(sources.collect(day, project), bounds)
|
||||
except (RegistryError, OSError) as exc:
|
||||
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
|
||||
return 0
|
||||
# |unbacked minus counted| = |unbacked union counted| - |counted|.
|
||||
gap = union_seconds(unbacked + counted) - union_seconds(counted)
|
||||
return int(gap // 60)
|
||||
|
||||
|
||||
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]
|
||||
|
||||
@ -13,23 +13,36 @@ heartbeat covering the five minutes before it. Only events recorded for
|
||||
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
|
||||
beat it extends the segment; later it ends the segment at that last beat. It
|
||||
never opens a segment. Other heartbeat sources never chain the timer.
|
||||
|
||||
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
|
||||
if real activity of the same project backs it, whole span or nothing. A
|
||||
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
|
||||
accepted LeetCode submission (leetcode projects only); ``browser`` never
|
||||
backs. A span ``(a, b)`` is backed if a backing event lies in
|
||||
``[a - 300, b]``, or between two consecutive backing events at most
|
||||
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
|
||||
rule (every span counts), so paid days never change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import importlib
|
||||
from itertools import pairwise
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from focus_project._paths import activity_path
|
||||
from focus_project.sources import Interval, heartbeat_interval
|
||||
from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from focus_project._model import Project
|
||||
|
||||
MAX_GAP_S = 600.0
|
||||
BACKING_FROM: Final = date(2026, 11, 8)
|
||||
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
|
||||
BRIDGE_S: Final = 1200.0
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -84,12 +97,68 @@ def _is_beat(event: dict[str, Any]) -> bool:
|
||||
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
|
||||
|
||||
|
||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
|
||||
events = read_events(project.id)
|
||||
def sessions(project_id: str) -> list[Interval]:
|
||||
"""Contiguous focus sessions of ``project_id``, time-ordered, as (start, end).
|
||||
|
||||
Built from the same timer/tutor-beat segments (and so the same
|
||||
:data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one
|
||||
session, which ends at its last beat.
|
||||
"""
|
||||
events = read_events(project_id)
|
||||
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
|
||||
merged: list[Interval] = []
|
||||
for lo, hi in timer_intervals(timer):
|
||||
if merged and lo <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
|
||||
else:
|
||||
merged.append((lo, hi))
|
||||
return merged
|
||||
|
||||
|
||||
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
|
||||
"""Sorted times of the events that may back a timer span.
|
||||
|
||||
The LeetCode ledger is read in its own guard: if it fails, only the
|
||||
submissions are lost, never the heartbeats (fail closed, narrow).
|
||||
"""
|
||||
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
|
||||
try:
|
||||
# Imported lazily: it pulls in earned_time, and the timer must keep
|
||||
# working (heartbeat backing only) where that is not installed.
|
||||
leetcode = importlib.import_module("focus_project.sources.leetcode")
|
||||
times += leetcode.submission_times(project)
|
||||
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
|
||||
_LOG.exception("leetcode backing unavailable, heartbeats only")
|
||||
return sorted(times)
|
||||
|
||||
|
||||
def is_backed(span: Interval, times: list[float]) -> bool:
|
||||
"""Whether a backing event lies in or just before ``span``, or bridges it."""
|
||||
lo, hi = span
|
||||
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
|
||||
return True
|
||||
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
|
||||
|
||||
|
||||
def split_timer(
|
||||
day: date, project: Project, events: list[dict[str, Any]] | None = None
|
||||
) -> tuple[list[Interval], list[Interval]]:
|
||||
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
|
||||
found = read_events(project.id) if events is None else events
|
||||
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
|
||||
spans = timer_intervals(chain)
|
||||
if day < BACKING_FROM:
|
||||
return spans, []
|
||||
times = backing_times(found, project)
|
||||
backed = [s for s in spans if is_backed(s, times)]
|
||||
return backed, [s for s in spans if s not in backed]
|
||||
|
||||
|
||||
def intervals(day: date, project: Project) -> list[Interval]:
|
||||
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
|
||||
events = read_events(project.id)
|
||||
beats = [e for e in events if e.get("source") != "timer"]
|
||||
return [
|
||||
*timer_intervals(timer),
|
||||
*split_timer(day, project, events)[0],
|
||||
*(heartbeat_interval(float(e["ts"])) for e in beats),
|
||||
]
|
||||
|
||||
@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
||||
def submission_times(project: Project) -> list[float]:
|
||||
"""Times of verified accepted submissions; [] for a non-leetcode project."""
|
||||
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
|
||||
return []
|
||||
key = ledger.read_key()
|
||||
@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||
_LOG.warning("leetcode source: no signing key, contributes 0")
|
||||
return []
|
||||
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
|
||||
return [heartbeat_interval(ts) for ts in stamps if ts is not None]
|
||||
return [ts for ts in stamps if ts is not None]
|
||||
|
||||
|
||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
||||
return [heartbeat_interval(ts) for ts in submission_times(project)]
|
||||
|
||||
@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from focus_project import _api, _milestones, carry, evidence
|
||||
from focus_project import _api, _milestones, carry, evidence, minutes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
@ -36,7 +36,8 @@ def status(day: date) -> dict[str, Any]:
|
||||
|
||||
``minutes`` is what was really worked plus what the day took from the
|
||||
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
|
||||
is the capped figure that pays.
|
||||
is the capped figure that pays. ``unbacked_minutes`` is timer time the
|
||||
anti-cheat gate refused (shown as "not counted", never paid).
|
||||
"""
|
||||
project = _api.active(day)
|
||||
if project is None:
|
||||
@ -52,6 +53,7 @@ def status(day: date) -> dict[str, Any]:
|
||||
"unlock_minutes": None,
|
||||
"ends": None,
|
||||
"replaces": [],
|
||||
"unbacked_minutes": 0,
|
||||
"milestones": [],
|
||||
"missed": False,
|
||||
}
|
||||
@ -72,6 +74,7 @@ def status(day: date) -> dict[str, Any]:
|
||||
"unlock_minutes": project.unlock_on(day),
|
||||
"ends": project.last_day.isoformat(),
|
||||
"replaces": list(project.replaces_on(day)),
|
||||
"unbacked_minutes": minutes.unbacked_minutes_for(day),
|
||||
# Interview projects: [{id, due ISO, state}] and whether any is red.
|
||||
"milestones": rows,
|
||||
"missed": missed,
|
||||
|
||||
@ -3,10 +3,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from datetime import date, datetime, time, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project._paths import activity_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -50,3 +52,14 @@ def shot(tmp_path: Path, name: str = "rules.png") -> Path:
|
||||
def states(project: fp.Project, now: datetime) -> dict[str, str]:
|
||||
"""Milestone id -> state."""
|
||||
return {m.id: m.state for m in fp.milestones(project, now)}
|
||||
|
||||
|
||||
def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None:
|
||||
"""A timer session start -> check-in every ``step`` min -> stop."""
|
||||
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
with activity_path().open("a", encoding="utf-8") as handle:
|
||||
for offset in range(0, minutes + 1, step):
|
||||
kind = "start" if offset == 0 else "checkin"
|
||||
when = start + timedelta(minutes=offset)
|
||||
row = {"ts": when.timestamp(), "source": "timer", "kind": kind}
|
||||
handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n")
|
||||
|
||||
@ -137,6 +137,7 @@ def test_status_active_reports_the_worked_minutes() -> None:
|
||||
"unlock_minutes": 30,
|
||||
"ends": "2026-11-05",
|
||||
"replaces": ["leetcode"],
|
||||
"unbacked_minutes": 0,
|
||||
"milestones": [],
|
||||
"missed": False,
|
||||
}
|
||||
@ -160,6 +161,7 @@ def test_status_inactive() -> None:
|
||||
"unlock_minutes",
|
||||
"ends",
|
||||
"replaces",
|
||||
"unbacked_minutes",
|
||||
"milestones",
|
||||
"missed",
|
||||
}
|
||||
|
||||
181
focus_project/focus_project/tests/test_backing.py
Normal file
181
focus_project/focus_project/tests/test_backing.py
Normal file
@ -0,0 +1,181 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""0.10.0: a timer span earns only if a same-project beat backs it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import _api, minutes
|
||||
from focus_project.sources import activity
|
||||
from focus_project.tests import test_minutes as tm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
DAY = date(2026, 11, 8) # BACKING_FROM
|
||||
|
||||
|
||||
def at(hour: int, minute: int = 0) -> float:
|
||||
return tm.at(hour, minute, day=DAY)
|
||||
|
||||
|
||||
def proj() -> None:
|
||||
fp.create_project(
|
||||
"hr",
|
||||
name="HR prep",
|
||||
start=DAY,
|
||||
end=date(2026, 11, 12),
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=120,
|
||||
unlock_minutes=30,
|
||||
site_patterns=["leetcode.com"],
|
||||
)
|
||||
|
||||
|
||||
def rows(*items: tuple[str, str, float]) -> None:
|
||||
tm.events(*items)
|
||||
|
||||
|
||||
def timer_only() -> None:
|
||||
rows(
|
||||
("timer", "start", at(9)),
|
||||
("timer", "checkin", at(9, 5)),
|
||||
("timer", "stop", at(9, 10)),
|
||||
)
|
||||
|
||||
|
||||
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
assert minutes.minutes_for(DAY) == 0
|
||||
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||
assert fp.status(DAY)["unbacked_minutes"] == 10
|
||||
|
||||
|
||||
def test_a_beat_in_the_span_backs_all_of_it() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
rows(("sql_drills", "heartbeat", at(9, 5)))
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_a_beat_backs_only_the_spans_it_touches() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
rows(("sql_drills", "heartbeat", at(9, 7)))
|
||||
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
|
||||
assert minutes.unbacked_minutes_for(DAY) == 2
|
||||
|
||||
|
||||
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
|
||||
proj()
|
||||
rows(
|
||||
("timer", "start", at(9)),
|
||||
("timer", "stop", at(9, 10)),
|
||||
("sql_drills", "heartbeat", at(9, 10)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_a_beat_just_before_the_span_backs_it() -> None:
|
||||
proj()
|
||||
rows(
|
||||
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
|
||||
("timer", "start", at(9)),
|
||||
("timer", "stop", at(9, 5)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
|
||||
|
||||
def test_a_late_beat_does_not_back_it() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
rows(("sql_drills", "heartbeat", at(9, 40)))
|
||||
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
|
||||
proj()
|
||||
rows(
|
||||
("tutor", "heartbeat", at(8, 50)),
|
||||
("timer", "start", at(9)),
|
||||
("timer", "checkin", at(9, 5)),
|
||||
("timer", "stop", at(9, 8)),
|
||||
("tutor", "heartbeat", at(9, 10)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
|
||||
|
||||
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
|
||||
proj()
|
||||
rows(
|
||||
("tutor", "heartbeat", at(8, 30)),
|
||||
("timer", "start", at(9)),
|
||||
("timer", "stop", at(9, 8)),
|
||||
("tutor", "heartbeat", at(9, 40)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||
|
||||
|
||||
def test_browser_never_backs() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
rows(("browser", "heartbeat", at(9, 7)))
|
||||
# The beat pays its own 9:02-9:07 but backs no timer span.
|
||||
assert minutes.unbacked_minutes_for(DAY) == 5
|
||||
assert minutes.minutes_for(DAY) == 5
|
||||
|
||||
|
||||
def test_days_before_backing_from_keep_the_old_rule() -> None:
|
||||
fp.create_project(
|
||||
"hr",
|
||||
name="Old",
|
||||
start=date(2026, 11, 2),
|
||||
end=date(2026, 11, 5),
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=120,
|
||||
unlock_minutes=30,
|
||||
site_patterns=["leetcode.com"],
|
||||
)
|
||||
old = date(2026, 11, 2)
|
||||
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
|
||||
assert minutes.minutes_for(old) == 10
|
||||
assert minutes.unbacked_minutes_for(old) == 0
|
||||
|
||||
|
||||
def test_no_project_is_zero_unbacked() -> None:
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
assert fp.status(DAY)["unbacked_minutes"] == 0
|
||||
|
||||
|
||||
def test_unbacked_is_clipped_at_now() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
|
||||
|
||||
|
||||
def test_split_timer_returns_backed_and_unbacked() -> None:
|
||||
proj()
|
||||
timer_only()
|
||||
project = _api.active(DAY)
|
||||
assert project is not None
|
||||
backed, unbacked = activity.split_timer(DAY, project)
|
||||
assert backed == []
|
||||
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
|
||||
|
||||
|
||||
def test_backing_from_is_exported() -> None:
|
||||
assert date(2026, 11, 8) == fp.BACKING_FROM
|
||||
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
|
||||
|
||||
|
||||
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def boom(_day: date) -> None:
|
||||
raise OSError
|
||||
|
||||
monkeypatch.setattr("focus_project._api.active", boom)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
@ -0,0 +1,131 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
|
||||
|
||||
The core rule lives in ``test_backing.py``; these pin the edges the spec
|
||||
named (C5-C7) with leetcode-ledger and other-project fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import minutes
|
||||
from focus_project._paths import activity_path
|
||||
from focus_project.tests.test_leetcode_source import ac_row, write
|
||||
from focus_project.tests.test_minutes import at
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
DAY = date(2026, 11, 9) # after the cutover
|
||||
PRE = date(2026, 11, 7) # before it
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Nothing may fall back to the real home directory."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
|
||||
|
||||
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
|
||||
fp.create_project(
|
||||
pid,
|
||||
name=pid,
|
||||
start=date(2026, 11, 6),
|
||||
end=date(2026, 11, 12),
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=240,
|
||||
unlock_minutes=30,
|
||||
site_patterns=list(patterns),
|
||||
)
|
||||
|
||||
|
||||
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
|
||||
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
|
||||
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
with activity_path().open("a", encoding="utf-8") as handle:
|
||||
for item in items:
|
||||
source, kind, ts = item[:3]
|
||||
pid = item[3] if len(item) == 4 else "hr"
|
||||
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
|
||||
handle.write(json.dumps(row) + "\n")
|
||||
|
||||
|
||||
def timer(day: date, *marks: tuple[int, int]) -> None:
|
||||
"""A segment: start at the first mark, check-ins, stop at the last."""
|
||||
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
|
||||
rows(
|
||||
*(
|
||||
("timer", k, at(h, m, day=day))
|
||||
for k, (h, m) in zip(kinds, marks, strict=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
|
||||
rows((source, "heartbeat", at(h, m, day=day), pid))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
|
||||
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
|
||||
make()
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
beat(source, 9, 5)
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_browser_and_other_project_beats_do_not_back() -> None:
|
||||
make() # "other" needs no registry entry: rows are filtered by id
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
beat("browser", 9, 4)
|
||||
beat("tutor", 9, 4, pid="other")
|
||||
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
|
||||
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
|
||||
|
||||
|
||||
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
|
||||
make()
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
|
||||
|
||||
|
||||
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
|
||||
make("hr", ("sqlzoo.net",))
|
||||
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||
assert minutes.minutes_for(DAY) == 0
|
||||
|
||||
|
||||
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
|
||||
make()
|
||||
rows(
|
||||
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
|
||||
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
|
||||
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
|
||||
)
|
||||
assert minutes.minutes_for(date(2026, 11, 7)) == 2
|
||||
assert minutes.minutes_for(date(2026, 11, 8)) == 0
|
||||
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
|
||||
|
||||
|
||||
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
|
||||
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
|
||||
|
||||
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
|
||||
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
|
||||
nothing bridges it, so it shows as unbacked.
|
||||
"""
|
||||
make()
|
||||
rows(
|
||||
("timer", "start", at(9, 0, day=DAY)),
|
||||
("timer", "checkin", at(9, 8, day=DAY)),
|
||||
)
|
||||
beat("tutor", 9, 16)
|
||||
assert minutes.minutes_for(DAY) == 8
|
||||
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||
@ -14,6 +14,8 @@ import pytest
|
||||
import focus_project as fp
|
||||
from focus_project import minutes, sources
|
||||
from focus_project.sources import leetcode
|
||||
from focus_project.tests.test_backing import DAY, proj, rows
|
||||
from focus_project.tests.test_backing import at as day_at
|
||||
from focus_project.tests.test_minutes import D1, at, project
|
||||
|
||||
KEY = b"k" * 32
|
||||
@ -127,3 +129,29 @@ def test_read_only_replay_of_real_ledger(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
assert got
|
||||
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
|
||||
assert REAL.stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_accepted_submission_backs_a_timer_span(tmp_path: Path) -> None:
|
||||
"""0.10.0: an accepted submission backs timer time on a backed day."""
|
||||
proj()
|
||||
rows(("timer", "start", day_at(9)), ("timer", "stop", day_at(9, 10)))
|
||||
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||
write(tmp_path, ac_row(day_at(9, 10)))
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
assert minutes.minutes_for(DAY) == 10
|
||||
|
||||
|
||||
def test_unreadable_ledger_leaves_heartbeat_backing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def boom(_project: object) -> list[float]:
|
||||
raise OSError
|
||||
|
||||
monkeypatch.setattr(leetcode, "submission_times", boom)
|
||||
proj()
|
||||
rows(
|
||||
("timer", "start", day_at(9)),
|
||||
("timer", "stop", day_at(9, 10)),
|
||||
("tutor", "heartbeat", day_at(9, 10)),
|
||||
)
|
||||
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||
|
||||
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import evidence
|
||||
from focus_project.tests.helpers import SAT1, SAT2, at, make, shot, states
|
||||
from focus_project.tests.helpers import SAT1, SAT2, at, make, session, shot, states
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -97,6 +97,7 @@ def _clean(tmp_path: Path) -> None:
|
||||
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
|
||||
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
|
||||
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
|
||||
session(at(24, 7), 90)
|
||||
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
|
||||
|
||||
|
||||
@ -147,13 +148,6 @@ def test_on_time_is_done_at_the_boundary(tmp_path: Path) -> None:
|
||||
assert states(p, at(10, 23, 59))["rules"] == "done"
|
||||
|
||||
|
||||
def test_mock_needs_a_score(tmp_path: Path) -> None:
|
||||
p = make()
|
||||
assert states(p, at(26, 8))["mock"] == "missed"
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
|
||||
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
|
||||
|
||||
|
||||
def test_slot_needs_entry_and_confirm_tap() -> None:
|
||||
p = make()
|
||||
fp.record_evidence("iv", "slot", "slot", "2026-11-26T20:00:00", now=at(25, 9))
|
||||
|
||||
61
focus_project/focus_project/tests/test_milestones_mock.py
Normal file
61
focus_project/focus_project/tests/test_milestones_mock.py
Normal file
@ -0,0 +1,61 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The mock milestone: a score counts only after a >= 90 min focus session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project.tests.helpers import at, make, session, states
|
||||
|
||||
|
||||
def test_mock_needs_a_score_after_a_long_session() -> None:
|
||||
p = make()
|
||||
assert states(p, at(26, 8))["mock"] == "missed"
|
||||
session(at(25, 20), 90)
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
|
||||
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
|
||||
|
||||
|
||||
def test_score_without_a_session_does_not_complete_mock() -> None:
|
||||
p = make()
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
assert states(p, at(26, 8))["mock"] == "missed"
|
||||
assert fp.owed_lock(at(26, 8)) is not None
|
||||
|
||||
|
||||
def test_short_session_does_not_complete_mock() -> None:
|
||||
p = make()
|
||||
session(at(24, 7), 89)
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
|
||||
|
||||
def test_session_broken_by_a_gap_does_not_complete_mock() -> None:
|
||||
p = make()
|
||||
session(at(24, 6), 60) # 06:00-07:00, then an 11 min hole (> 600 s)
|
||||
session(at(24, 7, 11), 60)
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
|
||||
|
||||
def test_session_must_end_before_the_score() -> None:
|
||||
p = make()
|
||||
session(at(24, 8), 90) # ends 09:30
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
fp.record_evidence("iv", "mock", "score", 66, now=at(24, 9, 30))
|
||||
assert states(p, at(24, 10))["mock"] == "done" # ends exactly at the score
|
||||
|
||||
|
||||
def test_other_projects_sessions_do_not_count() -> None:
|
||||
p = make()
|
||||
session(at(24, 7), 90, pid="other")
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||
assert states(p, at(24, 10))["mock"] == "pending"
|
||||
|
||||
|
||||
def test_session_on_time_but_score_late_is_late_done() -> None:
|
||||
p = make()
|
||||
session(at(25, 20), 100)
|
||||
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 0, 5))
|
||||
assert states(p, at(26, 1))["mock"] == "late_done"
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "focus_project"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
||||
requires-python = ">=3.14"
|
||||
# Stdlib only on purpose: guards import it from the system python.
|
||||
|
||||
@ -93,6 +93,14 @@ 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: no Google non-free sign-in
|
||||
|
||||
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
|
||||
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
|
||||
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
|
||||
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
|
||||
in a built APK with `--apk`; apps use `google_account_token` instead.
|
||||
|
||||
## Flutter apps: the launcher rules
|
||||
|
||||
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
||||
|
||||
@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
|
||||
from repo_contract import bootstrap
|
||||
from repo_contract.check import violations
|
||||
from repo_contract.infer import propose
|
||||
from repo_contract.install_gms import install_gms, plan_gms
|
||||
from repo_contract.install_web import install_web, plan_web
|
||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||
from repo_contract.stacks import detect_stacks
|
||||
@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]:
|
||||
|
||||
A website (detected or declared in repo-contract.toml) also gets the
|
||||
AgentReady gate: config, shim, workflow and hook (see install_web.py).
|
||||
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
|
||||
"""
|
||||
todo = []
|
||||
if _needs_shim(repo):
|
||||
@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]:
|
||||
todo.append(str(WORKFLOW))
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||
return todo + plan_web(repo)
|
||||
return todo + plan_web(repo) + plan_gms(repo)
|
||||
|
||||
|
||||
def install(repo: Path) -> list[str]:
|
||||
@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]:
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
write_hook(repo, _template("pre-commit-snippet.yaml"))
|
||||
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||
return done + install_web(repo)
|
||||
return done + install_web(repo) + install_gms(repo)
|
||||
|
||||
|
||||
def bootstrap_commands(repo: Path) -> list[str]:
|
||||
|
||||
62
repo_contract/install_gms.py
Normal file
62
repo_contract/install_gms.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
|
||||
|
||||
Why it rides on install_repo_contract_gate.sh: that installer is the one step
|
||||
every new repo gets after `git init`, so a new Flutter app cannot come out of
|
||||
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
|
||||
utils/google_account_token in November 2026). Pieces: the
|
||||
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
|
||||
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
|
||||
level down, e.g. tutor/app) get it; utils itself holds the real gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||
|
||||
TEMPLATES = Path(__file__).resolve().parent / "templates"
|
||||
SHIM = Path("scripts/check_no_gms_signin.sh")
|
||||
HOOK_ID = "no-gms-signin"
|
||||
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
|
||||
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
|
||||
|
||||
|
||||
def _template(name: str) -> str:
|
||||
return (TEMPLATES / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def is_flutter_android(repo: Path) -> bool:
|
||||
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
|
||||
if (repo / "repo_contract" / "check.py").is_file():
|
||||
return False # utils: SHIM is the real gate, never a delegate
|
||||
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
|
||||
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
|
||||
|
||||
|
||||
def plan_gms(repo: Path) -> list[str]:
|
||||
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
|
||||
if not is_flutter_android(repo):
|
||||
return []
|
||||
todo = []
|
||||
shim = repo / SHIM
|
||||
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
|
||||
SHIM_TEMPLATE
|
||||
):
|
||||
todo.append(str(SHIM))
|
||||
if needs_hook(repo, HOOK_ID):
|
||||
todo.append(HOOK_PIECE)
|
||||
return todo
|
||||
|
||||
|
||||
def install_gms(repo: Path) -> list[str]:
|
||||
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
|
||||
todo = plan_gms(repo)
|
||||
if str(SHIM) in todo:
|
||||
target = repo / SHIM
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
|
||||
target.chmod(0o755)
|
||||
if HOOK_PIECE in todo:
|
||||
write_hook(repo, _template("no-gms-signin-hook.yaml"))
|
||||
return todo
|
||||
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if this app links Google's non-free sign-in (google_sign_in,
|
||||
# play-services-auth, googleid) instead of google_account_token.
|
||||
#
|
||||
# Thin delegate to the shared gate in ~/src/utils, which owns the package
|
||||
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
|
||||
# in a repo; never copy the shared gate itself.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
|
||||
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
|
||||
# No Google non-free sign-in: google_sign_in links play-services-auth /
|
||||
# googleid into the APK; apps use utils/google_account_token instead.
|
||||
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
|
||||
# pull or merge, and a staged-only trigger would never look again.
|
||||
- id: no-gms-signin
|
||||
name: no google_sign_in / GMS auth (use google_account_token)
|
||||
entry: scripts/check_no_gms_signin.sh .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
77
repo_contract/tests/test_install_gms.py
Normal file
77
repo_contract/tests/test_install_gms.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from repo_contract import install as inst
|
||||
from repo_contract import install_gms as gms
|
||||
|
||||
from .conftest import write
|
||||
|
||||
|
||||
def flutter_android(root: Path, sub: str = "") -> Path:
|
||||
"""A Flutter project at `root/sub` with an android/ dir."""
|
||||
base = f"{sub}/" if sub else ""
|
||||
write(root, f"{base}pubspec.yaml", "name: x\n")
|
||||
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
|
||||
return root
|
||||
|
||||
|
||||
def test_no_pubspec_means_no_gate(repo: Path) -> None:
|
||||
assert gms.plan_gms(repo) == []
|
||||
assert gms.install_gms(repo) == []
|
||||
|
||||
|
||||
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
|
||||
write(repo, "pubspec.yaml", "name: x\n")
|
||||
assert gms.plan_gms(repo) == []
|
||||
|
||||
|
||||
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
shim = repo / gms.SHIM
|
||||
assert shim.stat().st_mode & stat.S_IXUSR
|
||||
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
|
||||
assert f"id: {gms.HOOK_ID}" in config
|
||||
assert "always_run: true" in config
|
||||
assert gms.plan_gms(repo) == []
|
||||
assert gms.install_gms(repo) == []
|
||||
|
||||
|
||||
def test_nested_app_dir_counts(repo: Path) -> None:
|
||||
flutter_android(repo, "app")
|
||||
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||
|
||||
|
||||
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
gms.install_gms(repo)
|
||||
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
|
||||
assert gms.install_gms(repo) == [str(gms.SHIM)]
|
||||
|
||||
|
||||
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
|
||||
flutter_android(repo, "google_account_token")
|
||||
write(repo, "repo_contract/check.py")
|
||||
assert gms.plan_gms(repo) == []
|
||||
|
||||
|
||||
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
|
||||
flutter_android(repo)
|
||||
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
|
||||
gms.install_gms(repo)
|
||||
utils_root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
["bash", str(repo / gms.SHIM), str(repo)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
assert proc.returncode == 1
|
||||
assert "google_account_token" in proc.stderr
|
||||
193
scripts/check_no_gms_signin.sh
Executable file
193
scripts/check_no_gms_signin.sh
Executable file
@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
|
||||
#
|
||||
# Every app signs in through kuhy's FOSS plugin google_account_token
|
||||
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
|
||||
# google_sign_in_android, which links play-services-auth / googleid into the
|
||||
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
|
||||
# 2026-11-10; nothing else stops a later session from re-adding it.
|
||||
#
|
||||
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
|
||||
# files under each lock's sibling android/ dir. Web-only pieces
|
||||
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
|
||||
# google_identity_services_web) are allowed while google_sign_in_android is
|
||||
# absent: kartka's web build uses them for the GIS button and they never
|
||||
# reach an APK. APK mode looks for the GMS auth library marker files in the
|
||||
# built APK, the ground truth when a dependency arrives some other way.
|
||||
#
|
||||
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
|
||||
# check_no_gms_signin.sh --apk app.apk
|
||||
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
|
||||
# With no arguments, checks ./pubspec.lock (passes if there is none).
|
||||
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
|
||||
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
|
||||
google_identity_services_web)
|
||||
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
|
||||
# Anchored so com.google.gms:google-services (Firebase) and
|
||||
# play-services-auth-api-phone do not trip it.
|
||||
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
|
||||
# APK mode: library marker files AGP copies to the APK root (or META-INF),
|
||||
# one per linked artifact. Dex strings are not used: play-services-base and
|
||||
# -basement reference GoogleSignInAccount without linking play-services-auth.
|
||||
# play-services-fido (passkeys) is deliberately allowed.
|
||||
readonly APK_MARKERS=(
|
||||
googleid.properties
|
||||
play-services-auth.properties
|
||||
play-services-auth-base.properties
|
||||
play-services-auth-api-phone.properties
|
||||
play-services-auth-blockstore.properties
|
||||
play-services-identity-credentials.properties
|
||||
META-INF/androidx.credentials_credentials-play-services-auth.version
|
||||
)
|
||||
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
|
||||
-o -name node_modules)
|
||||
|
||||
usage() {
|
||||
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
|
||||
echo " $SCRIPT_NAME --apk app.apk"
|
||||
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
fix_hint() {
|
||||
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
|
||||
echo " google_account_token:" >&2
|
||||
echo " git:" >&2
|
||||
echo " url: https://github.com/kuhyx/utils" >&2
|
||||
echo " ref: google_account_token-v0.1.0" >&2
|
||||
echo " path: google_account_token" >&2
|
||||
echo " See utils/google_account_token/README.md." >&2
|
||||
}
|
||||
|
||||
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
|
||||
lock_packages() {
|
||||
awk '
|
||||
/^packages:[[:space:]]*$/ { in_packages = 1; next }
|
||||
/^[^[:space:]]/ { in_packages = 0; next }
|
||||
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
|
||||
sub(/^ /, ""); sub(/:.*/, ""); print
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Prints the offending packages of one lock, one per line.
|
||||
lock_offenders() {
|
||||
local -A present=()
|
||||
local name
|
||||
while IFS= read -r name; do
|
||||
present["$name"]=1
|
||||
done < <(lock_packages "$1")
|
||||
for name in "${BANNED[@]}"; do
|
||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||
done
|
||||
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
|
||||
for name in "${WEB_ONLY[@]}"; do
|
||||
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||
done
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Prints file:line:text for every non-comment GMS sign-in coordinate under
|
||||
# the lock's sibling android/ dir.
|
||||
gradle_offenders() {
|
||||
local android_dir="$1/android" file
|
||||
[[ -d "$android_dir" ]] || return 0
|
||||
while IFS= read -r -d '' file; do
|
||||
grep -nE "$GRADLE_PATTERN" "$file" \
|
||||
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
|
||||
| sed "s|^|$file:|" || true
|
||||
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
|
||||
}
|
||||
|
||||
indent() {
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
}
|
||||
|
||||
check_lock() {
|
||||
local lock="$1" offenders gradle
|
||||
offenders="$(lock_offenders "$lock")"
|
||||
gradle="$(gradle_offenders "$(dirname "$lock")")"
|
||||
[[ -z "$offenders" && -z "$gradle" ]] && return 0
|
||||
if [[ -n "$offenders" ]]; then
|
||||
echo "Error: $lock links Google's non-free sign-in:" >&2
|
||||
indent <<<"$offenders" >&2
|
||||
fi
|
||||
if [[ -n "$gradle" ]]; then
|
||||
echo "Error: gradle names the GMS sign-in stack:" >&2
|
||||
indent <<<"$gradle" >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
check_apk() {
|
||||
local apk="$1" entries found=()
|
||||
local marker
|
||||
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
|
||||
entries="$(unzip -Z1 "$apk")"
|
||||
for marker in "${APK_MARKERS[@]}"; do
|
||||
grep -qxF -- "$marker" <<<"$entries" && found+=("$marker")
|
||||
done
|
||||
[[ "${#found[@]}" -eq 0 ]] && return 0
|
||||
echo "Error: $apk bundles Google's non-free sign-in libraries:" >&2
|
||||
printf ' %s\n' "${found[@]}" >&2
|
||||
fix_hint
|
||||
return 1
|
||||
}
|
||||
|
||||
# Expands the arguments into pubspec.lock paths, NUL-separated.
|
||||
collect_locks() {
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if [[ -d "$arg" ]]; then
|
||||
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||
-name pubspec.lock -print0
|
||||
else
|
||||
printf '%s\0' "$arg"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
--apk)
|
||||
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
|
||||
check_apk "$2"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
if [[ $# -eq 0 ]]; then
|
||||
[[ -f pubspec.lock ]] || return 0
|
||||
set -- pubspec.lock
|
||||
fi
|
||||
|
||||
local -a locks=()
|
||||
local arg lock failed=0
|
||||
# Validated up front: collect_locks runs in a process substitution, whose
|
||||
# exit status would be lost, and a typo'd path must not pass silently.
|
||||
for arg in "$@"; do
|
||||
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
|
||||
done
|
||||
mapfile -d '' locks < <(collect_locks "$@")
|
||||
for lock in "${locks[@]}"; do
|
||||
check_lock "$lock" || failed=1
|
||||
done
|
||||
[[ "$failed" -eq 0 ]] || fix_hint
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -9,6 +9,7 @@
|
||||
# when the repo has .dep-freshness-no-workflow), Commands section. A website
|
||||
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
|
||||
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
|
||||
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
|
||||
#
|
||||
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
|
||||
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo
|
||||
|
||||
195
scripts/tests/test_check_no_gms_signin.py
Normal file
195
scripts/tests/test_check_no_gms_signin.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
|
||||
|
||||
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
|
||||
android/ gradle files) or a fake APK built with zipfile, runs the real
|
||||
script, and asserts on the exit code and the message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
|
||||
|
||||
# The package keys of kuhy's real kartka lock (web-only GIS button).
|
||||
KARTKA_KEYS = (
|
||||
"google_account_token",
|
||||
"google_identity_services_web",
|
||||
"google_sign_in_platform_interface",
|
||||
"google_sign_in_web",
|
||||
)
|
||||
|
||||
|
||||
def _lock(root: Path, *packages: str) -> Path:
|
||||
"""Write a pubspec.lock under `root` with the given package keys."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
body = ["# Generated by pub", "packages:"]
|
||||
for name in ("collection", *packages):
|
||||
body += [
|
||||
f" {name}:",
|
||||
' dependency: "direct main"',
|
||||
" description:",
|
||||
f" name: {name}",
|
||||
" source: hosted",
|
||||
' version: "1.0.0"',
|
||||
]
|
||||
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
|
||||
lock = root / "pubspec.lock"
|
||||
lock.write_text("\n".join(body), encoding="utf-8")
|
||||
return lock
|
||||
|
||||
|
||||
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["bash", str(SCRIPT), *map(str, args)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_clean_lock_passes(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path, "google_account_token", "http")
|
||||
assert _run(lock).returncode == 0
|
||||
|
||||
|
||||
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path, "google_sign_in_android")
|
||||
proc = _run(lock)
|
||||
assert proc.returncode == 1
|
||||
assert str(lock) in proc.stderr
|
||||
assert "google_sign_in_android" in proc.stderr
|
||||
assert "google_account_token-v0.1.0" in proc.stderr
|
||||
assert "google_account_token/README.md" in proc.stderr
|
||||
|
||||
|
||||
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
|
||||
proc = _run(_lock(tmp_path, "google_sign_in"))
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
|
||||
|
||||
|
||||
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
|
||||
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
|
||||
|
||||
|
||||
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
|
||||
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in_web" in proc.stderr
|
||||
|
||||
|
||||
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
|
||||
gradle.parent.mkdir(parents=True)
|
||||
gradle.write_text(
|
||||
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
|
||||
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = _run(lock)
|
||||
assert proc.returncode == 1
|
||||
assert str(gradle) in proc.stderr
|
||||
assert "play-services-auth" in proc.stderr
|
||||
|
||||
|
||||
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
|
||||
toml.parent.mkdir(parents=True)
|
||||
toml.write_text(
|
||||
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _run(lock).returncode == 1
|
||||
|
||||
|
||||
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
|
||||
lock = _lock(tmp_path)
|
||||
gradle = tmp_path / "android" / "build.gradle"
|
||||
gradle.parent.mkdir(parents=True)
|
||||
gradle.write_text(
|
||||
"// play-services-auth was removed 2026-11-02\n"
|
||||
"classpath 'com.google.gms:google-services:4.4.2'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _run(lock).returncode == 0
|
||||
|
||||
|
||||
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
|
||||
_lock(tmp_path / "app", "google_account_token")
|
||||
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
|
||||
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
|
||||
# Agent worktrees are other checkouts, judged in their own repo.
|
||||
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
|
||||
assert _run(tmp_path).returncode == 0
|
||||
_lock(tmp_path / "other", "google_sign_in_ios")
|
||||
proc = _run(tmp_path)
|
||||
assert proc.returncode == 1
|
||||
assert "google_sign_in_ios" in proc.stderr
|
||||
|
||||
|
||||
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||
assert _run(tmp_path / "nope.lock").returncode == 2
|
||||
|
||||
|
||||
CREDENTIALS_MARKER = "META-INF/androidx.credentials_credentials-play-services-auth.version"
|
||||
DEX_REF = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignInAccount;\x00" * 2
|
||||
|
||||
|
||||
def _apk(path: Path, dex: bytes = b"", markers: tuple[str, ...] = ()) -> Path:
|
||||
"""Fake APK: dex strings plus library marker files AGP copies in."""
|
||||
with zipfile.ZipFile(path, "w") as apk:
|
||||
apk.writestr("AndroidManifest.xml", b"\x03\x00")
|
||||
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
|
||||
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
|
||||
for name in markers:
|
||||
apk.writestr(name, b"version=1")
|
||||
return path
|
||||
|
||||
|
||||
def test_apk_with_only_dex_references_passes(tmp_path: Path) -> None:
|
||||
"""play-services-base references GoogleSignInAccount without linking auth."""
|
||||
markers = (
|
||||
"play-services-base.properties",
|
||||
"play-services-basement.properties",
|
||||
"play-services-tasks.properties",
|
||||
"play-services-fido.properties",
|
||||
)
|
||||
proc = _run("--apk", _apk(tmp_path / "ok.apk", DEX_REF, markers))
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
|
||||
def test_apk_without_markers_passes(tmp_path: Path) -> None:
|
||||
assert _run("--apk", _apk(tmp_path / "clean.apk")).returncode == 0
|
||||
|
||||
|
||||
def test_apk_banned_markers_fail_and_are_named(tmp_path: Path) -> None:
|
||||
banned = (
|
||||
"play-services-auth.properties",
|
||||
"googleid.properties",
|
||||
"play-services-auth-base.properties",
|
||||
"play-services-auth-api-phone.properties",
|
||||
"play-services-auth-blockstore.properties",
|
||||
"play-services-identity-credentials.properties",
|
||||
CREDENTIALS_MARKER,
|
||||
)
|
||||
for marker in banned:
|
||||
apk = _apk(tmp_path / "bad.apk", DEX_REF, ("play-services-base.properties", marker))
|
||||
proc = _run("--apk", apk)
|
||||
assert proc.returncode == 1, marker
|
||||
assert marker in proc.stderr
|
||||
assert "google_account_token" in proc.stderr
|
||||
|
||||
|
||||
def test_apk_nested_marker_name_is_not_a_root_marker(tmp_path: Path) -> None:
|
||||
"""Only APK-root files count; a same-named file in a subdir is not AGP's."""
|
||||
apk = _apk(tmp_path / "nested.apk", markers=("assets/play-services-auth.properties",))
|
||||
assert _run("--apk", apk).returncode == 0
|
||||
|
||||
|
||||
def test_apk_missing_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||
assert _run("--apk", tmp_path / "nope.apk").returncode == 2
|
||||
@ -5,7 +5,7 @@ description: >-
|
||||
injected closures -- no keystore or platform plugin access lives in this
|
||||
package itself.
|
||||
publish_to: 'none'
|
||||
version: 0.6.2
|
||||
version: 0.6.3
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -19,7 +19,7 @@ dependencies:
|
||||
data_transfer_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: data_transfer_flutter-v0.2.1
|
||||
ref: data_transfer_flutter-v0.2.2
|
||||
path: data_transfer_flutter
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
Loading…
Reference in New Issue
Block a user