174 lines
8.1 KiB
Python
174 lines
8.1 KiB
Python
"""Character visual identity: fingerprint parity with the browser, and the form-aware pair review."""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-charid-test-config-"))
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
import routes.characters as characters
|
||
from routes.conversation import (
|
||
_CHARACTER_IDENTITY_FIELDS, _CHARACTER_IDENTITY_OVERRIDE_FIELDS, _CHARACTER_VISUAL_PROMPT_VERSION,
|
||
_character_identity_fingerprint,
|
||
)
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
JS = (ROOT / "static/js/library-characters.js").read_text(encoding="utf-8")
|
||
|
||
|
||
def _js_list(name: str) -> list[str]:
|
||
m = re.search(rf"const {name} = \[(.*?)\];", JS, re.S)
|
||
return re.findall(r"'([^']+)'", m.group(1))
|
||
|
||
|
||
def _js_fingerprint(sheets: list[dict]) -> list[str]:
|
||
"""Run the browser's own fingerprint code in Node."""
|
||
def grab(pattern: str) -> str:
|
||
return re.search(pattern, JS, re.S).group(0)
|
||
src = "\n".join([
|
||
grab(r"function _libStr\(v\) \{.*?\n\}"),
|
||
grab(r"const LIB_IDENTITY_FIELDS = \[.*?\];"),
|
||
grab(r"const LIB_IDENTITY_OVERRIDE_FIELDS = \[.*?\];"),
|
||
grab(r"function _libIdentityFingerprint\(sheet\) \{.*?\n\}"),
|
||
f"console.log(JSON.stringify({json.dumps(sheets)}.map(_libIdentityFingerprint)));",
|
||
])
|
||
out = subprocess.run(["node", "-e", src], capture_output=True, text=True, check=True, timeout=30)
|
||
return json.loads(out.stdout)
|
||
|
||
|
||
def _legacy_fingerprint(sheet: dict) -> str:
|
||
"""The algorithm before override fields existed, for backward-compat checks."""
|
||
raw = "|".join(f"{k}:{sheet.get(k) if sheet.get(k) is not None else ''}" for k in _CHARACTER_IDENTITY_FIELDS)
|
||
value = 0
|
||
for ch in raw:
|
||
value = ((value << 5) - value + ord(ch)) & 0xFFFFFFFF
|
||
return format(value, "x")
|
||
|
||
|
||
class FingerprintTests(unittest.TestCase):
|
||
def test_browser_and_server_use_the_same_field_lists(self):
|
||
self.assertEqual(_js_list("LIB_IDENTITY_FIELDS"), list(_CHARACTER_IDENTITY_FIELDS))
|
||
self.assertEqual(_js_list("LIB_IDENTITY_OVERRIDE_FIELDS"), list(_CHARACTER_IDENTITY_OVERRIDE_FIELDS))
|
||
|
||
def test_browser_and_server_compute_identical_fingerprints(self):
|
||
sheets = [
|
||
{},
|
||
{"physical": "tall, white hair", "clothing": "grey coat", "gender": "female", "age_estimate": "40"},
|
||
{"physical": "x", "visual_appearance": "copper hair, green eyes", "visual_costume": "blue wool coat"},
|
||
{"physical": "ä ö ü ß – »Zitat« 😀", "marks": ["scar", "tattoo"], "visual_species": "Elf"},
|
||
]
|
||
self.assertEqual(_js_fingerprint(sheets), [_character_identity_fingerprint(s) for s in sheets])
|
||
|
||
def test_a_pair_made_before_overrides_existed_keeps_a_valid_fingerprint(self):
|
||
sheet = {"physical": "tall, white hair", "clothing": "grey coat", "gender": "female"}
|
||
self.assertEqual(_character_identity_fingerprint(sheet), _legacy_fingerprint(sheet))
|
||
|
||
def test_editing_an_override_marks_the_pair_stale(self):
|
||
base = {"physical": "tall", "clothing": "coat"}
|
||
before = _character_identity_fingerprint(base)
|
||
for key in _CHARACTER_IDENTITY_OVERRIDE_FIELDS:
|
||
with self.subTest(key=key):
|
||
self.assertNotEqual(_character_identity_fingerprint({**base, key: "changed"}), before)
|
||
|
||
def test_active_form_changes_the_fingerprint_but_not_the_base(self):
|
||
sheet = {"physical": "human", "forms": [{"id": "wolf", "name": "Wolf", "physical": "grey fur"}]}
|
||
base = _character_identity_fingerprint({**sheet, "active_form_id": ""})
|
||
self.assertNotEqual(_character_identity_fingerprint({**sheet, "active_form_id": "wolf"}), base)
|
||
|
||
|
||
_PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||
|
||
|
||
def _img(n: int) -> str:
|
||
"""A distinct valid data URI per n (digests differ)."""
|
||
return "data:image/png;base64," + base64.b64encode(_PNG + bytes([n])).decode()
|
||
|
||
|
||
def _pair_meta(sheet: dict) -> dict:
|
||
return {
|
||
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
|
||
"consistent_identity_fingerprint": _character_identity_fingerprint(sheet),
|
||
"identity_consistent": True,
|
||
}
|
||
|
||
|
||
class VisualPairReviewTests(unittest.TestCase):
|
||
def setUp(self):
|
||
app = FastAPI()
|
||
app.include_router(characters.router)
|
||
self.client = TestClient(app)
|
||
|
||
def review(self, records):
|
||
with mock.patch.object(characters, "char_get_all", return_value=records):
|
||
r = self.client.get("/api/characters/visual-pair-review")
|
||
self.assertEqual(r.status_code, 200)
|
||
return r.json()
|
||
|
||
def test_a_healthy_base_pair_and_a_healthy_form_pair_pass(self):
|
||
base = {"physical": "human", "clothing": "coat"}
|
||
form = {"id": "wolf", "name": "Wolf", "physical": "grey fur"}
|
||
form_fp = _character_identity_fingerprint({**base, "forms": [form], "active_form_id": "wolf"})
|
||
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
|
||
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
|
||
"forms": [{**form, "image": _img(3), "concept_art_image": _img(4),
|
||
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
|
||
"consistent_identity_fingerprint": form_fp, "identity_consistent": True}],
|
||
"active_form_id": "wolf"}}
|
||
self.assertEqual(self.review([rec])["issues"], [])
|
||
|
||
def test_a_stale_form_pair_is_reported_under_the_form_name(self):
|
||
base = {"physical": "human"}
|
||
form = {"id": "wolf", "name": "Wolf", "physical": "grey fur", "image": _img(3), "concept_art_image": _img(4),
|
||
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
|
||
"consistent_identity_fingerprint": "deadbeef", "identity_consistent": True}
|
||
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
|
||
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2), "forms": [form]}}
|
||
issues = self.review([rec])["issues"]
|
||
self.assertEqual(len(issues), 1)
|
||
self.assertEqual(issues[0]["form_id"], "wolf")
|
||
self.assertEqual(issues[0]["form_name"], "Wolf")
|
||
self.assertIn("appearance_changed_since_generation", issues[0]["issues"])
|
||
|
||
def test_an_active_form_no_longer_makes_the_base_pair_look_unfingerprinted(self):
|
||
base = {"physical": "human"}
|
||
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
|
||
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
|
||
"forms": [{"id": "wolf", "name": "Wolf", "physical": "grey fur"}], "active_form_id": "wolf"}}
|
||
self.assertEqual(self.review([rec])["issues"], [])
|
||
|
||
def test_a_form_without_images_is_not_reviewed(self):
|
||
base = {"physical": "human"}
|
||
rec = {"id": "c1", "name": "A", "image": _img(1),
|
||
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
|
||
"forms": [{"id": "f", "name": "F", "physical": "x"}]}}
|
||
self.assertEqual(self.review([rec])["issues"], [])
|
||
|
||
def test_the_same_character_reusing_its_picture_in_another_book_is_not_flagged(self):
|
||
s = {"physical": "a"}
|
||
recs = [{"id": f"b{i}::darrag", "book": f"Book {i}", "name": "Darrag", "image": _img(9),
|
||
"sheet": {**s, **_pair_meta(s), "concept_art_image": _img(20 + i)}} for i in range(2)]
|
||
recs[1]["name"] = " darrag "
|
||
self.assertEqual(self.review(recs)["issues"], [])
|
||
|
||
def test_the_same_asset_on_two_characters_is_flagged(self):
|
||
s = {"physical": "a"}
|
||
recs = [{"id": f"c{i}", "name": f"N{i}", "image": _img(9),
|
||
"sheet": {**s, **_pair_meta(s), "concept_art_image": _img(10 + i)}} for i in range(2)]
|
||
issues = self.review(recs)["issues"]
|
||
self.assertEqual({i["id"] for i in issues}, {"c0", "c1"})
|
||
self.assertTrue(all("asset_reused_across_characters" in i["issues"] for i in issues))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|