mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
tool/tagger.py did not run at all: 48957a1 renamed pack.default_dir to
default_dirs and missed the caller, so the tagger raised ImportError at
module scope through two releases. Nothing could have caught it -- tool/ is
outside --cov and mypy ran with [signal_bot, fake_signal]. Both are widened
here, and tests/test_tool_imports.py imports every script under tool/.
--import was a filename check and shutil.copy2. Pointed at a phone export
that overlaps the album it re-imported the same photo under whatever name
the exporter used that time. It now identifies files by magic bytes, strips
them through the same media_strip path !add_photo uses, and dedups on exact
bytes twice: the source hash, then -- only for what survives -- the hash
after stripping. sources.json records both, so a second run on the same
folder is a no-op. What the album refuses is moved to _skipped/<reason>/
beside the source, never deleted.
Two things that made "strip on import" impossible until now:
media_tags claims -all= zeroes the QuickTime date tags and allows them by
value. It does not; measured, it leaves CreateDate at its real value, and
every mp4 was being refused. -time:all= is what zeroes them.
And a tag allowlist cannot see the worst class at all. IMG_6924.mp4 carries
mebx timed-metadata holding per-frame face bounding boxes and a face id,
and those survive a complete tag strip because they live in mdat as opaque
samples. media_tracks.py drops the track with ffmpeg and verifies with
ffprobe that only video and audio survived. This is not the ffmpeg mistake
media_strip warns about: that was ffmpeg replacing exiftool for tags, this
is track removal with exiftool still doing every tag, and the gate is a
positive read-back rather than an exit code.
The roster was write-once. The setup form only appeared on an empty album,
Album.setup overwrote the file whole, and keys were positional (p{len+1}),
so inserting somebody renumbered everyone after them and repointed every
tags.json entry at the wrong person. Keys now come from the highest number
in use, the form merges, and a person can be added mid-session.
--untagged filters the page and never the server's list. /media/{index} and
/api/tag both index the full album, so a shorter list server-side would
show item i of the album while tagging item i of the filtered set.
Measured on the live album: 174 source files -> 1 not media, 82 already in
pack, 24 duplicate in batch, 67 imported, and re-running imports nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
141 lines
5.7 KiB
Python
141 lines
5.7 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Appending to a roster that already exists, without renumbering it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from signal_bot.groups import ConfigError
|
|
from signal_bot.pack import PEOPLE_FILE
|
|
from signal_bot.pack_custom import CUSTOM_FILE
|
|
from signal_bot.pack_people import add_person, merge, next_key, normalise, reserved
|
|
from tests.conftest import WATCHED_GROUP
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def _roster(tmp_path: Path, people: list[dict[str, Any]]) -> Path:
|
|
(tmp_path / PEOPLE_FILE).write_text(
|
|
json.dumps({"groups": [WATCHED_GROUP], "people": people}), encoding="utf-8"
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
def test_the_next_key_is_one_past_the_highest_not_the_count() -> None:
|
|
# p2 deleted by hand: a count-based key would hand out p3 again and the
|
|
# new person would silently inherit the old p3's photos.
|
|
assert next_key([{"key": "p1"}, {"key": "p3"}]) == "p4"
|
|
|
|
|
|
def test_the_next_key_on_an_empty_roster() -> None:
|
|
assert next_key([]) == "p1"
|
|
|
|
|
|
def test_the_next_key_ignores_entries_that_are_not_keys() -> None:
|
|
assert next_key([{"key": "pX"}, {"key": 7}, {}, {"key": "p2"}]) == "p3"
|
|
|
|
|
|
def test_a_trigger_may_be_typed_with_or_without_the_bang() -> None:
|
|
assert normalise([" alfa ", "!beta", "", " "]) == ["!alfa", "!beta"]
|
|
|
|
|
|
def test_the_bot_s_own_commands_are_known() -> None:
|
|
# Folded, and folding keeps the "!" -- which is why add_person compares
|
|
# against normalise()'s "!"-prefixed spelling and not the bare word.
|
|
assert "!kot" in reserved()
|
|
assert not any(word.startswith("_") for word in reserved())
|
|
|
|
|
|
def test_a_person_is_appended_with_a_stable_key(tmp_path: Path) -> None:
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
assert add_person(directory, ["delta"]) == "p2"
|
|
document = json.loads((directory / PEOPLE_FILE).read_text(encoding="utf-8"))
|
|
assert document["people"][0] == {"key": "p1", "triggers": ["!alfa"]}
|
|
assert document["people"][1] == {"key": "p2", "triggers": ["!delta"]}
|
|
|
|
|
|
def test_a_person_can_be_added_to_an_album_with_no_roster_yet(tmp_path: Path) -> None:
|
|
(tmp_path / PEOPLE_FILE).write_text(json.dumps({"groups": [WATCHED_GROUP]}), encoding="utf-8")
|
|
assert add_person(tmp_path, ["!alfa"]) == "p1"
|
|
|
|
|
|
def test_a_person_with_no_triggers_is_refused(tmp_path: Path) -> None:
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
with pytest.raises(ConfigError, match="at least one trigger"):
|
|
add_person(directory, [" "])
|
|
|
|
|
|
def test_a_trigger_that_is_already_a_command_is_refused(tmp_path: Path) -> None:
|
|
# !kot would make personal_data_sources find "kot" in aliases.json and
|
|
# block every commit forever, fixable only by editing a private album.
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
with pytest.raises(ConfigError, match="already a command"):
|
|
add_person(directory, ["kot"])
|
|
|
|
|
|
def test_a_trigger_with_a_digit_is_refused_and_nothing_is_written(tmp_path: Path) -> None:
|
|
# Folding maps 0134578 onto letters, so a digit is ambiguous by design.
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
before = (directory / PEOPLE_FILE).read_bytes()
|
|
with pytest.raises(ConfigError):
|
|
add_person(directory, ["!alfa2"])
|
|
assert (directory / PEOPLE_FILE).read_bytes() == before
|
|
|
|
|
|
def test_a_trigger_colliding_with_an_existing_person_is_refused(tmp_path: Path) -> None:
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
before = (directory / PEOPLE_FILE).read_bytes()
|
|
with pytest.raises(ConfigError):
|
|
add_person(directory, ["!alfa"])
|
|
assert (directory / PEOPLE_FILE).read_bytes() == before
|
|
|
|
|
|
def test_a_trigger_colliding_with_a_custom_label_is_refused(tmp_path: Path) -> None:
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
(directory / CUSTOM_FILE).write_text(json.dumps({"t1": ["wakacje"]}), encoding="utf-8")
|
|
with pytest.raises(ConfigError):
|
|
add_person(directory, ["wakacje"])
|
|
|
|
|
|
def test_a_failed_add_restores_a_roster_that_did_not_exist(tmp_path: Path) -> None:
|
|
with pytest.raises(ConfigError):
|
|
add_person(tmp_path, ["!alfa"])
|
|
assert not (tmp_path / PEOPLE_FILE).exists()
|
|
|
|
|
|
def test_a_roster_that_is_not_json_is_refused_rather_than_overwritten(tmp_path: Path) -> None:
|
|
# Strict, unlike the ledger: appending to a document that failed to parse
|
|
# would write a roster that silently dropped everyone already in it.
|
|
(tmp_path / PEOPLE_FILE).write_text("{oops", encoding="utf-8")
|
|
with pytest.raises(ConfigError, match="not valid JSON"):
|
|
add_person(tmp_path, ["!alfa"])
|
|
|
|
|
|
def test_a_roster_that_is_not_an_object_is_refused(tmp_path: Path) -> None:
|
|
(tmp_path / PEOPLE_FILE).write_text("[]", encoding="utf-8")
|
|
with pytest.raises(ConfigError, match="not an object"):
|
|
add_person(tmp_path, ["!alfa"])
|
|
|
|
|
|
def test_merge_keeps_existing_keys_where_they_are() -> None:
|
|
# The parseSetup bug, pinned: positional keys renumber everyone after an
|
|
# insertion and repoint every tags.json entry at the wrong person.
|
|
document = {"people": [{"key": "p1", "triggers": ["!alfa"]}, {"key": "p2", "triggers": ["!b"]}]}
|
|
merged = merge(document, [{"key": "p2", "triggers": ["!b", "!beta"]}])
|
|
assert [person["key"] for person in merged] == ["p1", "p2"]
|
|
assert merged[1]["triggers"] == ["!b", "!beta"]
|
|
|
|
|
|
def test_merge_appends_someone_new_with_a_fresh_key() -> None:
|
|
document = {"people": [{"key": "p1", "triggers": ["!alfa"]}]}
|
|
merged = merge(document, [{"triggers": ["!delta"]}])
|
|
assert merged[1]["key"] == "p2"
|
|
|
|
|
|
def test_merge_on_an_empty_document() -> None:
|
|
assert merge({}, [{"triggers": ["!alfa"]}])[0]["key"] == "p1"
|