"""Observable builder contracts. Run with Python's unittest discovery.""" import base64 from contextlib import redirect_stderr import importlib.util from html.parser import HTMLParser import io import json import re from pathlib import Path import shutil import subprocess import sys import tempfile import unittest ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location("build_explorer", ROOT / "scripts" / "build_explorer.py") builder = importlib.util.module_from_spec(spec) spec.loader.exec_module(builder) class ExplorerBuildTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.folder = Path(self.tmp.name) self.manifest = self.folder / "manifest.json" self.output = self.folder / "output" / "explore.html" self.source = self.folder / "sample.html" self.source.write_text('
同一内容', encoding="utf-8") self.data = {"schemaVersion": 1, "project": "项目", "brief": "同一内容比较", "round": "01", "candidates": [{"id": "a", "name": "方向一", "concept": "编辑式", "typography": "宋体与黑体", "palette": ["#112233", "#fff"], "traits": ["大标题"], "kind": "html", "source": "sample.html"}]} self.save() def save(self): self.manifest.write_text(json.dumps(self.data, ensure_ascii=False), encoding="utf-8") def payload(self, page): return json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0]) def run_browser_probe(self, result_id="i18n-result", profile="chrome-probe"): chrome = (shutil.which("google-chrome") or shutil.which("chromium") or "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") runner = r""" import {spawn} from 'node:child_process'; const chrome=spawn(process.argv[1],['--headless=new','--remote-debugging-pipe', '--no-first-run','--no-default-browser-check','--disable-extensions', '--force-prefers-reduced-motion',`--user-data-dir=${process.argv[2]}`,'about:blank'], {stdio:['ignore','ignore','ignore','pipe','pipe']}); let seq=0,buffer='';const pending=new Map(); chrome.on('error',error=>{console.error(error);process.exitCode=1;}); chrome.stdio[4].on('data',data=>{ buffer+=data.toString();let end; while((end=buffer.indexOf('\0'))>=0){ const message=JSON.parse(buffer.slice(0,end));buffer=buffer.slice(end+1); if(pending.has(message.id)){const {resolve,reject}=pending.get(message.id);pending.delete(message.id); message.error?reject(Error(message.error.message)):resolve(message.result);} } }); const send=(method,params={},sessionId)=>new Promise((resolve,reject)=>{ const id=++seq;pending.set(id,{resolve,reject}); chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0'); }); const deadline=setTimeout(()=>{console.error('Browser probe timed out');chrome.kill('SIGKILL');process.exitCode=1;},15000); try{ const {targetId}=await send('Target.createTarget',{url:'about:blank'}); const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); await send('Page.enable',{},sessionId); await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]},sessionId); await send('Page.navigate',{url:process.argv[3]},sessionId); let result; for(let i=0;i<100;i++){ const response=await send('Runtime.evaluate',{expression:"document.querySelector('#i18n-result')?.textContent",returnByValue:true},sessionId); result=response.result.value;if(result)break; await new Promise(resolve=>setTimeout(resolve,50)); } if(!result)throw Error('The page did not complete its interface/copy probe'); console.log(result); }catch(error){console.error(error);process.exitCode=1;} finally{clearTimeout(deadline);chrome.kill();} """ runner = runner.replace("#i18n-result", "#" + result_id) return subprocess.run([shutil.which("node"), "--input-type=module", "-e", runner, chrome, str(self.folder / profile), self.output.as_uri()], capture_output=True, text=True, timeout=25, encoding="utf-8") def test_lang_is_optional_and_preserved_when_explicit(self): builder.build(self.manifest, self.output) self.assertNotIn("lang", self.payload(self.output.read_text(encoding="utf-8"))) for lang in ("zh", "en"): with self.subTest(lang=lang): self.data["lang"] = lang self.save() builder.build(self.manifest, self.output, force=True) self.assertEqual(self.payload(self.output.read_text(encoding="utf-8"))["lang"], lang) def test_invalid_lang_preserves_existing_output(self): builder.build(self.manifest, self.output) original = self.output.read_bytes() for lang in (None, "", "ZH", "zh-CN", "en-US", "fr", 1, True, [], {}): with self.subTest(lang=lang): self.data["lang"] = lang self.save() with self.assertRaisesRegex(ValueError, "manifest.lang"): builder.build(self.manifest, self.output, force=True) self.assertEqual(original, self.output.read_bytes()) def test_native_viewport_formats_are_normalized_and_equivalent(self): fingerprints = set() for value in ("1920x1080", "1920×1080", " 1920 X 1080 ", {"width": 1920, "height": 1080}, {"width": 1920.0, "height": 1080.0}): for top_level in (True, False): with self.subTest(value=value, top_level=top_level): self.data.pop("nativeViewport", None) self.data["candidates"][0].pop("nativeViewport", None) target = self.data if top_level else self.data["candidates"][0] target["nativeViewport"] = value self.save() fingerprints.add(builder.build(self.manifest, self.output, force=True)["fingerprint"]) payload = self.payload(self.output.read_text(encoding="utf-8")) self.assertEqual(payload["candidates"][0]["nativeViewport"], {"width": 1920, "height": 1080}) self.assertEqual(len(fingerprints), 1) def test_native_viewport_inheritance_override_and_identity(self): default = builder.build(self.manifest, self.output) self.assertNotIn("nativeViewport", self.payload(self.output.read_text(encoding="utf-8"))["candidates"][0]) self.data["nativeViewport"] = "1920x720" self.data["candidates"].extend([ dict(self.data["candidates"][0], id="b", nativeViewport={"width": 1366, "height": 1024}), dict(self.data["candidates"][0], id="c", nativeViewport="198×242"), ]) self.save() result = builder.build(self.manifest, self.output, force=True) candidates = self.payload(self.output.read_text(encoding="utf-8"))["candidates"] self.assertEqual([c["nativeViewport"] for c in candidates], [ {"width": 1920, "height": 720}, {"width": 1366, "height": 1024}, {"width": 198, "height": 242}]) self.assertNotEqual(result["fingerprint"], default["fingerprint"]) self.data["candidates"][0]["nativeViewport"] = "1920x1080" self.save() self.assertNotEqual(builder.build(self.manifest, self.output, force=True)["fingerprint"], result["fingerprint"]) def test_invalid_native_viewports_report_received_values_and_preserve_output(self): builder.build(self.manifest, self.output) original = self.output.read_bytes() invalid = (None, True, 1920, [], {}, "", "1920", "1920:1080", "-1x1080", "0×1080", "1920.5x1080", {"width": 1920}, {"width": "1920", "height": 1080}, {"width": True, "height": 1080}, {"width": 1920, "height": 0}, {"width": 1920, "height": 1.5}) for value in invalid: for top_level in (True, False): with self.subTest(value=value, top_level=top_level): self.data.pop("nativeViewport", None) self.data["candidates"][0].pop("nativeViewport", None) target = self.data if top_level else self.data["candidates"][0] target["nativeViewport"] = value self.save() with self.assertRaises(builder.ManifestError) as caught: builder.build(self.manifest, self.output, force=True) message = str(caught.exception) for expected in ("候选 a", "nativeViewport", json.dumps(value, ensure_ascii=False), '"1920x1080"', '"1920×1080"', '{"width":1920,"height":1080}'): self.assertIn(expected, message) self.assertEqual(self.output.read_bytes(), original) def test_native_viewport_errors_are_collected_with_other_manifest_problems(self): self.data["nativeViewport"] = "bad-default" self.data["candidates"].append(dict(self.data["candidates"][0], id="b", nativeViewport="bad-b", palette=["red"])) self.data["candidates"].append(dict(self.data["candidates"][0], id="c", nativeViewport={"height": 242})) self.save() with self.assertRaises(builder.ManifestError) as caught: builder.build(self.manifest, self.output) self.assertEqual(len(caught.exception.problems), 4) for expected in ("manifest(用于候选 a)", "bad-default", "候选 b:nativeViewport", "bad-b", "候选 b:palette", "候选 c:nativeViewport", '{"height": 242}'): self.assertIn(expected, str(caught.exception)) @unittest.skipUnless(shutil.which("node"), "Node.js is needed to execute preview fitting") def test_default_viewports_and_narrow_native_canvas_fitting(self): template = builder.TEMPLATE.read_text(encoding="utf-8") fit = template.split(" function fit(s){", 1)[1].split("\n const fitAll=", 1)[0] program = r""" const SIZES={desktop:{w:1280,h:900},mobile:{w:390,h:844}},st={viewport:'desktop'}; const byId={a:{}},innerHeight=844,$=()=>({offsetHeight:100}); const narrow=()=>true,getComputedStyle=()=>({paddingLeft:'12',paddingRight:'12',paddingTop:'16',paddingBottom:'16'}); const m={style:{}},box={clientWidth:390,clientHeight:0,dataset:{zoom:'fit'},classList:{contains:()=>false}}; const s={isConnected:true,dataset:{id:'a'},parentElement:box,querySelector:()=>m,style:{}}; """ + "function fit(s){" + fit + r""" const result=[]; fit(s);result.push({...m.style,stage:{...s.style}}); st.viewport='mobile';fit(s);result.push({...m.style,stage:{...s.style}}); byId.a.nativeViewport={width:198,height:242};fit(s);result.push({...m.style,stage:{...s.style}}); byId.a.nativeViewport={width:1366,height:1024};box.classList.contains=()=>true; fit(s);result.push({...m.style,stage:{...s.style}}); byId.a.nativeViewport={width:198,height:2000};fit(s);result.push({...m.style,stage:{...s.style}}); box.dataset.zoom='actual';fit(s);result.push({...m.style,stage:{...s.style}}); console.log(JSON.stringify(result)); """ run = subprocess.run([shutil.which("node"), "-e", program], capture_output=True, text=True, timeout=10, encoding="utf-8") self.assertEqual(run.returncode, 0, run.stderr) results = json.loads(run.stdout) for result, (width, height, scale) in zip(results, [ (1280, 900, 390 / 1280), (390, 844, 1), (198, 242, 1), (1366, 1024, 366 / 1366), (198, 2000, 712 / 2000), (198, 2000, 1)]): self.assertEqual(result["width"], f"{width}px") self.assertEqual(result["height"], f"{height}px") self.assertAlmostEqual(float(result["transform"][6:-1]), scale) self.assertAlmostEqual(float(result["stage"]["width"][:-2]), width * scale) self.assertAlmostEqual(float(result["stage"]["height"][:-2]), height * scale) @unittest.skipUnless(shutil.which("node") and (shutil.which("google-chrome") or shutil.which("chromium") or Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome").is_file()), "Chrome/Chromium is needed for rendered viewport checks") def test_rendered_native_viewports_fit_cards_loupe_and_actual_size(self): self.data["nativeViewport"] = "1920x1080" self.data["candidates"].extend([ dict(self.data["candidates"][0], id="b", nativeViewport="1920×720"), dict(self.data["candidates"][0], id="c", nativeViewport={"width": 198, "height": 242}), ]) self.save() builder.build(self.manifest, self.output) probe = r"""""" self.output.write_text(self.output.read_text(encoding="utf-8").replace("