mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
Compare commits
3
Commits
v3.8.3
...
adb4520f3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb4520f3d | ||
|
|
93123aae7a | ||
|
|
d3d49858de |
@@ -25,6 +25,12 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
that will now render the font it asked for.
|
||||
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
|
||||
the display are written (`config/wifi_status.json`).
|
||||
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that
|
||||
registered each font with `FontManager.register_manager_font()`, published
|
||||
by the display service to the shared cache (`src/font_usage.py`) and merged
|
||||
into `GET /api/v3/fonts/catalog` as `used_by`. Deleting a font a plugin
|
||||
uses now names those plugins in the confirmation (it is not blocked).
|
||||
`FontManager.forget_manager_fonts()` is new; unloading a plugin calls it.
|
||||
|
||||
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
|
||||
|
||||
+30
-2
@@ -173,8 +173,10 @@ font = self.font_manager.resolve_font(
|
||||
> install directory.
|
||||
>
|
||||
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
|
||||
> font files in `assets/fonts/`. It does not show fonts registered
|
||||
> through `register_manager_font()` and has no override editor (the
|
||||
> font files in `assets/fonts/`. Its **Used by** column shows which
|
||||
> loaded plugins registered each file through `register_manager_font()`
|
||||
> (see [Font usage in the web UI](#font-usage-in-the-web-ui)), and it
|
||||
> warns before deleting one of them. It has no override editor (the
|
||||
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
|
||||
> The programmatic override workflow in
|
||||
> [Manual Font Overrides](#manual-font-overrides) below still works.
|
||||
@@ -276,6 +278,31 @@ Place font files in `assets/fonts/` directory:
|
||||
- Font family name is derived from filename (without extension)
|
||||
- Will be automatically discovered on next initialization
|
||||
|
||||
## Font usage in the web UI
|
||||
|
||||
The web interface runs in its own process and has no FontManager, so the
|
||||
display service publishes which plugin uses which font
|
||||
(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it:
|
||||
|
||||
- **Source**: `register_manager_font()` registrations of the loaded
|
||||
plugins. `get_font()` and `resolve_font()` do not know the calling plugin
|
||||
and are not counted, and neither is a plugin that opens a font file
|
||||
directly with PIL — register the fonts your plugin draws with if you want
|
||||
them listed.
|
||||
- **Names**: a family, alias (`press_start`, `four_by_six`,
|
||||
`five_by_seven`, `tom_thumb`) or path is resolved through
|
||||
`font_catalog` to the file it loads and reported under that file's name
|
||||
without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`,
|
||||
`tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside
|
||||
`assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families
|
||||
that resolve to nothing are left out.
|
||||
- **When**: a daemon thread started once plugins have loaded checks every
|
||||
10 seconds and writes the `font_usage_snapshot` cache key only when the
|
||||
usage changed (and once a day, so the cache's cleanup never expires it).
|
||||
Unloading a plugin drops its registrations (`forget_manager_fonts`).
|
||||
- **Unknown**: until the display service has published, the column reads
|
||||
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
```python
|
||||
@@ -374,6 +401,7 @@ self.font = self.font_manager.resolve_font(
|
||||
### FontManager Methods
|
||||
|
||||
- `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage
|
||||
- `forget_manager_fonts(manager_id)` - Drop a manager's registrations (core calls it when a plugin unloads)
|
||||
- `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support
|
||||
- `get_font(family, size_px)` - Get font directly (bypasses overrides)
|
||||
- `measure_text(text, font)` - Measure text dimensions
|
||||
|
||||
@@ -1490,23 +1490,37 @@ Delete a stored backup.
|
||||
|
||||
Fonts in `assets/fonts/`, keyed by file name without extension.
|
||||
|
||||
`used_by` lists the plugins that use the font, as the display service last
|
||||
reported: the loaded plugins that registered it through
|
||||
`FontManager.register_manager_font()`, under whatever family, alias
|
||||
(`press_start`, `four_by_six`, ...) or path they gave. It is `[]` when no
|
||||
loaded plugin registered it and `null` when the display service has not
|
||||
reported yet (`font_usage.available` is then `false`). A plugin that opens a
|
||||
font file directly is not counted, so `[]` does not prove a font is unused.
|
||||
The file scan is cached for 5 minutes; `used_by` is read on every request.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"catalog": {
|
||||
"press_start": {
|
||||
"filename": "press_start.ttf",
|
||||
"PressStart2P-Regular": {
|
||||
"filename": "PressStart2P-Regular.ttf",
|
||||
"family_name": "Press Start 2P",
|
||||
"display_name": "Press Start 2P",
|
||||
"path": "assets/fonts/press_start.ttf",
|
||||
"path": "assets/fonts/PressStart2P-Regular.ttf",
|
||||
"type": "ttf",
|
||||
"is_system": true,
|
||||
"scalable": true,
|
||||
"native_size": null,
|
||||
"metadata": { ... }
|
||||
"metadata": { ... },
|
||||
"used_by": ["calendar", "hello-world"]
|
||||
}
|
||||
},
|
||||
"font_usage": {
|
||||
"available": true,
|
||||
"generated_at": "2026-09-23T10:00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1561,7 +1575,9 @@ Upload a custom font file. It is saved as `assets/fonts/<font_family><ext>`.
|
||||
**DELETE** `/api/v3/fonts/<font_family>`
|
||||
|
||||
Delete an uploaded font (`<font_family>` is the file name without
|
||||
extension). System fonts answer `403`.
|
||||
extension). System fonts answer `403`. A font a plugin uses is not refused;
|
||||
the Fonts tab names those plugins (the catalog's `used_by`) in its
|
||||
confirmation first.
|
||||
|
||||
### Font Preview
|
||||
|
||||
|
||||
@@ -403,6 +403,11 @@ class DisplayController:
|
||||
logger.exception("Plugin system initialization failed")
|
||||
self.plugin_manager = None
|
||||
|
||||
# The web UI's Fonts tab ("Used by") reads what this publishes.
|
||||
from src.font_usage import start_font_usage_publisher
|
||||
self._font_usage_publisher = start_font_usage_publisher(
|
||||
self.cache_manager, self.font_manager, self.plugin_manager)
|
||||
|
||||
# Display rotation state
|
||||
self.current_mode_index = 0
|
||||
self.current_display_mode = None
|
||||
@@ -3338,6 +3343,8 @@ class DisplayController:
|
||||
self.config_service.shutdown()
|
||||
except Exception as e:
|
||||
logger.warning("Error shutting down config service: %s", e)
|
||||
if getattr(self, '_font_usage_publisher', None) is not None:
|
||||
self._font_usage_publisher.stop()
|
||||
logger.info("Cleaning up display controller...")
|
||||
if hasattr(self, 'display_manager'):
|
||||
self.display_manager.cleanup()
|
||||
|
||||
+19
-1
@@ -78,6 +78,10 @@ class FontManager:
|
||||
# Manager font registration - NEW for manager-centric model
|
||||
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
|
||||
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
|
||||
# Bumped when a manager's registered families change (not when one
|
||||
# re-registers what it already had), so src/font_usage.py can tell
|
||||
# "nothing new" without rebuilding its snapshot.
|
||||
self.manager_fonts_version = 0
|
||||
|
||||
# Dynamic font loading
|
||||
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
||||
@@ -156,7 +160,10 @@ class FontManager:
|
||||
}
|
||||
if color:
|
||||
font_spec["color"] = color
|
||||
|
||||
|
||||
previous = self.manager_fonts[manager_id].get(element_key)
|
||||
if previous is None or previous.get("family") != family:
|
||||
self.manager_fonts_version += 1
|
||||
self.manager_fonts[manager_id][element_key] = font_spec
|
||||
|
||||
# Track usage in detected_fonts
|
||||
@@ -168,6 +175,17 @@ class FontManager:
|
||||
|
||||
logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px")
|
||||
|
||||
def forget_manager_fonts(self, manager_id: str) -> None:
|
||||
"""Drop every registration ``manager_id`` made. Called by core when a
|
||||
plugin is unloaded, so a reloaded plugin starts from what its new
|
||||
instance registers and the web UI's Fonts tab stops listing it."""
|
||||
removed = self.manager_fonts.pop(manager_id, None)
|
||||
for element_key, spec in list(self.detected_fonts.items()):
|
||||
if spec.get("manager_id") == manager_id:
|
||||
self.detected_fonts.pop(element_key, None)
|
||||
if removed:
|
||||
self.manager_fonts_version += 1
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Which plugins use which font, published for the web interface.
|
||||
|
||||
The display service and the web interface are separate processes. Plugins
|
||||
run, and register their fonts with ``FontManager.register_manager_font()``,
|
||||
in the display service only; the web interface's Fonts tab lists the files in
|
||||
``assets/fonts/`` from its own scan and has no FontManager to ask. So the
|
||||
display service publishes a small snapshot to the shared cache directory --
|
||||
the same channel, and the same file permissions, as ``display_current_state``
|
||||
and ``plugin_error_snapshot``: files are 0660 and carry the cache directory's
|
||||
group, so root writes and the web user reads.
|
||||
|
||||
FONT_USAGE_KEY written by the display service only
|
||||
|
||||
The snapshot::
|
||||
|
||||
{"generated_at": "2026-09-23T10:00:00",
|
||||
"fonts": {"PressStart2P-Regular": ["calendar", "hello-world"],
|
||||
"4x6-font": ["calendar"]}}
|
||||
|
||||
``fonts`` is keyed the way ``GET /api/v3/fonts/catalog`` keys its rows: the
|
||||
file name in ``assets/fonts/`` without its extension, as it is on disk. A
|
||||
plugin names a font by FontManager family ("press_start", "5x7", "6x13b"), by
|
||||
alias, or by path; :func:`catalog_key_for` resolves each through FontManager's
|
||||
own catalog to the file it loads and keys it by that file's stem. Fonts that
|
||||
live outside ``assets/fonts/`` (a plugin's own ``plugin_id::family`` fonts)
|
||||
and families FontManager cannot resolve are left out: the Fonts tab has no
|
||||
row for them.
|
||||
|
||||
Only registrations are counted. ``get_font()`` does not know which plugin is
|
||||
calling it, and many plugins open font files directly with PIL, so a font no
|
||||
plugin registered may still be in use; the web interface says so.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FONT_USAGE_KEY = "font_usage_snapshot"
|
||||
|
||||
#: How often the display service checks for a change. A check compares two
|
||||
#: integers and a set of plugin ids; the snapshot is rebuilt and written only
|
||||
#: when one of them moved, so plugins that register fonts every frame (the
|
||||
#: countdown plugin registers per countdown at render time) cost nothing.
|
||||
TICK_INTERVAL = 10.0
|
||||
|
||||
#: Rewrite an unchanged snapshot this often anyway. The cache's disk cleanup
|
||||
#: deletes entries older than its default retention (30 days), and a display
|
||||
#: service that runs longer than that without a font change would otherwise
|
||||
#: turn the Fonts tab back to "unknown".
|
||||
REFRESH_INTERVAL = 24 * 3600.0
|
||||
|
||||
FONT_EXTENSIONS = ('.ttf', '.otf', '.bdf')
|
||||
|
||||
# Bounds on what the web interface accepts from the snapshot file.
|
||||
_MAX_FONTS = 1000
|
||||
_MAX_PLUGINS_PER_FONT = 200
|
||||
_MAX_ID_CHARS = 200
|
||||
|
||||
|
||||
def _fonts_dir() -> str:
|
||||
from src.common.font_layout import resolve_asset_path
|
||||
return resolve_asset_path("assets/fonts")
|
||||
|
||||
|
||||
def _same_dir(a: str, b: str) -> bool:
|
||||
try:
|
||||
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def catalog_key_for(family: Any, font_catalog: Dict[str, str],
|
||||
fonts_dir: Optional[str] = None) -> Optional[str]:
|
||||
"""The Fonts-tab catalog key for a family a plugin registered, or None.
|
||||
|
||||
Resolution, in order:
|
||||
|
||||
1. ``family`` as a FontManager family -- a scanned file ("5x7",
|
||||
"pressstart2p-regular"), an alias ("press_start", "four_by_six",
|
||||
"five_by_seven", "tom_thumb"), or a plugin's ``plugin_id::family``.
|
||||
FontManager keys scanned files in lower case, so the lookup falls back
|
||||
to ``family.lower()``.
|
||||
2. ``family`` as a file name or path ("5x7.bdf",
|
||||
"assets/fonts/4x6-font.ttf", an absolute path).
|
||||
|
||||
The key is the resolved file's name without its extension, and only for a
|
||||
file directly in ``assets/fonts/`` -- anything else has no catalog row.
|
||||
"""
|
||||
if not isinstance(family, str):
|
||||
return None
|
||||
name = family.strip()
|
||||
if not name:
|
||||
return None
|
||||
fonts_dir = fonts_dir or _fonts_dir()
|
||||
|
||||
path = font_catalog.get(name) or font_catalog.get(name.lower())
|
||||
if not path:
|
||||
if not name.lower().endswith(FONT_EXTENSIONS):
|
||||
return None
|
||||
path = name
|
||||
directory, filename = os.path.split(str(path))
|
||||
stem, ext = os.path.splitext(filename)
|
||||
if not stem or ext.lower() not in FONT_EXTENSIONS:
|
||||
return None
|
||||
if directory:
|
||||
if not os.path.isabs(directory):
|
||||
from src.common.font_layout import resolve_asset_path
|
||||
directory = resolve_asset_path(directory)
|
||||
if not _same_dir(directory, fonts_dir):
|
||||
return None
|
||||
return stem
|
||||
|
||||
|
||||
def build_font_usage(font_manager: Any,
|
||||
plugin_ids: Optional[FrozenSet[str]] = None,
|
||||
fonts_dir: Optional[str] = None) -> Dict[str, List[str]]:
|
||||
"""``{catalog key: sorted plugin ids}`` from FontManager's registrations.
|
||||
|
||||
``plugin_ids`` limits it to loaded plugins: a plugin whose constructor
|
||||
registered fonts and which then failed validation is not using them.
|
||||
"""
|
||||
fonts_dir = fonts_dir or _fonts_dir()
|
||||
# list() copies in one step under the GIL; register_manager_font() runs
|
||||
# on the render thread while this runs on the publisher's.
|
||||
registrations = list(getattr(font_manager, 'manager_fonts', {}).items())
|
||||
font_catalog = dict(getattr(font_manager, 'font_catalog', {}))
|
||||
usage: Dict[str, set] = {}
|
||||
for manager_id, elements in registrations:
|
||||
if not isinstance(manager_id, str):
|
||||
continue
|
||||
if plugin_ids is not None and manager_id not in plugin_ids:
|
||||
continue
|
||||
for spec in list(elements.values()) if isinstance(elements, dict) else ():
|
||||
family = spec.get('family') if isinstance(spec, dict) else None
|
||||
key = catalog_key_for(family, font_catalog, fonts_dir)
|
||||
if key is not None:
|
||||
usage.setdefault(key, set()).add(manager_id)
|
||||
return {key: sorted(ids) for key, ids in sorted(usage.items())}
|
||||
|
||||
|
||||
class FontUsagePublisher:
|
||||
"""Publishes the display service's font usage to the shared cache.
|
||||
|
||||
tick() is the whole job; start() calls it from a daemon thread every
|
||||
TICK_INTERVAL seconds. Nothing here raises: a failure is logged at debug
|
||||
and retried on the next tick.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager: Any, font_manager: Any, plugin_manager: Any = None,
|
||||
clock: Callable[[], float] = time.monotonic) -> None:
|
||||
self.cache_manager = cache_manager
|
||||
self.font_manager = font_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self._clock = clock
|
||||
self._signature: Optional[Tuple[Any, ...]] = None
|
||||
# None forces a first write, replacing whatever a previous run of the
|
||||
# service left in the cache with this run's usage.
|
||||
self._published: Optional[Dict[str, List[str]]] = None
|
||||
self._published_at: Optional[float] = None
|
||||
self._lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def _loaded_plugin_ids(self) -> Optional[FrozenSet[str]]:
|
||||
plugins = getattr(self.plugin_manager, 'plugins', None)
|
||||
if not isinstance(plugins, dict):
|
||||
return None
|
||||
return frozenset(list(plugins))
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Publish if the usage changed. True if a snapshot was written."""
|
||||
with self._lock:
|
||||
try:
|
||||
plugin_ids = self._loaded_plugin_ids()
|
||||
signature = (
|
||||
getattr(self.font_manager, 'manager_fonts_version', None),
|
||||
getattr(self.font_manager, 'cache_generation', None),
|
||||
plugin_ids,
|
||||
)
|
||||
now = self._clock()
|
||||
refresh_due = (self._published_at is not None
|
||||
and now - self._published_at >= REFRESH_INTERVAL)
|
||||
if signature == self._signature and not refresh_due:
|
||||
return False
|
||||
usage = build_font_usage(self.font_manager, plugin_ids)
|
||||
if usage == self._published and not refresh_due:
|
||||
self._signature = signature
|
||||
return False
|
||||
self.cache_manager.set(FONT_USAGE_KEY, {
|
||||
'generated_at': datetime.now().isoformat(timespec='seconds'),
|
||||
'fonts': usage,
|
||||
})
|
||||
self._signature = signature
|
||||
self._published = usage
|
||||
self._published_at = now
|
||||
return True
|
||||
except Exception as err: # never let reporting break the display
|
||||
logger.debug("Could not publish font usage: %s", err, exc_info=True)
|
||||
return False
|
||||
|
||||
def start(self, interval: float = TICK_INTERVAL) -> None:
|
||||
"""Tick now, then from a daemon thread until stop(). No-op while running."""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
|
||||
def run() -> None:
|
||||
self.tick()
|
||||
while not self._stop.wait(interval):
|
||||
self.tick()
|
||||
|
||||
self._thread = threading.Thread(target=run, name="font-usage-publisher", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2)
|
||||
self._thread = None
|
||||
|
||||
|
||||
def start_font_usage_publisher(cache_manager: Any, font_manager: Any,
|
||||
plugin_manager: Any) -> Optional[FontUsagePublisher]:
|
||||
"""Start publishing font usage for the web interface. Never raises.
|
||||
|
||||
Call from the display service, once its plugins are loaded.
|
||||
"""
|
||||
try:
|
||||
if cache_manager is None or font_manager is None or plugin_manager is None:
|
||||
return None
|
||||
publisher = FontUsagePublisher(cache_manager, font_manager, plugin_manager)
|
||||
publisher.start()
|
||||
return publisher
|
||||
except Exception as err:
|
||||
logger.warning("Font usage reporting to the web interface is unavailable: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
# --- Reading side (web interface) -------------------------------------------
|
||||
|
||||
def read_font_usage(cache_manager: Any) -> Optional[Dict[str, Any]]:
|
||||
"""The display service's latest snapshot, or None if it has not published.
|
||||
|
||||
memory_ttl=0: the display service writes this key, so only the file is
|
||||
current. The contents are checked and bounded -- they arrive from another
|
||||
process through a shared directory.
|
||||
"""
|
||||
try:
|
||||
snapshot = cache_manager.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0)
|
||||
except Exception as err:
|
||||
logger.debug("Could not read font usage: %s", err, exc_info=True)
|
||||
return None
|
||||
if not isinstance(snapshot, dict) or not isinstance(snapshot.get('fonts'), dict):
|
||||
return None
|
||||
fonts: Dict[str, List[str]] = {}
|
||||
for key, ids in list(snapshot['fonts'].items())[:_MAX_FONTS]:
|
||||
if not isinstance(key, str) or not isinstance(ids, list):
|
||||
continue
|
||||
clean = sorted({i[:_MAX_ID_CHARS] for i in ids[:_MAX_PLUGINS_PER_FONT]
|
||||
if isinstance(i, str) and i})
|
||||
if clean:
|
||||
fonts[key[:_MAX_ID_CHARS]] = clean
|
||||
generated_at = snapshot.get('generated_at')
|
||||
return {
|
||||
'generated_at': generated_at if isinstance(generated_at, str) else None,
|
||||
'fonts': fonts,
|
||||
}
|
||||
@@ -624,6 +624,13 @@ class PluginManager:
|
||||
# Delegate sub-module and cached-module cleanup to the loader
|
||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||
|
||||
# Its font registrations go with it (the Fonts tab's "Used by").
|
||||
try:
|
||||
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
||||
self.font_manager.forget_manager_fonts(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
|
||||
self.state_manager.clear_state(plugin_id)
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"""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)
|
||||
@@ -9,6 +9,46 @@ from web_interface.blueprints.api_v3 import (
|
||||
)
|
||||
|
||||
|
||||
def _catalog_response(catalog):
|
||||
"""The catalog response, with each font's ``used_by`` merged in now.
|
||||
|
||||
Usage comes from the display service (src/font_usage.py) and changes
|
||||
independently of the files, so it is read per request and never stored
|
||||
in the 5-minute ``fonts_catalog`` cache: entries are copied, not edited.
|
||||
``used_by`` is a list of plugin ids, empty when no loaded plugin
|
||||
registered the font, and None when the display service has not reported.
|
||||
"""
|
||||
from src.font_usage import read_font_usage
|
||||
from web_interface.blueprints.api_v3.display import _cache_manager
|
||||
try:
|
||||
usage = read_font_usage(_cache_manager())
|
||||
except Exception:
|
||||
logger.debug("[FontCatalog] Could not read font usage", exc_info=True)
|
||||
usage = None
|
||||
|
||||
used_by = {}
|
||||
if usage is not None:
|
||||
# The snapshot keys fonts by file stem, as the catalog does; match
|
||||
# case-insensitively too, since FontManager lower-cases families.
|
||||
by_lower = {key.lower(): key for key in catalog}
|
||||
for key, plugin_ids in usage['fonts'].items():
|
||||
row = key if key in catalog else by_lower.get(key.lower())
|
||||
if row is not None:
|
||||
used_by.setdefault(row, set()).update(plugin_ids)
|
||||
|
||||
merged = {
|
||||
key: dict(info, used_by=(sorted(used_by.get(key, ())) if usage is not None else None))
|
||||
for key, info in catalog.items()
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': {
|
||||
'catalog': merged,
|
||||
'font_usage': {
|
||||
'available': usage is not None,
|
||||
'generated_at': usage['generated_at'] if usage is not None else None,
|
||||
},
|
||||
}})
|
||||
|
||||
|
||||
@api_v3.route('/fonts/catalog', methods=['GET'])
|
||||
def get_fonts_catalog():
|
||||
"""Get fonts catalog"""
|
||||
@@ -18,7 +58,7 @@ def get_fonts_catalog():
|
||||
from web_interface.cache import get_cached, set_cached
|
||||
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
|
||||
if cached_result is not None:
|
||||
return jsonify({'status': 'success', 'data': {'catalog': cached_result}})
|
||||
return _catalog_response(cached_result)
|
||||
except ImportError:
|
||||
# Cache not available, continue without caching
|
||||
get_cached = None
|
||||
@@ -116,7 +156,7 @@ def get_fonts_catalog():
|
||||
except Exception:
|
||||
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
||||
|
||||
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
|
||||
return _catalog_response(catalog)
|
||||
except Exception as e:
|
||||
logger.error("%s failed", request.path, exc_info=True)
|
||||
return jsonify({'status': 'error',
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
<!-- Available Fonts -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-3">Available Font Families</h3>
|
||||
<div id="available-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-40 overflow-y-auto">
|
||||
<div id="available-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-64 overflow-y-auto">
|
||||
<div class="text-gray-400">Loading...</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mt-2">All available font families in the system</p>
|
||||
<p class="text-sm text-gray-600 mt-2">All available font families in the system.
|
||||
<span id="font-usage-note"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -299,8 +300,11 @@ async function loadFontData() {
|
||||
|
||||
try {
|
||||
// Use absolute URLs to ensure they work when loaded via HTMX
|
||||
// no-store: /api/v3 GETs are browser-cacheable for 5 s, and this
|
||||
// reload follows an upload or delete (or the usage check before one),
|
||||
// so a cached copy would show the list as it was before the change.
|
||||
const [catalogRes, tokensRes] = await Promise.all([
|
||||
fetch(`/api/v3/fonts/catalog`),
|
||||
fetch(`/api/v3/fonts/catalog`, { cache: 'no-store' }),
|
||||
fetch(`/api/v3/fonts/tokens`)
|
||||
]);
|
||||
|
||||
@@ -325,6 +329,7 @@ async function loadFontData() {
|
||||
|
||||
// Update both window properties and local references
|
||||
window.fontCatalog = catalogData.data.catalog || {};
|
||||
window.fontUsage = catalogData.data.font_usage || { available: false, generated_at: null };
|
||||
window.fontTokens = tokensData.data.tokens || {};
|
||||
|
||||
// Update local variable references
|
||||
@@ -355,10 +360,29 @@ async function loadFontData() {
|
||||
}
|
||||
}
|
||||
|
||||
// The plugin ids that use a font, as the catalog reports them: an array
|
||||
// (possibly empty) when the display service has reported, null when not.
|
||||
function fontUsedBy(fontInfo) {
|
||||
if (!fontInfo || typeof fontInfo !== 'object') return null;
|
||||
return Array.isArray(fontInfo.used_by)
|
||||
? fontInfo.used_by.filter(id => typeof id === 'string')
|
||||
: null;
|
||||
}
|
||||
|
||||
function updateAvailableFontsDisplay() {
|
||||
const container = document.getElementById('available-fonts');
|
||||
if (!container) return;
|
||||
|
||||
const usage = window.fontUsage || {};
|
||||
const note = document.getElementById('font-usage-note');
|
||||
if (note) {
|
||||
const reported = usage.generated_at
|
||||
? ` (reported ${String(usage.generated_at).replace('T', ' ')})` : '';
|
||||
note.textContent = usage.available
|
||||
? `"Used by" lists the running plugins that registered the font with the display service${reported}. A plugin that opens a font file directly is not listed.`
|
||||
: '"Used by" is unknown: the display service has not reported which fonts its plugins use. Start it, or reload this tab a few seconds after it starts.';
|
||||
}
|
||||
|
||||
if (Object.keys(fontCatalog).length === 0) {
|
||||
container.innerHTML = '<div class="text-gray-400">No fonts available</div>';
|
||||
return;
|
||||
@@ -371,18 +395,34 @@ function updateAvailableFontsDisplay() {
|
||||
const fontType = typeof fontInfo === 'object' ? (fontInfo.type || '').toUpperCase() : '';
|
||||
// Use is_system flag from API (single source of truth)
|
||||
const isSystem = typeof fontInfo === 'object' ? (fontInfo.is_system === true) : false;
|
||||
return { name, filename, displayName, fontType, fontPath, isSystem };
|
||||
const usedBy = fontUsedBy(fontInfo);
|
||||
return { name, filename, displayName, fontType, fontPath, isSystem, usedBy };
|
||||
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
// Build list using DOM APIs to prevent XSS
|
||||
// Build list using DOM APIs to prevent XSS (plugin ids are untrusted too)
|
||||
container.innerHTML = '';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'flex items-center gap-2 pb-1 mb-1 border-b border-gray-600 text-xs uppercase text-gray-400';
|
||||
const fontHead = document.createElement('span');
|
||||
fontHead.className = 'flex-1 min-w-0';
|
||||
fontHead.textContent = 'Font';
|
||||
const usedHead = document.createElement('span');
|
||||
usedHead.className = 'flex-1 min-w-0';
|
||||
usedHead.textContent = 'Used by';
|
||||
const actionHead = document.createElement('span');
|
||||
actionHead.className = 'w-24 flex-shrink-0';
|
||||
header.append(fontHead, usedHead, actionHead);
|
||||
container.appendChild(header);
|
||||
|
||||
fontEntries.forEach(font => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between py-1 border-b border-gray-700 last:border-0';
|
||||
row.className = 'flex items-start gap-2 py-1 border-b border-gray-700 last:border-0';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate flex-1';
|
||||
nameSpan.className = 'truncate flex-1 min-w-0';
|
||||
nameSpan.textContent = font.displayName;
|
||||
nameSpan.title = font.filename;
|
||||
|
||||
if (font.fontType) {
|
||||
const typeSpan = document.createElement('span');
|
||||
@@ -393,29 +433,83 @@ function updateAvailableFontsDisplay() {
|
||||
|
||||
row.appendChild(nameSpan);
|
||||
|
||||
// Wraps rather than truncates: the list is what a delete is weighed
|
||||
// against, and a title tooltip is out of reach on a phone.
|
||||
const usedSpan = document.createElement('span');
|
||||
usedSpan.className = 'flex-1 min-w-0 break-words';
|
||||
if (font.usedBy === null) {
|
||||
usedSpan.classList.add('text-gray-500', 'italic');
|
||||
usedSpan.textContent = 'unknown';
|
||||
usedSpan.title = 'The display service has not reported which fonts its plugins use';
|
||||
} else if (font.usedBy.length === 0) {
|
||||
usedSpan.classList.add('text-gray-500');
|
||||
usedSpan.textContent = '—';
|
||||
usedSpan.title = 'No running plugin registered this font';
|
||||
} else {
|
||||
usedSpan.classList.add('text-gray-300');
|
||||
usedSpan.textContent = font.usedBy.join(', ');
|
||||
}
|
||||
row.appendChild(usedSpan);
|
||||
|
||||
const actionCell = document.createElement('span');
|
||||
actionCell.className = 'w-24 flex-shrink-0 text-right';
|
||||
if (font.isSystem) {
|
||||
const systemBadge = document.createElement('span');
|
||||
systemBadge.className = 'text-gray-600 text-xs ml-2';
|
||||
systemBadge.className = 'text-gray-600 text-xs';
|
||||
systemBadge.textContent = '[system]';
|
||||
row.appendChild(systemBadge);
|
||||
actionCell.appendChild(systemBadge);
|
||||
} else {
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs ml-2';
|
||||
deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs';
|
||||
deleteBtn.title = 'Delete font';
|
||||
deleteBtn.textContent = '[delete]';
|
||||
deleteBtn.dataset.fontName = font.name;
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
deleteFont(this.dataset.fontName);
|
||||
});
|
||||
row.appendChild(deleteBtn);
|
||||
actionCell.appendChild(deleteBtn);
|
||||
}
|
||||
row.appendChild(actionCell);
|
||||
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Which plugins use a font now. Re-reads the catalog so the warning reflects
|
||||
// the display service's latest report rather than the one the tab opened
|
||||
// with; falls back to that one if the request fails.
|
||||
async function currentFontUsedBy(fontName) {
|
||||
const own = (obj) => obj && typeof obj === 'object'
|
||||
&& Object.prototype.hasOwnProperty.call(obj, fontName);
|
||||
try {
|
||||
const response = await fetch('/api/v3/fonts/catalog', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
const catalog = (await response.json())?.data?.catalog;
|
||||
if (own(catalog)) return fontUsedBy(catalog[fontName]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not refresh font usage before delete:', error);
|
||||
}
|
||||
return own(fontCatalog) ? fontUsedBy(fontCatalog[fontName]) : null;
|
||||
}
|
||||
|
||||
// The confirm() text for deleting a font. Plain text: confirm() renders no
|
||||
// HTML, so a plugin id cannot inject markup here.
|
||||
function deleteFontConfirmMessage(fontFamily, usedBy) {
|
||||
if (Array.isArray(usedBy) && usedBy.length > 0) {
|
||||
return `Font "${fontFamily}" is in use.\n\nUsed by: ${usedBy.join(', ')}.\n\n`
|
||||
+ 'Delete anyway? This action cannot be undone.';
|
||||
}
|
||||
const base = `Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`;
|
||||
if (usedBy === null) {
|
||||
return `${base}\n\nWhich plugins use it is unknown: the display service has not reported.`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async function deleteFont(fontFamily) {
|
||||
if (!confirm(`Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`)) {
|
||||
const usedBy = await currentFontUsedBy(fontFamily);
|
||||
if (!confirm(deleteFontConfirmMessage(fontFamily, usedBy))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user