mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:07:15 +01:00
The three Python packages pinned ruff twice at different versions: requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the older one ever ran, so `select = ["ALL"]` was enforced two minor versions behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and no other rule. Resolved by adding the notice rather than ignoring the rule, so the gate keeps its teeth. Two gatelock modules sat at exactly the 250-line cap, so a one-line header tipped them to 251. Both are split along a real seam rather than shaved: _widget_fills.py colour arithmetic over LockConfig, no tkinter import _surfaces_focus.py selects among existing surfaces, never mutates them The focus split keeps _surfaces.py's documented containment (it stays the only module that creates, moves or destroys windows), and the static guard in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is covered with no list to extend. widgets.py re-exports the fills helpers, which keeps gatelock.widgets the single public surface its tests import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
250 lines
7.8 KiB
Python
250 lines
7.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Tests for ``main()`` in ``tool/migrate_github_to_firebase``.
|
|
|
|
Split from :mod:`test_tool_migrate_github` to stay under the 250-line cap.
|
|
|
|
This is a one-shot destructive-ish migration, so the properties under test are
|
|
the safety ones: ``--dry-run`` must write nothing at all, the byte-for-byte
|
|
read-back must actually fail the run when a file does not round-trip, and the
|
|
report must be emitted *before* anything is written. ``main()`` reads argv
|
|
directly, so every test patches ``sys.argv``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tool import migrate_github_to_firebase as mg
|
|
|
|
_BLOBS = [
|
|
mg.Blob(path="todo-sync/notes/a.json", text='{"a": 1}'),
|
|
mg.Blob(path="diet-guard-sync/devices/pc/log.json", text='{"b": 2}'),
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def github() -> MagicMock:
|
|
"""Patch the whole GitHub-reading half with two migratable blobs."""
|
|
entries = [(blob.path, f"sha-{index}") for index, blob in enumerate(_BLOBS)]
|
|
entries.append(("todo-sync/changesets/old.json", "sha-skip"))
|
|
|
|
with (
|
|
patch.object(mg, "_github_session") as session,
|
|
patch.object(mg, "_list_blobs", return_value=entries),
|
|
patch.object(
|
|
mg, "_fetch_blob", side_effect=lambda _s, path, _sha: _by_path(path)
|
|
),
|
|
patch.object(mg, "_GITHUB_TOKEN_FILE", _token_file()),
|
|
):
|
|
yield session
|
|
|
|
|
|
def _token_file() -> MagicMock:
|
|
"""A stand-in for the token path whose read_text yields a padded token."""
|
|
token_file = MagicMock()
|
|
token_file.read_text.return_value = "the-token\n"
|
|
return token_file
|
|
|
|
|
|
def _by_path(path: str) -> mg.Blob:
|
|
"""Return the canned blob for ``path``."""
|
|
return next(blob for blob in _BLOBS if blob.path == path)
|
|
|
|
|
|
def _client_that_round_trips() -> MagicMock:
|
|
"""A Firebase client whose read-back matches what was written."""
|
|
written: dict[str, str] = {}
|
|
client = MagicMock()
|
|
client.put_file_text.side_effect = lambda path, text, **_: written.__setitem__(
|
|
path, text
|
|
)
|
|
client.get_file_text.side_effect = written.get
|
|
return client
|
|
|
|
|
|
def test_dry_run_writes_nothing(github: MagicMock) -> None:
|
|
"""The whole point of the flag: no client is even constructed."""
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate", "--dry-run"]),
|
|
patch.object(mg, "_firebase_client") as client_for,
|
|
):
|
|
code = mg.main()
|
|
|
|
assert code == 0
|
|
client_for.assert_not_called()
|
|
|
|
|
|
def test_dry_run_still_reports_what_would_move(
|
|
github: MagicMock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A dry run that printed nothing would be useless."""
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate", "--dry-run"]),
|
|
patch.object(mg, "_firebase_client"),
|
|
caplog.at_level(logging.INFO, logger="migrate"),
|
|
):
|
|
mg.main()
|
|
|
|
assert "2 files to migrate" in caplog.text
|
|
assert "DRY RUN" in caplog.text
|
|
|
|
|
|
def test_the_skipped_paths_are_listed_with_their_reason(
|
|
github: MagicMock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Silently dropping files is the failure mode this reporting prevents."""
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate", "--dry-run"]),
|
|
patch.object(mg, "_firebase_client"),
|
|
caplog.at_level(logging.INFO, logger="migrate"),
|
|
):
|
|
mg.main()
|
|
|
|
assert "1 skipped" in caplog.text
|
|
assert "todo-sync/changesets/old.json" in caplog.text
|
|
|
|
|
|
def test_skipped_files_are_never_fetched(github: MagicMock) -> None:
|
|
"""Fetching then discarding would pay the egress the skip list avoids."""
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate", "--dry-run"]),
|
|
patch.object(mg, "_firebase_client"),
|
|
patch.object(
|
|
mg, "_fetch_blob", side_effect=lambda _s, p, _sha: _by_path(p)
|
|
) as f,
|
|
):
|
|
mg.main()
|
|
|
|
fetched = {c.args[1] for c in f.call_args_list}
|
|
assert fetched == {blob.path for blob in _BLOBS}
|
|
|
|
|
|
def test_a_real_run_writes_every_blob_and_verifies(github: MagicMock) -> None:
|
|
"""The happy path: written, read back, byte-for-byte equal."""
|
|
client = _client_that_round_trips()
|
|
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate"]),
|
|
patch.object(mg, "_firebase_client", return_value=client),
|
|
):
|
|
code = mg.main()
|
|
|
|
assert code == 0
|
|
assert client.put_file_text.call_count == 2
|
|
assert client.get_file_text.call_count == 2
|
|
|
|
|
|
def test_the_report_is_emitted_before_anything_is_written(
|
|
github: MagicMock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Ordering matters: the operator sees the plan before it executes."""
|
|
order: list[str] = []
|
|
client = _client_that_round_trips()
|
|
client.put_file_text.side_effect = lambda *a, **k: order.append("write")
|
|
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate"]),
|
|
patch.object(mg, "_firebase_client", return_value=client),
|
|
patch.object(mg, "_report", side_effect=lambda *_: order.append("report")),
|
|
):
|
|
mg.main()
|
|
|
|
assert order[0] == "report"
|
|
assert "write" in order
|
|
|
|
|
|
def test_a_file_that_does_not_round_trip_fails_the_run(github: MagicMock) -> None:
|
|
"""The check that makes 'OK' mean something.
|
|
|
|
Without it a migration that silently dropped a file would report success,
|
|
which is the one outcome worse than failing loudly.
|
|
"""
|
|
client = MagicMock()
|
|
client.get_file_text.return_value = "something else entirely"
|
|
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate"]),
|
|
patch.object(mg, "_firebase_client", return_value=client),
|
|
):
|
|
code = mg.main()
|
|
|
|
assert code == 1
|
|
|
|
|
|
def test_every_mismatching_path_is_named(
|
|
github: MagicMock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Reporting only the first would need N runs to find N problems."""
|
|
client = MagicMock()
|
|
client.get_file_text.return_value = None
|
|
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate"]),
|
|
patch.object(mg, "_firebase_client", return_value=client),
|
|
caplog.at_level(logging.ERROR, logger="migrate"),
|
|
):
|
|
mg.main()
|
|
|
|
assert "2 file(s) did not round-trip" in caplog.text
|
|
for blob in _BLOBS:
|
|
assert blob.path in caplog.text
|
|
|
|
|
|
def test_a_partial_mismatch_still_fails(github: MagicMock) -> None:
|
|
"""One bad file out of two is a failed migration, not a 50% success."""
|
|
client = MagicMock()
|
|
client.get_file_text.side_effect = [_BLOBS[0].text, "corrupted"]
|
|
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate"]),
|
|
patch.object(mg, "_firebase_client", return_value=client),
|
|
):
|
|
assert mg.main() == 1
|
|
|
|
|
|
def test_the_github_token_is_read_and_stripped(github: MagicMock) -> None:
|
|
"""A trailing newline in the token file would break the auth header."""
|
|
client = _client_that_round_trips()
|
|
|
|
with (
|
|
patch.object(mg.sys, "argv", ["migrate"]),
|
|
patch.object(mg, "_firebase_client", return_value=client),
|
|
):
|
|
mg.main()
|
|
|
|
github.assert_called_once_with("the-token")
|
|
|
|
|
|
def test_report_lists_the_largest_file_first(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Size order is what makes the report scannable for the costly ones."""
|
|
blobs = [
|
|
mg.Blob(path="small.json", text="x"),
|
|
mg.Blob(path="large.json", text="x" * 100),
|
|
]
|
|
|
|
with caplog.at_level(logging.INFO, logger="migrate"):
|
|
mg._report(blobs, [])
|
|
|
|
listed = [r.getMessage() for r in caplog.records if ".json" in r.getMessage()]
|
|
assert "large.json" in listed[0]
|
|
assert "small.json" in listed[1]
|
|
|
|
|
|
def test_report_omits_the_skipped_section_when_nothing_was_skipped(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""An empty 'skipped' heading is noise that hides the real one."""
|
|
with caplog.at_level(logging.INFO, logger="migrate"):
|
|
mg._report([mg.Blob(path="a.json", text="x")], [])
|
|
|
|
assert "skipped" not in caplog.text
|