doc-inspector / scripts /verify_ui_layout.py
steven0226's picture
deploy: sync verified GitHub v1.1.0
aa7b4a6 verified
Raw
History Blame Contribute Delete
35.4 kB
"""Capture responsive UI evidence and fail on basic layout regressions."""
from __future__ import annotations
import json
import os
from pathlib import Path
import sys
from tempfile import gettempdir
from urllib.parse import urlsplit
from playwright.sync_api import Locator, Page, sync_playwright
ROOT = Path(__file__).resolve().parents[1]
PUBLIC_ASSET_DIR = ROOT / "docs" / "assets"
DEFAULT_AUDIT_OUTPUT_DIR = ROOT / "outputs" / "ui-audit"
AUDIT_OUTPUT_DIR = Path(
os.environ.get("UI_TEST_OUTPUT_DIR", DEFAULT_AUDIT_OUTPUT_DIR)
)
URL = os.environ.get("UI_TEST_URL", "http://127.0.0.1:7862")
VERIFY_ACTION_PLAN = os.environ.get("UI_TEST_EXPECT_ACTION_PLAN") == "1"
DEMO_DIR = Path(
os.environ.get(
"UI_TEST_DEMO_DIR",
Path(gettempdir()) / "doc-inspector-ui-fixture-7862" / "demo",
)
)
def validate_test_target(url: str, *, allow_non_fixture: bool = False) -> None:
"""Refuse to aim an automated UI test at the normal app by accident."""
if allow_non_fixture:
return
parsed = urlsplit(url)
if parsed.hostname not in {"127.0.0.1", "localhost"} or parsed.port != 7862:
raise RuntimeError(
"UI 稽核預設只允許 127.0.0.1:7862 離線 fixture;"
"若確定要測其他目標,請顯式設定 UI_TEST_ALLOW_NON_FIXTURE=1。"
)
def open_app(page: Page, url: str = URL) -> None:
"""Load the interface and wait for the rendered shell rather than network idle.
The source-verification panel keeps its data in Gradio session state, and
Gradio holds a heartbeat stream open for the life of such a page, so
``networkidle`` never fires.
"""
page.goto(url, wait_until="domcontentloaded")
page.get_by_text("結果燈號", exact=True).wait_for(state="visible")
def _metrics(locator: Locator) -> dict[str, float | str]:
return locator.evaluate(
"""element => {
const box = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return {
x: Math.round(box.x),
y: Math.round(box.y),
width: Math.round(box.width),
height: Math.round(box.height),
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
borderTopWidth: style.borderTopWidth
};
}"""
)
def _all_metrics(locator: Locator) -> list[dict[str, float | str]]:
return locator.evaluate_all(
"""elements => elements.map(element => {
const box = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return {
x: Math.round(box.x),
y: Math.round(box.y),
width: Math.round(box.width),
height: Math.round(box.height),
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
borderTopWidth: style.borderTopWidth
};
})"""
)
def _dropdown_metrics(page: Page) -> dict:
combo = page.locator("#demo-selector [role='combobox']").first
combo.click()
listbox = page.locator('#demo-selector [role="listbox"]').first
option = page.locator(
'#demo-selector [role="listbox"] [role="option"]'
).first
option.wait_for(state="visible")
metrics = {
"control": _metrics(combo),
"listbox": _metrics(listbox),
"option": _metrics(option),
"frontmostRole": option.evaluate(
"""element => {
const box = element.getBoundingClientRect();
return document.elementFromPoint(
box.left + 16,
box.top + box.height / 2
)?.getAttribute("role") ?? "";
}"""
),
}
page.keyboard.press("Escape")
return metrics
def _page_metrics(page: Page) -> dict:
dropdown = _dropdown_metrics(page)
metrics = {
"viewportWidth": page.evaluate("window.innerWidth"),
"documentWidth": page.evaluate("document.documentElement.scrollWidth"),
"documentHeight": page.evaluate("document.documentElement.scrollHeight"),
"header": _metrics(page.locator(".app-header").first),
"h1": _metrics(page.locator(".app-header h1").first),
"bodyText": _metrics(page.locator(".app-header p").first),
"masthead": _metrics(page.locator(".masthead").first),
"resultLegend": _metrics(page.locator(".result-legend").first),
"legendTitle": _metrics(page.locator(".result-legend h2").first),
"legendItem": _metrics(page.locator(".legend-items li").first),
"legendCards": _all_metrics(page.locator(".legend-items li")),
"legendLabel": _metrics(page.locator(".legend-items strong").first),
"legendAction": _metrics(page.locator(".legend-action").first),
"legendDots": _all_metrics(page.locator(".legend-dot")),
"sourceHeading": _metrics(page.locator(".source-brief h3").first),
"sourceText": _metrics(page.locator(".source-brief p").first),
"demoHeading": _metrics(page.locator(".demo-heading h3").first),
"demoText": _metrics(page.locator(".demo-heading p").first),
"demoDropdownControl": dropdown["control"],
"demoDropdownListbox": dropdown["listbox"],
"demoDropdownOption": dropdown["option"],
"demoDropdownFrontmostRole": dropdown["frontmostRole"],
"demoSelectorAccessible": page.get_by_label("範例文件").is_visible(),
"documentPickerLabelVisible": page.locator(
"#document-upload"
).get_by_text(
"選擇申請表、收據或發票", exact=True
).is_visible(),
"workbench": _metrics(page.locator(".workbench").first),
"uploadPanel": _metrics(page.locator(".upload-section").first),
"sourceBrief": _metrics(page.locator(".source-brief").first),
"uploadControl": _metrics(page.locator(".upload-control").first),
"uploadPrompt": _metrics(
page.locator(".upload-control > button > .wrap").first
),
"settingsPanel": _metrics(page.locator(".settings-section").first),
"actionPanel": _metrics(page.locator(".action-section").first),
"taskHeading": _metrics(page.locator(".task-heading").first),
"stepMarkers": _all_metrics(page.locator(".task-step")),
"taskTitle": _metrics(page.locator(".task-heading h2").first),
"taskCopy": _metrics(page.locator(".task-heading p").first),
"fieldLabel": _metrics(
page.locator('#schema-selector [data-testid="block-info"]').first
),
"uploadGuidance": _metrics(page.locator(".upload-guidance").first),
"noticeGrid": _metrics(page.locator(".notice-grid").first),
"privacyText": _metrics(page.locator(".privacy-note p").first),
"actionGrid": _metrics(page.locator(".action-grid").first),
"primaryButton": _metrics(page.locator(".primary-btn").first),
"statusText": _metrics(page.locator(".status-card").first),
"actionPlan": _metrics(page.locator(".action-plan").first),
"resultTabs": _metrics(page.locator(".result-tabs").first),
"emptyResultHint": page.locator(
"#extraction-table .result-empty"
).inner_text(),
"layoutAncestors": page.locator(".app-header").first.evaluate(
"""element => {
const ancestors = [];
let current = element.parentElement;
while (current && ancestors.length < 10) {
const box = current.getBoundingClientRect();
const style = window.getComputedStyle(current);
ancestors.push({
tag: current.tagName,
id: current.id,
className: current.className,
width: Math.round(box.width),
paddingLeft: style.paddingLeft,
paddingRight: style.paddingRight
});
current = current.parentElement;
}
return ancestors;
}"""
),
}
upload = metrics["uploadControl"]
prompt = metrics["uploadPrompt"]
metrics["uploadPromptInsetTop"] = prompt["y"] - upload["y"]
metrics["uploadPromptInsetBottom"] = (
upload["y"] + upload["height"] - prompt["y"] - prompt["height"]
)
return metrics
def _theme_contrast(browser, color_scheme: str) -> dict:
"""Measure rendered text contrast under a browser color preference."""
context = browser.new_context(
viewport={"width": 1440, "height": 1000},
color_scheme=color_scheme,
)
page = context.new_page()
open_app(page)
selectors = (
".source-brief p",
".upload-guidance span",
"#document-upload button .wrap",
"#demo-selector input",
"#schema-selector input",
".privacy-note p",
".task-heading p",
)
samples = {}
for selector in selectors:
samples[selector] = page.locator(selector).first.evaluate(
"""element => {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", {willReadFrequently: true});
const parseColor = value => {
context.clearRect(0, 0, 1, 1);
context.fillStyle = "rgba(0, 0, 0, 0)";
context.fillStyle = value;
context.fillRect(0, 0, 1, 1);
const [red, green, blue, alpha] = context.getImageData(
0, 0, 1, 1
).data;
return [red, green, blue, alpha / 255];
};
const composite = (front, back) => {
const alpha = front[3] + back[3] * (1 - front[3]);
if (alpha === 0) return [0, 0, 0, 0];
return [
(front[0] * front[3]
+ back[0] * back[3] * (1 - front[3])) / alpha,
(front[1] * front[3]
+ back[1] * back[3] * (1 - front[3])) / alpha,
(front[2] * front[3]
+ back[2] * back[3] * (1 - front[3])) / alpha,
alpha
];
};
const layers = [];
let current = element;
while (current) {
const background = parseColor(
getComputedStyle(current).backgroundColor
);
if (background[3] > 0) layers.push(background);
current = current.parentElement;
}
let background = [255, 255, 255, 1];
for (const layer of layers.reverse()) {
background = composite(layer, background);
}
const foreground = composite(
parseColor(getComputedStyle(element).color),
background
);
const luminance = color => {
const channels = color.slice(0, 3).map(channel => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
});
return (
0.2126 * channels[0]
+ 0.7152 * channels[1]
+ 0.0722 * channels[2]
);
};
const foregroundLuminance = luminance(foreground);
const backgroundLuminance = luminance(background);
const ratio = (
Math.max(foregroundLuminance, backgroundLuminance) + 0.05
) / (
Math.min(foregroundLuminance, backgroundLuminance) + 0.05
);
return {
color: getComputedStyle(element).color,
backgroundColor: `rgb(${background.slice(0, 3)
.map(Math.round).join(", ")})`,
contrastRatio: Math.round(ratio * 100) / 100
};
}"""
)
container_theme = page.locator(".gradio-container").first.evaluate(
"""element => {
const style = getComputedStyle(element);
return {
browserPrefersDark: matchMedia(
"(prefers-color-scheme: dark)"
).matches,
renderedColorScheme: style.colorScheme,
bodyTextColor: style.getPropertyValue("--body-text-color").trim(),
inputBackground: style.getPropertyValue(
"--input-background-fill"
).trim()
};
}"""
)
result = {
"requestedColorScheme": color_scheme,
"theme": container_theme,
"samples": samples,
"minimumContrastRatio": min(
sample["contrastRatio"] for sample in samples.values()
),
}
context.close()
return result
def _capture(
browser,
*,
width: int,
height: int,
filename: str,
output_dir: Path,
console_errors: list[str],
page_errors: list[str],
) -> tuple[dict, bool]:
context = browser.new_context(
viewport={"width": width, "height": height},
color_scheme="light",
)
page = context.new_page()
page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: page_errors.append(str(error)))
open_app(page)
page.get_by_text(
"完成後照顏色處理", exact=True
).wait_for(state="visible")
for label in [
"送件前先修正",
"對照原文件確認",
"目前未發現問題",
]:
page.get_by_text(label, exact=False).wait_for(state="visible")
for step in [
"準備要檢查的文件",
"確認預檢設定",
"確認傳送並開始",
"查看預檢結果",
]:
page.get_by_text(step, exact=True).wait_for(state="visible")
page.get_by_text("文件從哪裡來?", exact=True).wait_for(state="visible")
page.get_by_text("請上傳已填好的申請表", exact=False).wait_for(
state="visible"
)
page.get_by_text("沒有文件?", exact=True).wait_for(state="visible")
page.get_by_text("尚未產生修正建議", exact=True).wait_for(state="visible")
page.screenshot(path=output_dir / filename, full_page=True)
metrics = _page_metrics(page)
consent_unchecked = not page.locator("#cloud-consent input").is_checked()
context.close()
return metrics, consent_unchecked
def _verify_consent_gate(
browser,
console_errors: list[str],
page_errors: list[str],
) -> tuple[bool, bool, bool]:
context = browser.new_context(
viewport={"width": 1280, "height": 900},
color_scheme="light",
)
page = context.new_page()
page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: page_errors.append(str(error)))
open_app(page)
page.locator(".primary-btn").click()
error = page.get_by_text("請先勾選雲端傳送告知", exact=False)
error.wait_for(state="visible")
visible = error.is_visible()
status_near_action = page.locator(
".action-section .status-output .status-card.status-red"
).is_visible()
button_reenabled = page.locator(".primary-btn").is_enabled()
context.close()
return visible, status_near_action, button_reenabled
def _verify_demo_loader(
browser,
console_errors: list[str],
page_errors: list[str],
) -> tuple[bool, bool, bool]:
context = browser.new_context(
viewport={"width": 1280, "height": 1000},
color_scheme="light",
)
page = context.new_page()
page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: page_errors.append(str(error)))
open_app(page)
page.locator("#load-demo").click()
loaded = page.get_by_text("已載入安全範例:", exact=False)
loaded.wait_for(state="visible", timeout=30_000)
file_visible = page.locator(
'#document-upload [aria-label="receipt_green.png"]'
).is_visible()
status_visible = loaded.is_visible()
schema_updated = (
page.locator("#schema-selector input").input_value() == "收據/發票"
)
context.close()
return file_visible, status_visible, schema_updated
def _verify_actionable_result(
browser,
console_errors: list[str],
page_errors: list[str],
output_dir: Path,
) -> tuple[bool, bool, bool, bool]:
context = browser.new_context(
viewport={"width": 1440, "height": 1000},
color_scheme="light",
)
page = context.new_page()
page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: page_errors.append(str(error)))
open_app(page)
red_demo_path = DEMO_DIR / "subsidy_red.png"
page.locator('#document-upload input[type="file"]').set_input_files(
red_demo_path
)
page.get_by_text("subsidy_red.png", exact=True).wait_for(state="visible")
page.locator("#cloud-consent input").check()
page.locator(".primary-btn").click()
page.get_by_text("先修正 4 項", exact=False).wait_for(
state="visible",
timeout=30_000,
)
page.get_by_text("查看修正建議 ↓", exact=True).click()
action_plan_visible = page.get_by_text(
"先照這份清單處理", exact=True
).is_visible()
next_step_visible = page.get_by_text(
"回到上方換上修正後的文件", exact=False
).is_visible()
plain_language_visible = page.get_by_text(
"逐字核對身分證字號", exact=False
).is_visible()
machine_paths_hidden = page.locator("#action-plan").get_by_text(
"applicants.0", exact=False
).count() == 0
page.screenshot(path=output_dir / "result-red.png", full_page=True)
context.close()
return (
action_plan_visible,
next_step_visible,
plain_language_visible,
machine_paths_hidden,
)
def _provenance_snapshot(page: Page) -> dict:
card = page.locator("#provenance-detail .provenance-card").first
card.wait_for(state="visible")
return {
"cardClass": card.get_attribute("class") or "",
"text": card.inner_text(),
"pagePreviewVisible": page.locator("#provenance-preview img").count() > 0,
}
def _select_provenance_field(page: Page, option_text: str) -> dict:
page.locator("#provenance-field [role='combobox']").first.click()
page.locator(
"#provenance-field [role='listbox'] [role='option']"
).filter(has_text=option_text).first.click()
page.wait_for_function(
"""text => {
const card = document.querySelector("#provenance-detail .provenance-card");
const row = card && [...card.querySelectorAll(".provenance-row dd")][0];
return Boolean(row && row.textContent.includes(text));
}""",
arg=option_text,
timeout=30_000,
)
return _provenance_snapshot(page)
def _verify_evidence_provenance(
browser,
console_errors: list[str],
page_errors: list[str],
output_dir: Path,
) -> dict:
"""Drive the source-verification panel across every verification status."""
context = browser.new_context(
viewport={"width": 1440, "height": 1000},
color_scheme="light",
)
page = context.new_page()
page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: page_errors.append(str(error)))
open_app(page)
page.locator('#document-upload input[type="file"]').set_input_files(
DEMO_DIR / "subsidy_provenance.pdf"
)
page.get_by_text("subsidy_provenance.pdf", exact=True).wait_for(state="visible")
page.locator("#cloud-consent input").check()
page.locator(".primary-btn").click()
page.locator(".status-card.status-green").wait_for(state="visible", timeout=60_000)
page.get_by_role("tab", name="來源核驗").click()
selector = page.locator("#provenance-field [role='combobox']").first
selector_box = selector.bounding_box() or {"width": 0, "height": 0}
selector.focus()
focus_outline = selector.evaluate(
"""element => {
const style = getComputedStyle(element);
return {
outlineWidth: parseFloat(style.outlineWidth || "0"),
outlineStyle: style.outlineStyle
};
}"""
)
verified = _provenance_snapshot(page)
page.screenshot(
path=PUBLIC_ASSET_DIR / "evidence-provenance.png",
full_page=True,
)
ambiguous = _select_provenance_field(page, "申報總額")
page_only = _select_provenance_field(page, "附件金額")
unresolved = _select_provenance_field(page, "承辦註記")
page.screenshot(path=output_dir / "provenance-unresolved.png", full_page=True)
context.close()
mobile_context = browser.new_context(
viewport={"width": 390, "height": 844},
color_scheme="light",
)
mobile_page = mobile_context.new_page()
mobile_page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
mobile_page.on("pageerror", lambda error: page_errors.append(str(error)))
open_app(mobile_page)
mobile_page.get_by_role("tab", name="來源核驗").click()
mobile_page.locator("#provenance-detail .provenance-card").first.wait_for(
state="visible"
)
mobile_overflow = mobile_page.evaluate(
"document.documentElement.scrollWidth > window.innerWidth"
)
mobile_context.close()
return {
"selectorWidth": round(selector_box["width"]),
"selectorHeight": round(selector_box["height"]),
"selectorFocusOutlineWidth": focus_outline["outlineWidth"],
"selectorFocusOutlineStyle": focus_outline["outlineStyle"],
"verified": verified,
"ambiguous": ambiguous,
"pageOnly": page_only,
"unresolved": unresolved,
"mobileHorizontalOverflow": mobile_overflow,
}
def main() -> None:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
validate_test_target(
URL,
allow_non_fixture=os.environ.get("UI_TEST_ALLOW_NON_FIXTURE") == "1",
)
PUBLIC_ASSET_DIR.mkdir(parents=True, exist_ok=True)
AUDIT_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
console_errors: list[str] = []
page_errors: list[str] = []
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
wide, wide_unchecked = _capture(
browser,
width=1920,
height=1080,
filename="wide.png",
output_dir=AUDIT_OUTPUT_DIR,
console_errors=console_errors,
page_errors=page_errors,
)
desktop, desktop_unchecked = _capture(
browser,
width=1440,
height=1000,
filename="desktop.png",
output_dir=PUBLIC_ASSET_DIR,
console_errors=console_errors,
page_errors=page_errors,
)
mobile, mobile_unchecked = _capture(
browser,
width=390,
height=844,
filename="mobile.png",
output_dir=AUDIT_OUTPUT_DIR,
console_errors=console_errors,
page_errors=page_errors,
)
light_theme_contrast = _theme_contrast(browser, "light")
dark_preference_contrast = _theme_contrast(browser, "dark")
(
consent_error_visible,
status_near_action,
button_reenabled_after_error,
) = _verify_consent_gate(
browser,
console_errors,
page_errors,
)
demo_file_visible, demo_status_visible, demo_schema_updated = _verify_demo_loader(
browser,
console_errors,
page_errors,
)
if VERIFY_ACTION_PLAN:
(
action_plan_visible,
next_step_visible,
plain_language_visible,
machine_paths_hidden,
) = _verify_actionable_result(
browser,
console_errors,
page_errors,
AUDIT_OUTPUT_DIR,
)
else:
action_plan_visible = None
next_step_visible = None
plain_language_visible = None
machine_paths_hidden = None
provenance = _verify_evidence_provenance(
browser,
console_errors,
page_errors,
AUDIT_OUTPUT_DIR,
)
browser.close()
report = {
"url": URL,
"screenshots": [
"wide.png",
"desktop.png",
"mobile.png",
*(["result-red.png"] if VERIFY_ACTION_PLAN else []),
"evidence-provenance.png",
"provenance-unresolved.png",
],
"wide_metrics": wide,
"desktop_metrics": desktop,
"mobile_metrics": mobile,
"theme_contrast": {
"light": light_theme_contrast,
"dark_system_preference": dark_preference_contrast,
},
"consent_control_default_unchecked": (
wide_unchecked and desktop_unchecked and mobile_unchecked
),
"consent_error_visible_before_api_call": consent_error_visible,
"persistent_status_near_action": status_near_action,
"button_reenabled_after_error": button_reenabled_after_error,
"demo_file_loaded_without_api": demo_file_visible,
"demo_status_visible": demo_status_visible,
"demo_schema_updated": demo_schema_updated,
"action_plan_checked": VERIFY_ACTION_PLAN,
"action_plan_visible": action_plan_visible,
"action_plan_next_step_visible": next_step_visible,
"action_plan_plain_language_visible": plain_language_visible,
"action_plan_machine_paths_hidden": machine_paths_hidden,
"evidence_provenance": provenance,
"console_errors": console_errors,
"page_errors": page_errors,
}
(AUDIT_OUTPUT_DIR / "browser-report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
desktop_step_x = [marker["x"] for marker in desktop["stepMarkers"]]
mobile_step_x = [marker["x"] for marker in mobile["stepMarkers"]]
desktop_dot_widths = [dot["width"] for dot in desktop["legendDots"]]
desktop_dot_heights = [dot["height"] for dot in desktop["legendDots"]]
mobile_dot_widths = [dot["width"] for dot in mobile["legendDots"]]
mobile_dot_heights = [dot["height"] for dot in mobile["legendDots"]]
for viewport_name, metrics in (
("wide", wide),
("desktop", desktop),
("mobile", mobile),
):
legend_cards = metrics["legendCards"]
assert len(legend_cards) == 3, (
f"{viewport_name} viewport should render exactly three legend cards"
)
legend_widths = [card["width"] for card in legend_cards]
legend_heights = [card["height"] for card in legend_cards]
assert max(legend_widths) - min(legend_widths) <= 1, (
f"{viewport_name} legend cards should have equal widths: {legend_widths}"
)
assert max(legend_heights) - min(legend_heights) <= 1, (
f"{viewport_name} legend cards should have equal heights: {legend_heights}"
)
assert desktop["viewportWidth"] == desktop["documentWidth"]
assert mobile["viewportWidth"] == mobile["documentWidth"]
assert float(desktop["bodyText"]["fontSize"].removesuffix("px")) >= 20
assert float(desktop["legendTitle"]["fontSize"].removesuffix("px")) >= 22
assert float(desktop["legendItem"]["fontSize"].removesuffix("px")) >= 18
assert float(desktop["legendLabel"]["fontSize"].removesuffix("px")) >= 22
assert float(desktop["legendAction"]["fontSize"].removesuffix("px")) >= 20
assert len(desktop["legendDots"]) == 3
assert desktop_dot_widths == [18, 18, 18]
assert desktop_dot_heights == [18, 18, 18]
assert float(desktop["sourceText"]["fontSize"].removesuffix("px")) >= 19
assert float(desktop["demoText"]["fontSize"].removesuffix("px")) >= 18
assert (
float(desktop["demoDropdownControl"]["fontSize"].removesuffix("px"))
>= 21
)
assert (
float(desktop["demoDropdownOption"]["fontSize"].removesuffix("px"))
>= 20
)
assert desktop["demoDropdownOption"]["height"] >= 44
assert (
desktop["demoDropdownListbox"]["y"]
>= desktop["demoDropdownControl"]["y"]
+ desktop["demoDropdownControl"]["height"]
)
assert desktop["demoDropdownFrontmostRole"] == "option"
assert len(desktop["stepMarkers"]) == 4
assert all(marker["width"] >= 46 for marker in desktop["stepMarkers"])
assert all(marker["height"] >= 46 for marker in desktop["stepMarkers"])
assert max(desktop_step_x) - min(desktop_step_x) <= 1
assert float(desktop["taskTitle"]["fontSize"].removesuffix("px")) >= 28
assert float(desktop["taskCopy"]["fontSize"].removesuffix("px")) >= 20
assert float(desktop["fieldLabel"]["fontSize"].removesuffix("px")) >= 18
assert (
float(desktop["uploadGuidance"]["fontSize"].removesuffix("px")) >= 18
)
assert float(desktop["primaryButton"]["fontSize"].removesuffix("px")) >= 20
assert float(desktop["statusText"]["fontSize"].removesuffix("px")) >= 19
assert float(mobile["bodyText"]["fontSize"].removesuffix("px")) >= 19
assert float(mobile["legendItem"]["fontSize"].removesuffix("px")) >= 18
assert float(mobile["legendLabel"]["fontSize"].removesuffix("px")) >= 21
assert float(mobile["legendAction"]["fontSize"].removesuffix("px")) >= 19
assert len(mobile["legendDots"]) == 3
assert mobile_dot_widths == [18, 18, 18]
assert mobile_dot_heights == [18, 18, 18]
assert float(mobile["sourceText"]["fontSize"].removesuffix("px")) >= 19
assert (
float(mobile["demoDropdownOption"]["fontSize"].removesuffix("px"))
>= 20
)
assert mobile["demoDropdownOption"]["height"] >= 44
assert (
mobile["demoDropdownListbox"]["y"]
>= mobile["demoDropdownControl"]["y"]
+ mobile["demoDropdownControl"]["height"]
)
assert mobile["demoDropdownFrontmostRole"] == "option"
assert len(mobile["stepMarkers"]) == 4
assert all(marker["width"] >= 46 for marker in mobile["stepMarkers"])
assert all(marker["height"] >= 46 for marker in mobile["stepMarkers"])
assert max(mobile_step_x) - min(mobile_step_x) <= 1
assert float(mobile["fieldLabel"]["fontSize"].removesuffix("px")) >= 18
assert desktop["masthead"]["height"] <= 150
assert desktop["resultLegend"]["height"] <= 140
assert desktop["uploadPanel"]["height"] <= 480
assert desktop["noticeGrid"]["height"] <= 180
assert desktop["actionGrid"]["height"] <= 110
assert desktop["workbench"]["y"] <= 225
assert desktop["uploadPromptInsetTop"] >= 6
assert desktop["uploadPromptInsetBottom"] >= 6
assert desktop["documentHeight"] <= 1900
assert (
float(
desktop["settingsPanel"]["borderTopWidth"].removesuffix("px")
)
>= 8
)
assert (
float(desktop["actionPanel"]["borderTopWidth"].removesuffix("px"))
>= 8
)
assert (
abs(
desktop["settingsPanel"]["y"]
- (
desktop["uploadPanel"]["y"]
+ desktop["uploadPanel"]["height"]
)
)
<= 2
)
assert (
abs(
desktop["actionPanel"]["y"]
- (
desktop["settingsPanel"]["y"]
+ desktop["settingsPanel"]["height"]
)
)
<= 2
)
assert mobile["resultLegend"]["height"] <= 180
assert mobile["masthead"]["height"] <= 340
assert mobile["workbench"]["y"] <= 410
assert mobile["workbench"]["width"] / mobile["viewportWidth"] >= 0.9
assert mobile["uploadPromptInsetTop"] >= 6
assert mobile["uploadPromptInsetBottom"] >= 6
assert mobile["documentHeight"] <= 2900
assert "尚未執行預檢" in desktop["emptyResultHint"]
assert desktop["resultTabs"]["height"] >= 130
assert desktop["demoSelectorAccessible"]
assert mobile["demoSelectorAccessible"]
assert desktop["documentPickerLabelVisible"]
assert mobile["documentPickerLabelVisible"]
assert not light_theme_contrast["theme"]["browserPrefersDark"]
assert dark_preference_contrast["theme"]["browserPrefersDark"]
assert light_theme_contrast["theme"]["renderedColorScheme"] == "light"
assert dark_preference_contrast["theme"]["renderedColorScheme"] == "light"
for preference in (light_theme_contrast, dark_preference_contrast):
assert preference["minimumContrastRatio"] >= 4.5
assert report["consent_control_default_unchecked"]
assert report["consent_error_visible_before_api_call"]
assert report["persistent_status_near_action"]
assert report["button_reenabled_after_error"]
assert report["demo_file_loaded_without_api"]
assert report["demo_status_visible"]
assert report["demo_schema_updated"]
if VERIFY_ACTION_PLAN:
assert report["action_plan_visible"]
assert report["action_plan_next_step_visible"]
assert report["action_plan_plain_language_visible"]
assert report["action_plan_machine_paths_hidden"]
assert provenance["selectorHeight"] >= 44
assert provenance["selectorFocusOutlineWidth"] >= 2
assert provenance["selectorFocusOutlineStyle"] != "none"
assert not provenance["mobileHorizontalOverflow"]
verified = provenance["verified"]
assert "provenance-verified" in verified["cardClass"]
assert "已核驗來源" in verified["text"]
assert "PDF 內建文字層" in verified["text"]
assert verified["pagePreviewVisible"]
ambiguous = provenance["ambiguous"]
assert "provenance-ambiguous" in ambiguous["cardClass"]
assert "provenance-verified" not in ambiguous["cardClass"]
assert "找到多處相同證據" in ambiguous["text"]
page_only = provenance["pageOnly"]
assert "provenance-page_only" in page_only["cardClass"]
assert "provenance-verified" not in page_only["cardClass"]
assert "未經本機驗證" in page_only["text"]
unresolved = provenance["unresolved"]
assert "provenance-unresolved" in unresolved["cardClass"]
assert "provenance-verified" not in unresolved["cardClass"]
assert "找不到這段證據" in unresolved["text"]
assert not unresolved["pagePreviewVisible"]
assert not console_errors
assert not page_errors
print(json.dumps(report, ensure_ascii=False))
if __name__ == "__main__":
main()