"""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('Hope') 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()