"""HEL-373: 黑金下拉全状态 + 日期年月直选。""" from __future__ import annotations import os import threading import unittest from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path ROOT = Path(__file__).resolve().parents[1] WEB = ROOT / "web" SHOT_DIR = ROOT.parent / "hel373-shots" try: from playwright.sync_api import sync_playwright except ImportError: # pragma: no cover sync_playwright = None def _prepare_chrome_libs() -> Path | None: candidates = [ROOT / ".chrome-libs" / "lib"] for lib_dir in candidates: if (lib_dir / "libatk-1.0.so.0").exists(): current = os.environ.get("LD_LIBRARY_PATH", "") prefix = str(lib_dir) if prefix not in current.split(":"): os.environ["LD_LIBRARY_PATH"] = ( f"{prefix}:{current}" if current else prefix ) return lib_dir return None def _chromium_available() -> bool: if not sync_playwright: return False _prepare_chrome_libs() try: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) browser.close() return True except Exception: return False PROBE_HTML = """
""" class Hel373SourceContractTests(unittest.TestCase): def test_custom_listbox_and_year_month_exist(self) -> None: css = (WEB / "design-system.css").read_text(encoding="utf-8") js_select = (WEB / "select.js").read_text(encoding="utf-8") js_date = (WEB / "datepicker.js").read_text(encoding="utf-8") admin = (WEB / "admin.html").read_text(encoding="utf-8") company = (WEB / "company.html").read_text(encoding="utf-8") self.assertIn("select.select", js_select) self.assertIn('setAttribute("role", "combobox")', js_select) self.assertIn('setAttribute("role", "listbox")', js_select) self.assertIn("JinniuSelect", js_select) self.assertIn("data-dp-year", js_date) self.assertIn("data-dp-month", js_date) self.assertIn("YEAR_PAD", js_date) self.assertIn(".ds-listbox", css) self.assertIn(".ds-combo-trigger", css) self.assertIn(".ds-listbox-option.is-placeholder", css) self.assertIn("html[data-theme=\"night\"] .ds-listbox", css) self.assertIn("var(--gold-line)", css[css.index("html[data-theme=\"night\"] .ds-listbox") :]) self.assertIn("html[data-theme=\"night\"] .ds-listbox-option.is-selected", css) self.assertIn("select.js?v=1", admin) self.assertIn("select.js?v=1", company) self.assertIn("datepicker.js?v=2", admin) self.assertIn("datepicker.js?v=2", company) self.assertIn("design-system.css?v=14", admin) self.assertIn("design-system.css?v=14", company) def test_listbox_uses_tokens_not_hardcoded_night_hex(self) -> None: css = (WEB / "design-system.css").read_text(encoding="utf-8") start = css.index("/* ─── 主题化 combobox") end = css.index("@media (prefers-reduced-motion: reduce)", start) block = css[start:end] self.assertNotIn("#fff", block) self.assertNotIn("#ffffff", block.lower()) self.assertNotIn("#000", block) self.assertIn("var(--surface)", block) self.assertIn("var(--text)", block) self.assertIn("var(--gold)", block) self.assertIn("var(--fill)", block) @unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器探针") @unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器探针") class Hel373BrowserProbeTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: handler = partial(SimpleHTTPRequestHandler, directory=str(WEB)) cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler) cls.port = cls.httpd.server_address[1] cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) cls.thread.start() cls.base = f"http://127.0.0.1:{cls.port}" @classmethod def tearDownClass(cls) -> None: cls.httpd.shutdown() cls.httpd.server_close() def _open(self, page, *, night: bool, width: int = 1440, height: int = 900) -> None: page.set_viewport_size({"width": width, "height": height}) theme_attr = 'data-theme="night"' if night else 'data-theme="day"' page.set_content( PROBE_HTML.format(theme_attr=theme_attr, base=self.base), wait_until="domcontentloaded", ) page.wait_for_function("() => window.JinniuDatePicker && window.JinniuSelect") page.wait_for_selector("#probe-empty + .ds-combo-trigger, .ds-combo #probe-empty") page.wait_for_function( "() => document.querySelectorAll('.ds-combo-trigger').length >= 2" ) def _trigger(self, page, select_id: str): return page.locator(f"#{select_id}").locator( "xpath=ancestor::div[contains(@class,'ds-combo')]" ).locator(".ds-combo-trigger") def test_night_placeholder_unselected_selected_disabled(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=True) empty_btn = self._trigger(page, "probe-empty") self.assertTrue(empty_btn.evaluate("el => el.classList.contains('is-placeholder')")) empty_btn.click() page.wait_for_selector("#ds-listbox:not([hidden])") snapshot = page.evaluate( """() => { const box = document.getElementById('ds-listbox'); const opts = [...box.querySelectorAll('.ds-listbox-option')].map((el) => { const cs = getComputedStyle(el); return { text: el.textContent, color: cs.color, bg: cs.backgroundColor, selected: el.classList.contains('is-selected'), placeholder: el.classList.contains('is-placeholder'), disabled: el.classList.contains('is-disabled'), }; }); const bcs = getComputedStyle(box); const tcs = getComputedStyle(document.querySelector('#probe-empty').closest('.ds-combo').querySelector('.ds-combo-trigger')); return { boxBg: bcs.backgroundColor, boxColor: bcs.color, boxBorder: bcs.borderTopColor, triggerBg: tcs.backgroundColor, triggerColor: tcs.color, opts, }; }""" ) page.keyboard.press("Escape") filled = self._trigger(page, "probe-filled") filled.click() page.wait_for_selector("#ds-listbox:not([hidden])") selected = page.evaluate( """() => { const el = document.querySelector('#ds-listbox .is-selected'); const cs = getComputedStyle(el); return { color: cs.color, bg: cs.backgroundColor, text: el.textContent }; }""" ) native = page.evaluate("() => document.getElementById('probe-filled').value") browser.close() white = "rgb(255, 255, 255)" self.assertNotEqual(white, snapshot["boxBg"]) self.assertNotEqual(white, snapshot["boxColor"]) self.assertNotEqual(white, snapshot["triggerBg"]) self.assertNotEqual(white, snapshot["triggerColor"]) placeholder = next(o for o in snapshot["opts"] if o["placeholder"]) plain = next(o for o in snapshot["opts"] if not o["placeholder"] and not o["disabled"] and not o["selected"]) disabled = next(o for o in snapshot["opts"] if o["disabled"]) self.assertNotEqual(white, placeholder["color"]) self.assertNotEqual(white, placeholder["bg"]) self.assertNotEqual(white, plain["color"]) self.assertNotEqual(white, plain["bg"]) self.assertNotEqual(white, disabled["color"]) self.assertIn("217", selected["color"]) self.assertEqual("河南金牛煤业有限公司", selected["text"]) self.assertEqual("b", native) def test_keyboard_and_form_value(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=True) btn = self._trigger(page, "probe-empty") btn.focus() page.keyboard.press("ArrowDown") page.wait_for_selector("#ds-listbox:not([hidden])") page.keyboard.press("ArrowDown") page.keyboard.press("Enter") value = page.evaluate("() => document.getElementById('probe-empty').value") label = btn.inner_text() btn.focus() page.keyboard.press("Enter") page.wait_for_selector("#ds-listbox:not([hidden])") page.keyboard.press("Escape") hidden = page.evaluate("() => document.getElementById('ds-listbox').hidden") browser.close() self.assertEqual("a", value) self.assertIn("农业", label) self.assertTrue(hidden) def test_year_month_jump_keeps_selected_and_skips_invalid_day(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=True) page.click("#probe-date") page.wait_for_selector("#ds-datepicker:not([hidden])") before = page.evaluate("() => document.getElementById('probe-date').value") year_btn = page.locator("#ds-datepicker [data-dp-year]").locator( "xpath=ancestor::div[contains(@class,'ds-combo')]" ).locator(".ds-combo-trigger") year_btn.click() page.wait_for_selector("#ds-listbox:not([hidden])") page.locator("#ds-listbox .ds-listbox-option", has_text="2024年").click() page.wait_for_function( "() => document.querySelector('[data-dp-year]').value === '2024'" ) month_btn = page.locator("#ds-datepicker [data-dp-month]").locator( "xpath=ancestor::div[contains(@class,'ds-combo')]" ).locator(".ds-combo-trigger") month_btn.click() page.wait_for_selector("#ds-listbox:not([hidden])") page.locator("#ds-listbox .ds-listbox-option", has_text="02月").click() after = page.evaluate( """() => { const input = document.getElementById('probe-date'); const panel = document.getElementById('ds-datepicker'); const selected = panel.querySelector('.ds-dp-day.is-selected'); const days = [...panel.querySelectorAll('.ds-dp-day:not(.is-other)')].map((el) => el.textContent); const year = document.querySelector('[data-dp-year]').value; const month = document.querySelector('[data-dp-month]').value; return { value: input.value, selected: selected && selected.getAttribute('data-iso'), has31: days.includes('31'), year, month, titleYear: document.querySelector('#ds-datepicker .ds-combo-trigger').textContent, }; }""" ) browser.close() self.assertEqual("2026-01-31", before) self.assertEqual("2026-01-31", after["value"]) self.assertIsNone(after["selected"]) self.assertFalse(after["has31"]) self.assertEqual("2024", after["year"]) self.assertEqual("1", after["month"]) def test_leap_year_feb29_and_minmax_year_range(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=False) page.click("#probe-date") page.wait_for_selector("#ds-datepicker:not([hidden])") page.locator("#ds-datepicker [data-dp-year]").locator( "xpath=ancestor::div[contains(@class,'ds-combo')]" ).locator(".ds-combo-trigger").click() years = page.evaluate( """() => [...document.querySelectorAll('#ds-listbox .ds-listbox-option')].map((el) => el.textContent)""" ) page.locator("#ds-listbox .ds-listbox-option", has_text="2024年").click() page.locator("#ds-datepicker [data-dp-month]").locator( "xpath=ancestor::div[contains(@class,'ds-combo')]" ).locator(".ds-combo-trigger").click() page.locator("#ds-listbox .ds-listbox-option", has_text="02月").click() days = page.evaluate( """() => [...document.querySelectorAll('#ds-datepicker .ds-dp-day:not(.is-other)')].map((el) => el.textContent)""" ) page.click('#ds-datepicker [data-iso="2024-02-29"]') value = page.evaluate("() => document.getElementById('probe-date').value") page.click("#probe-free-date") page.wait_for_selector("#ds-datepicker:not([hidden])") page.locator("#ds-datepicker [data-dp-year]").locator( "xpath=ancestor::div[contains(@class,'ds-combo')]" ).locator(".ds-combo-trigger").click() free_years = page.evaluate( """() => [...document.querySelectorAll('#ds-listbox .ds-listbox-option')].map((el) => el.textContent)""" ) browser.close() from datetime import date year = date.today().year self.assertIn("2020年", years) self.assertIn("2028年", years) self.assertNotIn("2019年", years) self.assertIn("29", days) self.assertEqual("2024-02-29", value) self.assertIn(f"{year - 80}年", free_years) self.assertIn(f"{year + 20}年", free_years) def test_prev_next_month_still_works(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=True) page.click("#probe-date") page.wait_for_selector("#ds-datepicker:not([hidden])") page.click('#ds-datepicker [data-dp-nav="1"]') state = page.evaluate( """() => ({ month: document.querySelector('[data-dp-month]').value, year: document.querySelector('[data-dp-year]').value, value: document.getElementById('probe-date').value, })""" ) browser.close() self.assertEqual("1", state["month"]) self.assertEqual("2026", state["year"]) self.assertEqual("2026-01-31", state["value"]) def test_day_theme_trigger_stays_light(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=False) colors = page.evaluate( """() => { const btn = document.querySelector('#probe-filled').closest('.ds-combo').querySelector('.ds-combo-trigger'); const cs = getComputedStyle(btn); return { bg: cs.backgroundColor, color: cs.color }; }""" ) browser.close() self.assertNotEqual("rgb(16, 15, 13)", colors["bg"]) def test_viewports_and_shots(self) -> None: SHOT_DIR.mkdir(parents=True, exist_ok=True) viewports = [360, 390, 820, 1440] with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) for width in viewports: for night in (False, True): page = browser.new_page(viewport={"width": width, "height": 800}) self._open(page, night=night, width=width, height=800) theme = "night" if night else "day" self._trigger(page, "probe-empty").click() page.wait_for_selector("#ds-listbox:not([hidden])") page.screenshot( path=str(SHOT_DIR / f"select-empty-{theme}-{width}.png"), full_page=True, ) page.keyboard.press("Escape") self._trigger(page, "probe-filled").click() page.wait_for_selector("#ds-listbox:not([hidden])") page.screenshot( path=str(SHOT_DIR / f"select-filled-{theme}-{width}.png"), full_page=True, ) page.keyboard.press("Escape") page.click("#probe-date") page.wait_for_selector("#ds-datepicker:not([hidden])") page.screenshot( path=str(SHOT_DIR / f"datepicker-{theme}-{width}.png"), full_page=True, ) page.locator("#ds-datepicker [data-dp-year]").locator( "xpath=ancestor::div[contains(@class,'ds-combo')]" ).locator(".ds-combo-trigger").click() page.wait_for_selector("#ds-listbox:not([hidden])") page.screenshot( path=str(SHOT_DIR / f"datepicker-year-{theme}-{width}.png"), full_page=True, ) page.close() browser.close() self.assertTrue((SHOT_DIR / "select-empty-night-1440.png").exists()) self.assertTrue((SHOT_DIR / "datepicker-year-night-360.png").exists()) if __name__ == "__main__": unittest.main()