Files
Anthias/tests/test_webview_dark_mode.py
Viktor Petersson d323bf147d feat(viewer): add "Prefer dark mode" setting for web page assets (#3050)
* feat(viewer): add "Prefer dark mode" setting for web page assets

Adds a "Prefer dark mode" toggle in Settings that instructs the Qt
webview to render web page assets dark. The C++ webview owns the
realization: applyDarkModePreference() reads ANTHIAS_PREFER_DARK_MODE
and injects --blink-settings=forceDarkModeEnabled=true into Chromium
before QtWebEngine inits, working uniformly across Qt5 (Pi 1-4) and
Qt6 (Pi 5/x86) without a version macro.

Python owns the setting: the viewer exports the env var per board in
_build_webview_env() and respawns the webview when the operator toggles
the setting (reusing the rotation-bounce handoff). Wired through the
settings page, page_context, the form POST, and the v2 device-settings
API.

Validated on a Pi 5 testbed: with the setting on, AnthiasViewer's env
carries ANTHIAS_PREFER_DARK_MODE=1 and the QtWebEngine renderer
processes run with --blink-settings=forceDarkModeEnabled=true; with it
off, neither is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): annotate pixel list so mypy doesn't flag no-any-return

PIL's getdata() is untyped, so sum(list(...)) was Any and the
-> float return tripped mypy's no-any-return. Annotate the list as
list[int].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(viewer): address review on dark-mode flag and its test

- main.cpp: merge forceDarkModeEnabled into an existing
  --blink-settings switch instead of appending a duplicate (Chromium
  keeps only the last occurrence, so a second switch would silently
  drop any Blink settings already set); no-op if already requested.
- test_webview_dark_mode: launch via the suite's browser_type /
  browser_type_launch_args fixtures so the conftest --no-sandbox
  override (and any future launch config) is inherited rather than
  hardcoded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:57:10 +01:00

124 lines
4.8 KiB
Python

"""Integration coverage for the "Prefer dark mode" setting.
The setting is realised entirely inside the C++ webview: when the
operator enables it, the Python viewer exports
``ANTHIAS_PREFER_DARK_MODE=1`` (see ``_build_webview_env`` in
``src/anthias_viewer/__init__.py``) and ``applyDarkModePreference`` in
``src/anthias_webview/src/main.cpp`` translates that into the Chromium
switch ``--blink-settings=forceDarkModeEnabled=true`` before QtWebEngine
boots its Chromium context.
These tests pin the behaviour of that exact switch by driving Chromium —
the same Blink engine QtWebEngine embeds — headless, rendering a plain
white page, screenshotting it, and asserting on the average pixel
luminance: dark with the flag, light without it. Running the full
AnthiasViewer binary end-to-end (offscreen QPA + screenshot) belongs on
a real testbed, not CI; this validates the engine-level mechanism the
device relies on.
The flag literal below is duplicated from main.cpp on purpose — keep the
two in sync; the test fails loudly if Chromium ever stops honouring it.
"""
import io
from typing import Any
import pytest
from PIL import Image
from playwright.sync_api import BrowserType
# Must match applyDarkModePreference() in src/anthias_webview/src/main.cpp.
FORCE_DARK_FLAG = '--blink-settings=forceDarkModeEnabled=true'
# A deliberately light page: a pure-white viewport with no
# ``prefers-color-scheme`` hints, so Chromium's force-dark feature has
# something to invert and the no-flag baseline stays bright.
WHITE_PAGE = (
'<!doctype html><html><head><meta charset="utf-8">'
'<style>html,body{margin:0;padding:0;height:100%;'
'background:#ffffff;color:#000;}</style></head>'
'<body><div style="width:100vw;height:100vh;"></div></body></html>'
)
# Pure white renders at luminance ~255; Chromium force-darkens it to a
# near-black canvas (~#121212). The thresholds leave a wide margin either
# side of the midpoint so anti-aliasing or a future engine tweak to the
# exact dark shade can't flip the result.
LIGHT_THRESHOLD = 200
DARK_THRESHOLD = 120
def _mean_center_luminance(
browser_type: BrowserType,
launch_args: dict[str, Any],
extra_args: list[str],
) -> float:
"""Render the white page in headless Chromium and return the mean
luminance (0-255) of the centre of the screenshot.
Launches through the suite's ``browser_type`` /
``browser_type_launch_args`` fixtures (rather than a bare
``sync_playwright`` call) so any suite-wide launch configuration —
notably the ``--no-sandbox`` override in conftest.py needed because
the test container runs as root — is inherited here too and can't
drift. We must launch our own browser instead of reusing the shared
``page`` fixture because the force-dark switch differs per test and
Chromium only reads it at startup.
Cropping to the centre quarter avoids any window-chrome or
scrollbar pixels skewing a full-frame average.
"""
args = [*launch_args.get('args', []), '--hide-scrollbars', *extra_args]
browser = browser_type.launch(**{**launch_args, 'args': args})
try:
page = browser.new_page(viewport={'width': 400, 'height': 300})
page.set_content(WHITE_PAGE, wait_until='load')
# Force-dark is applied during paint; give the compositor a
# beat so the screenshot captures the darkened frame rather
# than the initial white flash.
page.wait_for_timeout(300)
png = page.screenshot()
finally:
browser.close()
image = Image.open(io.BytesIO(png)).convert('L')
width, height = image.size
centre = image.crop(
(width // 4, height // 4, 3 * width // 4, 3 * height // 4)
)
pixels: list[int] = list(centre.getdata())
return sum(pixels) / len(pixels)
@pytest.mark.integration
def test_force_dark_flag_renders_web_page_dark(
browser_type: BrowserType,
browser_type_launch_args: dict[str, Any],
) -> None:
""" "Prefer dark mode" on: the Chromium switch the webview injects
must darken an otherwise-white page."""
luminance = _mean_center_luminance(
browser_type, browser_type_launch_args, [FORCE_DARK_FLAG]
)
assert luminance < DARK_THRESHOLD, (
f'expected a dark render with {FORCE_DARK_FLAG}, '
f'got mean luminance {luminance:.1f}'
)
@pytest.mark.integration
def test_without_flag_web_page_stays_light(
browser_type: BrowserType,
browser_type_launch_args: dict[str, Any],
) -> None:
""" "Prefer dark mode" off: with no flag the same white page must
render light, proving the dark result above is caused by the flag
and not the harness."""
luminance = _mean_center_luminance(
browser_type, browser_type_launch_args, []
)
assert luminance > LIGHT_THRESHOLD, (
'expected a light render without the force-dark flag, '
f'got mean luminance {luminance:.1f}'
)