Files

118 lines
6.3 KiB
Python

"""Offline token contract, native identity, generator drift and browser cascade tests."""
import copy
import importlib.util
import json
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[2]
module_spec = importlib.util.spec_from_file_location("hope_theme_generator", ROOT / "scripts/generate_hope_themes.py")
generator = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(generator)
class HopeThemeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.source = generator.SPEC.read_bytes()
cls.spec = json.loads(cls.source)
def test_contract_defaults_and_identifiers(self):
self.assertEqual(self.spec["defaultPalette"], "opal")
self.assertEqual(self.spec["defaultAppearance"], "system")
self.assertEqual(list(self.spec["palettes"]), ["opal", "graphite", "ocean", "sage", "lavender"])
generator.validate(self.spec)
def test_opal_exact_native_identity(self):
# Fixed from voice-ios/Sources/App/Nearlight.swift; no external checkout needed in CI.
expected = {
"light": ["#FFFDFD", "#FFF7FA", "#FFF2F7", "#302B40", "#706074", "#A64270", "#FFFFFF"],
"dark": ["#201820", "#302330", "#3B2836", "#FFF2F7", "#CBB6C7", "#F0A8C8", "#201820"],
}
for appearance, values in expected.items():
for role, value in zip(("ground", "surface", "personaSurface", "ink", "quiet", "accent", "onAccent"), values):
self.assertEqual(self.spec["palettes"]["opal"][appearance][role], value)
self.assertEqual(self.spec["primitives"]["alertLight"], [0.675, 0.208, 0.282])
self.assertEqual(self.spec["primitives"]["alertDark"], [1.0, 0.604, 0.675])
def test_all_required_contrast_pairs(self):
checks = generator.contrast_checks(self.spec)
self.assertEqual(len(checks), 230)
for row in checks:
with self.subTest(**row):
self.assertGreaterEqual(row["ratio"], row["minimum"])
self.assertAlmostEqual(generator.contrast((1, 1, 1), (0, 0, 0)), 21)
def test_bad_contrast_rejected(self):
changed = copy.deepcopy(self.spec)
changed["palettes"]["opal"]["light"]["quiet"] = "#FFFDFD"
with self.assertRaisesRegex(ValueError, "Contrast verification failed"):
generator.validate(changed)
def test_missing_role_rejected(self):
changed = copy.deepcopy(self.spec)
del changed["palettes"]["ocean"]["dark"]["focus"]
with self.assertRaisesRegex(ValueError, "Incomplete semantic roles"):
generator.validate(changed)
def test_invalid_primitive_rejected(self):
for value in ("#abc", [1.1, 0, 0], [True, 0, 0], "transparent"):
with self.subTest(value=value), self.assertRaises(ValueError):
generator.rgb(value)
def test_alert_is_not_palette_accent(self):
for appearance in ("light", "dark"):
alerts = {generator.resolve(self.spec, palette, appearance)["alert"] for palette in self.spec["palettes"]}
self.assertEqual(len(alerts), 1)
def test_derived_roles_are_opaque_surface_composites(self):
colors = generator.resolve(self.spec, "opal", "light")
for role, fg, alpha in (("separator", "ink", .14), ("selectedWash", "accent", .10), ("errorWash", "alert", .08)):
self.assertEqual(colors[role], tuple(f * alpha + b * (1 - alpha) for f, b in zip(colors[fg], colors["surface"])))
def test_generated_artifacts_are_current_and_deterministic(self):
first = generator.artifacts(self.spec, self.source)
self.assertEqual(first, generator.artifacts(self.spec, self.source))
for key, path in generator.OUTPUTS.items():
self.assertEqual(path.read_text(), first[key], str(path))
def test_swift_channels_round_trip(self):
for palette in self.spec["palettes"]:
for appearance in ("light", "dark"):
for color in generator.resolve(self.spec, palette, appearance).values():
self.assertEqual(tuple(float(generator.number(c)) for c in color), color)
class HopeThemeBrowserTests(unittest.TestCase):
def test_browser_palette_appearance_cascade(self):
try:
from playwright.sync_api import sync_playwright
except ImportError:
self.skipTest("Optional Playwright unavailable; install it to verify browser cascade")
spec = json.loads(generator.SPEC.read_bytes())
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
try:
page = browser.new_page()
page.set_content('<html><head></head><body><span id="probe">Hope</span></body></html>')
page.add_style_tag(content=generator.OUTPUTS["css"].read_text())
page.add_style_tag(content='#probe { color: var(--hope-ink); background-color: var(--hope-ground); }')
for system in ("light", "dark"):
page.emulate_media(color_scheme=system)
for palette in [None, "obsolete"] + list(spec["palettes"]):
for appearance in (None, "obsolete", "system", "light", "dark"):
page.evaluate('([p, a]) => { for (const [key, value] of [["palette", p], ["appearance", a]]) { if (value === null) delete document.documentElement.dataset[key]; else document.documentElement.dataset[key] = value; } }', [palette, appearance])
actual = page.locator("#probe").evaluate('(el) => { const s = getComputedStyle(el); return [s.color, s.backgroundColor]; }')
resolved_palette = palette if palette in spec["palettes"] else "opal"
resolved_appearance = appearance if appearance in ("light", "dark") else system
colors = generator.resolve(spec, resolved_palette, resolved_appearance)
expected = ["rgb(" + ", ".join(str(round(c * 255)) for c in colors[role]) + ")" for role in ("ink", "ground")]
with self.subTest(system=system, palette=palette, appearance=appearance):
self.assertEqual(actual, expected)
finally:
browser.close()
if __name__ == "__main__":
unittest.main()