mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
The visible-mark path had grown three copies of one ladder sweep, four
near-identical `detect` arms, and four hand-rolled `footprint_mask` overrides;
mark knowledge sat in five hand-maintained tables across three modules; and the
flagship `all`/`batch` pipeline existed only in cli.py, written twice with
divergent behavior.
Detection is now one measurement. `_ladder_best` replaces the three sweeps,
`_scan`/`_verdict` replace the four arms, and the winning box travels to the
mask on `TextMarkDetection.match_box` instead of being swept a second time.
`detect_both` returns the strict and relaxed verdicts from one scan, which
halves the arbiter's perception cost (260 -> 130 matchTemplate calls on a 2048²
image, verdicts identical field for field). A per-mark demotion goes in the new
`_post_gate` hook, never in a `detect` override -- an override is invisible to
the single-pass path, which is how the RunningHub and Yuanbao anchor gates
briefly stopped applying.
Everything about a mark is now one registry row: product, label regime, the
platform sentence `identify` reports, the metadata signals that confirm it, and
its TC260 producer codes. `identify._VISIBLE_MARK_PLATFORM`, the signal mapping
in `api.visible_provenance`, `_PRODUCT_OF` and the pill veto are derived from
those rows.
`api.remove_all` / `api.remove_batch` are the library form of the `all` and
`batch` commands; the CLI is a wrapper that owns console text and exit codes.
Progress is a `(stage, detail)` pair of stable tokens, so the CLI keys its
wording off structure rather than parsing the library's prose back.
Two intentional behavior changes, both verified against a recorded 811-image
sample of detector verdicts, removal-mask hashes, arbiter decisions and
`identify` reports:
* A TC260 label now relaxes the vendor its `ContentProducer` names rather than
ByteDance's pair on every China-AIGC image. 333 of 811 samples move; on 185
of them the previously relaxed pair was simply the wrong vendor, and the
mark actually present never reached the relaxed gate its own
`provenance_ncc_factor` was calibrated for.
* A confident LibLibAI detection suppresses the Jimeng pill, like every other
TC260 product's mark. It was registered alongside RunningHub and Baidu, both
of which were added to the hand-written veto list, and it was not. 1 sample
moves, and it is exactly the co-firing case.
Nothing else in that record changes: detector verdicts, mask hashes and
`identify` verdicts are byte-identical, and all 200 calibration constants are
untouched.
Also: `aigc_label` and friends plus `extract_c2pa_info` are memoized on
(path, mtime_ns, size) -- size because this package rewrites in place; the
native TC260 container readers route on magic bytes instead of the file
extension, so a mislabeled AVI or FLV is no longer invisible; `identify` shares
one pixel decode between the DWT-DCT and visible stages (TrustMark keeps its own
Pillow decode, which is not substitutable); and the six `stabilize_*` video
wrappers collapse into one policy table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
457 lines
19 KiB
Python
457 lines
19 KiB
Python
"""High-level convenience API (remove_visible / visible_provenance) and the lazy
|
|
top-level re-exports."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import remove_ai_watermarks as raiw
|
|
from remove_ai_watermarks import api
|
|
|
|
SAMPLES = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance"
|
|
DOUBAO = SAMPLES / "doubao-1.png"
|
|
CHATGPT = SAMPLES / "chatgpt-1.png"
|
|
|
|
|
|
class TestTopLevelExports:
|
|
def test_lazy_reexports_resolve(self):
|
|
assert raiw.remove_visible is api.remove_visible
|
|
assert raiw.visible_provenance is api.visible_provenance
|
|
|
|
def test_unknown_attribute_raises(self):
|
|
with pytest.raises(AttributeError):
|
|
_ = raiw.does_not_exist
|
|
|
|
def test_bare_import_is_light(self):
|
|
# importing the package must not pull the heavy cv2/torch stack (PEP 562 lazy).
|
|
# Checked in a FRESH interpreter -- another test in this process may already
|
|
# have imported cv2, so an in-process sys.modules check would be flaky.
|
|
import subprocess
|
|
import sys
|
|
|
|
code = "import remove_ai_watermarks, sys; print(int(any(m in sys.modules for m in ('cv2','torch'))))"
|
|
out = subprocess.run( # noqa: S603 -- fixed sys.executable + literal code, no untrusted input
|
|
[sys.executable, "-c", code], check=True, capture_output=True, text=True
|
|
)
|
|
assert out.stdout.strip() == "0", f"bare import pulled a heavy module: {out.stdout!r}"
|
|
|
|
|
|
class TestRemoveVisibleArray:
|
|
def test_array_no_mark_is_noop_copy(self):
|
|
arr = np.zeros((256, 256, 3), np.uint8)
|
|
result, removed = raiw.remove_visible(arr, backend="cv2")
|
|
assert removed == []
|
|
assert result.shape == arr.shape
|
|
assert np.array_equal(result, arr)
|
|
|
|
def test_array_accepts_knobs(self):
|
|
arr = np.zeros((256, 256, 3), np.uint8)
|
|
result, removed = raiw.remove_visible(arr, sensitivity="strict", backend="cv2")
|
|
assert removed == []
|
|
assert result.shape == arr.shape
|
|
|
|
def test_bad_source_raises(self, tmp_path):
|
|
with pytest.raises(ValueError, match="Could not read image"):
|
|
raiw.remove_visible(tmp_path / "nope.png")
|
|
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
class TestRemoveVisiblePath:
|
|
def test_path_removes_and_writes(self, tmp_path):
|
|
out = tmp_path / "clean.png"
|
|
result, removed = raiw.remove_visible(DOUBAO, out, backend="cv2")
|
|
assert out.exists()
|
|
assert any("Doubao" in lbl for lbl in removed)
|
|
assert result.shape[2] == 3
|
|
|
|
def test_path_no_output_returns_without_writing(self, tmp_path):
|
|
# output=None returns the array but writes nothing
|
|
result, _ = raiw.remove_visible(DOUBAO, backend="cv2")
|
|
assert result.ndim == 3
|
|
|
|
|
|
class TestNoOpPreservesOriginal:
|
|
def test_no_mark_copies_original_bytes(self, tmp_path):
|
|
# A clean image (no mark) same-format-out must be copied VERBATIM, not
|
|
# re-encoded -- so a no-op never degrades the original ("work with originals").
|
|
import filecmp
|
|
|
|
from PIL import Image
|
|
|
|
src = tmp_path / "clean.jpg"
|
|
Image.fromarray(np.full((40, 40, 3), 120, np.uint8), "RGB").save(src, quality=90)
|
|
out = tmp_path / "clean_out.jpg"
|
|
_, removed = raiw.remove_visible(str(src), str(out), sensitivity="strict", backend="cv2")
|
|
assert removed == []
|
|
assert filecmp.cmp(str(src), str(out), shallow=False) # byte-identical
|
|
|
|
|
|
class TestVisibleProvenance:
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_doubao_tc260_maps_to_the_producer_it_names(self):
|
|
"""The TC260 producer identifies the vendor, so only Doubao is relaxed.
|
|
|
|
This used to relax Doubao AND Jimeng on every China-AIGC image, because the
|
|
label alone does not say which vendor made it. Its ``ContentProducer`` does.
|
|
"""
|
|
prov = raiw.visible_provenance(DOUBAO)
|
|
assert "doubao" in prov
|
|
assert "jimeng" not in prov
|
|
|
|
def test_unmapped_tc260_producer_falls_back_to_the_bytedance_pair(self, monkeypatch, tmp_path):
|
|
"""An unrecognized producer must not lose the relaxation entirely: the label is
|
|
still evidence that some China-AIGC vendor made the image."""
|
|
from types import SimpleNamespace
|
|
|
|
from remove_ai_watermarks import identify, metadata
|
|
|
|
monkeypatch.setattr(
|
|
identify,
|
|
"identify",
|
|
lambda *a, **k: SimpleNamespace(platform=None, signals=[SimpleNamespace(name="aigc")]),
|
|
)
|
|
monkeypatch.setattr(metadata, "aigc_label", lambda _p: {"ContentProducer": "0011999999999999999999999"})
|
|
assert raiw.visible_provenance(tmp_path / "x.png") == frozenset({"doubao", "jimeng"})
|
|
|
|
def test_known_tc260_producer_names_a_single_vendor(self, monkeypatch, tmp_path):
|
|
from types import SimpleNamespace
|
|
|
|
from remove_ai_watermarks import identify, metadata
|
|
|
|
monkeypatch.setattr(
|
|
identify,
|
|
"identify",
|
|
lambda *a, **k: SimpleNamespace(platform=None, signals=[SimpleNamespace(name="aigc")]),
|
|
)
|
|
# 001 + 1 + USCC(18) + 5-digit product suffix, the Qwen entity.
|
|
monkeypatch.setattr(metadata, "aigc_label", lambda _p: {"ContentProducer": "001191440101MA9Y9T4H7A00001"})
|
|
assert raiw.visible_provenance(tmp_path / "x.png") == frozenset({"qwen"})
|
|
|
|
def test_every_mapped_producer_names_a_registered_mark(self):
|
|
"""The table drives the arbiter's provenance set, so a typo'd key would relax
|
|
nothing and fail silently. Checked here, not at import: importing the registry
|
|
from _internal.constants would drag it into every metadata-only path."""
|
|
from remove_ai_watermarks._internal.constants import TC260_FALLBACK_VENDORS
|
|
from remove_ai_watermarks.watermark_registry import known_marks, mark_keys, tc260_producer_vendors
|
|
|
|
keys = set(mark_keys())
|
|
assert set(tc260_producer_vendors().values()) <= keys
|
|
assert keys >= TC260_FALLBACK_VENDORS
|
|
# Every TC260 mark should name its producer, or it silently falls back to the
|
|
# ByteDance pair on an image carrying that mark.
|
|
unmapped = {m.key for m in known_marks() if m.label_regime == "tc260" and not m.tc260_producer_codes}
|
|
assert unmapped == {"jimeng_pill"}, f"TC260 marks with no producer code: {unmapped}"
|
|
|
|
@pytest.mark.skipif(not CHATGPT.exists(), reason="chatgpt sample not present")
|
|
def test_openai_image_has_no_visible_vendor(self):
|
|
# OpenAI C2PA is not one of the visible-mark vendors -> empty provenance
|
|
assert raiw.visible_provenance(CHATGPT) == frozenset()
|
|
|
|
def test_unreadable_path_is_empty(self, tmp_path):
|
|
assert raiw.visible_provenance(tmp_path / "missing.png") == frozenset()
|
|
|
|
def test_uses_report_signals_for_falsy_metadata_values(self, monkeypatch, tmp_path):
|
|
"""An empty TC260 object and Samsung genAIType=0 are still present signals.
|
|
|
|
The report has already normalized those values, so the public API must not
|
|
re-read the file and accidentally discard them by truthiness.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
from remove_ai_watermarks import identify
|
|
|
|
report = SimpleNamespace(
|
|
platform=None,
|
|
signals=[SimpleNamespace(name="aigc"), SimpleNamespace(name="samsung_genai")],
|
|
)
|
|
monkeypatch.setattr(identify, "identify", lambda *args, **kwargs: report)
|
|
|
|
assert raiw.visible_provenance(tmp_path / "synthetic.png") == frozenset({"doubao", "jimeng", "samsung"})
|
|
|
|
|
|
class TestRemoveVisibleOutputPath:
|
|
"""Output-path robustness: in-place clean (#3) and a missing output dir (#4)."""
|
|
|
|
def _write_clean(self, p: Path) -> None:
|
|
from remove_ai_watermarks import image_io
|
|
|
|
image_io.imwrite(str(p), np.full((128, 128, 3), 200, np.uint8))
|
|
|
|
def test_inplace_clean_no_crash(self, tmp_path: Path):
|
|
p = tmp_path / "clean.png"
|
|
self._write_clean(p)
|
|
_, removed = raiw.remove_visible(str(p), str(p), backend="cv2")
|
|
assert removed == []
|
|
assert p.exists()
|
|
|
|
def test_creates_missing_output_dir(self, tmp_path: Path):
|
|
src = tmp_path / "in.png"
|
|
self._write_clean(src)
|
|
out = tmp_path / "sub" / "out.png"
|
|
raiw.remove_visible(str(src), str(out), backend="cv2")
|
|
assert out.exists()
|
|
|
|
|
|
class TestRemoveAllLibrary:
|
|
"""The three-stage pipeline is a library function, not CLI-only.
|
|
|
|
It used to live only in ``cli.py``, written twice (once for ``all``, once for
|
|
``batch``) with divergent behavior, so no library caller could reach it.
|
|
"""
|
|
|
|
def _patched_engine(self, monkeypatch, calls: list):
|
|
from remove_ai_watermarks import invisible_engine
|
|
|
|
class FakeEngine:
|
|
def remove_watermark(self, *args, **kwargs):
|
|
calls.append(kwargs.get("image_path") or args[0])
|
|
|
|
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
|
|
return FakeEngine()
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_runs_all_three_stages_and_reports_each(self, monkeypatch, tmp_path):
|
|
from remove_ai_watermarks import api
|
|
|
|
calls: list = []
|
|
engine = self._patched_engine(monkeypatch, calls)
|
|
monkeypatch.setattr(api._SourceEvidence, "has_invisible_target", lambda _self: True)
|
|
out = tmp_path / "clean.png"
|
|
events: list[tuple[str, str]] = []
|
|
|
|
result = api.remove_all(DOUBAO, out, backend="cv2", engine=engine, progress=lambda s, d: events.append((s, d)))
|
|
|
|
assert out.exists()
|
|
assert result.invisible == "removed"
|
|
assert result.visible_label is not None # the Doubao mark fired
|
|
assert calls, "the invisible engine was never invoked"
|
|
# Progress is (stage, stable-token), never prose the caller has to parse back.
|
|
assert any(stage == "visible" for stage, _ in events)
|
|
assert ("invisible", "removed") in events
|
|
assert ("metadata", "stripped") in events
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_no_signal_skips_the_scrub_but_still_writes(self, monkeypatch, tmp_path):
|
|
from remove_ai_watermarks import api
|
|
|
|
calls: list = []
|
|
engine = self._patched_engine(monkeypatch, calls)
|
|
monkeypatch.setattr(api._SourceEvidence, "has_invisible_target", lambda _self: False)
|
|
out = tmp_path / "clean.png"
|
|
|
|
result = api.remove_all(DOUBAO, out, backend="cv2", engine=engine)
|
|
|
|
assert result.invisible == "no-signal"
|
|
assert not calls
|
|
assert out.exists() # a deliberate skip is still a successful run
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_missing_gpu_extra_is_reported_not_raised(self, monkeypatch, tmp_path):
|
|
from remove_ai_watermarks import api, invisible_engine
|
|
|
|
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
|
|
out = tmp_path / "clean.png"
|
|
|
|
result = api.remove_all(DOUBAO, out, backend="cv2")
|
|
|
|
assert result.invisible == "unavailable"
|
|
assert out.exists() # it LOOKS processed -- which is why the caller must warn
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_incomplete_strip_leaves_no_output_file(self, monkeypatch, tmp_path):
|
|
"""The contract the CLI depends on: an AI-readable output plus a non-zero exit
|
|
is worse than no output at all, so the raise happens BEFORE the final write."""
|
|
from remove_ai_watermarks import api, invisible_engine, metadata
|
|
|
|
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
|
|
monkeypatch.setattr(metadata, "strip_and_verify", lambda src, dst: (dst, {"c2pa"}))
|
|
out = tmp_path / "clean.png"
|
|
|
|
with pytest.raises(api.MetadataStripIncomplete, match="c2pa"):
|
|
api.remove_all(DOUBAO, out, backend="cv2")
|
|
assert not out.exists()
|
|
|
|
def test_unreadable_source_raises_valueerror(self, tmp_path):
|
|
from remove_ai_watermarks import api
|
|
|
|
with pytest.raises(ValueError, match="Could not read image"):
|
|
api.remove_all(tmp_path / "nope.png", tmp_path / "out.png")
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_stages_through_the_system_temp_dir_not_the_output_dir(self, monkeypatch, tmp_path):
|
|
"""Staging next to the output would defeat the point: the user must not see a
|
|
partial file there during a long model download."""
|
|
from remove_ai_watermarks import api, invisible_engine
|
|
|
|
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
seen: list[set[str]] = []
|
|
|
|
real = api.remove_all
|
|
|
|
def spy(*args, **kwargs):
|
|
result = real(*args, **kwargs)
|
|
seen.append({p.name for p in out_dir.iterdir()})
|
|
return result
|
|
|
|
spy(DOUBAO, out_dir / "clean.png", backend="cv2")
|
|
assert seen == [{"clean.png"}], "an intermediate was left in the output directory"
|
|
|
|
|
|
class TestRemoveBatchLibrary:
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_visible_mode_writes_every_image(self, tmp_path):
|
|
import shutil
|
|
|
|
from remove_ai_watermarks import api
|
|
|
|
src = tmp_path / "in"
|
|
src.mkdir()
|
|
for i in range(3):
|
|
shutil.copyfile(DOUBAO, src / f"img{i}.png")
|
|
out = tmp_path / "out"
|
|
|
|
summary = api.remove_batch(src, out, mode="visible", backend="cv2")
|
|
|
|
assert summary.processed == 3
|
|
assert summary.failed == 0
|
|
assert sorted(p.name for p in out.iterdir()) == ["img0.png", "img1.png", "img2.png"]
|
|
|
|
def test_one_bad_file_does_not_abandon_the_rest(self, tmp_path):
|
|
from remove_ai_watermarks import api
|
|
|
|
src = tmp_path / "in"
|
|
src.mkdir()
|
|
(src / "broken.png").write_bytes(b"not a png at all")
|
|
image = np.full((64, 64, 3), 120, np.uint8)
|
|
raiw.remove_visible(image) # sanity: the registry is importable here
|
|
from remove_ai_watermarks import image_io
|
|
|
|
image_io.imwrite(src / "good.png", image)
|
|
out = tmp_path / "out"
|
|
|
|
summary = api.remove_batch(src, out, mode="visible", backend="cv2")
|
|
|
|
assert summary.processed == 1
|
|
assert summary.failed == 1
|
|
assert [p.name for p, _ in summary.errors] == ["broken.png"]
|
|
assert (out / "good.png").exists()
|
|
|
|
def test_a_failed_write_is_counted_not_swallowed(self, tmp_path, monkeypatch):
|
|
"""Tier E: a read-only output directory once produced zero files and exit 0."""
|
|
from remove_ai_watermarks import api, image_io
|
|
|
|
src = tmp_path / "in"
|
|
src.mkdir()
|
|
image_io.imwrite(src / "a.png", np.full((64, 64, 3), 120, np.uint8))
|
|
monkeypatch.setattr(image_io, "write_bgr_with_alpha", lambda *a, **k: False)
|
|
|
|
summary = api.remove_batch(src, tmp_path / "out", mode="visible", backend="cv2")
|
|
|
|
assert summary.processed == 0
|
|
assert summary.failed == 1
|
|
|
|
|
|
class TestSourceEvidenceHolder:
|
|
"""One metadata extraction per source file, per call.
|
|
|
|
``remove_all`` asks the same file two provenance questions (which vendor is
|
|
confirmed, and is there an invisible target); both start from the same extraction.
|
|
"""
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_remove_all_extracts_evidence_once(self, monkeypatch, tmp_path):
|
|
import shutil
|
|
|
|
from remove_ai_watermarks import api, identify, invisible_engine
|
|
|
|
class Fake:
|
|
def remove_watermark(self, *args, **kwargs):
|
|
pass
|
|
|
|
source = tmp_path / "in.png"
|
|
shutil.copyfile(DOUBAO, source)
|
|
calls: list[int] = []
|
|
real = identify.extract_provenance_evidence
|
|
monkeypatch.setattr(identify, "extract_provenance_evidence", lambda p: (calls.append(1), real(p))[1])
|
|
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
|
|
|
|
api.remove_all(source, tmp_path / "out.png", backend="cv2", engine=Fake())
|
|
assert len(calls) == 1
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_holder_agrees_with_the_standalone_functions(self):
|
|
from remove_ai_watermarks import api, identify
|
|
|
|
holder = api._SourceEvidence(DOUBAO)
|
|
assert holder.visible_provenance() == api.visible_provenance(DOUBAO)
|
|
assert holder.has_invisible_target() == identify.has_invisible_target(DOUBAO)
|
|
|
|
def test_extraction_failure_fails_safe_in_both_directions(self, monkeypatch, tmp_path):
|
|
"""No provenance means no relaxation; an unknown invisible target means SCRUB.
|
|
Leaving a watermark on a paid removal is worse than over-regenerating."""
|
|
from remove_ai_watermarks import api, identify
|
|
|
|
def boom(_path):
|
|
raise OSError("extract exploded")
|
|
|
|
monkeypatch.setattr(identify, "extract_provenance_evidence", boom)
|
|
holder = api._SourceEvidence(tmp_path / "x.png")
|
|
assert holder.visible_provenance() == frozenset()
|
|
assert holder.has_invisible_target() is True
|
|
|
|
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
|
def test_a_verdict_failure_also_fails_safe(self, monkeypatch):
|
|
"""The suppress must span the VERDICT and the mapping, not just the extraction:
|
|
a raise here used to escape as a traceback where the old code returned empty."""
|
|
from remove_ai_watermarks import api, identify
|
|
|
|
def boom(*args, **kwargs):
|
|
raise RuntimeError("verdict exploded")
|
|
|
|
monkeypatch.setattr(identify, "identify_from_evidence", boom)
|
|
holder = api._SourceEvidence(DOUBAO)
|
|
assert holder.visible_provenance() == frozenset()
|
|
assert holder.has_invisible_target() is True
|
|
|
|
|
|
class TestBatchProgressIsStructured:
|
|
"""`remove_batch` emits exactly one terminal event per image, in every mode.
|
|
|
|
Regression: the CLI advanced its progress bar by string-matching a stage line that
|
|
only ``mode="all"`` ever emitted, so a `visible` or `metadata` batch sat at 0% for
|
|
the whole run and jumped to 100% at the end.
|
|
"""
|
|
|
|
def _run(self, tmp_path, mode: str) -> list[tuple[str, str, str]]:
|
|
from remove_ai_watermarks import api, image_io
|
|
|
|
src = tmp_path / "in"
|
|
src.mkdir()
|
|
for i in range(3):
|
|
image_io.imwrite(src / f"img{i}.png", np.full((64, 64, 3), 120, np.uint8))
|
|
events: list[tuple[str, str, str]] = []
|
|
api.remove_batch(
|
|
src,
|
|
tmp_path / "out",
|
|
mode=mode, # type: ignore[arg-type]
|
|
backend="cv2",
|
|
progress=lambda p, stage, detail: events.append((p.name, stage, detail)),
|
|
)
|
|
return events
|
|
|
|
@pytest.mark.parametrize("mode", ["visible", "metadata"])
|
|
def test_one_terminal_event_per_image(self, tmp_path, mode):
|
|
events = self._run(tmp_path, mode)
|
|
terminal = [name for name, stage, _ in events if stage in ("done", "failed")]
|
|
assert sorted(terminal) == ["img0.png", "img1.png", "img2.png"]
|
|
|
|
def test_progress_is_a_token_not_prose(self, tmp_path):
|
|
"""The CLI keys console text off these tokens; free text would break it."""
|
|
events = self._run(tmp_path, "visible")
|
|
assert {stage for _, stage, _ in events} <= {"visible", "invisible", "metadata", "done", "failed"}
|