mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* feat(web): show which plugins use each font, warn before deleting one
The Fonts tab lists font files from the web process's own scan, and the
plugins that register fonts run in the display process, so the tab had no
way to say whether a font was in use before deleting it.
The display service now publishes {catalog key: [plugin ids]} to the
shared cache (font_usage_snapshot, src/font_usage.py), built from the
loaded plugins' FontManager.register_manager_font() registrations. A
daemon thread checks every 10 s and writes only when the usage changed
(plus a daily refresh so cache cleanup cannot expire it); it never raises.
Families, aliases (press_start, four_by_six, ...) and paths are resolved
through FontManager's catalog to the file stem the Fonts tab keys rows by;
fonts outside assets/fonts are left out. Unloading a plugin drops its
registrations (new FontManager.forget_manager_fonts).
GET /api/v3/fonts/catalog merges used_by into each row per request (the
5-minute scan cache is copied, never edited): a list of plugin ids, or
null when the display service has not reported. The tab shows a Used by
column ("unknown" / "-" / ids, rendered as text) and deleting an in-use
font names the plugins in the confirmation, from a fresh read. The server
still refuses only system fonts. Catalog fetches bypass the browser's
5-second API cache, which otherwise served the pre-delete list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: call forget_manager_fonts through a hasattr check pylint can follow
getattr(..., None) then callable() is fine at runtime, but pylint's E1102
("not callable") can't see through it, and Codacy fails the check on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
485 lines
22 KiB
Python
485 lines
22 KiB
Python
"""The Fonts tab's "Used by" column, across the two services.
|
|
|
|
Plugins register fonts with FontManager in the display service; the web
|
|
interface lists font files from its own scan and has no FontManager. The
|
|
display side publishes {catalog key: [plugin ids]} to the shared cache
|
|
(src/font_usage.py) and GET /api/v3/fonts/catalog merges it in per request.
|
|
|
|
As in test_error_snapshot_cross_process.py, the two services are two
|
|
CacheManagers over one temporary directory.
|
|
"""
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from src.cache_manager import CacheManager # noqa: E402
|
|
from src.font_manager import FontManager # noqa: E402
|
|
from src import font_usage # noqa: E402
|
|
from src.font_usage import ( # noqa: E402
|
|
FONT_USAGE_KEY, REFRESH_INTERVAL, FontUsagePublisher, build_font_usage,
|
|
catalog_key_for, read_font_usage, start_font_usage_publisher,
|
|
)
|
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
|
|
REPO_FONTS = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self):
|
|
self.now = 1000.0
|
|
|
|
def __call__(self):
|
|
return self.now
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def fm():
|
|
return FontManager({})
|
|
|
|
|
|
@pytest.fixture
|
|
def shared_cache(tmp_path, monkeypatch):
|
|
"""Two cache managers over one directory: the display's and the web's."""
|
|
cache_dir = tmp_path / "cache"
|
|
cache_dir.mkdir()
|
|
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(cache_dir))
|
|
display_cache, web_cache = CacheManager(), CacheManager()
|
|
yield display_cache, web_cache
|
|
display_cache.stop_cleanup_thread()
|
|
web_cache.stop_cleanup_thread()
|
|
|
|
|
|
@pytest.fixture
|
|
def display(shared_cache):
|
|
"""A FontManager, the loaded-plugin table and a publisher over them."""
|
|
display_cache, _ = shared_cache
|
|
font_manager = FontManager({})
|
|
plugin_manager = SimpleNamespace(plugins={})
|
|
clock = FakeClock()
|
|
publisher = FontUsagePublisher(display_cache, font_manager, plugin_manager, clock=clock)
|
|
return font_manager, plugin_manager, publisher, clock
|
|
|
|
|
|
def _load(font_manager, plugin_manager, plugin_id, *families):
|
|
"""What a plugin's constructor does, then the plugin manager."""
|
|
for i, family in enumerate(families):
|
|
font_manager.register_manager_font(plugin_id, f"{plugin_id}.e{i}", family, 8)
|
|
plugin_manager.plugins[plugin_id] = object()
|
|
|
|
|
|
def _published(web_cache):
|
|
return web_cache.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0)
|
|
|
|
|
|
# --- Normalisation to the catalog's keys ------------------------------------
|
|
|
|
class TestCatalogKeys:
|
|
"""The web catalog keys a font by its file name without the extension,
|
|
as on disk. Plugins name fonts by FontManager family, alias or path."""
|
|
|
|
@pytest.mark.parametrize("family, key", [
|
|
("press_start", "PressStart2P-Regular"),
|
|
("four_by_six", "4x6-font"),
|
|
("five_by_seven", "5x7"),
|
|
("tom_thumb", "tom-thumb"),
|
|
])
|
|
def test_aliases(self, fm, family, key):
|
|
assert catalog_key_for(family, fm.font_catalog) == key
|
|
|
|
@pytest.mark.parametrize("family, key", [
|
|
("5x7", "5x7"),
|
|
("4x6", "4x6"), # the BDF, not 4x6-font.ttf
|
|
("6x13b", "6x13B"), # FontManager lower-cases stems
|
|
("6x13B", "6x13B"),
|
|
("PressStart2P-Regular", "PressStart2P-Regular"),
|
|
("5by7.regular", "5by7.regular"), # a dot inside the stem
|
|
])
|
|
def test_scanned_families_keep_the_file_names_case(self, fm, family, key):
|
|
assert catalog_key_for(family, fm.font_catalog) == key
|
|
|
|
@pytest.mark.parametrize("family, key", [
|
|
("assets/fonts/4x6-font.ttf", "4x6-font"),
|
|
("5x7.bdf", "5x7"),
|
|
(str(REPO_FONTS / "MatrixChunky8.bdf"), "MatrixChunky8"),
|
|
])
|
|
def test_paths(self, fm, family, key):
|
|
assert catalog_key_for(family, fm.font_catalog) == key
|
|
|
|
def test_absolute_path_through_the_catalog(self, fm, tmp_path):
|
|
catalog = {"custom": str(tmp_path / "Custom-Face.ttf")}
|
|
assert catalog_key_for("custom", catalog, fonts_dir=str(tmp_path)) == "Custom-Face"
|
|
|
|
def test_a_font_outside_assets_fonts_has_no_row(self, fm, tmp_path):
|
|
# A plugin's own font ("plugin::family") lives in its directory; a
|
|
# file of the same name there is not the catalog's 5x7.
|
|
catalog = {"weather::5x7": str(tmp_path / "5x7.bdf")}
|
|
assert catalog_key_for("weather::5x7", catalog) is None
|
|
assert catalog_key_for(str(tmp_path / "5x7.bdf"), fm.font_catalog) is None
|
|
assert catalog_key_for("plugins/x/fonts/5x7.bdf", fm.font_catalog) is None
|
|
|
|
@pytest.mark.parametrize("family", ["no_such_font", "", " ", None, 8, "notafont.txt"])
|
|
def test_unresolvable(self, fm, family):
|
|
assert catalog_key_for(family, fm.font_catalog) is None
|
|
|
|
def test_build_merges_aliases_and_names_of_one_file(self, fm):
|
|
font_manager = FontManager({})
|
|
font_manager.register_manager_font("calendar", "calendar.title", "press_start", 8)
|
|
font_manager.register_manager_font("weather", "weather.t", "PressStart2P-Regular", 16)
|
|
font_manager.register_manager_font("weather", "weather.c", "four_by_six", 8)
|
|
font_manager.register_manager_font("weather", "weather.x", "no_such_font", 8)
|
|
assert build_font_usage(font_manager) == {
|
|
"4x6-font": ["weather"],
|
|
"PressStart2P-Regular": ["calendar", "weather"],
|
|
}
|
|
|
|
|
|
# --- Display side -----------------------------------------------------------
|
|
|
|
class TestPublishing:
|
|
def test_publishes_after_plugins_load(self, display, shared_cache):
|
|
font_manager, plugin_manager, publisher, _ = display
|
|
_, web_cache = shared_cache
|
|
_load(font_manager, plugin_manager, "calendar", "four_by_six", "press_start")
|
|
_load(font_manager, plugin_manager, "of-the-day", "press_start")
|
|
assert publisher.tick() is True
|
|
snapshot = _published(web_cache)
|
|
assert snapshot["fonts"] == {
|
|
"4x6-font": ["calendar"],
|
|
"PressStart2P-Regular": ["calendar", "of-the-day"],
|
|
}
|
|
assert snapshot["generated_at"]
|
|
|
|
def test_a_plugin_that_failed_to_load_is_not_listed(self, display, shared_cache):
|
|
# Its constructor registered fonts, then validation failed, so it
|
|
# never reached plugin_manager.plugins.
|
|
font_manager, plugin_manager, publisher, _ = display
|
|
_, web_cache = shared_cache
|
|
font_manager.register_manager_font("broken", "broken.x", "press_start", 8)
|
|
_load(font_manager, plugin_manager, "calendar", "four_by_six")
|
|
publisher.tick()
|
|
assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]}
|
|
|
|
def test_load_and_unload_later_republish(self, display, shared_cache):
|
|
font_manager, plugin_manager, publisher, _ = display
|
|
_, web_cache = shared_cache
|
|
_load(font_manager, plugin_manager, "calendar", "four_by_six")
|
|
publisher.tick()
|
|
|
|
_load(font_manager, plugin_manager, "weather", "four_by_six", "five_by_seven")
|
|
assert publisher.tick() is True
|
|
assert _published(web_cache)["fonts"] == {
|
|
"4x6-font": ["calendar", "weather"], "5x7": ["weather"]}
|
|
|
|
# What PluginManager.unload_plugin does.
|
|
del plugin_manager.plugins["weather"]
|
|
font_manager.forget_manager_fonts("weather")
|
|
assert publisher.tick() is True
|
|
assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]}
|
|
|
|
def test_first_tick_replaces_a_previous_runs_snapshot(self, display, shared_cache):
|
|
_, web_cache = shared_cache
|
|
web_cache.set(FONT_USAGE_KEY, {"generated_at": "old", "fonts": {"5x7": ["gone"]}})
|
|
_, _, publisher, _ = display
|
|
assert publisher.tick() is True
|
|
assert _published(web_cache)["fonts"] == {}
|
|
|
|
def test_nothing_is_written_when_nothing_changed(self, display):
|
|
font_manager, plugin_manager, publisher, clock = display
|
|
_load(font_manager, plugin_manager, "countdown", "press_start")
|
|
publisher.tick()
|
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
|
with patch.object(font_usage, "build_font_usage",
|
|
wraps=font_usage.build_font_usage) as build:
|
|
for _ in range(20):
|
|
# The countdown plugin registers per countdown at render time.
|
|
font_manager.register_manager_font("countdown", "countdown.e0", "press_start", 8)
|
|
clock.now += 5
|
|
assert publisher.tick() is False
|
|
build.assert_not_called()
|
|
publisher.cache_manager.set.assert_not_called()
|
|
|
|
def test_a_registration_that_leaves_usage_unchanged_is_not_written(self, display):
|
|
font_manager, plugin_manager, publisher, _ = display
|
|
_load(font_manager, plugin_manager, "countdown", "press_start")
|
|
publisher.tick()
|
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
|
# A new element, same font: the version moves, the usage does not.
|
|
font_manager.register_manager_font("countdown", "countdown.c2.value", "press_start", 12)
|
|
assert publisher.tick() is False
|
|
publisher.cache_manager.set.assert_not_called()
|
|
|
|
def test_an_unchanged_snapshot_is_refreshed_daily(self, display):
|
|
# The cache's disk cleanup deletes entries older than 30 days.
|
|
_, _, publisher, clock = display
|
|
publisher.tick()
|
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
|
clock.now += REFRESH_INTERVAL - 1
|
|
assert publisher.tick() is False
|
|
clock.now += 1
|
|
assert publisher.tick() is True
|
|
publisher.cache_manager.set.assert_called_once()
|
|
|
|
|
|
class TestFailureIsolation:
|
|
def test_a_failing_cache_write_does_not_raise_and_is_retried(self, display, shared_cache):
|
|
font_manager, plugin_manager, publisher, _ = display
|
|
_, web_cache = shared_cache
|
|
_load(font_manager, plugin_manager, "calendar", "four_by_six")
|
|
real = publisher.cache_manager
|
|
publisher.cache_manager = MagicMock()
|
|
publisher.cache_manager.set.side_effect = PermissionError("read-only cache")
|
|
assert publisher.tick() is False
|
|
publisher.cache_manager = real
|
|
assert publisher.tick() is True
|
|
assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]}
|
|
|
|
def test_a_broken_font_manager_does_not_raise(self, shared_cache):
|
|
display_cache, _ = shared_cache
|
|
broken = SimpleNamespace(manager_fonts=None, font_catalog={})
|
|
publisher = FontUsagePublisher(display_cache, broken, SimpleNamespace(plugins={}))
|
|
assert publisher.tick() is False
|
|
|
|
def test_start_never_raises(self):
|
|
assert start_font_usage_publisher(MagicMock(), MagicMock(), None) is None
|
|
with patch.object(font_usage.FontUsagePublisher, "start",
|
|
side_effect=RuntimeError("no threads")):
|
|
assert start_font_usage_publisher(MagicMock(), MagicMock(), MagicMock()) is None
|
|
|
|
def test_start_publishes_from_a_thread(self, shared_cache, monkeypatch):
|
|
display_cache, web_cache = shared_cache
|
|
font_manager = FontManager({})
|
|
plugin_manager = SimpleNamespace(plugins={})
|
|
_load(font_manager, plugin_manager, "calendar", "press_start")
|
|
publisher = start_font_usage_publisher(display_cache, font_manager, plugin_manager)
|
|
try:
|
|
import time
|
|
deadline = time.monotonic() + 5
|
|
while _published(web_cache) is None and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
assert _published(web_cache)["fonts"] == {"PressStart2P-Regular": ["calendar"]}
|
|
finally:
|
|
publisher.stop()
|
|
|
|
|
|
class TestFontManagerRegistrations:
|
|
def test_version_moves_only_when_a_family_changes(self):
|
|
font_manager = FontManager({})
|
|
v0 = font_manager.manager_fonts_version
|
|
font_manager.register_manager_font("p", "p.a", "press_start", 8)
|
|
v1 = font_manager.manager_fonts_version
|
|
font_manager.register_manager_font("p", "p.a", "press_start", 10) # size only
|
|
assert font_manager.manager_fonts_version == v1 > v0
|
|
font_manager.register_manager_font("p", "p.a", "four_by_six", 8)
|
|
assert font_manager.manager_fonts_version > v1
|
|
|
|
def test_forget_drops_every_registration_of_that_manager(self):
|
|
font_manager = FontManager({})
|
|
font_manager.register_manager_font("p", "p.a", "press_start", 8)
|
|
font_manager.register_manager_font("q", "q.a", "press_start", 8)
|
|
version = font_manager.manager_fonts_version
|
|
font_manager.forget_manager_fonts("p")
|
|
assert "p" not in font_manager.manager_fonts
|
|
assert "p.a" not in font_manager.detected_fonts
|
|
assert "q" in font_manager.manager_fonts and "q.a" in font_manager.detected_fonts
|
|
assert font_manager.manager_fonts_version > version
|
|
font_manager.forget_manager_fonts("never-registered") # no error
|
|
|
|
def test_unload_plugin_forgets_its_fonts(self, tmp_path):
|
|
from src.plugin_system.plugin_manager import PluginManager
|
|
font_manager = FontManager({})
|
|
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
|
display_manager=None, cache_manager=None,
|
|
font_manager=font_manager)
|
|
try:
|
|
font_manager.register_manager_font("calendar", "calendar.t", "press_start", 8)
|
|
pm.plugins["calendar"] = SimpleNamespace()
|
|
assert pm.unload_plugin("calendar") is True
|
|
assert "calendar" not in font_manager.manager_fonts
|
|
finally:
|
|
pm.stop_update_worker()
|
|
|
|
def test_unload_survives_a_font_manager_that_raises(self, tmp_path):
|
|
from src.plugin_system.plugin_manager import PluginManager
|
|
font_manager = MagicMock()
|
|
font_manager.forget_manager_fonts.side_effect = RuntimeError("boom")
|
|
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
|
display_manager=None, cache_manager=None,
|
|
font_manager=font_manager)
|
|
try:
|
|
pm.plugins["calendar"] = SimpleNamespace()
|
|
assert pm.unload_plugin("calendar") is True
|
|
finally:
|
|
pm.stop_update_worker()
|
|
|
|
|
|
def test_display_controller_starts_the_publisher(test_display_controller, mock_cache_manager):
|
|
publisher = test_display_controller._font_usage_publisher
|
|
assert isinstance(publisher, FontUsagePublisher)
|
|
assert publisher.plugin_manager is test_display_controller.plugin_manager
|
|
publisher.tick()
|
|
assert "fonts" in mock_cache_manager._memory_cache[FONT_USAGE_KEY]
|
|
|
|
|
|
# --- Reading side -----------------------------------------------------------
|
|
|
|
class TestReading:
|
|
def test_nothing_published(self, shared_cache):
|
|
_, web_cache = shared_cache
|
|
assert read_font_usage(web_cache) is None
|
|
|
|
@pytest.mark.parametrize("junk", [
|
|
"a string", {"fonts": "nope"}, {"generated_at": "x"}, [1, 2],
|
|
])
|
|
def test_a_malformed_snapshot_reads_as_unknown(self, shared_cache, junk):
|
|
_, web_cache = shared_cache
|
|
web_cache.set(FONT_USAGE_KEY, junk)
|
|
assert read_font_usage(web_cache) is None
|
|
|
|
def test_bad_entries_are_dropped(self, shared_cache):
|
|
_, web_cache = shared_cache
|
|
web_cache.set(FONT_USAGE_KEY, {"generated_at": 5, "fonts": {
|
|
"5x7": ["b", "a", 3, None, "", "a"], "4x6": "calendar", "tom-thumb": [],
|
|
}})
|
|
assert read_font_usage(web_cache) == {"generated_at": None, "fonts": {"5x7": ["a", "b"]}}
|
|
|
|
def test_a_cache_that_raises_reads_as_unknown(self):
|
|
cache = MagicMock()
|
|
cache.get.side_effect = OSError("gone")
|
|
assert read_font_usage(cache) is None
|
|
|
|
|
|
# --- Web side ---------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def web(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa: F811
|
|
"""The real blueprint over a scratch assets/fonts and the shared cache."""
|
|
_, web_cache = shared_cache
|
|
api_v3_module.api_v3.cache_manager = web_cache
|
|
fonts_dir = tmp_path / "root" / "assets" / "fonts"
|
|
fonts_dir.mkdir(parents=True)
|
|
for name in ("PressStart2P-Regular.ttf", "5x7.bdf", "my-custom.ttf"):
|
|
(fonts_dir / name).write_bytes(b"not really a font")
|
|
from web_interface.cache import delete_cached
|
|
delete_cached("fonts_catalog")
|
|
with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path / "root"):
|
|
yield api_v3_client, web_cache, fonts_dir
|
|
delete_cached("fonts_catalog")
|
|
|
|
|
|
def _catalog(client):
|
|
response = client.get("/api/v3/fonts/catalog")
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
return response.get_json()["data"]
|
|
|
|
|
|
class TestCatalogEndpoint:
|
|
def test_unknown_until_the_display_service_reports(self, web):
|
|
client, _, _ = web
|
|
data = _catalog(client)
|
|
assert data["font_usage"] == {"available": False, "generated_at": None}
|
|
assert {key: info["used_by"] for key, info in data["catalog"].items()} == {
|
|
"PressStart2P-Regular": None, "5x7": None, "my-custom": None}
|
|
|
|
def test_usage_is_merged_into_the_rows(self, web):
|
|
client, web_cache, _ = web
|
|
web_cache.set(FONT_USAGE_KEY, {"generated_at": "2026-09-23T10:00:00", "fonts": {
|
|
"PressStart2P-Regular": ["calendar", "clock-simple"],
|
|
"my-custom": ["football-scoreboard"],
|
|
"not-in-the-catalog": ["weather"],
|
|
}})
|
|
data = _catalog(client)
|
|
assert data["font_usage"] == {"available": True, "generated_at": "2026-09-23T10:00:00"}
|
|
rows = data["catalog"]
|
|
assert rows["PressStart2P-Regular"]["used_by"] == ["calendar", "clock-simple"]
|
|
assert rows["my-custom"]["used_by"] == ["football-scoreboard"]
|
|
assert rows["5x7"]["used_by"] == []
|
|
assert "not-in-the-catalog" not in rows
|
|
# The existing fields are still there.
|
|
assert rows["5x7"]["type"] == "bdf" and rows["5x7"]["is_system"] is True
|
|
|
|
def test_keys_match_case_insensitively(self, web):
|
|
client, web_cache, _ = web
|
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {"pressstart2p-regular": ["calendar"],
|
|
"PressStart2P-Regular": ["weather"]}})
|
|
rows = _catalog(client)["catalog"]
|
|
assert rows["PressStart2P-Regular"]["used_by"] == ["calendar", "weather"]
|
|
|
|
def test_the_scan_stays_cached_while_usage_is_read_per_request(self, web):
|
|
client, web_cache, _ = web
|
|
_catalog(client) # fills the 5-minute cache
|
|
from web_interface.blueprints.api_v3 import fonts as fonts_module
|
|
with patch.object(fonts_module.os, "listdir",
|
|
side_effect=AssertionError("rescanned assets/fonts")):
|
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {"5x7": ["weather"]}})
|
|
assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["weather"]
|
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {"5x7": ["calendar"]}})
|
|
assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["calendar"]
|
|
# ...and usage never leaks into the cached scan.
|
|
from web_interface.cache import get_cached
|
|
cached = get_cached("fonts_catalog", ttl_seconds=300)
|
|
assert cached and all("used_by" not in info for info in cached.values())
|
|
|
|
def test_end_to_end_from_the_display_publisher(self, web, display):
|
|
client, _, fonts_dir = web
|
|
font_manager, plugin_manager, publisher, _ = display
|
|
_load(font_manager, plugin_manager, "calendar", "press_start", "five_by_seven")
|
|
publisher.tick()
|
|
rows = _catalog(client)["catalog"]
|
|
assert rows["PressStart2P-Regular"]["used_by"] == ["calendar"]
|
|
assert rows["5x7"]["used_by"] == ["calendar"]
|
|
# A later change reaches the web process too: it reads the file, not
|
|
# the copy its own memory tier kept from the first read.
|
|
_load(font_manager, plugin_manager, "weather", "five_by_seven")
|
|
publisher.tick()
|
|
assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["calendar", "weather"]
|
|
|
|
|
|
class TestDeleteInUseFont:
|
|
"""The UI warns with the catalog's used_by; the server does not block."""
|
|
|
|
def test_the_catalog_names_the_plugins_and_delete_still_works(self, web):
|
|
client, web_cache, fonts_dir = web
|
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {"my-custom": ["football-scoreboard", "clock-simple"]}})
|
|
assert _catalog(client)["catalog"]["my-custom"]["used_by"] == [
|
|
"clock-simple", "football-scoreboard"]
|
|
response = client.delete("/api/v3/fonts/my-custom")
|
|
assert response.status_code == 200, response.get_json()
|
|
assert not (fonts_dir / "my-custom.ttf").exists()
|
|
assert "my-custom" not in _catalog(client)["catalog"]
|
|
|
|
def test_system_fonts_are_still_refused(self, web):
|
|
client, web_cache, fonts_dir = web
|
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {}})
|
|
assert client.delete("/api/v3/fonts/5x7").status_code == 403
|
|
assert (fonts_dir / "5x7.bdf").exists()
|
|
|
|
|
|
class TestFontsTemplate:
|
|
"""fonts.html renders usage with DOM text APIs and warns before delete."""
|
|
|
|
TEMPLATE = (Path(__file__).resolve().parent.parent / "web_interface" / "templates"
|
|
/ "v3" / "partials" / "fonts.html").read_text(encoding="utf-8")
|
|
|
|
def test_used_by_is_never_written_as_html(self):
|
|
assert "usedSpan.textContent = font.usedBy.join(', ')" in self.TEMPLATE
|
|
assert "usedSpan.innerHTML" not in self.TEMPLATE
|
|
|
|
def test_delete_confirms_with_the_plugins(self):
|
|
assert "Used by: ${usedBy.join(', ')}" in self.TEMPLATE
|
|
assert "confirm(deleteFontConfirmMessage(fontFamily, usedBy))" in self.TEMPLATE
|
|
|
|
def test_catalog_fetches_bypass_the_browser_cache(self):
|
|
# /api/v3 GETs carry max-age=5. The usage check just before a delete
|
|
# would otherwise be the copy the post-delete reload is served, and
|
|
# the deleted font would stay listed.
|
|
fetches = [line for line in self.TEMPLATE.splitlines()
|
|
if "fetch(" in line and "/api/v3/fonts/catalog" in line]
|
|
assert len(fetches) == 2
|
|
assert all("cache: 'no-store'" in line for line in fetches)
|