lct-hack/scripts/smoke_mobile.py
2026-09-26 17:13:45 +00:00

431 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Browser smoke for the local demo, including mobile layout and DDS workflow."""
import argparse
import json
import time
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from playwright.sync_api import sync_playwright
from load_browser import local_url
def layout(page) -> dict:
return page.evaluate(
r"""() => {
const root = document.documentElement;
const visible = [...document.querySelectorAll('body *')].filter((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const closed = element.closest('details:not([open])');
return (!closed || element.tagName === 'SUMMARY') && style.display !== 'none'
&& style.visibility !== 'hidden' && rect.width > 0;
});
return {
viewport: innerWidth,
document_width: root.scrollWidth,
lesson_start_position: (() => {
const bar = document.querySelector('.instructor-layout .lesson-start-bar');
return bar ? getComputedStyle(bar).position : null;
})(),
visible_json: /\bjson\b/i.test(document.body.innerText)
|| /\{\s*["'][\w-]+["']\s*:/.test(document.body.innerText),
overflowing: visible.filter((element) => {
const rect = element.getBoundingClientRect();
return rect.right > innerWidth + 2 || rect.left < -2;
}).slice(0, 20).map((element) => ({
tag: element.tagName.toLowerCase(),
class: element.className || '',
text: (element.textContent || '').trim().slice(0, 80),
left: Math.round(element.getBoundingClientRect().left),
right: Math.round(element.getBoundingClientRect().right),
})),
small_controls: [...document.querySelectorAll(
'button, select, textarea, input:not([type=radio]):not([type=checkbox]), summary'
)].filter((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const closedDetails = element.closest('details:not([open])');
return style.display !== 'none' && style.visibility !== 'hidden'
&& !closedDetails
&& !element.disabled && (rect.height < 40 || rect.width < 40);
}).slice(0, 20).map((element) => ({
tag: element.tagName.toLowerCase(),
label: element.getAttribute('aria-label') || element.textContent?.trim().slice(0, 60)
|| element.getAttribute('placeholder') || '',
width: Math.round(element.getBoundingClientRect().width),
height: Math.round(element.getBoundingClientRect().height),
})),
};
}"""
)
def open_instructor(page, base_url: str) -> None:
page.goto(f"{base_url}/instructor", wait_until="networkidle")
demo_login = page.get_by_role("button", name="Войти в демо одним нажатием")
if demo_login.is_visible():
demo_login.click()
page.locator("#ai-scenario-editor").wait_for()
def stop_exercise_from_instructor(page, base_url: str, exercise_href: str) -> None:
session_id = parse_qs(urlparse(exercise_href).query).get("session", [None])[0]
if not session_id:
raise RuntimeError(f"в ссылке АРМ нет session id: {exercise_href}")
page.goto(f"{base_url}/instructor?session={session_id}", wait_until="networkidle")
page.get_by_role("button", name="Завершить занятие").click()
page.get_by_text("занятие завершено", exact=True).wait_for()
def choose_trainee(page) -> None:
# Match the step title even when mobile layout renders the step number
# inside the same cell. Do not use exact text on the nested th: its actual
# accessible text is now "4 Курсант".
exercise_table = page.locator(".instructor-layout > section").first.locator(
"table.grid"
).first
trainee_row = exercise_table.locator(".lesson-step-title").filter(
has_text="Курсант"
).locator("xpath=../..")
trainee_select = trainee_row.locator("select")
if trainee_select.count():
if trainee_select.locator("option").count() < 2:
raise RuntimeError("Список курсантов не содержит доступного выбора")
demo_option = trainee_select.locator('option[value="__demo__"]')
trainee_select.select_option(
value="__demo__" if demo_option.count() else trainee_select.locator("option").nth(1).get_attribute("value")
)
else:
trainee_row.locator('input[type="text"]').fill("Мобильный smoke")
def start_exercise(page, label: str, link_row: str) -> str:
page.get_by_role("radio", name=label).check()
choose_trainee(page)
page.get_by_role("button", name="Начать занятие").click()
row = page.locator("tr").filter(has_text=link_row).last
link = row.get_by_role("link", name="открыть", exact=False)
link.wait_for()
href = link.get_attribute("href")
if not href:
raise RuntimeError(f"не получена ссылка {link_row}")
return href
def exercise_dds_flow(page, scenario_title: str) -> dict:
"""Accept the card, select its crew and complete the training briefing."""
service_button = page.locator(".dds-service-dock button").first
service_title = service_button.locator("b").inner_text()
service_button.click()
next_status = page.locator(
'.dds-status-editor select[aria-label^="Следующий статус"]'
)
next_status.wait_for()
labels = next_status.locator("option").all_text_contents()
if not any("Принята" in label for label in labels):
raise RuntimeError(f"статус «Принята» недоступен для {service_title}: {labels}")
next_status.select_option(value="accepted")
service_name = next_status.get_attribute("aria-label").removeprefix("Следующий статус ")
page.get_by_role("textbox", name=f"Основание статуса {service_name}").fill("Учебный доклад бригады")
page.get_by_role("textbox", name=f"Полученные сведения {service_name}").fill(
"Служба приняла карточку и направила подразделение к месту происшествия."
)
page.locator('button[aria-label="Сохранить статус"]').click()
page.wait_for_function("""() => [...document.querySelectorAll(
'.dds-service-dock button')].some((button) => button.innerText.includes('Принята'))""")
page.locator(".dds-service-dock button").filter(has_text="Связь").click()
crew_select = page.locator("#dds-crew")
crew_select.wait_for()
options = crew_select.locator("option").all_text_contents()
crew = next((option.strip() for option in options
if option.strip().startswith(service_title)), None)
if not crew:
raise RuntimeError(f"для {service_title} нет доступной бригады: {options}")
crew_select.select_option(label=crew)
page.get_by_role("button", name="Позвонить старшему группы").click()
address = page.locator(".dds-case-address > strong").inner_text().strip()
page.locator("#dds-phone-address").fill(address)
page.locator("#dds-phone-incident").fill(scenario_title)
page.locator("#dds-phone-request").fill(
"Подтвердите выезд и доложите о прибытии."
)
page.get_by_role("button", name="Передать сообщение").click()
transcript = page.locator(".dds-phone-transcript")
transcript.wait_for()
page.wait_for_function("""() => {
const transcript = document.querySelector('.dds-phone-transcript');
const alert = document.querySelector('.dds-alert[role="alert"]');
return transcript?.innerText.includes('Адрес:') || Boolean(alert?.innerText.trim());
}""", timeout=10_000)
text = transcript.inner_text()
if "Адрес:" not in text or "Старший группы" not in text:
alert = page.locator('.dds-alert[role="alert"]')
error = alert.inner_text() if alert.count() else "нет сообщения об ошибке"
raise RuntimeError(
f"учебный доклад не попал в журнал: {text}; ошибка: {error}; "
f"адрес={address!r}, происшествие={scenario_title!r}"
)
return {"service": service_title, "status": "Принята", "crew": crew,
"brief_recorded": True}
def smoke_dds_queue(page, base_url: str, output: Path, screenshot_suffix: str) -> dict:
"""Check per-card state isolation while a second card arrives in queue."""
open_instructor(page, base_url)
queue = page.locator("details.scenario-queue")
queue.locator("summary").click()
queue.locator('input[type="checkbox"]').first.check()
page.get_by_text("Настроить нормативы и критерии оценки").click()
page.get_by_label("Интервал поступления карточек ДДС, секунд").fill("6")
page.get_by_label("Максимум ожидающих карточек ДДС").fill("1")
dds_href = start_exercise(page, "Диспетчер ДДС", "АРМ ДДС")
page.goto(dds_href, wait_until="networkidle")
page.locator(".dds-workspace").wait_for()
first_row = page.locator('.dds-registry tbody tr[aria-current="true"]')
first_row.wait_for()
first_number = first_row.locator('td[data-label="Номер"] button').inner_text()
first_row.get_by_role("button", name="Открыть карточку").click()
page.locator(".dds-case-layout").wait_for()
first_status_button = page.locator(".dds-service-dock button").first
service_title = first_status_button.locator("b").inner_text()
first_status_button.click()
next_status = page.locator(
'.dds-status-editor select[aria-label^="Следующий статус"]'
)
next_status.wait_for()
service_name = next_status.get_attribute("aria-label").removeprefix("Следующий статус ")
if not any("Принята" in option for option in next_status.locator("option").all_text_contents()):
raise RuntimeError(f"для первой карточки недоступен статус «Принята»: {service_title}")
next_status.select_option(value="accepted")
page.get_by_role("textbox", name=f"Основание статуса {service_name}").fill("Учебный доклад бригады")
page.get_by_role("textbox", name=f"Полученные сведения {service_name}").fill(
"Служба приняла карточку и направила подразделение к месту происшествия."
)
page.locator('button[aria-label="Сохранить статус"]').click()
page.wait_for_function(
"""(service) => [...document.querySelectorAll('.dds-service-dock button')]
.some((button) => button.innerText.includes(service)
&& button.innerText.includes('Принята'))""",
arg=service_title,
timeout=5_000,
)
page.locator(".dds-card-back").click()
page.wait_for_function(
"document.querySelectorAll('.dds-registry tbody tr').length === 2",
timeout=20_000,
)
rows = page.locator(".dds-registry tbody tr")
if rows.count() != 2 or page.locator('.dds-registry tbody tr[aria-current="true"]').count() != 1:
raise RuntimeError("ожидались две карточки в очереди и ровно одна активная")
second_row = rows.nth(1)
second_number = second_row.locator('td[data-label="Номер"] button').inner_text()
second_row.get_by_role("button", name="Открыть карточку").click()
page.locator(".dds-call-incident").get_by_text(second_number, exact=False).wait_for()
page.locator(".dds-card-back").click()
page.locator(".dds-registry tbody tr").filter(has_text=first_number).get_by_role(
"button", name="Открыть карточку"
).click()
page.locator(".dds-call-incident").get_by_text(first_number, exact=False).wait_for()
restored_status = page.locator(".dds-service-dock button").filter(
has_text=service_title
).first.inner_text()
if "Принята" not in restored_status:
raise RuntimeError(f"статус первой карточки потерян после поступления/переключения: {restored_status}")
page.locator(".dds-card-back").click()
screen = layout(page)
page.screenshot(path=output / f"dds-queue-{screenshot_suffix}.png", full_page=True)
stop_exercise_from_instructor(page, base_url, dds_href)
return {"cards_visible": 2, "active_cards": 1,
"arrival_interval_seconds": 6, "max_waiting": 1,
"managed_service": service_name,
"first_card_status_preserved_after_second_arrival": True,
"switched_to_second_and_back": True,
"session_finished": True, "screen": screen}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="http://127.0.0.1:5175")
parser.add_argument("--output", type=Path, default=Path("/tmp/lct-mobile-smoke"))
parser.add_argument("--report", type=Path)
parser.add_argument("--login")
parser.add_argument("--password")
parser.add_argument("--results-only", action="store_true",
help="stop after verifying the completed lesson report UI")
parser.add_argument("--width", type=int, default=390)
parser.add_argument("--height", type=int, default=844)
parser.add_argument("--device-scale-factor", type=float, default=2)
args = parser.parse_args()
if args.width < 320 or args.height < 480 or args.device_scale_factor <= 0:
parser.error("viewport must be at least 320×480 and scale factor must be positive")
screenshot_suffix = "mobile" if args.width <= 600 else "desktop"
args.base_url = local_url(args.base_url, {"http", "https"})
args.output.mkdir(parents=True, exist_ok=True)
report = {
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"base_url": args.base_url,
"viewport": {
"width": args.width,
"height": args.height,
"device_scale_factor": args.device_scale_factor,
"touch_target_minimum_px": 40 if args.width <= 600 else None,
},
"screens": {},
"workflow": {},
}
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
context = browser.new_context(
viewport={"width": args.width, "height": args.height},
device_scale_factor=args.device_scale_factor,
locale="ru-RU",
)
page = context.new_page()
browser_errors: list[str] = []
page.on("pageerror", lambda error: browser_errors.append(str(error)))
page.goto(f"{args.base_url}/instructor", wait_until="networkidle")
demo_login = page.get_by_role("button", name="Войти в демо одним нажатием")
if demo_login.is_visible():
demo_login.click()
elif args.login and args.password:
page.locator('input:not([type="password"])').first.fill(args.login)
page.locator('input[type="password"]').fill(args.password)
page.get_by_role("button", name="Войти", exact=True).click()
elif page.get_by_role("heading", name="Учебный симулятор системы-112").is_visible():
raise RuntimeError("stand requires --login and --password")
page.locator("#ai-scenario-editor").wait_for()
page.screenshot(path=args.output / f"instructor-{screenshot_suffix}.png", full_page=True)
report["screens"]["instructor"] = layout(page)
step_titles = [
" ".join(value.split())
for value in page.locator(".lesson-step-title").all_inner_texts()
]
if step_titles[:4] != [
"1 Сценарий", "2 Упражнение", "3 Режим", "4 Курсант",
]:
raise RuntimeError(f"Шаги создания занятия отображаются не по порядку: {step_titles[:4]}")
page.get_by_label("Поиск сценария").fill("smoke-no-matching-scenario")
page.locator(".scenario-empty-state").wait_for()
page.get_by_role("button", name="Сбросить фильтры").last.click()
page.locator(".scenario-empty-state").wait_for(state="hidden")
page.locator(".selected-scenario-summary").wait_for()
report["workflow"]["lesson_setup"] = {
"ordered_steps": step_titles[:4],
"empty_search_explains_next_action": True,
"filter_reset_restores_scenario": True,
}
trainee_href = start_exercise(
page, "Оператор 112 · текстовая вводная", "АРМ курсанта"
)
page.goto(trainee_href, wait_until="networkidle")
page.locator(".kio-arm").wait_for()
page.screenshot(path=args.output / f"trainee-{screenshot_suffix}.png", full_page=True)
report["screens"]["trainee"] = layout(page)
stop_exercise_from_instructor(page, args.base_url, trainee_href)
results_heading = page.get_by_role("heading", name="Результаты занятия")
results_heading.wait_for()
if not page.locator(".debrief-summary").is_visible():
raise RuntimeError("после завершения занятия не показана сводка результатов")
technical_details = page.locator(".debrief-tech-details")
technical_open = technical_details.evaluate("element => element.open")
if technical_open:
raise RuntimeError("технические сведения должны быть скрыты до раскрытия")
results_text = page.locator(".debrief").last.inner_text()
if "JSON" in results_text:
raise RuntimeError("в итогах занятия отображается JSON")
page.screenshot(path=args.output / f"instructor-results-{screenshot_suffix}.png", full_page=True)
report["screens"]["instructor_results"] = layout(page)
report["workflow"]["results_summary"] = {
"visible_after_finish": True,
"technical_details_collapsed": not technical_open,
"json_visible": False,
}
if args.results_only:
browser.close()
results_screen = report["screens"]["instructor_results"]
report["passed"] = (
results_screen["document_width"] <= results_screen["viewport"] + 2
and results_screen["small_controls"] == []
and not results_screen["visible_json"]
and results_screen["lesson_start_position"] in (None, "static")
and not browser_errors
)
report["browser_errors"] = browser_errors
report["problems"] = {
"instructor_results_horizontal_overflow": not report["passed"],
"instructor_results_small_controls": results_screen["small_controls"],
"instructor_results_visible_json": results_screen["visible_json"],
"browser_errors": browser_errors,
}
rendered = json.dumps(report, ensure_ascii=False, indent=2)
print(rendered)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(rendered + "\n", encoding="utf-8")
return 0 if report["passed"] else 1
open_instructor(page, args.base_url)
dds_href = start_exercise(
page, "Диспетчер ДДС", "АРМ ДДС"
)
page.goto(dds_href, wait_until="networkidle")
page.locator(".dds-workspace").wait_for()
page.screenshot(path=args.output / f"dds-{screenshot_suffix}.png", full_page=True)
report["screens"]["dds"] = layout(page)
active_row = page.locator(".dds-registry tbody tr.dds-selected")
scenario_title = active_row.locator(
'td[data-label="Тип происшествия"] .dds-cell-value > span'
).inner_text().split(" · ", 1)[0]
page.get_by_role("button", name="Открыть карточку").first.click()
page.locator(".dds-case-layout").wait_for()
page.screenshot(path=args.output / f"dds-card-{screenshot_suffix}.png", full_page=True)
report["screens"]["dds_card"] = layout(page)
report["workflow"].update(exercise_dds_flow(page, scenario_title))
page.screenshot(path=args.output / f"dds-workflow-{screenshot_suffix}.png", full_page=True)
report["screens"]["dds_workflow"] = layout(page)
page.locator(".dds-service-dock button").last.click()
page.locator(".dds-done").wait_for()
report["workflow"]["session_finished"] = True
queue = smoke_dds_queue(page, args.base_url, args.output, screenshot_suffix)
report["screens"]["dds_queue"] = queue.pop("screen")
report["queue"] = queue
browser.close()
problems = {
name: {
"horizontal_overflow": data["document_width"] > data["viewport"] + 2,
"visible_json": data["visible_json"],
"small_controls": len(data["small_controls"]),
"start_button_overlay": data["lesson_start_position"] not in (None, "static"),
"touch_target_check_applies": args.width <= 600,
}
for name, data in report["screens"].items()
}
report["problems"] = problems
report["browser_errors"] = browser_errors
report["passed"] = not browser_errors and all(
not item["horizontal_overflow"] and not item["visible_json"] and (
not item["touch_target_check_applies"] or item["small_controls"] == 0
) and not item["start_button_overlay"]
for item in problems.values()
)
rendered = json.dumps(report, ensure_ascii=False, indent=2)
print(rendered)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(rendered + "\n", encoding="utf-8")
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())