Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

343
scripts/smoke_mobile.py Normal file
View file

@ -0,0 +1,343 @@
#!/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(
"""() => {
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,
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();
return style.display !== 'none' && style.visibility !== 'hidden'
&& !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 label cell, not any row whose body happens to mention a
# trainee (the scenario/category tables include that word too).
exercise_table = page.locator(".instructor-layout > section").first.locator(
"table.grid"
).first
trainee_row = exercise_table.locator("th").get_by_text(
"Курсант", exact=True
).locator("xpath=..")
trainee_select = trainee_row.locator("select")
if trainee_select.count():
if trainee_select.locator("option").count() < 2:
raise RuntimeError("Список курсантов не содержит доступного выбора")
trainee_select.select_option(index=1)
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")
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_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.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-case-top button").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-case-top").get_by_text(second_number, exact=False).wait_for()
page.locator(".dds-case-top button").click()
page.locator(".dds-registry tbody tr").filter(has_text=first_number).get_by_role(
"button", name="Открыть карточку"
).click()
page.locator(".dds-case-top").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-case-top button").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("--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()
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)
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)
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"] = 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,
"small_controls": len(data["small_controls"]),
"touch_target_check_applies": args.width <= 600,
}
for name, data in report["screens"].items()
}
report["problems"] = problems
report["passed"] = all(
not item["horizontal_overflow"] and (
not item["touch_target_check_applies"] or item["small_controls"] == 0
)
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())