From f958cdfaab402ad75e9e3f14efca800e32aba98f Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:35:15 +0800 Subject: [PATCH 01/15] docs: define field acceptance upgrade plan --- .../2026-10-07-field-acceptance-workbench.md | 87 ++++++++++++ .../2026-10-07-field-acceptance-design.md | 129 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-07-field-acceptance-workbench.md create mode 100644 docs/superpowers/specs/2026-10-07-field-acceptance-design.md diff --git a/docs/superpowers/plans/2026-10-07-field-acceptance-workbench.md b/docs/superpowers/plans/2026-10-07-field-acceptance-workbench.md new file mode 100644 index 0000000..343e16c --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-field-acceptance-workbench.md @@ -0,0 +1,87 @@ +# Field Acceptance Workbench Implementation Plan + +> **For agentic workers:** Use superpowers:subagent-driven-development for task implementation and review. Steps use checkbox syntax. + +**Goal:** Deliver a configuration-driven, evidence-preserving industrial acceptance workflow with a hardware-independent demo and reviewable GitHub changes. + +**Architecture:** The existing protocol core stays behind a detector adapter. An inspection service serializes work, persists task snapshots to SQLite, and supplies the HTTP API, comparison and reports. The browser consumes only these APIs. + +**Tech Stack:** Python 3.10+, standard-library HTTP/SQLite/threads, existing psutil and pyserial, local HTML/CSS/JavaScript, pytest and coverage for development. + +## Global Constraints + +- Work in E:/学习/实习/工控验收升级 on codex/field-acceptance-workbench; preserve the original project directory. +- No real industrial device operations during development. Fake adapters and loopback fixtures only. +- No third-party Skill installation, no remote transmission of private code/configuration, preserve MIT license. +- Explicit --demo uses a synthetic catalog and never calls GenericDetector or hardware enumeration. +- Default bind 127.0.0.1; non-loopback bind requires QLDC_ACCESS_TOKEN. +- A single process/worker owns one SQLite task DB. Restart marks unfinished tasks interrupted, without replay. +- Verdicts PASS, FAIL, REVIEW, NOT_RUN; incomplete/failed tasks never count as fully passed. +- Modes/config hashes/scope must be checked before comparison claims; demo excluded from live statistics. +- Cancellation is cooperative at device boundaries and reports unfinished devices NOT_RUN. +- Existing config formats and legacy scan/retest endpoints remain available in live mode under a shared lock. + +### Task 1: Acceptance domain, persistence, adapters and HTTP + +**Files:** Create inspection/{__init__,domain,store,adapters,service,comparison,reports,http}.py and tests/test_workbench.py, tests/test_http.py; modify web_app.py into a compatibility entry point by preserving the old server in legacy_web.py; create demo catalog in code. + +**Interfaces:** InspectionService(db_path, demo=False, config_path=None, adapter=None); catalog() -> dict; create(payload, snapshot=None) -> job dict; get(job_id) -> dict; list_jobs(limit=20, offset=0, status='', query='') -> dict; cancel(job_id) -> dict; retest(job_id,payload) -> dict; insights() -> dict; close() requests cancellation and waits boundedly. Service exposes hardware_lock for legacy calls. Store.get/list/update persist JSON job records and event timestamps. compare_jobs(baseline,current) -> dict; export_job(job,format) -> (bytes,content_type). make_server(host,port,service,token='') -> ThreadingHTTPServer. + +**Job JSON:** id, status, mode ('demo'/'live'), scenario ('healthy'/'faults'/'timeout'/'crc'/'missing'), metadata {station_id,batch,operator,notes}, config_hash, config_snapshot, device_ids, parent_job_id, created_at/started_at/finished_at ISO UTC, duration_ms, progress {completed,total}, summary {expected,passed,failed,review,not_run,verdict}, results [], events []. Result fields device_id,name,connection_type,interface,protocol,scope,verdict,fault_code,summary,suggestion,request,response,duration_ms,simulated. catalog returns mode,config_hash,devices [{device_id,name,connection_type,interface,protocol,scope}],scenarios. Every expected device has an initial NOT_RUN result. + +- [ ] Write failing service tests for missing devices, restart interruption, cancellation, contention, snapshot preservation, retest selection, scope and mode mismatch, escaped reports. + +```python +def test_missing_device_cannot_pass(tmp_path): + service = InspectionService(tmp_path / 'jobs.db', demo=True) + job = service.create({'scenario': 'missing', 'station_id': 'DEMO-01'}) + finished = wait_finished(service, job['id']) + assert finished['summary']['failed'] >= 1 + assert len(finished['results']) == finished['summary']['expected'] + assert finished['summary']['verdict'] != 'PASS' +``` + +- [ ] Run pytest tests/test_workbench.py; confirm missing implementation fails. Implement domain verdicts, bounded validation, atomic SQLite store, then one-worker service. Adapter.run(config, device_ids, scenario, cancelled, on_result) produces normalized device results in order, including parent carrier checks for selected children. Live adapter performs per-top-level-group detection with StandardDeviceConfig(data=snapshot), strips unselected results, reports progress at group boundaries, never filters missing devices. Write-labelled operations are rejected in acceptance regardless of stored allow_write_tests; do not claim arbitrary custom bytes are safe. +- [ ] Implement reports and comparison. retest only FAIL/REVIEW/NOT_RUN devices using original full config snapshot; payload can change demo scenario and operator notes but cannot widen scope. Full acceptance comparison requires same selected scope; failed-only retest may compare its shared devices with explicit partial scope and cannot claim whole-unit recovery. +- [ ] Write failing real HTTP tests before http.py. Use ephemeral loopback HTTP server, JSON via urllib, test create/poll/list/cancel/retest/catalog/validate/insights/compare/export and malformed input, missing IDs, foreign origin, missing token, 64 KiB cap, static traversal, legacy isolation in demo, 409 conflict. + +```python +status, body = request('POST', '/api/jobs', {'scenario': 'faults'}) +assert status == 202 +assert body['job']['status'] in ('queued', 'running') +``` + +- [ ] Implement routes with wrappers {ok:true,job:...}/{ok:true,jobs:[],total:...}; compare {ok:true,comparison:...}, insights {ok:true,...}, catalog {ok:true,...}. Errors {ok:false,error:...}, use 400/401/403/404/409/413; do not expose exception traces. UI assets /assets/app.js and /assets/style.css served from fixed allowlist. Legacy UI /legacy; old APIs only live, share service lock. New root web/index.html is supplied in Task 3. +- [ ] Verify service and API tests, run existing self_test.py. Review task for state races, progress, persistent data and hardware access isolation; commit intended files. + +### Task 2: Protocol framing regressions + +**Files:** Modify core/protocol_engine.py and focused TCP receive paths in core/generic_detector.py; create core/transport.py and tests/test_protocol_regressions.py. + +**Interfaces:** receive_response(sock, timeout, max_bytes=4096, complete=None) -> bytes uses a monotonic deadline, accumulates fragments up to a bound, handles EOF, preserves partial response at timeout. A predicate identifies complete Modbus MBAP frames or a satisfied explicit custom response rule. Without a predicate, collect until EOF/deadline rather than assuming recv is one application frame. + +- [ ] Write failing tests for split custom TCP and Modbus TCP responses, byte-count mismatches, truncation, wrong transaction/unit/function, CRC error and write function refusal. + +```python +def test_declared_modbus_byte_count_cannot_be_ignored(): + packet = bytes.fromhex('00 01 00 00 00 05 01 03 04 00 2A') + assert not validate_modbus_tcp_response(packet, 1, 1, 3)[0] +``` + +- [ ] Run tests and capture observed failures. Replace one-recv assumptions in configured active TCP paths with bounded assembly; preserve actual partial bytes in evidence. Validate Modbus function 1/2 versus 3/4 response byte-count consistency, maximum ADU size, exception size and request count expectations where known. +- [ ] Run new protocol tests and original loopback self-test, review changes against Modbus official specification; commit. + +### Task 3: Local browser workbench, delivery and publication + +**Files:** Replace web/index.html; create web/assets/{app.js,style.css}; preserve old page as web/legacy.html. Update README.md, README_zh.md, docs/{ARCHITECTURE,API,DEMO,VALIDATION,DEPENDENCIES}.md, CHANGELOG.md, requirements.txt/requirements-dev.txt, .github/workflows/tests.yml, tests/browser_workflow.cjs, .gitignore. + +- [ ] Write browser acceptance script that runs demo on localhost, creates faults task, waits completed, opens evidence, downloads JSON, creates healthy failed-only retest, compares recovery, refreshes history, and checks narrow screen overflow. Run against missing UI, capture failure. +- [ ] Implement local assets with sidebar, station overview, new-task form, expected catalog, task progress and status, sortable/filterable history, evidence detail drawer, exports, failed retest and two-job comparison. API auth token stored only in session memory/storage, never URL. Text is assigned safely via textContent or escaped templating. Show SIMULATED and partial scope/comparability prominently. Do not fabricate dashboard values or measurement data. +- [ ] Verify using local server, browser console/network, desktop and 390 px screenshot. Confirm cancellation exits spinner and all completed task rows survive refresh. +- [ ] Lock reviewed dependency sources/commits; fixed CI Action commits and minimum test environment. No CDN or added runtime framework. Run pytest, coverage (report workbench separately from legacy detector), self-test, config validation, Python compile, JS syntax, fresh demo startup. +- [ ] Request independent branch review, fix actionable findings and rerun affected tests. Write truthful validation including unverified Linux/Kylin/physical-device paths. +- [ ] Use GitHub yeet workflow: inspect full diff, commit, push branch, connector create draft PR and attach PR artifact. Verify remote commit/PR. + +## Progress + +All tasks pending. The controller maintains the SDD progress ledger and records verified commands and commits. No user checkpoint is needed between authorized implementation tasks. diff --git a/docs/superpowers/specs/2026-10-07-field-acceptance-design.md b/docs/superpowers/specs/2026-10-07-field-acceptance-design.md new file mode 100644 index 0000000..d18db44 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-field-acceptance-design.md @@ -0,0 +1,129 @@ +# 工控设备现场验收工作台升级设计 + +日期:2026-10-07(Asia/Hong_Kong) + +状态:用户已确认按推荐方案实现并更新 GitHub;进入实施。 + +目标仓库:KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux。用户已明确仅升级此项目。 + +## 产品定位 + +面向设备厂商、工控系统集成商与交付工程师的 Linux 本地现场验收工作台。把设备配置转为验收清单,在执行过程中记录证据,交付后可以复测和比较。离线演示让面试、售前和培训无需连接现场设备。 + +目标差异化组合:配置驱动的应检设备覆盖 + 串口/网络/PCI 共用验收视图 + 通信路径与业务功能分级 + 故障证据 + 同配置复测对比 + 本机部署。这是产品方向判断,不宣称市场独占、已获客户或已经改善效率。 + +## 路线选择 + +| 路线 | 内容 | 取舍 | +|---|---|---| +| A:现场验收闭环(推荐) | 异步任务、应检清单、证据、历史记录、基线对比、演示、工程验证 | 在现有协议能力上形成可演示和可交付的业务流程 | +| B:界面与报告增强 | 美化界面,增加导出、文档和截图 | 较快,不能解决漏检和结果追溯问题 | +| C:集中设备管理平台 | 多站点、账号、资产、远程代理、工单、调度 | 运维和安全范围明显扩大,超出本轮验收工具定位 | + +采用 A。第一版针对单个工位,不做多站点云管理。 + +## 已核实的起点 + +- 远程 main:7accc80c7e815b5a5623713b95b6532cf2221472。 +- 当前 core/generic_detector.py 约 2700 行;负责接口枚举、设备搜索、协议收发和结果汇总。 +- web_app.py 同时负责 HTTP、全局扫描状态、串行互斥、检测业务、报告生成和清理。 +- 已有串口 Modbus RTU、Modbus TCP、通用 TCP/UDP、PCI 被动检查、单接口扫描与单设备复测。 +- 测试全部先筛选已发现设备再复测;未响应的应检设备可能未进入最终报告。 +- TCP 收发中存在一次 recv 后即判断的路径;分段响应需补充处理和回归测试。 +- 当前报告压缩后不保留完整报文证据;无持久任务索引、基线对比或硬件独立演示入口。 +- 本地目录不是 Git 仓库,web_app.py 与远程版本不同。实现从远程指定基线创建独立工作副本,保留原目录。 +- 使用现有虚拟环境运行 23 项离线自检通过,配置校验通过。验证只使用纯计算与本机回环模拟器。 + +## 本轮功能 + +### 1. 应检设备与验收判定 + +- 验收以配置中的设备清单为范围;展示应检、完成、通过、失败、待确认及未执行数量。 +- 未发现或未响应设备也生成记录,不因扫描过滤而消失。 +- 结果明确区分 PASS、FAIL、REVIEW、NOT_RUN;存在失败、待确认或未执行时,整体不得显示全部通过。 +- 继电器通过透明 IO 路径响应,仅证明通信路径;PCI 被动检查只证明实际采集的被动状态,不宣称业务功能或电气动作正常。 +- 保留现有全量扫描、单接口扫描、单设备复测能力和配置格式。 + +### 2. 异步任务与追溯 + +- 创建任务填写工位/设备编号、批次、操作者和备注,均有长度约束;支持验收和指定设备复测。 +- 后台执行,浏览器轮询状态。一个工位同一时间只允许一个硬件任务,冲突返回 409。 +- 任务状态 queued、running、cancelling、completed、cancelled、failed、interrupted。取消在设备边界生效;不伪装成即时终止串口或网络操作。 +- SQLite 保存任务、配置快照与 SHA-256、设备结果、原始证据、事件和实际耗时。服务重启时未完成任务标为 interrupted,不自动连接硬件重跑。 +- 任务创建时固定配置快照;运行中配置变化不改变正在执行的任务。 +- 每个结果有接口、协议、通信范围、请求/响应、失败分类及基于规则的排查建议。不能把规则建议表述为已确定根因。 +- 新任务数据库与已有 report 目录分离,不删除历史文件。 + +### 3. 历史、复测和导出 + +- 历史任务支持状态和文本筛选、分页、查看详情与按失败设备创建复测。 +- 比较两次任务:新增、移除、恢复、退化、仍失败、其他变化、未变化。 +- 模式和配置摘要一致时才能显示可比结论;不一致时明确提示,设备差异仅供查看。 +- HTML、JSON、CSV 导出包含任务元数据、判定、证据和配置摘要;CSV 防止公式注入,HTML 转义不可信内容。 +- 演示报告固定标记 SIMULATED,不混入真实设备通过率;统计仅来自已保存任务。 + +### 4. 硬件独立演示 + +- 独立 --demo 启动入口,使用内置合成设备清单与确定性 Fake 适配器,不初始化现场扫描或真实连接。 +- 场景覆盖正常、响应超时、CRC 错误与设备缺失;可以演示故障验收、失败复测、正常恢复及前后对比。 +- UI 始终显示演示模式和模拟证据。演示场景不等于真实硬件兼容性验证。 +- 真实模式继续默认绑定 127.0.0.1。公开到局域网需要配置访问令牌,并在 UI 输入会话凭证;凭证不进入 URL、报告和日志。 + +### 5. 架构和界面 + +```text +浏览器:工位总览 / 新建验收 / 设备清单 / 任务详情与证据 / 历史对比 + -> HTTP 输入校验与访问边界 + -> InspectionService(验收范围、任务状态、取消、复测) + -> DetectorAdapter / DemoAdapter(统一结果契约) + -> 已有 GenericDetector + protocol_engine + +InspectionService -> SQLiteStore(任务、事件、配置快照) +已保存任务 -> ComparisonService / ReportExporter +``` + +- 新业务模块按任务、持久化、适配、判定、比较和导出拆分;HTTP 不直接操作串口和 socket。 +- 复用现有配置与底层协议,不为了展示架构更换成微服务。 +- 单页前端拆分本地 CSS/JS,脱离 CDN,保留窄屏操作;包含进度、状态标签、故障证据侧栏与恢复对比。 +- HTTP 限制请求大小、验证枚举与资源 ID,错误不返回 traceback;检查非同源浏览器请求。 +- 自定义指令仍需对设备语义进行人工审查;单凭 operation=read 标签无法保证物理动作安全。 + +## 接口契约 + +| 接口 | 行为 | +|---|---| +| GET /api/health | 版本、模式、任务状态和持久化就绪信息 | +| GET /api/catalog | 当前模式下设备清单与配置摘要 | +| POST /api/config/validate | 校验上传 JSON,不写入现场配置 | +| POST /api/jobs | 创建验收/复测任务,202 返回任务 ID | +| GET /api/jobs | 分页和筛选任务 | +| GET /api/jobs/{id} | 状态、事件、结果和证据 | +| POST /api/jobs/{id}/cancel | 请求取消,最终状态在设备边界更新 | +| POST /api/jobs/{id}/retest | 从已保存失败项创建复测,保持原配置快照 | +| GET /api/compare?baseline=...¤t=... | 可比性和设备差异 | +| GET /api/jobs/{id}/export?format=html/json/csv | 任务报告 | +| GET /api/insights | 按真实/演示模式分开的实际运行统计 | + +原有 /api/scan、/api/scan-interface、/api/run、/api/test-device 的兼容入口保留,统一互斥并明确旧格式与新任务记录的边界;演示模式下不得通过兼容入口访问真实硬件。 + +## 验证与交付 + +1. 保留既有离线自检;新增 pytest 覆盖任务状态、持久化恢复、取消、复测、预期设备缺失、比较与导出。 +2. Fake 和回环协议测试覆盖超时、断线、分段响应、错误 CRC、事务号/功能码/长度不匹配和写操作禁止。 +3. 从真实 HTTP 入口验证所有新增接口、错误输入、访问边界与互斥;不调用现场设备。 +4. 浏览器跑通“故障演示验收 -> 查看证据 -> 导出 -> 恢复复测 -> 比较”,检查桌面与窄屏并保存截图。 +5. 报告实际覆盖率与未覆盖风险。Windows 无法证明银河麒麟、Linux 串口权限、驱动和真实设备兼容性;这些明确列为现场验收项目。 +6. 依赖不递归安装 Skill;必要依赖记录来源、许可证、审核 commit 和安装时间,版本固定。CI Action 固定审核 commit。 +7. 中英文 README、架构说明、部署和演示手册、API、变更记录与实际测试结果随代码交付。沿用远程已有 MIT 许可证。 +8. 验证后推送 codex/field-acceptance-workbench 分支,创建附测试结果的 GitHub PR,更新仓库供评审;不把未验证功能描述为已生产就绪。 + +## 范围边界 + +不包含工厂 MES 集成、多站点云服务、计费、完整用户体系、PLC 电气动作和自动写设备。保留扩展接口,后续由实际客户需求驱动。 + +## 市场参考 + +- NI TestStand:https://www.ni.com/en/shop/electronic-test-instrumentation/application-software-for-electronic-test-and-instrumentation-category/what-is-teststand.html 。官方列出测试执行、操作界面、报告和数据库记录能力。 +- OpenHTF:https://github.com/google/openhtf 。官方仓库介绍 test run、recipe、phase、measurement、attachment 和 plug 等概念。 + +上述参考用于确认测试管理能力属于市场已有基础能力。选择配置驱动、Linux 本地工位和现场复测证据作为聚焦方向,属于设计推断;本轮不安装这两个系统。 From 6d9f7d91bf09e37651a80646115df6a579c4bd56 Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:55:03 +0800 Subject: [PATCH 02/15] feat: add persistent configuration-driven acceptance tasks --- inspection/__init__.py | 1 + inspection/adapters.py | 67 ++++ inspection/comparison.py | 30 ++ inspection/domain.py | 94 ++++++ inspection/http.py | 216 ++++++++++++ inspection/reports.py | 28 ++ inspection/service.py | 191 +++++++++++ inspection/store.py | 55 ++++ legacy_web.py | 679 ++++++++++++++++++++++++++++++++++++++ tests/test_http.py | 136 ++++++++ tests/test_workbench.py | 314 ++++++++++++++++++ web/legacy.html | 624 +++++++++++++++++++++++++++++++++++ web_app.py | 696 ++------------------------------------- 13 files changed, 2468 insertions(+), 663 deletions(-) create mode 100644 inspection/__init__.py create mode 100644 inspection/adapters.py create mode 100644 inspection/comparison.py create mode 100644 inspection/domain.py create mode 100644 inspection/http.py create mode 100644 inspection/reports.py create mode 100644 inspection/service.py create mode 100644 inspection/store.py create mode 100644 legacy_web.py create mode 100644 tests/test_http.py create mode 100644 tests/test_workbench.py create mode 100644 web/legacy.html diff --git a/inspection/__init__.py b/inspection/__init__.py new file mode 100644 index 0000000..4b7812d --- /dev/null +++ b/inspection/__init__.py @@ -0,0 +1 @@ +"""Local field acceptance workbench; hardware access belongs to adapters.""" diff --git a/inspection/adapters.py b/inspection/adapters.py new file mode 100644 index 0000000..cc5565c --- /dev/null +++ b/inspection/adapters.py @@ -0,0 +1,67 @@ +import copy +import time + +from .domain import catalog_devices, ensure_read_only, initial_result + + +def demo_config(): + return {'schema_version': 2, 'workstation_id': 'DEMO-01', 'description': 'SIMULATED acceptance catalog', + 'settings': {'allow_write_tests': False}, 'devices': [ + {'device_id': 'DEMO_RELAY', 'device_name': '透明 IO 继电器通信路径', 'device_type': 'relay', 'connection_type': 'serial', 'interface': 'SIM-COM1', 'serial_config': {'baudrate': 9600}, 'protocol': {'type': 'modbus_rtu', 'function_code': 3, 'address': 0, 'count': 2}, 'test_scope': 'communication_path'}, + {'device_id': 'DEMO_PLC', 'device_name': 'PLC 通信寄存器', 'device_type': 'plc', 'connection_type': 'network', 'interface': 'SIM-ETH0', 'network_config': {'ip': '127.0.0.1', 'port': 502}, 'protocol': {'type': 'modbus_tcp', 'function_code': 3, 'address': 0, 'count': 2}}, + {'device_id': 'DEMO_PCI', 'device_name': 'PCI 通信卡被动状态', 'device_type': 'pci', 'connection_type': 'pci', 'pci_slot': 'SIM-PCI0', 'pci_match': {'vendor': '1234'}}]} + + +class DemoAdapter: + def run(self, config, device_ids, scenario, cancelled, on_result): + for index, device in enumerate(d for d in catalog_devices(config) if d['device_id'] in device_ids): + if cancelled(): + return + time.sleep(.03) + if cancelled(): + return + fault = scenario if scenario in ('timeout', 'crc', 'missing') and index == 0 else 'timeout' if scenario == 'faults' and index == 1 else '' + result = initial_result(device, True) + result.update(verdict='FAIL' if fault else 'PASS', fault_code=fault.upper(), + summary='SIMULATED ' + (fault or ('PCI passive state observed' if device['scope'] == 'pci_passive' else 'Communication path responded')), + suggestion='Check configured carrier, cabling and protocol parameters; verify onsite' if fault else 'Business and electrical function require separate verification', + request='SIMULATED READ 01 03 00 00 00 02', response='' if fault in ('timeout', 'missing') else 'SIMULATED CRC ERROR' if fault else 'SIMULATED 01 03 04 00 00 00 00', duration_ms=30) + on_result(result) + + +def normalize_result(device, raw): + result = initial_result(device, False) + if raw is None: + result.update(verdict='FAIL', fault_code='MISSING', summary='Expected device did not produce a result', suggestion='Check carrier availability and configuration') + return result + evidence = str(raw.get('evidence') or raw.get('summary') or '') + status = raw.get('status') + verdict = 'PASS' if status in ('正常', '在线', '已发现') else 'REVIEW' if status == '需确认' else 'FAIL' + lower = evidence.lower() + fault = '' if verdict == 'PASS' else 'TIMEOUT' if 'timeout' in lower or 'timed out' in lower or '超时' in evidence or '无响应' in evidence else 'CRC' if 'crc' in lower else 'CARRIER' if '中间模块' in evidence else 'RESPONSE_MISMATCH' if raw.get('response') else 'UNAVAILABLE' + result.update(verdict=verdict, fault_code=fault, interface=raw.get('interface') or device['interface'], summary=evidence, + suggestion='Check cabling, carrier and configured protocol; this is a rule-based suggestion' if fault else 'This result covers communication or passive state only; verify business function separately', + request=str(raw.get('request') or ''), response=str(raw.get('response') or ''), duration_ms=max(0, float(raw.get('duration_ms') or 0))) + return result + + +class LiveAdapter: + def run(self, config, device_ids, scenario, cancelled, on_result): + from core.config_manager import StandardDeviceConfig + from core.generic_detector import GenericDetector + ensure_read_only(config) + devices = {d['device_id']: d for d in catalog_devices(config)} + for parent in config['devices']: + group_ids = [parent['device_id']] + [d['device_id'] for d in parent.get('child_devices', [])] + chosen = [d for d in group_ids if d in device_ids] + if not chosen: + continue + if cancelled(): + return + # selected() retains the parent carrier for selected children. + selected = StandardDeviceConfig(data=config).selected(chosen) + selected.data.setdefault('settings', {})['allow_write_tests'] = False + raw = GenericDetector(config=selected, serial_probe=True, subnet_probe=False).run().to_dict() + index = {r.get('device_id'): r for r in raw.get('devices', [])} + for device_id in chosen: + on_result(normalize_result(devices[device_id], index.get(device_id))) diff --git a/inspection/comparison.py b/inspection/comparison.py new file mode 100644 index 0000000..fc0ada7 --- /dev/null +++ b/inspection/comparison.py @@ -0,0 +1,30 @@ +from .domain import catalog_devices + + +def compare_jobs(baseline, current): + left = {r['device_id']: r for r in baseline['results']} + right = {r['device_id']: r for r in current['results']} + same_scope = set(baseline['device_ids']) == set(current['device_ids']) + related_subset = current.get('parent_job_id') == baseline['id'] and set(current['device_ids']).issubset(baseline['device_ids']) + configured = {device['device_id'] for device in catalog_devices(current['config_snapshot'])} + partial = set(current['device_ids']) != configured + reasons = [] + if baseline['mode'] != current['mode']: + reasons.append('Mode mismatch') + if baseline['config_hash'] != current['config_hash']: + reasons.append('Configuration mismatch') + if not same_scope and not related_subset: + reasons.append('Selected scope mismatch') + if any(left[i]['scope'] != right[i]['scope'] for i in left.keys() & right.keys()): + reasons.append('Result scope mismatch') + if baseline['status'] not in ('completed', 'cancelled', 'failed', 'interrupted') or current['status'] not in ('completed', 'cancelled', 'failed', 'interrupted'): + reasons.append('Jobs are not finished') + output = {'baseline_id': baseline['id'], 'current_id': current['id'], 'comparable': not reasons, 'reasons': reasons, 'partial_scope': partial, + 'scope': 'shared_devices' if partial else 'full_selection', 'whole_unit_recovered': False, + 'added': sorted(right.keys() - left.keys()), 'removed': sorted(left.keys() - right.keys()), 'recovered': [], 'regressed': [], 'still_failed': [], 'changed': [], 'unchanged': []} + for device_id in sorted(left.keys() & right.keys()): + a, b = left[device_id]['verdict'], right[device_id]['verdict'] + key = 'recovered' if a != 'PASS' and b == 'PASS' else 'regressed' if a == 'PASS' and b != 'PASS' else 'still_failed' if a == b == 'FAIL' else 'unchanged' if a == b else 'changed' + output[key].append(device_id) + output['whole_unit_recovered'] = not reasons and not partial and bool(output['recovered']) and current['summary']['verdict'] == 'PASS' + return output diff --git a/inspection/domain.py b/inspection/domain.py new file mode 100644 index 0000000..a113649 --- /dev/null +++ b/inspection/domain.py @@ -0,0 +1,94 @@ +import copy +import hashlib +import json +from datetime import datetime, timezone + +STATUSES = {'queued', 'running', 'cancelling', 'completed', 'cancelled', 'failed', 'interrupted'} +TERMINAL = {'completed', 'cancelled', 'failed', 'interrupted'} +SCENARIOS = ['healthy', 'faults', 'timeout', 'crc', 'missing'] +VERDICTS = {'PASS', 'FAIL', 'REVIEW', 'NOT_RUN'} + + +def utc_now(): + return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') + + +def config_hash(snapshot): + return hashlib.sha256(json.dumps(snapshot, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest() + + +def catalog_devices(snapshot): + # Configuration validation is pure computation; detector imports stay in LiveAdapter. + from core.config_manager import StandardDeviceConfig + result = [] + for device in StandardDeviceConfig(data=snapshot).all_configured_devices(): + scope = 'pci_passive' if device['connection_type'] == 'pci' else 'communication_path' + result.append({'device_id': device['device_id'], 'name': device['device_name'], + 'connection_type': device['connection_type'], + 'interface': device.get('interface') or device.get('pci_slot') or '', + 'protocol': (device.get('protocol') or {}).get('type', ''), 'scope': scope}) + return result + + +def ensure_read_only(snapshot): + def visit(value): + if isinstance(value, dict): + if str(value.get('operation', '')).lower() == 'write': + raise ValueError('Acceptance rejects write-labelled operations') + if 'function_code' in value: + code = value['function_code'] + try: + number = int(code, 0) if isinstance(code, str) else int(code) + except (TypeError, ValueError): + raise ValueError('Invalid Modbus function code') from None + if number not in (1, 2, 3, 4): + raise ValueError('Acceptance rejects Modbus write operations') + for child in value.values(): + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + visit(snapshot) + + +def validate_payload(payload, devices, mode): + if not isinstance(payload, dict): + raise ValueError('Expected JSON object') + allowed = {'scenario', 'station_id', 'batch', 'operator', 'notes', 'device_ids', 'metadata'} + if set(payload) - allowed: + raise ValueError('Unknown job field') + scenario = payload.get('scenario', 'healthy') + if scenario not in SCENARIOS or (mode == 'live' and scenario != 'healthy'): + raise ValueError('Invalid scenario for this mode') + metadata = payload.get('metadata', {}) + if not isinstance(metadata, dict) or set(metadata) - {'station_id', 'batch', 'operator', 'notes'}: + raise ValueError('Invalid metadata') + metadata = {key: payload.get(key, metadata.get(key, '')) for key in ('station_id', 'batch', 'operator', 'notes')} + for key, value in metadata.items(): + if not isinstance(value, str) or len(value) > (2000 if key == 'notes' else 128): + raise ValueError('Invalid metadata length or type') + ids = payload.get('device_ids', [d['device_id'] for d in devices]) + known = {d['device_id'] for d in devices} + if not isinstance(ids, list) or not ids or len(ids) > 1000 or any(not isinstance(i, str) or i not in known for i in ids) or len(set(ids)) != len(ids): + raise ValueError('Invalid device selection') + selected = [d['device_id'] for d in devices if d['device_id'] in ids] + return scenario, metadata, selected + + +def initial_result(device, simulated): + return dict(copy.deepcopy(device), verdict='NOT_RUN', fault_code='NOT_RUN', summary='Not executed', + suggestion='Run acceptance for this device', request='', response='', duration_ms=0, simulated=simulated) + + +def summarize(results): + counts = {v: sum(r['verdict'] == v for r in results) for v in VERDICTS} + verdict = 'FAIL' if counts['FAIL'] else 'REVIEW' if counts['REVIEW'] else 'NOT_RUN' if counts['NOT_RUN'] or not results else 'PASS' + return {'expected': len(results), 'passed': counts['PASS'], 'failed': counts['FAIL'], 'review': counts['REVIEW'], 'not_run': counts['NOT_RUN'], 'verdict': verdict} + + +def refresh(job): + job['summary'] = summarize(job['results']) + if job['status'] in ('failed', 'cancelled', 'interrupted') and job['summary']['verdict'] == 'PASS': + job['summary']['verdict'] = 'REVIEW' + job['progress'] = {'completed': sum(r['verdict'] != 'NOT_RUN' for r in job['results']), 'total': len(job['results'])} + return job diff --git a/inspection/http.py b/inspection/http.py new file mode 100644 index 0000000..da5011e --- /dev/null +++ b/inspection/http.py @@ -0,0 +1,216 @@ +import hmac +import ipaddress +import json +import re +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlsplit + +from .comparison import compare_jobs +from .domain import catalog_devices, config_hash, ensure_read_only +from .reports import export_job + +BASE_DIR = Path(__file__).resolve().parent.parent +JOB_PATH = re.compile(r'^/api/jobs/([a-f0-9]{32})(?:/(cancel|retest|export))?$') +MAX_BODY = 65536 + + +class HttpError(Exception): + def __init__(self, status, message): + self.status = status + super().__init__(message) + + +def loopback(host): + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return host.lower() == 'localhost' + + +def make_server(host, port, service, token=''): + if not loopback(host) and not token: + raise ValueError('Nonloopback bind requires QLDC_ACCESS_TOKEN') + class Server(ThreadingHTTPServer): + daemon_threads = True + server = Server((host, port), Handler) + server.service = service + server.access_token = token + server.bind_host = host + return server + + +class Handler(BaseHTTPRequestHandler): + server_version = 'QLDeviceCheckWorkbench/3.0' + + def setup(self): + super().setup() + self.connection.settimeout(5) + + def do_GET(self): + self._dispatch('GET') + + def do_POST(self): + self._dispatch('POST') + + def _dispatch(self, method): + try: + if method == 'POST': + self._body() + parsed = urlsplit(self.path) + path = unquote(parsed.path) + self._access(path) + query = parse_qs(parsed.query) + if method == 'GET' and path in ('/', '/index.html', '/legacy', '/assets/app.js', '/assets/style.css'): + relative = {'/': 'web/index.html', '/index.html': 'web/index.html', '/legacy': 'web/legacy.html', '/assets/app.js': 'web/assets/app.js', '/assets/style.css': 'web/assets/style.css'}[path] + target = BASE_DIR / relative + if not target.is_file(): + raise HttpError(404, 'Asset not found') + mime = 'text/javascript' if path.endswith('.js') else 'text/css' if path.endswith('.css') else 'text/html' + return self._send(target.read_bytes(), mime + '; charset=utf-8') + if method == 'GET' and path == '/favicon.ico': + return self._send(b'', 'image/x-icon', 204) + svc = self.server.service + if method == 'GET' and path == '/api/health': + return self._json({'ok': True, 'version': '3.0', 'mode': svc.mode, 'active_job_id': svc.active_id, 'persistence_ready': not svc.closed}) + if method == 'GET' and path == '/api/catalog': + return self._json(dict(ok=True, **svc.catalog())) + if method == 'POST' and path == '/api/config/validate': + body = self._body() + snapshot = body.get('config', body.get('snapshot', body)) + if not isinstance(snapshot, dict): + raise ValueError('Expected configuration object') + devices = catalog_devices(snapshot) + ensure_read_only(snapshot) + return self._json({'ok': True, 'valid': True, 'config_hash': config_hash(snapshot), 'devices': devices, 'device_count': len(devices), 'warnings': ['Custom bytes require manual review; a read label does not establish physical safety']}) + if path == '/api/jobs': + if method == 'POST': + return self._json({'ok': True, 'job': svc.create(self._body())}, 202) + return self._json(dict(ok=True, **svc.list_jobs(limit=int(query.get('limit', ['20'])[0]), offset=int(query.get('offset', ['0'])[0]), status=query.get('status', [''])[0], query=query.get('query', query.get('q', ['']))[0]))) + if method == 'GET' and path == '/api/insights': + return self._json(dict(ok=True, **svc.insights())) + if method == 'GET' and path == '/api/compare': + baseline, current = query.get('baseline', [''])[0], query.get('current', [''])[0] + if not re.fullmatch('[a-f0-9]{32}', baseline) or not re.fullmatch('[a-f0-9]{32}', current): + raise ValueError('Invalid comparison job ID') + return self._json({'ok': True, 'comparison': compare_jobs(svc.get(baseline), svc.get(current))}) + match = JOB_PATH.fullmatch(path) + if match: + job_id, action = match.groups() + if method == 'GET' and action is None: + return self._json({'ok': True, 'job': svc.get(job_id)}) + if method == 'GET' and action == 'export': + format = query.get('format', ['html'])[0] + content, mime = export_job(svc.get(job_id), format) + return self._send(content, mime, headers={'Content-Disposition': 'attachment; filename="acceptance-' + job_id + '.' + format + '"'}) + if method == 'POST' and action in ('cancel', 'retest'): + body = self._body() + job = svc.cancel(job_id) if action == 'cancel' else svc.retest(job_id, body) + return self._json({'ok': True, 'job': job}, 202 if action == 'retest' else 200) + if path in ('/api/scan', '/api/scan-interface', '/api/run', '/api/test-device', '/api/standards'): + if svc.demo: + raise HttpError(403, 'Legacy hardware APIs are disabled in demo mode') + if path == '/api/standards' and method == 'GET': + from core.config_manager import StandardDeviceConfig + return self._json({'ok': True, 'devices': StandardDeviceConfig(svc.config_path).devices() if svc.config_path else StandardDeviceConfig().devices(), 'legacy': True}) + if method == 'POST': + body = self._body() + if not svc.hardware_lock.acquire(blocking=False): + raise RuntimeError('A station operation is already running') + try: + import legacy_web + legacy_web.RUN_LOCK = svc.hardware_lock + operations = {'/api/scan': legacy_web.scan_catalog, '/api/scan-interface': legacy_web.scan_interface, '/api/run': legacy_web.test_all_discovered, '/api/test-device': legacy_web.test_single_device} + return self._json(dict(operations[path](body), legacy=True)) + finally: + svc.hardware_lock.release() + if method == 'GET' and path.startswith('/report/'): + if svc.demo: + raise HttpError(403, 'Legacy reports are disabled in demo mode') + root = (BASE_DIR / 'report').resolve() + target = (root / path[len('/report/'):]).resolve() + if not target.is_relative_to(root) or target.suffix not in ('.html', '.json', '.txt') or not target.is_file(): + raise HttpError(404, 'Report not found') + mime = {'.html': 'text/html', '.json': 'application/json', '.txt': 'text/plain'}[target.suffix] + return self._send(target.read_bytes(), mime + '; charset=utf-8') + raise HttpError(404, 'Route not found') + except HttpError as exc: + self._json({'ok': False, 'error': str(exc)}, exc.status) + except KeyError: + self._json({'ok': False, 'error': 'Job not found'}, 404) + except (ValueError, TypeError): + self._json({'ok': False, 'error': 'Invalid request; check input fields and configuration'}, 400) + except RuntimeError: + self._json({'ok': False, 'error': 'Station operation conflict or service unavailable'}, 409) + except Exception: + self._json({'ok': False, 'error': 'Operation failed'}, 500) + + def _access(self, path): + host = self.headers.get('Host', '') + try: + parsed = urlsplit('http://' + host) + hostname, port = parsed.hostname or '', parsed.port or 80 + except ValueError: + raise HttpError(403, 'Invalid host') from None + if port != self.server.server_port or (loopback(self.server.bind_host) and not loopback(hostname)): + raise HttpError(403, 'Untrusted host') + origin = self.headers.get('Origin') + if origin and origin != 'http://' + host: + raise HttpError(403, 'Foreign origin is not allowed') + if self.headers.get('Sec-Fetch-Site') == 'cross-site': + raise HttpError(403, 'Cross-site requests are not allowed') + token = self.server.access_token + if token and (path.startswith('/api/') or path.startswith('/report/')): + if not hmac.compare_digest(self.headers.get('Authorization', ''), 'Bearer ' + token): + raise HttpError(401, 'Access token required') + + def _body(self): + if hasattr(self, '_parsed_body'): + return self._parsed_body + if self.headers.get('Transfer-Encoding'): + raise HttpError(400, 'Transfer encoding is not supported') + try: + length = int(self.headers.get('Content-Length', '0')) + except ValueError: + raise HttpError(400, 'Invalid content length') from None + if length < 0: + raise HttpError(400, 'Invalid content length') + if length > MAX_BODY: + # Bounded discard avoids a TCP reset hiding the 413 response on Windows. + # No oversized body is decoded, allocated in full, or dispatched. + self.connection.settimeout(.2) + try: + self.rfile.read(min(length, MAX_BODY + 1)) + except OSError: + pass + raise HttpError(413, 'Request exceeds 64 KiB') + raw = self.rfile.read(length) + if len(raw) != length: + raise HttpError(400, 'Incomplete request body') + try: + body = json.loads(raw.decode('utf-8')) if raw else {} + except (UnicodeError, ValueError): + raise HttpError(400, 'Malformed JSON') from None + if not isinstance(body, dict): + raise HttpError(400, 'Expected JSON object') + self._parsed_body = body + return body + + def _json(self, data, status=200): + self._send(json.dumps(data, ensure_ascii=False).encode(), 'application/json; charset=utf-8', status) + + def _send(self, content, content_type, status=200, headers=None): + self.send_response(status) + self.send_header('Content-Type', content_type) + self.send_header('Content-Length', str(len(content))) + self.send_header('Cache-Control', 'no-store') + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Referrer-Policy', 'no-referrer') + for key, value in (headers or {}).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(content) + + def log_message(self, fmt, *args): + # Request URLs can contain operator data; never log them or credentials. + pass diff --git a/inspection/reports.py b/inspection/reports.py new file mode 100644 index 0000000..6ba9b5c --- /dev/null +++ b/inspection/reports.py @@ -0,0 +1,28 @@ +import csv +import html +import io +import json + + +def export_job(job, format): + marker = 'SIMULATED' if job['mode'] == 'demo' else 'LIVE' + if format == 'json': + return json.dumps(dict(job, report_marker=marker), ensure_ascii=False, indent=2).encode(), 'application/json; charset=utf-8' + if format == 'html': + esc = lambda value: html.escape(str(value), quote=True) + # Complete evidence is retained, including the original configuration snapshot. + content = 'Acceptance report

' + marker + ' Acceptance report

Scope: communication path / PCI passive state. Business and electrical function require separate verification.

' + esc(json.dumps(job, ensure_ascii=False, indent=2)) + '
' + return content.encode(), 'text/html; charset=utf-8' + if format == 'csv': + stream = io.StringIO(newline='') + writer = csv.writer(stream) + columns = ['report_marker', 'job_id', 'status', 'mode', 'scenario', 'station_id', 'batch', 'operator', 'notes', 'config_hash', 'config_snapshot', 'created_at', 'finished_at', 'device_id', 'name', 'connection_type', 'interface', 'protocol', 'scope', 'verdict', 'fault_code', 'summary', 'suggestion', 'request', 'response', 'duration_ms', 'simulated'] + writer.writerow(columns) + def safe(value): + text = str(value if value is not None else '') + return "'" + text if text.lstrip().startswith(('=', '+', '-', '@')) or text.startswith(('\t', '\r', '\n')) else text + for result in job['results']: + row = dict(job, **job['metadata'], **result, report_marker=marker, job_id=job['id'], config_snapshot=json.dumps(job['config_snapshot'], ensure_ascii=False)) + writer.writerow([safe(row.get(key, '')) for key in columns]) + return stream.getvalue().encode('utf-8-sig'), 'text/csv; charset=utf-8' + raise ValueError('Unsupported export format') diff --git a/inspection/service.py b/inspection/service.py new file mode 100644 index 0000000..bb48df1 --- /dev/null +++ b/inspection/service.py @@ -0,0 +1,191 @@ +import copy +import threading +import time +import uuid + +from .adapters import DemoAdapter, LiveAdapter, demo_config +from .domain import SCENARIOS, TERMINAL, catalog_devices, config_hash, ensure_read_only, initial_result, refresh, utc_now, validate_payload +from .store import SQLiteStore + + +class InspectionService: + def __init__(self, db_path, demo=False, config_path=None, adapter=None): + self.demo = demo + self.mode = 'demo' if demo else 'live' + self.config_path = config_path + self.adapter = adapter if adapter is not None else DemoAdapter() if demo else LiveAdapter() + self.hardware_lock = threading.Lock() + self.lock = threading.RLock() + self.store = SQLiteStore(db_path) + self.closed = False + self.worker = None + self.active_id = None + self.active_started = None + self.cancel_event = threading.Event() + for job in self.store.all(): + if job['status'] not in TERMINAL: + job['status'] = 'interrupted' + job['finished_at'] = utc_now() + job['events'].append({'at': utc_now(), 'type': 'interrupted', 'message': 'Service restarted; no automatic hardware retry'}) + self.store.update(refresh(job)) + + def _snapshot(self): + if self.demo: + return demo_config() + from core.config_manager import StandardDeviceConfig + return StandardDeviceConfig(self.config_path).data if self.config_path else StandardDeviceConfig().data + + def catalog(self): + snapshot = self._snapshot() + return {'mode': self.mode, 'config_hash': config_hash(snapshot), 'config_snapshot': snapshot, + 'devices': catalog_devices(snapshot), 'scenarios': SCENARIOS if self.demo else ['healthy']} + + def create(self, payload, snapshot=None): + snapshot = copy.deepcopy(snapshot if snapshot is not None else self._snapshot()) + devices = catalog_devices(snapshot) + ensure_read_only(snapshot) + scenario, metadata, selected = validate_payload(payload, devices, self.mode) + with self.lock: + if self.closed: + raise RuntimeError('Service is closed') + if not self.hardware_lock.acquire(blocking=False): + raise RuntimeError('A station operation is already running') + try: + job = refresh({'id': uuid.uuid4().hex, 'status': 'queued', 'mode': self.mode, 'scenario': scenario, + 'metadata': metadata, 'config_hash': config_hash(snapshot), 'config_snapshot': snapshot, + 'device_ids': selected, 'parent_job_id': None, 'created_at': utc_now(), 'started_at': None, + 'finished_at': None, 'duration_ms': 0, 'results': [initial_result(d, self.demo) for d in devices if d['device_id'] in selected], + 'events': [{'at': utc_now(), 'type': 'queued', 'message': 'Acceptance queued'}]}) + self.store.insert(job) + self.active_id = job['id'] + self.cancel_event = threading.Event() + self.worker = threading.Thread(target=self._run, args=(job['id'],), daemon=True, name='acceptance-worker') + self.worker.start() + return copy.deepcopy(job) + except Exception: + self.hardware_lock.release() + raise + + def _run(self, job_id): + started = time.monotonic() + try: + with self.lock: + self.active_started = started + job = self.store.get(job_id) + job['status'] = 'cancelling' if self.cancel_event.is_set() else 'running' + job['started_at'] = utc_now() + job['events'].append({'at': utc_now(), 'type': 'running', 'message': 'Acceptance started'}) + self.store.update(job) + def on_result(result): + with self.lock: + if self.closed: + return + job = self.store.get(job_id) + matches = [i for i, r in enumerate(job['results']) if r['device_id'] == result.get('device_id')] + if not matches or result.get('verdict') not in ('PASS', 'FAIL', 'REVIEW', 'NOT_RUN'): + raise ValueError('Invalid adapter result') + expected = job['results'][matches[0]] + value = dict(expected, **copy.deepcopy(result)) + value['scope'] = expected['scope'] + value['simulated'] = self.demo + job['results'][matches[0]] = value + job['events'].append({'at': utc_now(), 'type': 'result', 'device_id': value['device_id'], 'message': value['verdict']}) + self.store.update(refresh(job)) + if not self.cancel_event.is_set(): + self.adapter.run(copy.deepcopy(job['config_snapshot']), list(job['device_ids']), job['scenario'], self.cancel_event.is_set, on_result) + with self.lock: + if not self.closed: + job = self.store.get(job_id) + status = 'cancelled' if self.cancel_event.is_set() else 'completed' + if status == 'completed': + for result in job['results']: + if result['verdict'] == 'NOT_RUN': + result.update(verdict='FAIL', fault_code='MISSING', summary='Expected device produced no result', suggestion='Check carrier and device configuration') + self._finish(job, status, started) + except Exception: + with self.lock: + if not self.closed: + self._finish(self.store.get(job_id), 'failed', started) + finally: + with self.lock: + self.active_id = None + self.active_started = None + self.hardware_lock.release() + + def _finish(self, job, status, started): + job.update(status=status, finished_at=utc_now(), duration_ms=round((time.monotonic() - started) * 1000, 2)) + job['events'].append({'at': utc_now(), 'type': status, 'message': 'Acceptance ' + status}) + self.store.update(refresh(job)) + + def get(self, job_id): + return self.store.get(job_id) + + def list_jobs(self, limit=20, offset=0, status='', query=''): + return self.store.list(limit, offset, status, query) + + def cancel(self, job_id): + with self.lock: + job = self.get(job_id) + if job['status'] in TERMINAL: + return job + if self.active_id != job_id: + raise RuntimeError('Job is not active') + self.cancel_event.set() + job['status'] = 'cancelling' + job['events'].append({'at': utc_now(), 'type': 'cancelling', 'message': 'Cancellation requested; effective at a device/group boundary'}) + self.store.update(job) + return job + + def retest(self, job_id, payload): + with self.lock: + original = self.get(job_id) + if original['status'] not in TERMINAL: + raise RuntimeError('Retest requires a finished job') + if original['mode'] != self.mode: + raise ValueError('Retest mode differs from service mode') + if not isinstance(payload, dict) or set(payload) - {'scenario', 'operator', 'notes', 'device_ids'}: + raise ValueError('Retest can only change scenario, operator or notes') + selected = [r['device_id'] for r in original['results'] if r['verdict'] in ('FAIL', 'REVIEW', 'NOT_RUN')] + if 'device_ids' in payload: + ids = payload['device_ids'] + if not isinstance(ids, list) or not ids or any(not isinstance(i, str) for i in ids) or len(set(ids)) != len(ids) or any(i not in selected for i in ids): + raise ValueError('Retest cannot widen scope') + selected = [i for i in selected if i in ids] + if not selected: + raise ValueError('No failed or pending devices to retest') + data = dict(original['metadata'], scenario=payload.get('scenario', original['scenario']), device_ids=selected) + data.update({k: v for k, v in payload.items() if k in ('operator', 'notes')}) + job = self.create(data, snapshot=original['config_snapshot']) + saved = self.store.get(job['id']) + saved['parent_job_id'] = original['id'] + self.store.update(saved) + job['parent_job_id'] = original['id'] + return job + + def insights(self): + jobs = self.store.all() + output = {} + for mode in ('demo', 'live'): + records = [job for job in jobs if job['mode'] == mode] + finished = [job for job in records if job['status'] == 'completed'] + output[mode] = {'total': len(records), 'completed': len(finished), 'passed': sum(job['summary']['verdict'] == 'PASS' for job in finished), + 'failed': sum(job['summary']['verdict'] == 'FAIL' for job in finished), 'devices_passed': sum(job['summary']['passed'] for job in finished), + 'devices_expected': sum(job['summary']['expected'] for job in finished)} + return output + + def close(self): + with self.lock: + if self.closed: + return + self.cancel_event.set() + worker = self.worker + if worker: + worker.join(timeout=2) + with self.lock: + if self.closed: + return + if self.active_id: + job = self.store.get(self.active_id) + self._finish(job, 'interrupted', self.active_started or time.monotonic()) + self.closed = True + self.store.close() diff --git a/inspection/store.py b/inspection/store.py new file mode 100644 index 0000000..fa39cdc --- /dev/null +++ b/inspection/store.py @@ -0,0 +1,55 @@ +import json +import sqlite3 +import threading +from pathlib import Path + +from .domain import STATUSES + + +class SQLiteStore: + """One process owns this store. Each complete JSON record is committed atomically.""" + def __init__(self, path): + Path(path).parent.mkdir(parents=True, exist_ok=True) + self.lock = threading.RLock() + self.connection = sqlite3.connect(str(path), check_same_thread=False, timeout=5) + self.connection.execute('PRAGMA journal_mode=WAL') + self.connection.execute('CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, record TEXT NOT NULL)') + self.connection.commit() + + def insert(self, job): + with self.lock, self.connection: + self.connection.execute('INSERT INTO jobs VALUES (?, ?, ?, ?)', (job['id'], job['created_at'], job['status'], json.dumps(job, ensure_ascii=False))) + + def get(self, job_id): + with self.lock: + row = self.connection.execute('SELECT record FROM jobs WHERE id=?', (job_id,)).fetchone() + if row is None: + raise KeyError('Job not found') + return json.loads(row[0]) + + def update(self, job): + with self.lock, self.connection: + cursor = self.connection.execute('UPDATE jobs SET status=?, record=? WHERE id=?', (job['status'], json.dumps(job, ensure_ascii=False), job['id'])) + if not cursor.rowcount: + raise KeyError('Job not found') + + def list(self, limit=20, offset=0, status='', query=''): + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100 or not isinstance(offset, int) or not 0 <= offset <= 1000000: + raise ValueError('Invalid pagination') + if status and status not in STATUSES: + raise ValueError('Invalid status') + if not isinstance(query, str) or len(query) > 128: + raise ValueError('Invalid query') + with self.lock: + rows = self.connection.execute('SELECT record FROM jobs ORDER BY created_at DESC, rowid DESC').fetchall() + jobs = [json.loads(row[0]) for row in rows] + jobs = [job for job in jobs if (not status or job['status'] == status) and (not query or query.casefold() in json.dumps(job['metadata'], ensure_ascii=False).casefold() or query.casefold() in job['id'].casefold())] + return {'jobs': jobs[offset:offset + limit], 'total': len(jobs), 'limit': limit, 'offset': offset} + + def all(self): + with self.lock: + return [json.loads(row[0]) for row in self.connection.execute('SELECT record FROM jobs').fetchall()] + + def close(self): + with self.lock: + self.connection.close() diff --git a/legacy_web.py b/legacy_web.py new file mode 100644 index 0000000..a78ffb1 --- /dev/null +++ b/legacy_web.py @@ -0,0 +1,679 @@ + +#!/usr/bin/env python3 +""" +Industrial PC generic test system - local Web UI. + +The page runs real local detection and matches devices against a configurable +standardized device list. Results are shown directly in the Web UI. +""" + +import argparse +import copy +import html as html_lib +import json +import os +import shutil +import sys +import threading +import time +import traceback +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import unquote, urlparse + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, BASE_DIR) + +from core.config_manager import StandardDeviceConfig +from core.generic_detector import DEFAULT_TCP_PORTS, GenericDetector + +REPORT_DIR = os.path.join(BASE_DIR, "report") +WEB_INDEX_PATH = os.path.join(BASE_DIR, "web", "index.html") +REPORT_KEEP_LIMIT = 20 +RUN_LOCK = threading.Lock() +SCAN_STATE = {"devices": {}, "updated_at": ""} +CONNECTED_STATUSES = {"在线", "已发现", "正常"} +INTERFACE_GROUP_KEYS = { + "serial": "serial_ports", + "network": "network_interfaces", + "pci": "pci_devices", +} +INTERFACE_CATEGORIES = {"serial": "串口", "network": "网口", "pci": "PCI"} + +def detector_from_payload(payload: dict, config: StandardDeviceConfig = None) -> GenericDetector: + raw_ports = payload.get("tcp_ports", ",".join(str(port) for port in DEFAULT_TCP_PORTS)) + return GenericDetector( + serial_probe=bool(payload.get("serial_probe", True)), + subnet_probe=bool(payload.get("subnet_probe", True)), + tcp_ports=parse_ports(raw_ports), + config=config, + ) + + +def parse_ports(value): + raw_parts = value if isinstance(value, list) else str(value).replace(",", ",").replace(";", ",").split(",") + ports = [] + for part in raw_parts: + try: + port = int(str(part).strip()) + except ValueError: + continue + if 1 <= port <= 65535 and port not in ports: + ports.append(port) + return ports or DEFAULT_TCP_PORTS + + +def connected_devices(data: dict): + return [item for item in data.get("devices", []) or [] if item.get("status") in CONNECTED_STATUSES] + + +def cache_scan_devices(devices): + SCAN_STATE["devices"] = { + str(item.get("device_id")): copy.deepcopy(item) + for item in devices + if item.get("device_id") + } + SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") + + +def same_interface(connection_type: str, left: str, right: str) -> bool: + left = str(left or "").strip() + right = str(right or "").strip() + if connection_type == "pci": + return left.replace("0000:", "") == right.replace("0000:", "") + return left == right + + +def cache_interface_devices(connection_type: str, interface: str, devices): + retained = { + device_id: item + for device_id, item in SCAN_STATE.get("devices", {}).items() + if not ( + str(item.get("connection_type") or "") == connection_type + and same_interface(connection_type, item.get("interface", ""), interface) + ) + } + for item in devices: + device_id = str(item.get("device_id") or "") + if device_id: + retained[device_id] = copy.deepcopy(item) + SCAN_STATE["devices"] = retained + SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") + + +def selected_catalog(device_ids): + interfaces = { + device_id: str(item.get("interface") or "") + for device_id, item in SCAN_STATE.get("devices", {}).items() + if item.get("interface") + } + return StandardDeviceConfig().selected(device_ids, interfaces) + + +def update_interface_counts(data: dict): + counts = { + connection_type: len(data.get(key, []) or []) + for connection_type, key in INTERFACE_GROUP_KEYS.items() + } + data.setdefault("summary", {}).update({ + "interface_total": sum(counts.values()), + "serial_count": counts["serial"], + "network_count": counts["network"], + "pci_count": counts["pci"], + }) + + +def prepare_ui_result(data: dict, mode: str, connected_only: bool = False) -> dict: + result = copy.deepcopy(data) + catalog_count = len(StandardDeviceConfig().all_configured_devices()) + devices = result.get("devices", []) or [] + if connected_only: + devices = [item for item in devices if item.get("status") in CONNECTED_STATUSES] + result["devices"] = devices + result["mode"] = mode + + passed = sum(1 for item in devices if item.get("status") in CONNECTED_STATUSES) + warning = sum(1 for item in devices if item.get("status") == "需确认") + failed = sum(1 for item in devices if item.get("status") == "异常") + summary = result.setdefault("summary", {}) + summary.update({ + "total": len(devices), + "catalog_count": catalog_count, + "device_count": len(devices), + "connected_count": passed, + "passed": passed, + "warning": warning, + "failed": failed, + "fault_count": failed, + "matched_count": passed, + }) + _rebuild_interface_devices(result, devices) + update_interface_counts(result) + return result + + +def _rebuild_interface_devices(data: dict, devices): + groups = { + connection_type: data.get(key, []) or [] + for connection_type, key in INTERFACE_GROUP_KEYS.items() + } + index = {} + for connection_type, items in groups.items(): + for item in items: + details = item.setdefault("details", {}) + details["configured_devices"] = [] + index[(connection_type, str(item.get("name") or ""))] = item + + for device in devices: + connection_type = str(device.get("connection_type") or "") + interface = str(device.get("interface") or "") + item = index.get((connection_type, interface)) + if not item: + continue + name = str(device.get("name") or device.get("device_id") or "") + if name: + item["details"]["configured_devices"].append(name) + + for items in groups.values(): + for item in items: + names = item.get("details", {}).get("configured_devices", []) or [] + if names: + item["summary"] = f"已发现 {len(names)} 个已知设备" + elif item.get("status") == "正常": + item["summary"] = "接口可用,未发现已知设备" + + data["pci_devices"] = [ + item for item in groups["pci"] + if not str(item.get("name") or "").startswith("匹配规则:") + or (item.get("details", {}).get("configured_devices", []) or []) + ] + + +def preserve_scan_interfaces(result: dict, scan_raw: dict): + """Keep every first-pass interface after testing discovered devices.""" + for connection_type, key in INTERFACE_GROUP_KEYS.items(): + scanned = copy.deepcopy(scan_raw.get(key, []) or []) + tested = copy.deepcopy(result.get(key, []) or []) + merged = [] + used = set() + for scanned_item in scanned: + match_index = next(( + index for index, tested_item in enumerate(tested) + if same_interface( + connection_type, + scanned_item.get("name", ""), + tested_item.get("name", ""), + ) + ), None) + if match_index is None: + merged.append(scanned_item) + continue + used.add(match_index) + tested_item = tested[match_index] + combined = copy.deepcopy(scanned_item) + combined.update(tested_item) + details = copy.deepcopy(scanned_item.get("details", {}) or {}) + details.update(copy.deepcopy(tested_item.get("details", {}) or {})) + combined["details"] = details + merged.append(combined) + merged.extend(item for index, item in enumerate(tested) if index not in used) + result[key] = merged + + _rebuild_interface_devices(result, result.get("devices", []) or []) + update_interface_counts(result) + return result + + +def scan_catalog(payload: dict) -> dict: + raw = detector_from_payload(payload).run().to_dict() + discovered = connected_devices(raw) + cache_scan_devices(discovered) + return prepare_ui_result(raw, "scan", connected_only=True) + + +def scan_interface(payload: dict) -> dict: + connection_type = str(payload.get("interface_type") or "").strip().lower() + interface = str(payload.get("interface") or "").strip() + if connection_type not in ("serial", "network", "pci"): + raise ValueError("interface_type必须是serial、network或pci") + if not interface: + raise ValueError("缺少接口名称") + + config = StandardDeviceConfig().for_interface(connection_type, interface) + if config is None: + now = time.strftime("%Y-%m-%d %H:%M:%S") + raw = { + "started_at": now, + "finished_at": now, + "duration_ms": 0, + "summary": {}, + "serial_ports": [], + "network_interfaces": [], + "pci_devices": [], + "devices": [], + "connection_tests": [], + "logs": [], + } + key = INTERFACE_GROUP_KEYS[connection_type] + category = INTERFACE_CATEGORIES[connection_type] + raw[key] = [{ + "name": interface, + "category": category, + "status": "正常", + "summary": "配置库中没有此类已知设备", + "details": {"configured_devices": []}, + }] + else: + raw = detector_from_payload(payload, config=config).run().to_dict() + + devices = [ + item for item in connected_devices(raw) + if str(item.get("connection_type") or "") == connection_type + and same_interface(connection_type, item.get("interface", ""), interface) + ] + raw["devices"] = devices + target_key = INTERFACE_GROUP_KEYS[connection_type] + for key in INTERFACE_GROUP_KEYS.values(): + items = raw.get(key, []) or [] + raw[key] = [ + item for item in items + if key == target_key and same_interface(connection_type, item.get("name", ""), interface) + ] + cache_interface_devices(connection_type, interface, devices) + result = prepare_ui_result(raw, "scan", connected_only=True) + result["interface_type"] = connection_type + result["interface_name"] = interface + return result + + +def test_all_discovered(payload: dict) -> dict: + scan_raw = detector_from_payload(payload).run().to_dict() + discovered = connected_devices(scan_raw) + cache_scan_devices(discovered) + device_ids = [str(item.get("device_id")) for item in discovered if item.get("device_id")] + if not device_ids: + result = prepare_ui_result(scan_raw, "test_all", connected_only=True) + else: + config = selected_catalog(device_ids) + tested = detector_from_payload(payload, config=config).run().to_dict() + result = prepare_ui_result(tested, "test_all") + preserve_scan_interfaces(result, scan_raw) + result["summary"]["scan_connected_count"] = len(discovered) + keep_limit = StandardDeviceConfig().settings().get("max_report_count", REPORT_KEEP_LIMIT) + save_report(result, keep_limit) + return result + + +def test_single_device(payload: dict) -> dict: + device_id = str(payload.get("device_id") or "").strip() + if not device_id: + raise ValueError("缺少device_id") + if device_id not in SCAN_STATE.get("devices", {}): + raise ValueError("设备不在当前扫描结果中,请先扫描设备") + config = selected_catalog([device_id]) + tested = detector_from_payload(payload, config=config).run().to_dict() + result = prepare_ui_result(tested, "single") + target = next((item for item in result.get("devices", []) if str(item.get("device_id")) == device_id), None) + if target is None: + raise ValueError("未生成该设备的测试结果") + return {"ok": True, "mode": "single", "device": target, "finished_at": result.get("finished_at", "")} + + +def save_report(data: dict, keep_limit: int = REPORT_KEEP_LIMIT) -> dict: + keep_limit = max(1, min(1000, int(keep_limit or REPORT_KEEP_LIMIT))) + os.makedirs(REPORT_DIR, exist_ok=True) + stamp = time.strftime("%Y%m%d_%H%M%S") + suffix = int((time.time() * 1000) % 1000) + folder = f"{stamp}_{suffix:03d}" + folder_path = os.path.join(REPORT_DIR, folder) + os.makedirs(folder_path, exist_ok=True) + html_name = "report.html" + txt_name = "report.txt" + json_name = "report.json" + report_info = { + "folder": folder, + "html": html_name, + "txt": txt_name, + "json": json_name, + "url": f"/report/{folder}/{html_name}", + "html_url": f"/report/{folder}/{html_name}", + "txt_url": f"/report/{folder}/{txt_name}", + "json_url": f"/report/{folder}/{json_name}", + "keep_limit": keep_limit, + } + logs = data.setdefault("logs", []) + logs.append(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 生成测试报告:report/{folder}/report.html、report.txt、report.json,report目录最多保留 {keep_limit} 个报告文件夹") + data["report"] = report_info + data.setdefault("summary", {})["report_folder"] = folder + data["summary"]["report_keep_limit"] = keep_limit + with open(os.path.join(folder_path, html_name), "w", encoding="utf-8") as f: + f.write(render_compact_report_html(data)) + with open(os.path.join(folder_path, txt_name), "w", encoding="utf-8") as f: + f.write(render_compact_report_txt(data)) + with open(os.path.join(folder_path, json_name), "w", encoding="utf-8") as f: + json.dump(compact_report_data(data), f, ensure_ascii=False, indent=2) + prune_reports(keep_limit) + return report_info + + +def prune_reports(keep_limit: int = REPORT_KEEP_LIMIT): + if not os.path.isdir(REPORT_DIR): + return + reports = [] + for name in os.listdir(REPORT_DIR): + path = os.path.join(REPORT_DIR, name) + if name.startswith("report_") and name.endswith(".html") and os.path.isfile(path): + try: + os.remove(path) + except OSError: + pass + continue + if os.path.isdir(path) and os.path.isfile(os.path.join(path, "report.json")): + reports.append((os.path.getmtime(path), path)) + reports.sort(reverse=True) + for _, path in reports[max(1, int(keep_limit or REPORT_KEEP_LIMIT)):]: + try: + shutil.rmtree(path) + except OSError: + pass + + +def compact_result_text(device: dict) -> str: + if device.get("status") in CONNECTED_STATUSES: + return "连通正常" + evidence = str(device.get("evidence") or "") + if "timed out" in evidence.lower() or "超时" in evidence or "未收到响应" in evidence: + return "无响应" + if "不存在" in evidence: + return "接口不存在" + return "测试失败" + + +def compact_connection_text(device: dict) -> str: + connection = {"serial": "串口", "network": "网口", "pci": "PCI"}.get( + str(device.get("connection_type") or ""), str(device.get("connection_type") or "-") + ) + protocol = str(device.get("protocol") or "").lower() + connection = { + "modbus_rtu": "Modbus RTU", + "modbus_tcp": "Modbus TCP", + "tcp": "TCP", + "udp": "UDP", + }.get(protocol, connection) + via = device.get("via_device", {}) or {} + via_name = str(via.get("device_name") or "") + return f"{connection} / 经{via_name}" if via_name else connection + + +def compact_report_data(data: dict) -> dict: + devices = data.get("devices", []) or [] + summary = data.get("summary", {}) or {} + interfaces = [] + for category, key in INTERFACE_GROUP_KEYS.items(): + for item in data.get(key, []) or []: + interfaces.append({ + "type": category, + "interface": item.get("name", ""), + "status": item.get("status", ""), + "devices": (item.get("details", {}) or {}).get("configured_devices", []) or [], + }) + + compact_devices = [] + for device in devices: + passed = device.get("status") in CONNECTED_STATUSES + via = device.get("via_device", {}) or {} + compact_devices.append({ + "device_id": device.get("device_id", ""), + "device_name": device.get("name", ""), + "device_type": device.get("type", ""), + "interface_type": device.get("connection_type", ""), + "interface": device.get("interface", ""), + "communication": compact_connection_text(device), + "via_device": via.get("device_name", ""), + "result": "PASS" if passed else "FAIL", + "test_report": compact_result_text(device), + }) + + return { + "report_version": 1, + "hostname": data.get("hostname", ""), + "started_at": data.get("started_at", ""), + "finished_at": data.get("finished_at", ""), + "duration_ms": round(float(data.get("duration_ms") or 0), 1), + "summary": { + "found": len(devices), + "passed": summary.get("passed", 0), + "failed": summary.get("failed", 0), + }, + "interfaces": interfaces, + "devices": compact_devices, + "report": data.get("report", {}) or {}, + } + + +def render_compact_report_txt(data: dict) -> str: + summary = data.get("summary", {}) or {} + devices = data.get("devices", []) or [] + lines = [ + "设备连通性测试报告", + "=" * 28, + f"主机: {data.get('hostname', '')}", + f"测试时间: {data.get('finished_at') or data.get('started_at', '')}", + f"发现设备: {summary.get('device_count', len(devices))}", + f"通过: {summary.get('passed', 0)}", + f"失败: {summary.get('failed', 0)}", + "", + "测试结果", + "-" * 28, + ] + if not devices: + lines.append("未发现配置库中的已知设备") + for index, device in enumerate(devices, start=1): + result = "通过" if device.get("status") in CONNECTED_STATUSES else "失败" + lines.append( + f"[{index}] {device.get('name', '')} | {device.get('interface', '-') or '-'} | " + f"{compact_connection_text(device)} | {result} | {compact_result_text(device)}" + ) + return "\n".join(lines) + "\n" + + +def render_compact_report_html(data: dict) -> str: + summary = data.get("summary", {}) or {} + devices = data.get("devices", []) or [] + + def esc(value) -> str: + return html_lib.escape(str(value if value is not None else ""), quote=True) + + rows = [] + for device in devices: + passed = device.get("status") in CONNECTED_STATUSES + result = "通过" if passed else "失败" + cls = "pass" if passed else "fail" + rows.append( + "" + f"{esc(device.get('name', ''))}" + f"{esc(device.get('interface', '') or '-')}" + f"{esc(compact_connection_text(device))}" + f"{result}" + f"{esc(compact_result_text(device))}" + "" + ) + if not rows: + rows.append('未发现配置库中的已知设备') + + return f""" + + + + + 设备连通性测试报告 + + + +
+

设备连通性测试报告

+
+
主机
{esc(data.get('hostname', '-'))}
+
测试时间
{esc(data.get('finished_at') or data.get('started_at', '-'))}
+
通过
{esc(summary.get('passed', 0))}
+
失败
{esc(summary.get('failed', 0))}
+
+
+ + + {''.join(rows)} +
设备接口连接方式结果测试报告
+
+
+ +""" + + +class Handler(BaseHTTPRequestHandler): + server_version = "QLDeviceCheckGeneric/2.0" + + def do_GET(self): + path = urlparse(self.path).path + if path in ("/", "/index.html"): + with open(WEB_INDEX_PATH, "r", encoding="utf-8") as f: + self._send_html(f.read()) + return + if path == "/api/health": + self._send_json({"ok": True}) + return + if path == "/api/standards": + self._send_json({"ok": True, "devices": StandardDeviceConfig().devices()}) + return + if path.startswith("/report/"): + self._send_report(path) + return + if path == "/favicon.ico": + self._send_empty() + return + self.send_error(404, "Not Found") + + def do_POST(self): + path = urlparse(self.path).path + handlers = { + "/api/scan": scan_catalog, + "/api/scan-interface": scan_interface, + "/api/run": test_all_discovered, + "/api/test-device": test_single_device, + } + operation = handlers.get(path) + if operation is None: + self.send_error(404, "Not Found") + return + if not RUN_LOCK.acquire(blocking=False): + self._send_json({"ok": False, "error": "检测任务正在运行,请稍候"}, status=409) + return + try: + payload = self._read_json() + self._send_json(operation(payload)) + except Exception as exc: + self._send_json({ + "ok": False, + "error": str(exc), + "traceback": traceback.format_exc().splitlines(), + }, status=500) + finally: + RUN_LOCK.release() + + def _read_json(self): + length = int(self.headers.get("Content-Length", "0")) + if length <= 0: + return {} + raw = self.rfile.read(length).decode("utf-8") + if not raw.strip(): + return {} + return json.loads(raw) + + def _send_html(self, html: str): + encoded = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def _send_report(self, path: str): + relative = unquote(path[len("/report/"):]).replace("\\", "/").strip("/") + if not relative or ".." in relative.split("/"): + self.send_error(404, "Not Found") + return + if not relative.endswith((".html", ".txt", ".json")): + self.send_error(404, "Not Found") + return + full_path = os.path.abspath(os.path.join(REPORT_DIR, *relative.split("/"))) + report_root = os.path.abspath(REPORT_DIR) + if not full_path.startswith(report_root + os.sep) or not os.path.isfile(full_path): + self.send_error(404, "Not Found") + return + content_types = { + ".html": "text/html; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".json": "application/json; charset=utf-8", + } + ext = os.path.splitext(full_path)[1] + with open(full_path, "rb") as f: + encoded = f.read() + self.send_response(200) + self.send_header("Content-Type", content_types.get(ext, "application/octet-stream")) + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def _send_json(self, data: dict, status: int = 200): + encoded = json.dumps(data, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def _send_empty(self, status: int = 204): + self.send_response(status) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, fmt, *args): + print("[%s] %s" % (self.log_date_time_string(), fmt % args)) + + +def main(): + parser = argparse.ArgumentParser(description="工控机通用测试系统 Web UI") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=8080, type=int) + args = parser.parse_args() + + server = ThreadingHTTPServer((args.host, args.port), Handler) + print("工控机通用测试系统已启动") + print(f"访问地址: http://{args.host}:{args.port}") + print("按 Ctrl+C 退出") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n正在退出...") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..6a1da7c --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,136 @@ +import json +import threading +import urllib.error +import urllib.request + +import pytest + +from inspection.http import make_server +from inspection.service import InspectionService +from test_workbench import BlockingAdapter, wait_finished + + +@pytest.fixture +def api(tmp_path): + svc = InspectionService(tmp_path / 'jobs.db', demo=True) + server = make_server('127.0.0.1', 0, svc) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield svc, server, requester(server) + server.shutdown() + server.server_close() + svc.close() + thread.join(2) + + +def requester(server): + def request(method, path, payload=None, headers=None, raw=None): + data = raw if raw is not None else json.dumps(payload).encode() if payload is not None else None + request = urllib.request.Request('http://127.0.0.1:%d%s' % (server.server_port, path), data=data, method=method, + headers={'Content-Type': 'application/json', **(headers or {})}) + try: + response = urllib.request.urlopen(request, timeout=3) + except urllib.error.HTTPError as exc: + response = exc + with response: + body = response.read() + return response.status, json.loads(body) if 'application/json' in response.headers.get('Content-Type', '') else body + return request + + +def test_real_http_job_workflow(api): + svc, server, request = api + status, catalog = request('GET', '/api/catalog') + assert status == 200 and catalog['ok'] and catalog['mode'] == 'demo' + assert request('GET', '/api/health')[1]['persistence_ready'] + assert request('POST', '/api/config/validate', {'config': catalog['config_snapshot']})[1]['valid'] + status, created = request('POST', '/api/jobs', {'scenario': 'faults'}) + assert status == 202 and created['job']['status'] in ('queued', 'running') + job = wait_finished(svc, created['job']['id']) + assert request('GET', '/api/jobs/' + job['id'])[1]['job'] == job + assert request('GET', '/api/jobs?limit=1&status=completed')[1]['total'] == 1 + assert request('GET', '/api/insights')[1]['demo']['completed'] == 1 + assert request('POST', '/api/jobs/' + job['id'] + '/cancel', {})[1]['job']['status'] == 'completed' + status, retest = request('POST', '/api/jobs/' + job['id'] + '/retest', {'scenario': 'healthy'}) + assert status == 202 + done = wait_finished(svc, retest['job']['id']) + status, comparison = request('GET', '/api/compare?baseline=' + job['id'] + '¤t=' + done['id']) + assert status == 200 and comparison['comparison']['partial_scope'] + for format in ('html', 'json', 'csv'): + status, data = request('GET', '/api/jobs/' + job['id'] + '/export?format=' + format) + assert status == 200 + assert data['id'] == job['id'] if format == 'json' else b'SIMULATED' in data + + +def test_input_boundaries(api): + svc, server, request = api + for raw in (b'{', b'[]', b'null', b'"string"'): + status, body = request('POST', '/api/jobs', raw=raw) + assert status == 400 and not body['ok'] and 'traceback' not in body + assert request('POST', '/api/jobs', raw=b' ' * 65537)[0] == 413 + assert request('GET', '/api/jobs/' + 'a' * 32)[0] == 404 + assert request('GET', '/api/jobs?limit=0')[0] == 400 + assert request('POST', '/api/config/validate', {'config': {}})[0] == 400 + assert request('GET', '/assets/../config/device_list.json')[0] == 404 + assert request('GET', '/assets/%2e%2e/config/device_list.json')[0] == 404 + assert request('GET', '/legacy')[0] == 200 + for path in ('/api/scan', '/api/run', '/api/scan-interface', '/api/test-device'): + assert request('POST', path, {})[0] == 403 + + +def test_origin_and_token(api): + svc, server, request = api + assert request('POST', '/api/jobs', {}, {'Origin': 'https://foreign.invalid'})[0] == 403 + server.access_token = 'secret' + assert request('GET', '/api/catalog')[0] == 401 + assert request('POST', '/api/jobs', {}, {'Authorization': 'Bearer wrong'})[0] == 401 + assert request('GET', '/api/catalog', headers={'Authorization': 'Bearer secret'})[0] == 200 + assert request('GET', '/api/catalog?token=secret')[0] == 401 + with pytest.raises(ValueError): + make_server('0.0.0.0', 0, svc) + + +def test_real_http_conflict_cancel_and_no_traceback(api): + svc, server, request = api + blocker = BlockingAdapter() + svc.adapter = blocker + status, job = request('POST', '/api/jobs', {}) + assert status == 202 and blocker.started.wait(1) + assert request('POST', '/api/jobs', {})[0] == 409 + assert request('POST', '/api/jobs/' + job['job']['id'] + '/retest', {})[0] == 409 + assert request('POST', '/api/jobs/' + job['job']['id'] + '/cancel', {})[1]['job']['status'] == 'cancelling' + assert wait_finished(svc, job['job']['id'])['status'] == 'cancelled' + + +def test_demo_never_imports_detector(tmp_path): + import subprocess + import sys + code = "import sys; from inspection.service import InspectionService; from inspection.http import make_server; s=InspectionService(sys.argv[1],demo=True); h=make_server('127.0.0.1',0,s); s.create({}); s.close(); h.server_close(); assert 'core.generic_detector' not in sys.modules; assert 'legacy_web' not in sys.modules" + subprocess.run([sys.executable, '-c', code, str(tmp_path / 'isolated.db')], check=True, timeout=5) + + +def test_http_error_never_leaks_traceback(api, monkeypatch): + svc, server, request = api + def fail(): + raise OSError('private path and password') + monkeypatch.setattr(svc, 'catalog', fail) + status, body = request('GET', '/api/catalog') + assert status == 500 + assert body == {'ok': False, 'error': 'Operation failed'} + assert request('GET', '/api/health', headers={'Host': 'foreign.invalid:' + str(server.server_port)})[0] == 403 + + +def test_legacy_live_calls_share_acceptance_lock_and_are_explicit(api, monkeypatch): + import legacy_web + svc, server, request = api + monkeypatch.setattr(legacy_web, 'scan_catalog', lambda payload: {'ok': True, 'devices': [], 'mode': 'scan'}) + svc.demo = False + svc.mode = 'live' + svc.hardware_lock.acquire() + try: + assert request('POST', '/api/scan', {})[0] == 409 + finally: + svc.hardware_lock.release() + status, body = request('POST', '/api/scan', {}) + assert status == 200 and body['legacy'] + assert legacy_web.RUN_LOCK is svc.hardware_lock diff --git a/tests/test_workbench.py b/tests/test_workbench.py new file mode 100644 index 0000000..eae57b0 --- /dev/null +++ b/tests/test_workbench.py @@ -0,0 +1,314 @@ +import copy +import json +import threading +import time + +import pytest + +from inspection.service import InspectionService +from inspection.store import SQLiteStore +from inspection.comparison import compare_jobs +from inspection.reports import export_job + + +TERMINAL = {'completed', 'cancelled', 'failed', 'interrupted'} + + +def wait_finished(service, job_id): + deadline = time.monotonic() + 4 + while time.monotonic() < deadline: + job = service.get(job_id) + if job['status'] in TERMINAL: + return job + time.sleep(.01) + raise AssertionError('job did not finish') + + +@pytest.fixture +def service(tmp_path): + value = InspectionService(tmp_path / 'jobs.db', demo=True) + yield value + value.close() + + +def test_missing_device_cannot_pass(service): + job = service.create({'scenario': 'missing', 'station_id': 'DEMO-01'}) + assert all(r['verdict'] == 'NOT_RUN' for r in job['results']) + done = wait_finished(service, job['id']) + assert done['summary']['failed'] >= 1 + assert len(done['results']) == done['summary']['expected'] + assert done['summary']['verdict'] != 'PASS' + assert all(r['simulated'] for r in done['results']) + + +def test_snapshot_and_retest_scope(service): + job = wait_finished(service, service.create({'scenario': 'faults'})['id']) + broken = [r['device_id'] for r in job['results'] if r['verdict'] != 'PASS'] + assert broken and len(broken) < len(job['results']) + with pytest.raises(ValueError): + service.retest(job['id'], {'device_ids': job['device_ids']}) + child = wait_finished(service, service.retest(job['id'], {'scenario': 'healthy', 'notes': 'fixed'})['id']) + assert child['device_ids'] == broken + assert child['config_snapshot'] == job['config_snapshot'] + assert child['config_hash'] == job['config_hash'] + assert child['parent_job_id'] == job['id'] + assert child['summary']['verdict'] == 'PASS' + comparison = compare_jobs(job, child) + assert comparison['comparable'] and comparison['partial_scope'] + assert not comparison['whole_unit_recovered'] + assert len(comparison['recovered']) == len(broken) + other = copy.deepcopy(child) + other['mode'] = 'live' + assert not compare_jobs(job, other)['comparable'] + other = copy.deepcopy(job) + other['device_ids'] = job['device_ids'][:1] + assert not compare_jobs(job, other)['comparable'] + + +class BlockingAdapter: + def __init__(self): + self.started = threading.Event() + self.release = threading.Event() + self.snapshot = None + + def run(self, config, device_ids, scenario, cancelled, on_result): + self.snapshot = copy.deepcopy(config) + self.started.set() + while not self.release.wait(.01) and not cancelled(): + pass + + +def test_contention_cancel_and_shared_lock(tmp_path): + adapter = BlockingAdapter() + svc = InspectionService(tmp_path / 'jobs.db', demo=True, adapter=adapter) + try: + svc.hardware_lock.acquire() + with pytest.raises(RuntimeError): + svc.create({}) + svc.hardware_lock.release() + job = svc.create({}) + assert adapter.started.wait(1) + job['config_snapshot']['devices'][0]['device_name'] = 'changed outside' + assert adapter.snapshot['devices'][0]['device_name'] != 'changed outside' + with pytest.raises(RuntimeError): + svc.create({}) + svc.cancel(job['id']) + done = wait_finished(svc, job['id']) + assert done['status'] == 'cancelled' + assert done['summary']['not_run'] == done['summary']['expected'] + assert done['summary']['verdict'] != 'PASS' + assert done['finished_at'] + assert done['events'][-1]['type'] == 'cancelled' + finally: + adapter.release.set() + svc.close() + + +def test_restart_marks_pending_interrupted(tmp_path, service): + job = wait_finished(service, service.create({})['id']) + job['status'] = 'running' + db = tmp_path / 'other.db' + store = SQLiteStore(db) + store.insert(job) + store.close() + recovered = InspectionService(db, demo=True) + try: + result = recovered.get(job['id']) + assert result['status'] == 'interrupted' + assert result['events'][-1]['type'] == 'interrupted' + finally: + recovered.close() + + +def test_reports_preserve_evidence_and_escape(service): + job = wait_finished(service, service.create({'notes': '', 'operator': '=CMD()'})['id']) + html, mime = export_job(job, 'html') + assert '' not in html.decode() + assert '<script>' in html.decode() + assert 'SIMULATED' in html.decode() + csv, mime = export_job(job, 'csv') + assert "'=CMD()" in csv.decode('utf-8-sig') + raw, mime = export_job(job, 'json') + assert json.loads(raw)['config_snapshot'] == job['config_snapshot'] + assert json.loads(raw)['results'][0]['response'] + + +def test_validation_history_insights(service): + for payload in ({'scenario': 'bad'}, {'device_ids': []}, {'device_ids': ['unknown']}, {'notes': 'x' * 2001}): + with pytest.raises(ValueError): + service.create(payload) + job = wait_finished(service, service.create({'batch': 'batch-find'})['id']) + assert service.list_jobs(query='batch-find')['total'] == 1 + assert service.list_jobs(status='completed', limit=1)['jobs'][0]['id'] == job['id'] + assert service.insights()['demo']['completed'] == 1 + assert service.insights()['live']['total'] == 0 + + +def test_live_adapter_group_selection_and_missing(monkeypatch): + from inspection.adapters import LiveAdapter, demo_config + import core.generic_detector + seen = [] + class FakeDetector: + def __init__(self, **kwargs): + seen.append(kwargs['config'].data) + def run(self): + return self + def to_dict(self): + return {'devices': []} + monkeypatch.setattr(core.generic_detector, 'GenericDetector', FakeDetector) + config = demo_config() + config['devices'][1]['child_devices'] = [copy.deepcopy(config['devices'][0])] + config['devices'][1]['child_devices'][0]['device_id'] = 'child' + results = [] + LiveAdapter().run(config, ['child'], 'healthy', lambda: False, results.append) + assert len(seen) == 1 and seen[0]['devices'][0]['device_id'] == config['devices'][1]['device_id'] + assert [r['device_id'] for r in results] == ['child'] + assert results[0]['verdict'] == 'FAIL' + + +def test_acceptance_rejects_write_before_adapter(service): + config = service.catalog()['config_snapshot'] + config['settings']['allow_write_tests'] = True + config['devices'][0]['protocol']['operation'] = 'write' + with pytest.raises(ValueError, match='write'): + service.create({}, snapshot=config) + + +def test_shutdown_is_bounded_and_does_not_record_late_pass(tmp_path): + class StubbornAdapter(BlockingAdapter): + def run(self, config, device_ids, scenario, cancelled, on_result): + from inspection.adapters import DemoAdapter + self.started.set() + self.release.wait(5) + DemoAdapter().run(config, device_ids, scenario, lambda: False, on_result) + adapter = StubbornAdapter() + path = tmp_path / 'shutdown.db' + svc = InspectionService(path, demo=True, adapter=adapter) + job = svc.create({}) + assert adapter.started.wait(1) + start = time.monotonic() + svc.close() + assert time.monotonic() - start < 2.6 + adapter.release.set() + svc.worker.join(1) + reopened = InspectionService(path, demo=True) + try: + saved = reopened.get(job['id']) + assert saved['status'] == 'interrupted' + assert saved['summary']['not_run'] == saved['summary']['expected'] + assert saved['duration_ms'] >= 1900 + finally: + reopened.close() + + +def test_simultaneous_creation_has_one_owner(tmp_path): + adapter = BlockingAdapter() + svc = InspectionService(tmp_path / 'race.db', demo=True, adapter=adapter) + barrier = threading.Barrier(6) + outcomes = [] + def run(): + barrier.wait() + try: + outcomes.append(svc.create({})['id']) + except RuntimeError: + outcomes.append('conflict') + threads = [threading.Thread(target=run) for _ in range(6)] + try: + for thread in threads: + thread.start() + for thread in threads: + thread.join(2) + assert len(outcomes) == 6 and outcomes.count('conflict') == 5 + assert svc.list_jobs()['total'] == 1 + finally: + svc.close() + + +def test_adapter_failure_cannot_leave_pass_verdict(tmp_path): + class FailsAfterResults: + def run(self, config, device_ids, scenario, cancelled, on_result): + from inspection.adapters import DemoAdapter + DemoAdapter().run(config, device_ids, scenario, cancelled, on_result) + raise RuntimeError('private traceback content') + svc = InspectionService(tmp_path / 'failed.db', demo=True, adapter=FailsAfterResults()) + try: + done = wait_finished(svc, svc.create({})['id']) + assert done['status'] == 'failed' + assert done['summary']['verdict'] != 'PASS' + assert 'private traceback' not in json.dumps(done) + finally: + svc.close() + + +def test_duplicate_retest_input_is_validation_error(service): + job = wait_finished(service, service.create({'scenario': 'faults'})['id']) + with pytest.raises(ValueError): + service.retest(job['id'], {'device_ids': [{'bad': 'object'}]}) + + +def test_failed_only_retest_never_claims_whole_unit_recovery(service): + original = wait_finished(service, service.create({'scenario': 'missing', 'device_ids': ['DEMO_RELAY']})['id']) + child = wait_finished(service, service.retest(original['id'], {'scenario': 'healthy'})['id']) + comparison = compare_jobs(original, child) + assert comparison['comparable'] and comparison['partial_scope'] + assert not comparison['whole_unit_recovered'] + + +def test_real_config_snapshot_survives_file_change(tmp_path): + from inspection.adapters import demo_config, DemoAdapter + path = tmp_path / 'config.json' + snapshot = demo_config() + path.write_text(json.dumps(snapshot), encoding='utf-8') + blocker = BlockingAdapter() + svc = InspectionService(tmp_path / 'live.db', config_path=str(path), adapter=blocker) + try: + job = svc.create({}) + assert blocker.started.wait(1) + snapshot['devices'][0]['device_name'] = 'new name' + path.write_text(json.dumps(snapshot), encoding='utf-8') + assert svc.catalog()['config_hash'] != job['config_hash'] + assert svc.get(job['id'])['config_snapshot']['devices'][0]['device_name'] != 'new name' + svc.cancel(job['id']) + original = wait_finished(svc, job['id']) + svc.adapter = DemoAdapter() + child = wait_finished(svc, svc.retest(original['id'], {})['id']) + assert child['config_snapshot'] == original['config_snapshot'] + assert child['config_hash'] == original['config_hash'] + assert all(not r['simulated'] for r in child['results']) + finally: + svc.close() + + +def test_live_missing_and_progress_at_group_boundaries(monkeypatch): + from inspection.adapters import LiveAdapter, demo_config + import core.generic_detector + seen = [] + results = [] + stop = threading.Event() + class FakeDetector: + def __init__(self, **kwargs): + self.config = kwargs['config'] + seen.append(self.config.data) + def run(self): + return self + def to_dict(self): + raw = self.config.raw_devices()[0] + return {'devices': [{'device_id': raw['device_id'], 'status': '正常', 'evidence': 'passive evidence', 'request': 'read', 'response': 'response'}]} + monkeypatch.setattr(core.generic_detector, 'GenericDetector', FakeDetector) + def record(result): + results.append(result) + stop.set() + snapshot = demo_config() + LiveAdapter().run(snapshot, [d['device_id'] for d in snapshot['devices']], 'healthy', stop.is_set, record) + assert len(seen) == len(results) == 1 + assert results[0]['verdict'] == 'PASS' and not results[0]['simulated'] + + +def test_comparison_rejects_configuration_or_evidence_scope_mismatch(service): + job = wait_finished(service, service.create({})['id']) + other = copy.deepcopy(job) + other['config_hash'] = 'different' + assert not compare_jobs(job, other)['comparable'] + other = copy.deepcopy(job) + other['results'][0]['scope'] = 'business_function' + assert not compare_jobs(job, other)['comparable'] diff --git a/web/legacy.html b/web/legacy.html new file mode 100644 index 0000000..977b98d --- /dev/null +++ b/web/legacy.html @@ -0,0 +1,624 @@ + + + + + + 工控机设备检测 + + + +
+
+

工控机设备检测

+
+ + +
+
+ +
+ 等待扫描 +
+ +
+
+
+
串

串口

+
0 个设备
+
+
尚未扫描
+
+ +
+
+
网

网口

+
0 个设备
+
+
尚未扫描
+
+ +
+
+
PCI

PCI

+
0 个设备
+
+
尚未扫描
+
+ +
+
+
报

测试报告

+
+
+
尚未生成报告
+
+
+
+
+ + + + diff --git a/web_app.py b/web_app.py index a78ffb1..15ac685 100644 --- a/web_app.py +++ b/web_app.py @@ -1,679 +1,49 @@ - #!/usr/bin/env python3 -""" -Industrial PC generic test system - local Web UI. - -The page runs real local detection and matches devices against a configurable -standardized device list. Results are shown directly in the Web UI. -""" - +"""Compatibility entry point for the local field acceptance workbench.""" import argparse -import copy -import html as html_lib -import json import os -import shutil -import sys -import threading -import time -import traceback -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import unquote, urlparse - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, BASE_DIR) - -from core.config_manager import StandardDeviceConfig -from core.generic_detector import DEFAULT_TCP_PORTS, GenericDetector - -REPORT_DIR = os.path.join(BASE_DIR, "report") -WEB_INDEX_PATH = os.path.join(BASE_DIR, "web", "index.html") -REPORT_KEEP_LIMIT = 20 -RUN_LOCK = threading.Lock() -SCAN_STATE = {"devices": {}, "updated_at": ""} -CONNECTED_STATUSES = {"在线", "已发现", "正常"} -INTERFACE_GROUP_KEYS = { - "serial": "serial_ports", - "network": "network_interfaces", - "pci": "pci_devices", -} -INTERFACE_CATEGORIES = {"serial": "串口", "network": "网口", "pci": "PCI"} - -def detector_from_payload(payload: dict, config: StandardDeviceConfig = None) -> GenericDetector: - raw_ports = payload.get("tcp_ports", ",".join(str(port) for port in DEFAULT_TCP_PORTS)) - return GenericDetector( - serial_probe=bool(payload.get("serial_probe", True)), - subnet_probe=bool(payload.get("subnet_probe", True)), - tcp_ports=parse_ports(raw_ports), - config=config, - ) - - -def parse_ports(value): - raw_parts = value if isinstance(value, list) else str(value).replace(",", ",").replace(";", ",").split(",") - ports = [] - for part in raw_parts: - try: - port = int(str(part).strip()) - except ValueError: - continue - if 1 <= port <= 65535 and port not in ports: - ports.append(port) - return ports or DEFAULT_TCP_PORTS - - -def connected_devices(data: dict): - return [item for item in data.get("devices", []) or [] if item.get("status") in CONNECTED_STATUSES] - - -def cache_scan_devices(devices): - SCAN_STATE["devices"] = { - str(item.get("device_id")): copy.deepcopy(item) - for item in devices - if item.get("device_id") - } - SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") - - -def same_interface(connection_type: str, left: str, right: str) -> bool: - left = str(left or "").strip() - right = str(right or "").strip() - if connection_type == "pci": - return left.replace("0000:", "") == right.replace("0000:", "") - return left == right - - -def cache_interface_devices(connection_type: str, interface: str, devices): - retained = { - device_id: item - for device_id, item in SCAN_STATE.get("devices", {}).items() - if not ( - str(item.get("connection_type") or "") == connection_type - and same_interface(connection_type, item.get("interface", ""), interface) - ) - } - for item in devices: - device_id = str(item.get("device_id") or "") - if device_id: - retained[device_id] = copy.deepcopy(item) - SCAN_STATE["devices"] = retained - SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") - - -def selected_catalog(device_ids): - interfaces = { - device_id: str(item.get("interface") or "") - for device_id, item in SCAN_STATE.get("devices", {}).items() - if item.get("interface") - } - return StandardDeviceConfig().selected(device_ids, interfaces) - - -def update_interface_counts(data: dict): - counts = { - connection_type: len(data.get(key, []) or []) - for connection_type, key in INTERFACE_GROUP_KEYS.items() - } - data.setdefault("summary", {}).update({ - "interface_total": sum(counts.values()), - "serial_count": counts["serial"], - "network_count": counts["network"], - "pci_count": counts["pci"], - }) - - -def prepare_ui_result(data: dict, mode: str, connected_only: bool = False) -> dict: - result = copy.deepcopy(data) - catalog_count = len(StandardDeviceConfig().all_configured_devices()) - devices = result.get("devices", []) or [] - if connected_only: - devices = [item for item in devices if item.get("status") in CONNECTED_STATUSES] - result["devices"] = devices - result["mode"] = mode - - passed = sum(1 for item in devices if item.get("status") in CONNECTED_STATUSES) - warning = sum(1 for item in devices if item.get("status") == "需确认") - failed = sum(1 for item in devices if item.get("status") == "异常") - summary = result.setdefault("summary", {}) - summary.update({ - "total": len(devices), - "catalog_count": catalog_count, - "device_count": len(devices), - "connected_count": passed, - "passed": passed, - "warning": warning, - "failed": failed, - "fault_count": failed, - "matched_count": passed, - }) - _rebuild_interface_devices(result, devices) - update_interface_counts(result) - return result - - -def _rebuild_interface_devices(data: dict, devices): - groups = { - connection_type: data.get(key, []) or [] - for connection_type, key in INTERFACE_GROUP_KEYS.items() - } - index = {} - for connection_type, items in groups.items(): - for item in items: - details = item.setdefault("details", {}) - details["configured_devices"] = [] - index[(connection_type, str(item.get("name") or ""))] = item - - for device in devices: - connection_type = str(device.get("connection_type") or "") - interface = str(device.get("interface") or "") - item = index.get((connection_type, interface)) - if not item: - continue - name = str(device.get("name") or device.get("device_id") or "") - if name: - item["details"]["configured_devices"].append(name) - - for items in groups.values(): - for item in items: - names = item.get("details", {}).get("configured_devices", []) or [] - if names: - item["summary"] = f"已发现 {len(names)} 个已知设备" - elif item.get("status") == "正常": - item["summary"] = "接口可用,未发现已知设备" - - data["pci_devices"] = [ - item for item in groups["pci"] - if not str(item.get("name") or "").startswith("匹配规则:") - or (item.get("details", {}).get("configured_devices", []) or []) - ] - - -def preserve_scan_interfaces(result: dict, scan_raw: dict): - """Keep every first-pass interface after testing discovered devices.""" - for connection_type, key in INTERFACE_GROUP_KEYS.items(): - scanned = copy.deepcopy(scan_raw.get(key, []) or []) - tested = copy.deepcopy(result.get(key, []) or []) - merged = [] - used = set() - for scanned_item in scanned: - match_index = next(( - index for index, tested_item in enumerate(tested) - if same_interface( - connection_type, - scanned_item.get("name", ""), - tested_item.get("name", ""), - ) - ), None) - if match_index is None: - merged.append(scanned_item) - continue - used.add(match_index) - tested_item = tested[match_index] - combined = copy.deepcopy(scanned_item) - combined.update(tested_item) - details = copy.deepcopy(scanned_item.get("details", {}) or {}) - details.update(copy.deepcopy(tested_item.get("details", {}) or {})) - combined["details"] = details - merged.append(combined) - merged.extend(item for index, item in enumerate(tested) if index not in used) - result[key] = merged - - _rebuild_interface_devices(result, result.get("devices", []) or []) - update_interface_counts(result) - return result - - -def scan_catalog(payload: dict) -> dict: - raw = detector_from_payload(payload).run().to_dict() - discovered = connected_devices(raw) - cache_scan_devices(discovered) - return prepare_ui_result(raw, "scan", connected_only=True) - +from pathlib import Path -def scan_interface(payload: dict) -> dict: - connection_type = str(payload.get("interface_type") or "").strip().lower() - interface = str(payload.get("interface") or "").strip() - if connection_type not in ("serial", "network", "pci"): - raise ValueError("interface_type必须是serial、network或pci") - if not interface: - raise ValueError("缺少接口名称") +from inspection.http import make_server +from inspection.service import InspectionService - config = StandardDeviceConfig().for_interface(connection_type, interface) - if config is None: - now = time.strftime("%Y-%m-%d %H:%M:%S") - raw = { - "started_at": now, - "finished_at": now, - "duration_ms": 0, - "summary": {}, - "serial_ports": [], - "network_interfaces": [], - "pci_devices": [], - "devices": [], - "connection_tests": [], - "logs": [], - } - key = INTERFACE_GROUP_KEYS[connection_type] - category = INTERFACE_CATEGORIES[connection_type] - raw[key] = [{ - "name": interface, - "category": category, - "status": "正常", - "summary": "配置库中没有此类已知设备", - "details": {"configured_devices": []}, - }] - else: - raw = detector_from_payload(payload, config=config).run().to_dict() - devices = [ - item for item in connected_devices(raw) - if str(item.get("connection_type") or "") == connection_type - and same_interface(connection_type, item.get("interface", ""), interface) - ] - raw["devices"] = devices - target_key = INTERFACE_GROUP_KEYS[connection_type] - for key in INTERFACE_GROUP_KEYS.values(): - items = raw.get(key, []) or [] - raw[key] = [ - item for item in items - if key == target_key and same_interface(connection_type, item.get("name", ""), interface) - ] - cache_interface_devices(connection_type, interface, devices) - result = prepare_ui_result(raw, "scan", connected_only=True) - result["interface_type"] = connection_type - result["interface_name"] = interface - return result - - -def test_all_discovered(payload: dict) -> dict: - scan_raw = detector_from_payload(payload).run().to_dict() - discovered = connected_devices(scan_raw) - cache_scan_devices(discovered) - device_ids = [str(item.get("device_id")) for item in discovered if item.get("device_id")] - if not device_ids: - result = prepare_ui_result(scan_raw, "test_all", connected_only=True) - else: - config = selected_catalog(device_ids) - tested = detector_from_payload(payload, config=config).run().to_dict() - result = prepare_ui_result(tested, "test_all") - preserve_scan_interfaces(result, scan_raw) - result["summary"]["scan_connected_count"] = len(discovered) - keep_limit = StandardDeviceConfig().settings().get("max_report_count", REPORT_KEEP_LIMIT) - save_report(result, keep_limit) - return result - - -def test_single_device(payload: dict) -> dict: - device_id = str(payload.get("device_id") or "").strip() - if not device_id: - raise ValueError("缺少device_id") - if device_id not in SCAN_STATE.get("devices", {}): - raise ValueError("设备不在当前扫描结果中,请先扫描设备") - config = selected_catalog([device_id]) - tested = detector_from_payload(payload, config=config).run().to_dict() - result = prepare_ui_result(tested, "single") - target = next((item for item in result.get("devices", []) if str(item.get("device_id")) == device_id), None) - if target is None: - raise ValueError("未生成该设备的测试结果") - return {"ok": True, "mode": "single", "device": target, "finished_at": result.get("finished_at", "")} - - -def save_report(data: dict, keep_limit: int = REPORT_KEEP_LIMIT) -> dict: - keep_limit = max(1, min(1000, int(keep_limit or REPORT_KEEP_LIMIT))) - os.makedirs(REPORT_DIR, exist_ok=True) - stamp = time.strftime("%Y%m%d_%H%M%S") - suffix = int((time.time() * 1000) % 1000) - folder = f"{stamp}_{suffix:03d}" - folder_path = os.path.join(REPORT_DIR, folder) - os.makedirs(folder_path, exist_ok=True) - html_name = "report.html" - txt_name = "report.txt" - json_name = "report.json" - report_info = { - "folder": folder, - "html": html_name, - "txt": txt_name, - "json": json_name, - "url": f"/report/{folder}/{html_name}", - "html_url": f"/report/{folder}/{html_name}", - "txt_url": f"/report/{folder}/{txt_name}", - "json_url": f"/report/{folder}/{json_name}", - "keep_limit": keep_limit, - } - logs = data.setdefault("logs", []) - logs.append(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 生成测试报告:report/{folder}/report.html、report.txt、report.json,report目录最多保留 {keep_limit} 个报告文件夹") - data["report"] = report_info - data.setdefault("summary", {})["report_folder"] = folder - data["summary"]["report_keep_limit"] = keep_limit - with open(os.path.join(folder_path, html_name), "w", encoding="utf-8") as f: - f.write(render_compact_report_html(data)) - with open(os.path.join(folder_path, txt_name), "w", encoding="utf-8") as f: - f.write(render_compact_report_txt(data)) - with open(os.path.join(folder_path, json_name), "w", encoding="utf-8") as f: - json.dump(compact_report_data(data), f, ensure_ascii=False, indent=2) - prune_reports(keep_limit) - return report_info - - -def prune_reports(keep_limit: int = REPORT_KEEP_LIMIT): - if not os.path.isdir(REPORT_DIR): - return - reports = [] - for name in os.listdir(REPORT_DIR): - path = os.path.join(REPORT_DIR, name) - if name.startswith("report_") and name.endswith(".html") and os.path.isfile(path): - try: - os.remove(path) - except OSError: - pass - continue - if os.path.isdir(path) and os.path.isfile(os.path.join(path, "report.json")): - reports.append((os.path.getmtime(path), path)) - reports.sort(reverse=True) - for _, path in reports[max(1, int(keep_limit or REPORT_KEEP_LIMIT)):]: - try: - shutil.rmtree(path) - except OSError: - pass - - -def compact_result_text(device: dict) -> str: - if device.get("status") in CONNECTED_STATUSES: - return "连通正常" - evidence = str(device.get("evidence") or "") - if "timed out" in evidence.lower() or "超时" in evidence or "未收到响应" in evidence: - return "无响应" - if "不存在" in evidence: - return "接口不存在" - return "测试失败" - - -def compact_connection_text(device: dict) -> str: - connection = {"serial": "串口", "network": "网口", "pci": "PCI"}.get( - str(device.get("connection_type") or ""), str(device.get("connection_type") or "-") - ) - protocol = str(device.get("protocol") or "").lower() - connection = { - "modbus_rtu": "Modbus RTU", - "modbus_tcp": "Modbus TCP", - "tcp": "TCP", - "udp": "UDP", - }.get(protocol, connection) - via = device.get("via_device", {}) or {} - via_name = str(via.get("device_name") or "") - return f"{connection} / 经{via_name}" if via_name else connection - - -def compact_report_data(data: dict) -> dict: - devices = data.get("devices", []) or [] - summary = data.get("summary", {}) or {} - interfaces = [] - for category, key in INTERFACE_GROUP_KEYS.items(): - for item in data.get(key, []) or []: - interfaces.append({ - "type": category, - "interface": item.get("name", ""), - "status": item.get("status", ""), - "devices": (item.get("details", {}) or {}).get("configured_devices", []) or [], - }) - - compact_devices = [] - for device in devices: - passed = device.get("status") in CONNECTED_STATUSES - via = device.get("via_device", {}) or {} - compact_devices.append({ - "device_id": device.get("device_id", ""), - "device_name": device.get("name", ""), - "device_type": device.get("type", ""), - "interface_type": device.get("connection_type", ""), - "interface": device.get("interface", ""), - "communication": compact_connection_text(device), - "via_device": via.get("device_name", ""), - "result": "PASS" if passed else "FAIL", - "test_report": compact_result_text(device), - }) - - return { - "report_version": 1, - "hostname": data.get("hostname", ""), - "started_at": data.get("started_at", ""), - "finished_at": data.get("finished_at", ""), - "duration_ms": round(float(data.get("duration_ms") or 0), 1), - "summary": { - "found": len(devices), - "passed": summary.get("passed", 0), - "failed": summary.get("failed", 0), - }, - "interfaces": interfaces, - "devices": compact_devices, - "report": data.get("report", {}) or {}, - } - - -def render_compact_report_txt(data: dict) -> str: - summary = data.get("summary", {}) or {} - devices = data.get("devices", []) or [] - lines = [ - "设备连通性测试报告", - "=" * 28, - f"主机: {data.get('hostname', '')}", - f"测试时间: {data.get('finished_at') or data.get('started_at', '')}", - f"发现设备: {summary.get('device_count', len(devices))}", - f"通过: {summary.get('passed', 0)}", - f"失败: {summary.get('failed', 0)}", - "", - "测试结果", - "-" * 28, - ] - if not devices: - lines.append("未发现配置库中的已知设备") - for index, device in enumerate(devices, start=1): - result = "通过" if device.get("status") in CONNECTED_STATUSES else "失败" - lines.append( - f"[{index}] {device.get('name', '')} | {device.get('interface', '-') or '-'} | " - f"{compact_connection_text(device)} | {result} | {compact_result_text(device)}" - ) - return "\n".join(lines) + "\n" - - -def render_compact_report_html(data: dict) -> str: - summary = data.get("summary", {}) or {} - devices = data.get("devices", []) or [] - - def esc(value) -> str: - return html_lib.escape(str(value if value is not None else ""), quote=True) - - rows = [] - for device in devices: - passed = device.get("status") in CONNECTED_STATUSES - result = "通过" if passed else "失败" - cls = "pass" if passed else "fail" - rows.append( - "" - f"{esc(device.get('name', ''))}" - f"{esc(device.get('interface', '') or '-')}" - f"{esc(compact_connection_text(device))}" - f"{result}" - f"{esc(compact_result_text(device))}" - "" - ) - if not rows: - rows.append('未发现配置库中的已知设备') - - return f""" - - - - - 设备连通性测试报告 - - - -
-

设备连通性测试报告

-
-
主机
{esc(data.get('hostname', '-'))}
-
测试时间
{esc(data.get('finished_at') or data.get('started_at', '-'))}
-
通过
{esc(summary.get('passed', 0))}
-
失败
{esc(summary.get('failed', 0))}
-
-
- - - {''.join(rows)} -
设备接口连接方式结果测试报告
-
-
- -""" - - -class Handler(BaseHTTPRequestHandler): - server_version = "QLDeviceCheckGeneric/2.0" - - def do_GET(self): - path = urlparse(self.path).path - if path in ("/", "/index.html"): - with open(WEB_INDEX_PATH, "r", encoding="utf-8") as f: - self._send_html(f.read()) - return - if path == "/api/health": - self._send_json({"ok": True}) - return - if path == "/api/standards": - self._send_json({"ok": True, "devices": StandardDeviceConfig().devices()}) - return - if path.startswith("/report/"): - self._send_report(path) - return - if path == "/favicon.ico": - self._send_empty() - return - self.send_error(404, "Not Found") - - def do_POST(self): - path = urlparse(self.path).path - handlers = { - "/api/scan": scan_catalog, - "/api/scan-interface": scan_interface, - "/api/run": test_all_discovered, - "/api/test-device": test_single_device, - } - operation = handlers.get(path) - if operation is None: - self.send_error(404, "Not Found") - return - if not RUN_LOCK.acquire(blocking=False): - self._send_json({"ok": False, "error": "检测任务正在运行,请稍候"}, status=409) - return - try: - payload = self._read_json() - self._send_json(operation(payload)) - except Exception as exc: - self._send_json({ - "ok": False, - "error": str(exc), - "traceback": traceback.format_exc().splitlines(), - }, status=500) - finally: - RUN_LOCK.release() - - def _read_json(self): - length = int(self.headers.get("Content-Length", "0")) - if length <= 0: - return {} - raw = self.rfile.read(length).decode("utf-8") - if not raw.strip(): - return {} - return json.loads(raw) - - def _send_html(self, html: str): - encoded = html.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def _send_report(self, path: str): - relative = unquote(path[len("/report/"):]).replace("\\", "/").strip("/") - if not relative or ".." in relative.split("/"): - self.send_error(404, "Not Found") - return - if not relative.endswith((".html", ".txt", ".json")): - self.send_error(404, "Not Found") - return - full_path = os.path.abspath(os.path.join(REPORT_DIR, *relative.split("/"))) - report_root = os.path.abspath(REPORT_DIR) - if not full_path.startswith(report_root + os.sep) or not os.path.isfile(full_path): - self.send_error(404, "Not Found") - return - content_types = { - ".html": "text/html; charset=utf-8", - ".txt": "text/plain; charset=utf-8", - ".json": "application/json; charset=utf-8", - } - ext = os.path.splitext(full_path)[1] - with open(full_path, "rb") as f: - encoded = f.read() - self.send_response(200) - self.send_header("Content-Type", content_types.get(ext, "application/octet-stream")) - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def _send_json(self, data: dict, status: int = 200): - encoded = json.dumps(data, ensure_ascii=False).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def _send_empty(self, status: int = 204): - self.send_response(status) - self.send_header("Content-Length", "0") - self.end_headers() - - def log_message(self, fmt, *args): - print("[%s] %s" % (self.log_date_time_string(), fmt % args)) +def __getattr__(name): + # Preserve explicitly requested Python helpers without importing hardware in demo. + if name == 'preserve_scan_interfaces': + from legacy_web import preserve_scan_interfaces + return preserve_scan_interfaces + raise AttributeError(name) def main(): - parser = argparse.ArgumentParser(description="工控机通用测试系统 Web UI") - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", default=8080, type=int) + parser = argparse.ArgumentParser(description='Field acceptance workbench') + parser.add_argument('--demo', action='store_true', help='Synthetic devices; never access hardware') + parser.add_argument('--data-dir', default=str(Path(__file__).resolve().parent / 'data')) + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', default=8080, type=int) + parser.add_argument('--config', default=None) args = parser.parse_args() - - server = ThreadingHTTPServer((args.host, args.port), Handler) - print("工控机通用测试系统已启动") - print(f"访问地址: http://{args.host}:{args.port}") - print("按 Ctrl+C 退出") + service = None + server = None try: + service = InspectionService(Path(args.data_dir) / 'jobs.db', demo=args.demo, config_path=args.config) + server = make_server(args.host, args.port, service, token=os.environ.get('QLDC_ACCESS_TOKEN', '')) + print('SIMULATED demo mode' if args.demo else 'LIVE field mode') + print(f'Workbench: http://{args.host}:{args.port}') server.serve_forever() except KeyboardInterrupt: - print("\n正在退出...") + print('Shutting down...') + except Exception: + print('Startup or operation failed. Check configuration, port and QLDC_ACCESS_TOKEN.') + return 1 finally: - server.server_close() + if server: + server.server_close() + if service: + service.close() + return 0 -if __name__ == "__main__": - main() +if __name__ == '__main__': + raise SystemExit(main()) From bbe0d7baed732a84e3280f136a198bbced2e648a Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:55:04 +0800 Subject: [PATCH 03/15] fix: assemble TCP frames and validate Modbus read lengths --- core/generic_detector.py | 12 ++- core/protocol_engine.py | 27 ++++++- core/transport.py | 34 ++++++++ tests/test_protocol_regressions.py | 126 +++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 core/transport.py create mode 100644 tests/test_protocol_regressions.py diff --git a/core/generic_detector.py b/core/generic_detector.py index 56dbabd..33cdade 100644 --- a/core/generic_detector.py +++ b/core/generic_detector.py @@ -18,6 +18,7 @@ import serial.tools.list_ports from .config_manager import StandardDeviceConfig +from .transport import modbus_tcp_complete, receive_response from .protocol_engine import ( ProtocolConfigError, build_modbus_rtu_read, @@ -786,6 +787,7 @@ def _test_modbus_rtu_configured(self, port: str, serial_cfg: dict, protocol: dic slave_id=frame[0], function_code=frame[1], accept_exception=bool(probe.get("accept_exception", protocol.get("accept_exception", False))), + expected_count=int.from_bytes(frame[4:6], "big"), ) rule = response_rule(protocol, probe) if response_ok and rule: @@ -1136,7 +1138,11 @@ def _test_tcp_protocol_configured(self, ip: str, port: int, iface: str, if payload: sock.sendall(payload) should_read = bool(rule) or bool(probe.get("read_response", bool(payload))) - response = sock.recv(max(1, min(65536, int(probe.get("read_size", 4096) or 4096)))) if should_read else b"" + response = receive_response( + sock, timeout, + max_bytes=max(1, min(65536, int(probe.get("read_size", 4096) or 4096))), + complete=(lambda data: match_response(data, rule)[0]) if rule else None, + ) if should_read else b"" if should_read: ok, note = match_response(response, rule) else: @@ -1206,13 +1212,15 @@ def _test_modbus_tcp_configured(self, ip: str, port: int, iface: str, self._bind_network_socket(sock, iface) sock.connect((ip, port)) sock.sendall(frame) - response = sock.recv(260) + response = receive_response(sock, float(reg.get("timeout", protocol.get("timeout", 3)) or 3), + max_bytes=260, complete=modbus_tcp_complete) response_ok, response_note, valid_frame = validate_modbus_tcp_response( response, transaction_id=index, unit_id=unit_id, function_code=function_code, accept_exception=bool(reg.get("accept_exception", protocol.get("accept_exception", False))), + expected_count=count, ) rule = response_rule(protocol, reg) if response_ok and rule: diff --git a/core/protocol_engine.py b/core/protocol_engine.py index 304e40a..9b1d227 100644 --- a/core/protocol_engine.py +++ b/core/protocol_engine.py @@ -199,7 +199,7 @@ def build_modbus_rtu_read(slave_id: int, function_code: int, address: int, count def find_modbus_rtu_response(data: bytes, slave_id: int, function_code: int, - accept_exception: bool = False) -> Tuple[bool, str, bytes]: + accept_exception: bool = False, expected_count: Optional[int] = None) -> Tuple[bool, str, bytes]: if not data: return False, "未收到响应", b"" for start in range(max(0, len(data) - 4)): @@ -210,7 +210,10 @@ def find_modbus_rtu_response(data: bytes, slave_id: int, function_code: int, if is_exception: frame_length = 5 elif actual_function == function_code: - frame_length = 5 + data[start + 2] + byte_count = data[start + 2] + if not 1 <= byte_count <= 250 or (function_code in (3, 4) and byte_count % 2): + continue + frame_length = 5 + byte_count else: continue end = start + frame_length @@ -224,6 +227,10 @@ def find_modbus_rtu_response(data: bytes, slave_id: int, function_code: int, if accept_exception: return True, f"设备返回Modbus异常码0x{code:02X},按配置允许异常响应", frame return False, f"设备返回Modbus异常码0x{code:02X}", frame + if expected_count is not None: + expected_bytes = expected_count * 2 if function_code in (3, 4) else (expected_count + 7) // 8 + if frame[2] != expected_bytes: + return False, "Modbus RTU响应数量与请求不匹配", frame return True, "返回有效Modbus RTU响应", frame return False, "收到数据,但站号、功能码或CRC不匹配", b"" @@ -249,24 +256,36 @@ def build_modbus_tcp_read(transaction_id: int, unit_id: int, function_code: int, def validate_modbus_tcp_response(data: bytes, transaction_id: int, unit_id: int, - function_code: int, accept_exception: bool = False) -> Tuple[bool, str, bytes]: + function_code: int, accept_exception: bool = False, + expected_count: Optional[int] = None) -> Tuple[bool, str, bytes]: if len(data) < 9: return False, "Modbus TCP响应长度不足", b"" if int.from_bytes(data[0:2], "big") != transaction_id or data[2:4] != b"\x00\x00": return False, "Modbus TCP事务号或协议标识不匹配", b"" declared_length = int.from_bytes(data[4:6], "big") frame_length = 6 + declared_length - if declared_length < 3 or len(data) < frame_length: + if declared_length < 3 or declared_length > 254 or len(data) < frame_length: return False, "Modbus TCP长度字段不正确", b"" frame = data[:frame_length] if frame[6] != (unit_id & 0xFF): return False, "Modbus TCP单元标识不匹配", frame actual_function = frame[7] if actual_function == (function_code | 0x80): + if declared_length != 3: + return False, "Modbus TCP异常响应长度不正确", frame code = frame[8] if accept_exception: return True, f"设备返回Modbus异常码0x{code:02X},按配置允许异常响应", frame return False, f"设备返回Modbus异常码0x{code:02X}", frame if actual_function != function_code: return False, "Modbus TCP功能码不匹配", frame + byte_count = frame[8] + if not 1 <= byte_count <= 250 or declared_length != 3 + byte_count: + return False, "Modbus TCP数据字节数与长度不匹配", frame + if function_code in (3, 4) and byte_count % 2: + return False, "Modbus TCP寄存器数据必须为完整的双字节", frame + if expected_count is not None: + expected_bytes = expected_count * 2 if function_code in (3, 4) else (expected_count + 7) // 8 + if byte_count != expected_bytes: + return False, "Modbus TCP响应数量与请求不匹配", frame return True, "返回有效Modbus TCP响应", frame diff --git a/core/transport.py b/core/transport.py new file mode 100644 index 0000000..f7109a3 --- /dev/null +++ b/core/transport.py @@ -0,0 +1,34 @@ +"""Bounded stream assembly; TCP recv boundaries are not application frames.""" +import socket +import time + + +def modbus_tcp_complete(data): + if len(data) < 6: + return False + length = int.from_bytes(data[4:6], 'big') + return length < 3 or length > 254 or len(data) >= 6 + length + + +def receive_response(sock, timeout, max_bytes=4096, complete=None): + deadline = time.monotonic() + timeout + data = bytearray() + while len(data) < max_bytes: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + sock.settimeout(remaining) + try: + fragment = sock.recv(max_bytes - len(data)) + except socket.timeout: + break + except ConnectionError: + if not data: + raise + break + if not fragment: + break + data.extend(fragment) + if complete and complete(bytes(data)): + break + return bytes(data) diff --git a/tests/test_protocol_regressions.py b/tests/test_protocol_regressions.py new file mode 100644 index 0000000..f62f585 --- /dev/null +++ b/tests/test_protocol_regressions.py @@ -0,0 +1,126 @@ +import socket +import threading +import time + +import pytest + +from core.protocol_engine import (ProtocolConfigError, append_checksum, + build_modbus_rtu_read, build_modbus_tcp_read, find_modbus_rtu_response, + validate_modbus_tcp_response) + + +def test_tcp_declared_byte_count_is_checked(): + packet = bytes.fromhex('00 01 00 00 00 05 01 03 04 00 2A') + assert not validate_modbus_tcp_response(packet, 1, 1, 3)[0] + + +@pytest.mark.parametrize('payload', ['010303000000', '010300', '01030200']) +def test_rtu_malformed_register_length_is_rejected(payload): + packet = append_checksum(bytes.fromhex(payload), 'crc16_modbus') + assert not find_modbus_rtu_response(packet, 1, 3)[0] + + +@pytest.mark.parametrize('function', [5, 6, 15, 16, 23]) +def test_automatic_write_requests_are_refused(function): + with pytest.raises(ProtocolConfigError): + build_modbus_rtu_read(1, function, 0, 1) + with pytest.raises(ProtocolConfigError): + build_modbus_tcp_read(1, 1, function, 0, 1) + + +def test_crc_corruption_and_truncation_are_rejected(): + packet = bytes.fromhex('01 03 02 00 2A 39 9B') + assert not find_modbus_rtu_response(packet[:-1], 1, 3)[0] + assert not find_modbus_rtu_response(packet[:-1] + b'\x00', 1, 3)[0] + + +@pytest.mark.parametrize('packet', [ + '00 02 00 00 00 05 01 03 02 00 2A', + '00 01 00 01 00 05 01 03 02 00 2A', + '00 01 00 00 00 05 02 03 02 00 2A', + '00 01 00 00 00 05 01 04 02 00 2A', + '00 01 00 00 00 05 01 03 02 00', + '00 01 00 00 00 04 01 83 02 00', +]) +def test_invalid_tcp_frames(packet): + assert not validate_modbus_tcp_response(bytes.fromhex(packet), 1, 1, 3)[0] + + +class FragmentSocket: + def __init__(self, fragments): + self.fragments = iter(fragments) + def settimeout(self, value): + pass + def recv(self, size): + value = next(self.fragments, b'') + if isinstance(value, Exception): + raise value + return value[:size] + + +def test_split_tcp_and_eof(): + from core.transport import receive_response + sock = FragmentSocket([b'PO', b'NG', b' DEVICE-01', b'']) + assert receive_response(sock, 1) == b'PONG DEVICE-01' + + +def test_timeout_preserves_partial_response(): + from core.transport import receive_response + assert receive_response(FragmentSocket([b'partial', socket.timeout()]), .1) == b'partial' + + +def test_complete_mbap_frame_does_not_wait_for_eof(): + from core.transport import receive_response, modbus_tcp_complete + raw = bytes.fromhex('00 01 00 00 00 05 01 03 02 00 2A') + assert receive_response(FragmentSocket([raw[:4], raw[4:8], raw[8:], AssertionError('extra recv')]), 1, complete=modbus_tcp_complete) == raw + + +def test_connection_reset_preserves_partial_bytes(): + from core.transport import receive_response + assert receive_response(FragmentSocket([b'partial', ConnectionResetError()]), 1) == b'partial' + + +def test_empty_connection_reset_remains_error(): + from core.transport import receive_response + with pytest.raises(ConnectionResetError): + receive_response(FragmentSocket([ConnectionResetError()]), 1) + + +def test_read_response_must_match_requested_count(): + tcp = bytes.fromhex('00 01 00 00 00 05 01 03 02 00 2A') + rtu = append_checksum(bytes.fromhex('01 03 02 00 2A'), 'crc16_modbus') + assert not validate_modbus_tcp_response(tcp, 1, 1, 3, expected_count=2)[0] + assert not find_modbus_rtu_response(rtu, 1, 3, expected_count=2)[0] + + +@pytest.mark.parametrize('modbus', [False, True]) +def test_configured_tcp_receives_fragmented_loopback_response(modbus): + from core.generic_detector import GenericDetector + server = socket.socket() + server.bind(('127.0.0.1', 0)) + server.listen(1) + server.settimeout(2) + port = server.getsockname()[1] + raw = bytes.fromhex('00 01 00 00 00 05 01 03 02 00 2A') if modbus else b'PONG DEVICE-01' + def serve(): + try: + conn, _ = server.accept() + with conn: + conn.recv(128) + for chunk in (raw[:2], raw[2:6], raw[6:]): + conn.sendall(chunk) + time.sleep(.015) + finally: + server.close() + thread = threading.Thread(target=serve, daemon=True) + thread.start() + detector = GenericDetector() + protocol = {'type': 'modbus_tcp', 'test_registers': [{'address': 0, 'count': 1}]} if modbus else {'type': 'fixture_tcp', 'probes': [{'request_text': 'PING', 'response_match': {'exact_text': 'PONG DEVICE-01'}}]} + parent = {'network_config': {'transport': 'tcp', 'device_role': 'server'}} + profile = detector._network_profile({'protocol': protocol}, parent) + try: + result = detector._test_modbus_tcp_configured('127.0.0.1', port, '', protocol, profile) if modbus else detector._test_tcp_protocol_configured('127.0.0.1', port, '', {'timeout': 1}, protocol, profile) + assert result['ok'] + assert raw.hex(' ').upper() in result['response'] + finally: + thread.join(2) From 38a2a47eda61c0c526c69db680d2c61293dde39a Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:05:34 +0800 Subject: [PATCH 04/15] feat: render printable acceptance reports with explicit coverage --- inspection/reports.py | 41 ++++++++++++++++++++++++++++++++++++++--- tests/test_workbench.py | 13 +++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/inspection/reports.py b/inspection/reports.py index 6ba9b5c..b076ebe 100644 --- a/inspection/reports.py +++ b/inspection/reports.py @@ -3,15 +3,50 @@ import io import json +from .domain import catalog_devices + + +def html_report(job, marker): + """A portable, printable report; evidence and snapshot travel with the file.""" + def esc(value): + return html.escape(str(value if value is not None else '—'), quote=True) + def code(value): + return '
' + esc(value) + '
' + expected = len(catalog_devices(job['config_snapshot'])) + selected = len(job['device_ids']) + scope = '仅选定设备' if selected != expected else '完整配置设备' + notice = ('SIMULATED · 合成演示,不能作为真实设备验收凭据。' if job['mode'] == 'demo' + else 'LIVE · 结论仅覆盖本次通信路径及 PCI 被动状态;电气性能、业务功能需另行验证。') + labels = {'PASS': '通过', 'FAIL': '未通过', 'REVIEW': '需复核', 'NOT_RUN': '未执行'} + metadata = [(label, job['metadata'].get(key, '')) for key, label in + [('station_id', '工位 / 设备编号'), ('batch', '批次'), ('operator', '操作者'), ('notes', '备注')]] + metadata += [('任务 ID', job['id']), ('复测源任务', job.get('parent_job_id')), ('任务状态', job['status']), + ('创建时间 UTC', job['created_at']), ('结束时间 UTC', job['finished_at']), + ('耗时 ms', job['duration_ms']), ('配置 SHA-256', job['config_hash'])] + info = ''.join('
' + esc(k) + '
' + esc(v) + '
' for k, v in metadata) + rows, evidence = [], [] + for result in job['results']: + verdict = result['verdict'] + scope_name = 'PCI 被动状态' if result['scope'] == 'pci_passive' else '通信路径' + rows.append('' + ''.join('' + esc(v) + '' for v in [result['device_id'], result['name'], + result['interface'], scope_name, labels[verdict] + ' / ' + verdict, result['fault_code'], result['duration_ms']]) + '') + evidence.append('

' + esc(result['name']) + ' · ' + esc(result['device_id']) + '

' + + esc(result['summary']) + '

排查建议:' + esc(result['suggestion']) + + '

请求 / Request

' + code(result['request']) + '

响应 / Response

' + code(result['response']) + '
') + summary = job['summary'] + totals = '通过 {passed} · 未通过 {failed} · 需复核 {review} · 未执行 {not_run}'.format(**summary) + return ''' +工控设备验收报告
QLDEVICECHECK / FIELD ACCEPTANCE

工控设备验收报告

''' + '

' + esc(notice) + '

' + esc(labels[summary['verdict']]) + ' / ' + esc(summary['verdict']) + '
' + esc(scope) + ':' + str(selected) + ' / ' + str(expected) + ' 台
' + esc(totals) + '

部分设备复测通过不表示整机恢复。任务中止、取消或失败时,已收集的证据仍保留,结论需复核。

验收记录

' + info + '

设备结论

' + ''.join(rows) + '
设备 ID设备名称接口验证范围结论故障类别耗时 ms

测试证据

' + ''.join(evidence) + '

配置快照与任务事件

SHA-256 用于识别配置版本;本报告未使用数字签名,不提供防篡改证明。

' + code(json.dumps({'config_snapshot': job['config_snapshot'], 'events': job['events']}, ensure_ascii=False, indent=2)) + '
' + def export_job(job, format): marker = 'SIMULATED' if job['mode'] == 'demo' else 'LIVE' if format == 'json': return json.dumps(dict(job, report_marker=marker), ensure_ascii=False, indent=2).encode(), 'application/json; charset=utf-8' if format == 'html': - esc = lambda value: html.escape(str(value), quote=True) - # Complete evidence is retained, including the original configuration snapshot. - content = 'Acceptance report

' + marker + ' Acceptance report

Scope: communication path / PCI passive state. Business and electrical function require separate verification.

' + esc(json.dumps(job, ensure_ascii=False, indent=2)) + '
' + content = html_report(job, marker) return content.encode(), 'text/html; charset=utf-8' if format == 'csv': stream = io.StringIO(newline='') diff --git a/tests/test_workbench.py b/tests/test_workbench.py index eae57b0..efb9037 100644 --- a/tests/test_workbench.py +++ b/tests/test_workbench.py @@ -133,6 +133,19 @@ def test_reports_preserve_evidence_and_escape(service): assert json.loads(raw)['results'][0]['response'] +def test_html_report_is_readable_and_declares_partial_scope(service): + job = wait_finished(service, service.create({'station_id': 'DEMO-UNIT-7', 'batch': 'B7', 'device_ids': ['DEMO_RELAY']})['id']) + content, mime = export_job(job, 'html') + report = content.decode('utf-8') + assert ' Date: Wed, 7 Oct 2026 13:15:19 +0800 Subject: [PATCH 05/15] build: package offline station installers and pinned verification environment --- .gitattributes | 12 ++ .github/workflows/tests.yml | 27 +++++ .gitignore | 7 ++ Start-Demo.cmd | 6 + docs/DEPENDENCIES.md | 23 ++++ docs/RELEASE.md | 45 +++++++ docs/dependency-audit.json | 227 ++++++++++++++++++++++++++++++++++++ pytest.ini | 4 + requirements-dev.txt | 8 ++ requirements-runtime.lock | 26 +++++ requirements.lock | 133 +++++++++++++++++++++ requirements.txt | 2 +- run_demo.ps1 | 3 + run_demo.sh | 4 + run_web.ps1 | 7 ++ setup_linux.sh | 8 +- setup_windows.ps1 | 22 ++++ tests/release_smoke.py | 98 ++++++++++++++++ tools/build_release.py | 67 +++++++++++ 19 files changed, 726 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/tests.yml create mode 100644 Start-Demo.cmd create mode 100644 docs/DEPENDENCIES.md create mode 100644 docs/RELEASE.md create mode 100644 docs/dependency-audit.json create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements-runtime.lock create mode 100644 requirements.lock create mode 100644 run_demo.ps1 create mode 100644 run_demo.sh create mode 100644 run_web.ps1 create mode 100644 setup_windows.ps1 create mode 100644 tests/release_smoke.py create mode 100644 tools/build_release.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5afc912 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto +*.sh text eol=lf +*.py text eol=lf +*.js text eol=lf +*.cjs text eol=lf +*.html text eol=lf +*.css text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.lock text eol=lf +*.ps1 text eol=crlf +*.cmd text eol=crlf diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7e9a338 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: Offline acceptance checks +on: + push: + pull_request: +permissions: + contents: read +jobs: + offline: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ['3.10', '3.13'] + runs-on: ${{ matrix.os }} + env: + PYTHONIOENCODING: utf-8 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install --require-hashes -r requirements.lock + - run: python validate_config.py + - run: python self_test.py + - run: python -m coverage run --source=inspection,core.protocol_engine,core.transport -m pytest + - run: python -m coverage report --show-missing + - run: python -m compileall -q inspection core web_app.py legacy_web.py diff --git a/.gitignore b/.gitignore index 85c67f4..6903657 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,13 @@ __pycache__/ # Generated reports report/ +data/ +output/ +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ +.superpowers/ # IDE .idea/ diff --git a/Start-Demo.cmd b/Start-Demo.cmd new file mode 100644 index 0000000..229f9c3 --- /dev/null +++ b/Start-Demo.cmd @@ -0,0 +1,6 @@ +@echo off +cd /d "%~dp0" +echo QLDeviceCheck demo: http://127.0.0.1:8080 +echo Leave this window open while using the workbench. Ctrl+C stops the station. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0run_demo.ps1" %* +if errorlevel 1 pause diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md new file mode 100644 index 0000000..dfaefcb --- /dev/null +++ b/docs/DEPENDENCIES.md @@ -0,0 +1,23 @@ +# Dependency provenance / 依赖来源 + +No runtime framework was added. Runtime uses Python's standard library plus the original psutil and pyserial dependencies. Exact release versions, official source commits, licenses, installed wheel URLs, SHA-256 and installation time are recorded in [dependency-audit.json](dependency-audit.json). + +| Package | Version | License | Role | +| --- | --- | --- | --- | +| psutil | 7.2.2 | BSD-3-Clause | Interface/system inspection | +| pyserial | 3.5 | BSD-3-Clause | Serial transport | +| pytest | 8.4.2 | MIT | Test runner | +| coverage | 7.10.6 | Apache-2.0 | Scoped coverage | +| iniconfig | 2.1.0 | MIT | Pytest dependency | +| packaging | 25.0 | Apache-2.0 / BSD-2-Clause | Pytest dependency | +| pluggy | 1.6.0 | MIT | Pytest dependency | +| Pygments | 2.19.2 | BSD-2-Clause | Pytest dependency | +| colorama | 0.4.6 | BSD-3-Clause | Windows test output | + +`requirements-runtime.lock` contains the two runtime releases. `requirements.lock` contains the complete test environment. Both use release artifact hashes; run `python -m pip install --require-hashes -r `. The corresponding `.txt` files are readable version manifests. Source tag commits were checked against official repositories; installed artifacts are PyPI distributions, not source builds. The audit records their separate identities without claiming bit-for-bit reproducibility between wheel and source commit. Hash verification verifies the selected distribution, not absence of vulnerabilities. + +CI Actions are fixed to reviewed commits: `actions/checkout` v4.2.2 at `11bd71901bbe5b1630ceea73d27597364c9af683`; `actions/setup-python` v5.6.0 at `a26af69be951a213d495a4c3e4e4022e16d87065` (both MIT). Runner images and Python patch versions remain platform-managed and are not fully reproducible artifacts. No new third-party Skill was installed or cloned. + +The optional browser regression uses an existing local Playwright/Chrome installation. It is not part of the application runtime or hash-locked Python environment. See [validation](VALIDATION.md) for the versions actually exercised. + +Release bundles include only the reviewed runtime wheels, selected for Windows x64 or Linux x86_64. `tools/build_release.py` refuses artifacts outside the audited SHA-256 set and includes source provenance plus copies of wheel license files. Python itself remains a prerequisite. Bundle installation uses `--no-index --find-links wheels`, following [pip's official offline download/install workflow](https://pip.pypa.io/en/stable/cli/pip_download/). diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..4c630a4 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,45 @@ +# Release 安装与使用 + +下载 [GitHub Release](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/releases) 中对应平台的包: + +| 包 | 用途 | +| --- | --- | +| `QLDeviceCheck-v3.0.0-windows-x64.zip` | Windows x64,含离线 psutil/pyserial 运行依赖 | +| `QLDeviceCheck-v3.0.0-linux-x86_64.zip` | Linux x86_64/glibc,含离线运行依赖 | +| `SHA256SUMS.txt` | 下载文件校验值 | + +需要预先安装 **CPython 3.10+**,包括 venv/pip。包不包含 Python 解释器;ARM、其他架构及 Python 自由线程构建需要匹配的依赖制品。Linux 的 psutil wheel 支持 glibc 2.12+ 的常规 CPython 构建。项目方已确认项目测试可用;本次 Release 的检查结果见 [VALIDATION.md](VALIDATION.md)。 + +## Windows + +1. 解压到可写目录。 +2. 双击 `Start-Demo.cmd`。首次启动自动创建 `.venv`,从随包 `wheels/` 离线安装固定依赖。 +3. 浏览器打开 `http://127.0.0.1:8080`,保留终端窗口;`Ctrl+C` 关闭服务。 + +执行真实验收使用 PowerShell: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\setup_windows.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\run_web.ps1 --config config\device_list.json --data-dir data\live +``` + +## Linux + +```bash +unzip QLDeviceCheck-v3.0.0-linux-x86_64.zip +cd QLDeviceCheck-v3.0.0 +bash setup_linux.sh +bash run_demo.sh +# 真实验收使用审核后的设备配置: +bash run_web.sh --config config/device_list.json --data-dir data/live +``` + +安装器检测到 `wheels/` 时只使用本地依赖,校验锁定的 SHA-256。源码包不含 wheels 时使用 PyPI 安装同一版本。每个发行包包含 `RELEASE-MANIFEST.json`,记录对应的 Git commit、平台、依赖来源/许可证/哈希。 + +## 数据与更新 + +默认监听本机;网络使用和访问令牌见 [部署说明](DEPLOYMENT.md)。任务在选定的 `data` 目录保留。升级时先停止服务,备份数据和配置,将新版本解压到新目录,再用 `--data-dir` 指向原数据目录。不要复制旧虚拟环境到新平台,重新运行安装器。旧扫描报告位于 `report/`,需要单独保留。 + +同一设备工位避免并发启动多个实例;同一数据库由独占锁保护。关闭服务后可打包备份,任务配置快照仍在记录中。Release 来源通过标签固定,PR 保留便于审阅,不自动合并主分支。 + +离线 wheel 选择与哈希校验遵循 [pip 官方下载说明](https://pip.pypa.io/en/stable/cli/pip_download/)和[安全安装说明](https://pip.pypa.io/en/stable/topics/secure-installs/)。 diff --git a/docs/dependency-audit.json b/docs/dependency-audit.json new file mode 100644 index 0000000..4c343ca --- /dev/null +++ b/docs/dependency-audit.json @@ -0,0 +1,227 @@ +{ + "installed_at_utc": "2026-10-07 04:40:22 UTC", + "dependencies": [ + { + "name": "psutil", + "version": "7.2.2", + "repo": "giampaolo/psutil", + "commit": "714a292bd14cf0c28b9454d34f5afc26dbb9b0dc", + "license": "BSD-3-Clause", + "installed_artifact_url": "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", + "installed_artifact_sha256": "eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", + "allowed_artifact_sha256": [ + "0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", + "076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", + "11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", + "1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", + "1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", + "1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", + "2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", + "7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", + "8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", + "917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", + "ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", + "ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", + "b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", + "b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", + "c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", + "e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", + "e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", + "eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", + "ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", + "eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", + "fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8" + ] + }, + { + "name": "pyserial", + "version": "3.5", + "repo": "pyserial/pyserial", + "commit": "0e7634747568547b8a7f9fd0c48ed74f16af4b23", + "license": "BSD-3-Clause", + "installed_artifact_url": "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", + "installed_artifact_sha256": "c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", + "allowed_artifact_sha256": [ + "3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", + "c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0" + ] + }, + { + "name": "pytest", + "version": "8.4.2", + "repo": "pytest-dev/pytest", + "commit": "24941e4bf50e0f03114dc5262fa4730d0e7dd6ba", + "license": "MIT", + "installed_artifact_url": "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", + "installed_artifact_sha256": "872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", + "allowed_artifact_sha256": [ + "86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", + "872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" + ] + }, + { + "name": "coverage", + "version": "7.10.6", + "repo": "nedbat/coveragepy", + "commit": "51066d16ebe7b6314f15f7c29dab94514c1aeca7", + "license": "Apache-2.0", + "installed_artifact_url": "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", + "installed_artifact_sha256": "628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", + "allowed_artifact_sha256": [ + "073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", + "081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", + "0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", + "0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", + "0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", + "0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", + "10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd", + "137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", + "160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", + "2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c", + "282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5", + "28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", + "2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5", + "2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", + "2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6", + "388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634", + "3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", + "3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", + "441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", + "4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", + "5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619", + "5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b", + "5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea", + "5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", + "6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", + "61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", + "628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", + "675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", + "689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a", + "692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", + "6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", + "6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", + "6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", + "70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356", + "752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a", + "7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e", + "7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144", + "80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb", + "851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", + "856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629", + "86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1", + "8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612", + "8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972", + "8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", + "8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc", + "90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352", + "90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6", + "92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", + "92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", + "95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", + "961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9", + "9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78", + "98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", + "99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a", + "99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c", + "a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d", + "a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", + "ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", + "acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80", + "adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", + "b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", + "b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153", + "bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c", + "c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460", + "c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", + "c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f", + "c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b", + "c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945", + "c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", + "cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", + "d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", + "d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", + "d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", + "db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", + "df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", + "e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528", + "e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba", + "e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301", + "ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62", + "efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2", + "f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", + "f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf", + "f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e", + "f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", + "f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", + "fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862", + "ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", + "ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6" + ] + }, + { + "name": "iniconfig", + "version": "2.1.0", + "repo": "pytest-dev/iniconfig", + "commit": "34793a6417e35e511d0c17ffdd8a02ade0f9568e", + "license": "MIT", + "installed_artifact_url": "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", + "installed_artifact_sha256": "9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", + "allowed_artifact_sha256": [ + "3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", + "9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" + ] + }, + { + "name": "packaging", + "version": "25.0", + "repo": "pypa/packaging", + "commit": "f58537628042c7f29780b9d33f31597e7fc9d664", + "license": "Apache-2.0 OR BSD-2-Clause", + "installed_artifact_url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", + "installed_artifact_sha256": "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "allowed_artifact_sha256": [ + "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ] + }, + { + "name": "pluggy", + "version": "1.6.0", + "repo": "pytest-dev/pluggy", + "commit": "fd08ab5f811a9b2fa9124ae8cbbd393221151e2c", + "license": "MIT", + "installed_artifact_url": "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", + "installed_artifact_sha256": "e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", + "allowed_artifact_sha256": [ + "7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ] + }, + { + "name": "Pygments", + "version": "2.19.2", + "repo": "pygments/pygments", + "commit": "cfca62e6e95136e48a255e8cbffb0bbe1d98456c", + "license": "BSD-2-Clause", + "installed_artifact_url": "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", + "installed_artifact_sha256": "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", + "allowed_artifact_sha256": [ + "636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ] + }, + { + "name": "colorama", + "version": "0.4.6", + "repo": "tartley/colorama", + "commit": "3de9f013df4b470069d03d250224062e8cf15c49", + "license": "BSD-3-Clause", + "installed_artifact_url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", + "installed_artifact_sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", + "allowed_artifact_sha256": [ + "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ] + } + ] +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c34e94f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +pythonpath = . +addopts = -ra diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..43d5519 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt +pytest==8.4.2 +coverage==7.10.6 +iniconfig==2.1.0 +packaging==25.0 +pluggy==1.6.0 +pygments==2.19.2 +colorama==0.4.6 diff --git a/requirements-runtime.lock b/requirements-runtime.lock new file mode 100644 index 0000000..51dff3c --- /dev/null +++ b/requirements-runtime.lock @@ -0,0 +1,26 @@ +# Reviewed runtime releases; provenance: docs/dependency-audit.json +psutil==7.2.2 \ + --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \ + --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \ + --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \ + --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \ + --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \ + --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \ + --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \ + --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \ + --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \ + --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \ + --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \ + --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \ + --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \ + --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \ + --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \ + --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \ + --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \ + --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \ + --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \ + --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \ + --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8 +pyserial==3.5 \ + --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \ + --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..ceb62ab --- /dev/null +++ b/requirements.lock @@ -0,0 +1,133 @@ +# Audited release hashes; source commits and licenses: docs/dependency-audit.json +psutil==7.2.2 \ + --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \ + --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \ + --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \ + --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \ + --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \ + --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \ + --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \ + --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \ + --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \ + --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \ + --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \ + --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \ + --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \ + --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \ + --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \ + --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \ + --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \ + --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \ + --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \ + --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \ + --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8 +pyserial==3.5 \ + --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \ + --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 +pytest==8.4.2 \ + --hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \ + --hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79 +coverage==7.10.6 \ + --hash=sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930 \ + --hash=sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747 \ + --hash=sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65 \ + --hash=sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7 \ + --hash=sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27 \ + --hash=sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034 \ + --hash=sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd \ + --hash=sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b \ + --hash=sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e \ + --hash=sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c \ + --hash=sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5 \ + --hash=sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc \ + --hash=sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5 \ + --hash=sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5 \ + --hash=sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6 \ + --hash=sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634 \ + --hash=sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003 \ + --hash=sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b \ + --hash=sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7 \ + --hash=sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb \ + --hash=sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619 \ + --hash=sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b \ + --hash=sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea \ + --hash=sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb \ + --hash=sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e \ + --hash=sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc \ + --hash=sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32 \ + --hash=sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb \ + --hash=sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a \ + --hash=sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282 \ + --hash=sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5 \ + --hash=sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5 \ + --hash=sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6 \ + --hash=sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356 \ + --hash=sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a \ + --hash=sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e \ + --hash=sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144 \ + --hash=sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb \ + --hash=sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4 \ + --hash=sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629 \ + --hash=sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1 \ + --hash=sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612 \ + --hash=sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972 \ + --hash=sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393 \ + --hash=sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc \ + --hash=sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352 \ + --hash=sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6 \ + --hash=sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0 \ + --hash=sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3 \ + --hash=sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80 \ + --hash=sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9 \ + --hash=sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78 \ + --hash=sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0 \ + --hash=sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a \ + --hash=sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c \ + --hash=sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d \ + --hash=sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32 \ + --hash=sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0 \ + --hash=sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80 \ + --hash=sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713 \ + --hash=sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27 \ + --hash=sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153 \ + --hash=sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c \ + --hash=sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460 \ + --hash=sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1 \ + --hash=sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f \ + --hash=sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b \ + --hash=sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945 \ + --hash=sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b \ + --hash=sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a \ + --hash=sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df \ + --hash=sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d \ + --hash=sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21 \ + --hash=sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4 \ + --hash=sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2 \ + --hash=sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528 \ + --hash=sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba \ + --hash=sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301 \ + --hash=sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62 \ + --hash=sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2 \ + --hash=sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d \ + --hash=sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf \ + --hash=sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e \ + --hash=sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90 \ + --hash=sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e \ + --hash=sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862 \ + --hash=sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5 \ + --hash=sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6 +iniconfig==2.1.0 \ + --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ + --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 +packaging==25.0 \ + --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ + --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +Pygments==2.19.2 \ + --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ + --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b +colorama==0.4.6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 diff --git a/requirements.txt b/requirements.txt index c3f7ee6..e4e07d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -psutil>=5.9 +psutil==7.2.2 pyserial==3.5 diff --git a/run_demo.ps1 b/run_demo.ps1 new file mode 100644 index 0000000..951e49c --- /dev/null +++ b/run_demo.ps1 @@ -0,0 +1,3 @@ +param([Parameter(ValueFromRemainingArguments=$true)][string[]]$WorkbenchArgs) +& "$PSScriptRoot\run_web.ps1" --demo --data-dir data/demo @WorkbenchArgs +exit $LASTEXITCODE diff --git a/run_demo.sh b/run_demo.sh new file mode 100644 index 0000000..3f4d744 --- /dev/null +++ b/run_demo.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +bash ./run_web.sh --demo --data-dir data/demo "$@" diff --git a/run_web.ps1 b/run_web.ps1 new file mode 100644 index 0000000..397ddf4 --- /dev/null +++ b/run_web.ps1 @@ -0,0 +1,7 @@ +param([Parameter(ValueFromRemainingArguments=$true)][string[]]$WorkbenchArgs) +$ErrorActionPreference = 'Stop' +Set-Location -LiteralPath $PSScriptRoot +if (-not (Test-Path -LiteralPath '.venv\Scripts\python.exe')) { & "$PSScriptRoot\setup_windows.ps1" } +$taskPython = Join-Path $PSScriptRoot '.venv\Scripts\python.exe' +& $taskPython -X utf8 web_app.py @WorkbenchArgs +exit $LASTEXITCODE diff --git a/setup_linux.sh b/setup_linux.sh index 0b54b3a..7a3f07e 100644 --- a/setup_linux.sh +++ b/setup_linux.sh @@ -13,8 +13,12 @@ if [ ! -d ".venv" ]; then fi source .venv/bin/activate -python -m pip install --upgrade pip -python -m pip install -r requirements.txt +python -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' || { echo '需要 Python 3.10+'; exit 1; } +install_args=(--require-hashes -r requirements-runtime.lock --disable-pip-version-check) +if [ -d wheels ]; then + install_args+=(--no-index --find-links wheels) +fi +python -m pip install "${install_args[@]}" echo "环境准备完成。" echo "启动网页系统: bash run_web.sh" diff --git a/setup_windows.ps1 b/setup_windows.ps1 new file mode 100644 index 0000000..3f17d0a --- /dev/null +++ b/setup_windows.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = 'Stop' +Set-Location -LiteralPath $PSScriptRoot + +if (-not (Test-Path -LiteralPath '.venv\Scripts\python.exe')) { + if (Get-Command py -ErrorAction SilentlyContinue) { + & py -3 -m venv .venv + } elseif (Get-Command python -ErrorAction SilentlyContinue) { + & python -m venv .venv + } else { + throw 'Python 3.10+ is required. Install CPython, then run setup_windows.ps1 again.' + } + if ($LASTEXITCODE -ne 0) { throw 'Could not create the virtual environment.' } +} +$taskPython = Join-Path $PSScriptRoot '.venv\Scripts\python.exe' +& $taskPython -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' +if ($LASTEXITCODE -ne 0) { throw 'This workbench requires Python 3.10+.' } +$taskInstallArgs = @('-m', 'pip', 'install', '--require-hashes', '-r', 'requirements-runtime.lock', '--disable-pip-version-check') +if (Test-Path -LiteralPath 'wheels') { $taskInstallArgs += @('--no-index', '--find-links', 'wheels') } +& $taskPython @taskInstallArgs +if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed. Check Python version and the release platform.' } +Write-Host 'Ready. Start-Demo.cmd opens the hardware-free local station.' +Write-Host 'For reviewed live configuration: .\run_web.ps1 --config config\device_list.json --data-dir data\live' diff --git a/tests/release_smoke.py b/tests/release_smoke.py new file mode 100644 index 0000000..d663e78 --- /dev/null +++ b/tests/release_smoke.py @@ -0,0 +1,98 @@ +"""Extract a Windows release, install offline, run and restart the real demo server.""" +import argparse +import json +import os +import socket +import subprocess +import tempfile +import time +import urllib.request +import zipfile +from pathlib import Path + + +def request(base, path, body=None): + req = urllib.request.Request(base + path, data=json.dumps(body).encode() if body is not None else None, + headers={'Content-Type': 'application/json'}) + with urllib.request.urlopen(req, timeout=3) as response: + return json.load(response) + + +def wait_for(callback, condition, timeout=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + value = callback() + if condition(value): + return value + except (OSError, ValueError): + pass + time.sleep(.05) + raise AssertionError('Release workflow timed out') + + +def smoke(package): + if os.name != 'nt': + raise RuntimeError('This smoke test exercises the Windows release launcher') + env = dict(os.environ, PYTHONIOENCODING='utf-8', PIP_NO_INDEX='1') + env.pop('QLDC_ACCESS_TOKEN', None) + with tempfile.TemporaryDirectory(prefix='release-smoke-', dir='output') as scratch: + scratch = Path(scratch).resolve() + assert scratch.is_relative_to(Path('output').resolve()) + with zipfile.ZipFile(package) as bundle: + for item in bundle.infolist(): + if not (scratch / item.filename).resolve().is_relative_to(scratch): + raise ValueError('Unsafe archive path') + bundle.extractall(scratch) + root = next(scratch.iterdir()) + install = subprocess.run(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(root / 'setup_windows.ps1')], + cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60) + if install.returncode: + raise AssertionError(install.stdout.decode('utf-8', errors='replace')) + launcher = subprocess.run(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(root / 'run_demo.ps1'), '--help'], + cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=10) + assert launcher.returncode == 0 and b'--demo' in launcher.stdout and b'--data-dir' in launcher.stdout + with socket.socket() as reserve: + reserve.bind(('127.0.0.1', 0)) + port = reserve.getsockname()[1] + base = 'http://127.0.0.1:' + str(port) + process = None + # Direct child ownership makes the forced-restart check deterministic. + command = [str(root / '.venv/Scripts/python.exe'), '-X', 'utf8', str(root / 'web_app.py'), '--demo', '--port', str(port), '--data-dir', str(root / 'data/demo')] + try: + for run in range(2): + process = subprocess.Popen(command, cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + wait_for(lambda: request(base, '/api/health'), lambda d: d['mode'] == 'demo') + with urllib.request.urlopen(base, timeout=3) as page: + assert 'FIELD ACCEPTANCE'.encode() in page.read() + if run == 0: + original = request(base, '/api/jobs', {'station_id': 'RELEASE-SMOKE-01', 'batch': 'RELEASE', 'scenario': 'faults'})['job'] + original = wait_for(lambda: request(base, '/api/jobs/' + original['id'])['job'], lambda j: j['status'] == 'completed') + assert original['summary']['failed'] > 0 + child = request(base, '/api/jobs/' + original['id'] + '/retest', {'scenario': 'healthy'})['job'] + child = wait_for(lambda: request(base, '/api/jobs/' + child['id'])['job'], lambda j: j['status'] == 'completed') + assert child['summary']['verdict'] == 'PASS' + comparison = request(base, '/api/compare?baseline=' + original['id'] + '¤t=' + child['id'])['comparison'] + assert comparison['comparable'] and comparison['partial_scope'] and not comparison['whole_unit_recovered'] + report = request(base, '/api/jobs/' + child['id'] + '/export?format=json') + assert report['report_marker'] == 'SIMULATED' and report['config_snapshot'] == original['config_snapshot'] + else: + assert request(base, '/api/jobs')['total'] == 2 + assert request(base, '/api/jobs/' + original['id'])['job']['summary']['failed'] > 0 + process.terminate() + process.wait(5) + process = None + manifest = json.loads((root / 'RELEASE-MANIFEST.json').read_text('utf-8')) + return {'result': 'PASS', 'source_commit': manifest['source_commit'], 'package': package.name, + 'offline_install': True, 'task_report_retest_restart': True} + finally: + if process is not None: + process.terminate() + process.wait(5) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('package', type=Path) + args = parser.parse_args() + print(json.dumps(smoke(args.package), ensure_ascii=False)) diff --git a/tools/build_release.py b/tools/build_release.py new file mode 100644 index 0000000..758a0f0 --- /dev/null +++ b/tools/build_release.py @@ -0,0 +1,67 @@ +"""Package a committed snapshot and already-audited wheels; no network access.""" +import argparse +import hashlib +import io +import json +import re +import subprocess +import zipfile +from datetime import datetime, timezone +from pathlib import Path + + +def build(ref, version, wheels_root, output): + if not re.fullmatch(r'v\d+\.\d+\.\d+', version): + raise ValueError('Use a semantic release version such as v3.0.0') + source_sha = subprocess.check_output(['git', 'rev-parse', ref], text=True).strip() + source = subprocess.check_output(['git', 'archive', '--format=zip', source_sha]) + audit = json.loads(subprocess.check_output(['git', 'show', source_sha + ':docs/dependency-audit.json'])) + runtime = {d['name'].lower(): d for d in audit['dependencies'] if d['name'].lower() in ('psutil', 'pyserial')} + output.mkdir(parents=True, exist_ok=True) + assets = [] + for folder, platform in [('windows', 'windows-x64'), ('linux', 'linux-x86_64')]: + wheels = sorted((wheels_root / folder).glob('*.whl')) + if len(wheels) != len(runtime): + raise ValueError('Expected exactly the audited runtime wheels for ' + platform) + manifest = {'version': version, 'source_commit': source_sha, 'platform': platform, + 'built_at_utc': datetime.now(timezone.utc).isoformat(), 'python_requirement': 'CPython >=3.10', + 'dependencies': []} + used = set() + for wheel in wheels: + name = wheel.name.split('-')[0].lower() + dep = runtime[name] + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + if digest not in dep['allowed_artifact_sha256'] or name in used: + raise ValueError('Wheel is not a unique audited release artifact: ' + wheel.name) + used.add(name) + manifest['dependencies'].append({'file': wheel.name, 'sha256': digest, 'version': dep['version'], + 'source_repository': dep['repo'], 'source_commit': dep['commit'], 'license': dep['license']}) + target = output / ('QLDeviceCheck-' + version + '-' + platform + '.zip') + prefix = 'QLDeviceCheck-' + version + '/' + with zipfile.ZipFile(io.BytesIO(source)) as base, zipfile.ZipFile(target, 'w', zipfile.ZIP_DEFLATED) as bundle: + for item in base.infolist(): + if item.is_dir(): + continue + bundle.writestr(prefix + item.filename, base.read(item)) + for wheel in wheels: + bundle.write(wheel, prefix + 'wheels/' + wheel.name) + with zipfile.ZipFile(wheel) as distribution: + for item in distribution.namelist(): + if not item.endswith('/') and ('license' in item.lower() or 'copying' in item.lower()): + bundle.writestr(prefix + 'third-party-licenses/' + wheel.name.split('-')[0] + '/' + Path(item).name, + distribution.read(item)) + bundle.writestr(prefix + 'RELEASE-MANIFEST.json', json.dumps(manifest, ensure_ascii=False, indent=2)) + assets.append(target) + checksum = output / 'SHA256SUMS.txt' + checksum.write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest() + ' ' + p.name + '\n' for p in assets), encoding='utf-8') + return {'source_commit': source_sha, 'assets': [str(p.resolve()) for p in assets + [checksum]]} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--ref', default='HEAD') + parser.add_argument('--version', default='v3.0.0') + parser.add_argument('--wheels', type=Path, default=Path('output/release-wheels')) + parser.add_argument('--output', type=Path, default=Path('output/release')) + args = parser.parse_args() + print(json.dumps(build(args.ref, args.version, args.wheels, args.output), ensure_ascii=False)) From 5018e4b7a6d4f0138fe6ec3c31401a0eadc42935 Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:21:32 +0800 Subject: [PATCH 06/15] fix: enforce station ownership and retain comparable protocol evidence --- core/generic_detector.py | 27 +- inspection/adapters.py | 14 +- inspection/comparison.py | 8 + inspection/domain.py | 11 +- inspection/http.py | 20 +- inspection/ownership.py | 36 +++ inspection/reports.py | 8 +- inspection/service.py | 53 +++- inspection/store.py | 9 +- legacy_web.py | 92 ++++--- tests/test_backend_fixes.py | 379 +++++++++++++++++++++++++++++ tests/test_http.py | 6 +- tests/test_protocol_regressions.py | 104 +++++++- tests/test_workbench.py | 4 +- web/legacy.html | 19 +- 15 files changed, 719 insertions(+), 71 deletions(-) create mode 100644 inspection/ownership.py create mode 100644 tests/test_backend_fixes.py diff --git a/core/generic_detector.py b/core/generic_detector.py index 33cdade..7a935ad 100644 --- a/core/generic_detector.py +++ b/core/generic_detector.py @@ -122,7 +122,10 @@ def _run_configured(self) -> GenericDetectionResult: parent_results: Dict[str, dict] = {} for config in raw_devices: phase = "中间模块测试" if config.get("is_intermediate_module") else "直连设备测试" + check_start = len(self.connection_tests) result = self._test_configured_device(config, phase) + for check in self.connection_tests[check_start:]: + check['device_id'] = str(config.get('device_id', '')) devices.append(result) parent_results[str(config.get("device_id", ""))] = result @@ -134,7 +137,11 @@ def _run_configured(self) -> GenericDetectionResult: continue parent_result = parent_results.get(str(parent.get("device_id", "")), {}) for child in children: + check_start = len(self.connection_tests) devices.append(self._test_configured_child_device(child, parent, parent_result)) + for check in self.connection_tests[check_start:]: + check['device_id'] = str(child.get('device_id', '')) + check['parent_device_id'] = str(parent.get('device_id', '')) self._attach_devices_to_interface_items(serial_ports, network_interfaces, pci_devices, devices) self._log(self._configured_child_summary(devices)) self._log("开始第四阶段:通信协议交互验证...") @@ -1359,10 +1366,12 @@ def _test_tcp_client_configured(self, iface: str, expected_ip: str, local_port: conn.settimeout(timeout) incoming = b"" if self._protocol_has_exchange(protocol): - try: - incoming = conn.recv(65536) - except socket.timeout: - incoming = b"" + probes = protocol_probes(protocol) + has_rules = any(response_rule(protocol, probe) for probe in probes) + incoming = receive_response( + conn, timeout, max_bytes=65536, + complete=(lambda data: self._match_incoming_protocol_payload(protocol, data)[0]) if has_rules else None, + ) payload_ok, payload_note = self._match_incoming_protocol_payload(protocol, incoming) matched = bool(source_ok and payload_ok) response = f"accepted {source[0]}:{source[1]}" @@ -1379,8 +1388,14 @@ def _test_tcp_client_configured(self, iface: str, expected_ip: str, local_port: reply_mapping["request_hex"] = first_probe.get("reply_hex") elif first_probe.get("reply_text") not in (None, ""): reply_mapping["request_text"] = first_probe.get("reply_text") - if reply_mapping: - conn.sendall(request_payload(reply_mapping)) + if reply_mapping and matched: + try: + conn.sendall(request_payload(reply_mapping)) + except OSError as exc: + matched = False + status_text = '异常' + summary = f'TCP回复异常: {exc}' + response += f'; reply error: {exc}' finally: conn.close() except socket.timeout: diff --git a/inspection/adapters.py b/inspection/adapters.py index cc5565c..0045f71 100644 --- a/inspection/adapters.py +++ b/inspection/adapters.py @@ -14,7 +14,9 @@ def demo_config(): class DemoAdapter: def run(self, config, device_ids, scenario, cancelled, on_result): - for index, device in enumerate(d for d in catalog_devices(config) if d['device_id'] in device_ids): + for index, device in enumerate(catalog_devices(config)): + if device['device_id'] not in device_ids: + continue if cancelled(): return time.sleep(.03) @@ -63,5 +65,13 @@ def run(self, config, device_ids, scenario, cancelled, on_result): selected.data.setdefault('settings', {})['allow_write_tests'] = False raw = GenericDetector(config=selected, serial_probe=True, subnet_probe=False).run().to_dict() index = {r.get('device_id'): r for r in raw.get('devices', [])} + checks = raw.get('connection_tests', []) or [] + def evidence(device_id): + result = normalize_result(devices[device_id], index.get(device_id)) + result['attempts'] = copy.deepcopy([check for check in checks if check.get('device_id') == device_id]) + return result for device_id in chosen: - on_result(normalize_result(devices[device_id], index.get(device_id))) + result = evidence(device_id) + if device_id != parent['device_id']: + result['supporting_checks'] = [evidence(parent['device_id'])] + on_result(result) diff --git a/inspection/comparison.py b/inspection/comparison.py index fc0ada7..4659087 100644 --- a/inspection/comparison.py +++ b/inspection/comparison.py @@ -9,6 +9,14 @@ def compare_jobs(baseline, current): configured = {device['device_id'] for device in catalog_devices(current['config_snapshot'])} partial = set(current['device_ids']) != configured reasons = [] + a_identity, b_identity = baseline.get('metadata', {}), current.get('metadata', {}) + a_station, b_station = a_identity.get('station_id', ''), b_identity.get('station_id', '') + if not a_station.strip() or not b_station.strip(): + reasons.append('Missing station/unit identity') + elif a_station != b_station: + reasons.append('Station/unit identity mismatch') + if a_identity.get('batch', '') != b_identity.get('batch', ''): + reasons.append('Batch mismatch') if baseline['mode'] != current['mode']: reasons.append('Mode mismatch') if baseline['config_hash'] != current['config_hash']: diff --git a/inspection/domain.py b/inspection/domain.py index a113649..5666b90 100644 --- a/inspection/domain.py +++ b/inspection/domain.py @@ -20,8 +20,14 @@ def config_hash(snapshot): def catalog_devices(snapshot): # Configuration validation is pure computation; detector imports stay in LiveAdapter. from core.config_manager import StandardDeviceConfig + if not isinstance(snapshot, dict) or not isinstance(snapshot.get('settings', {}), dict): + raise ValueError('Invalid configuration object/settings') + try: + configured = StandardDeviceConfig(data=snapshot).all_configured_devices() + except (AttributeError, TypeError): + raise ValueError('Invalid nested configuration shape') from None result = [] - for device in StandardDeviceConfig(data=snapshot).all_configured_devices(): + for device in configured: scope = 'pci_passive' if device['connection_type'] == 'pci' else 'communication_path' result.append({'device_id': device['device_id'], 'name': device['device_name'], 'connection_type': device['connection_type'], @@ -77,7 +83,8 @@ def validate_payload(payload, devices, mode): def initial_result(device, simulated): return dict(copy.deepcopy(device), verdict='NOT_RUN', fault_code='NOT_RUN', summary='Not executed', - suggestion='Run acceptance for this device', request='', response='', duration_ms=0, simulated=simulated) + suggestion='Run acceptance for this device', request='', response='', duration_ms=0, simulated=simulated, + attempts=[], supporting_checks=[]) def summarize(results): diff --git a/inspection/http.py b/inspection/http.py index da5011e..64e9f9e 100644 --- a/inspection/http.py +++ b/inspection/http.py @@ -86,7 +86,7 @@ def _dispatch(self, method): if path == '/api/jobs': if method == 'POST': return self._json({'ok': True, 'job': svc.create(self._body())}, 202) - return self._json(dict(ok=True, **svc.list_jobs(limit=int(query.get('limit', ['20'])[0]), offset=int(query.get('offset', ['0'])[0]), status=query.get('status', [''])[0], query=query.get('query', query.get('q', ['']))[0]))) + return self._json(dict(ok=True, **svc.list_jobs(limit=int(query.get('limit', ['20'])[0]), offset=int(query.get('offset', ['0'])[0]), status=query.get('status', [''])[0], query=query.get('query', query.get('q', ['']))[0], sort=query.get('sort', ['newest'])[0]))) if method == 'GET' and path == '/api/insights': return self._json(dict(ok=True, **svc.insights())) if method == 'GET' and path == '/api/compare': @@ -112,18 +112,20 @@ def _dispatch(self, method): raise HttpError(403, 'Legacy hardware APIs are disabled in demo mode') if path == '/api/standards' and method == 'GET': from core.config_manager import StandardDeviceConfig - return self._json({'ok': True, 'devices': StandardDeviceConfig(svc.config_path).devices() if svc.config_path else StandardDeviceConfig().devices(), 'legacy': True}) + snapshot = svc._snapshot() + catalog_devices(snapshot) + return self._json({'ok': True, 'devices': StandardDeviceConfig(data=snapshot).devices(), 'legacy': True}) if method == 'POST': body = self._body() - if not svc.hardware_lock.acquire(blocking=False): - raise RuntimeError('A station operation is already running') - try: + with svc.legacy_operation(): + from core.config_manager import StandardDeviceConfig + snapshot = svc._snapshot() + catalog_devices(snapshot) + ensure_read_only(snapshot) import legacy_web - legacy_web.RUN_LOCK = svc.hardware_lock operations = {'/api/scan': legacy_web.scan_catalog, '/api/scan-interface': legacy_web.scan_interface, '/api/run': legacy_web.test_all_discovered, '/api/test-device': legacy_web.test_single_device} - return self._json(dict(operations[path](body), legacy=True)) - finally: - svc.hardware_lock.release() + result = dict(operations[path](body, config=StandardDeviceConfig(data=snapshot), state=svc.legacy_state), legacy=True) + return self._json(result) if method == 'GET' and path.startswith('/report/'): if svc.demo: raise HttpError(403, 'Legacy reports are disabled in demo mode') diff --git a/inspection/ownership.py b/inspection/ownership.py new file mode 100644 index 0000000..dc47d18 --- /dev/null +++ b/inspection/ownership.py @@ -0,0 +1,36 @@ +"""OS-held database ownership; a crashed process releases its lease automatically.""" +import os +from pathlib import Path + + +class DatabaseOwner: + def __init__(self, db_path): + path = Path(db_path).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + self.file = open(str(path) + '.owner', 'a+b') + try: + if os.name == 'nt': + import msvcrt + if self.file.seek(0, 2) == 0: + self.file.write(b'\0') + self.file.flush() + self.file.seek(0) + msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(self.file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + self.file.close() + raise RuntimeError('Database is already owned by another service') from None + + def close(self): + if self.file.closed: + return + if os.name == 'nt': + import msvcrt + self.file.seek(0) + msvcrt.locking(self.file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(self.file.fileno(), fcntl.LOCK_UN) + self.file.close() diff --git a/inspection/reports.py b/inspection/reports.py index b076ebe..6097b42 100644 --- a/inspection/reports.py +++ b/inspection/reports.py @@ -32,7 +32,9 @@ def code(value): result['interface'], scope_name, labels[verdict] + ' / ' + verdict, result['fault_code'], result['duration_ms']]) + '') evidence.append('

' + esc(result['name']) + ' · ' + esc(result['device_id']) + '

' + esc(result['summary']) + '

排查建议:' + esc(result['suggestion']) + - '

请求 / Request

' + code(result['request']) + '

响应 / Response

' + code(result['response']) + '
') + '

请求 / Request

' + code(result['request']) + '

响应 / Response

' + code(result['response']) + + '

逐项探测 / Attempts

' + code(json.dumps(result.get('attempts', []), ensure_ascii=False, indent=2)) + + '

载体支持证据 / Supporting checks

' + code(json.dumps(result.get('supporting_checks', []), ensure_ascii=False, indent=2)) + '') summary = job['summary'] totals = '通过 {passed} · 未通过 {failed} · 需复核 {review} · 未执行 {not_run}'.format(**summary) return ''' @@ -51,13 +53,15 @@ def export_job(job, format): if format == 'csv': stream = io.StringIO(newline='') writer = csv.writer(stream) - columns = ['report_marker', 'job_id', 'status', 'mode', 'scenario', 'station_id', 'batch', 'operator', 'notes', 'config_hash', 'config_snapshot', 'created_at', 'finished_at', 'device_id', 'name', 'connection_type', 'interface', 'protocol', 'scope', 'verdict', 'fault_code', 'summary', 'suggestion', 'request', 'response', 'duration_ms', 'simulated'] + columns = ['report_marker', 'job_id', 'status', 'mode', 'scenario', 'station_id', 'batch', 'operator', 'notes', 'config_hash', 'config_snapshot', 'created_at', 'finished_at', 'device_id', 'name', 'connection_type', 'interface', 'protocol', 'scope', 'verdict', 'fault_code', 'summary', 'suggestion', 'request', 'response', 'duration_ms', 'simulated', 'attempts', 'supporting_checks'] writer.writerow(columns) def safe(value): text = str(value if value is not None else '') return "'" + text if text.lstrip().startswith(('=', '+', '-', '@')) or text.startswith(('\t', '\r', '\n')) else text for result in job['results']: row = dict(job, **job['metadata'], **result, report_marker=marker, job_id=job['id'], config_snapshot=json.dumps(job['config_snapshot'], ensure_ascii=False)) + for key in ('attempts', 'supporting_checks'): + row[key] = json.dumps(result.get(key, []), ensure_ascii=False) writer.writerow([safe(row.get(key, '')) for key in columns]) return stream.getvalue().encode('utf-8-sig'), 'text/csv; charset=utf-8' raise ValueError('Unsupported export format') diff --git a/inspection/service.py b/inspection/service.py index bb48df1..81116a5 100644 --- a/inspection/service.py +++ b/inspection/service.py @@ -2,10 +2,12 @@ import threading import time import uuid +from contextlib import contextmanager from .adapters import DemoAdapter, LiveAdapter, demo_config from .domain import SCENARIOS, TERMINAL, catalog_devices, config_hash, ensure_read_only, initial_result, refresh, utc_now, validate_payload from .store import SQLiteStore +from .ownership import DatabaseOwner class InspectionService: @@ -16,24 +18,51 @@ def __init__(self, db_path, demo=False, config_path=None, adapter=None): self.adapter = adapter if adapter is not None else DemoAdapter() if demo else LiveAdapter() self.hardware_lock = threading.Lock() self.lock = threading.RLock() - self.store = SQLiteStore(db_path) + self.owner = DatabaseOwner(db_path) + try: + self.store = SQLiteStore(db_path) + except Exception: + self.owner.close() + raise + self.legacy_state = {'devices': {}, 'updated_at': '', 'config_hash': None} self.closed = False self.worker = None self.active_id = None self.active_started = None self.cancel_event = threading.Event() - for job in self.store.all(): - if job['status'] not in TERMINAL: - job['status'] = 'interrupted' - job['finished_at'] = utc_now() - job['events'].append({'at': utc_now(), 'type': 'interrupted', 'message': 'Service restarted; no automatic hardware retry'}) - self.store.update(refresh(job)) + try: + for job in self.store.all(): + if job['status'] not in TERMINAL: + job['status'] = 'interrupted' + job['finished_at'] = utc_now() + job['events'].append({'at': utc_now(), 'type': 'interrupted', 'message': 'Service restarted; no automatic hardware retry'}) + self.store.update(refresh(job)) + except Exception: + self.store.close() + self.owner.close() + raise def _snapshot(self): if self.demo: return demo_config() from core.config_manager import StandardDeviceConfig - return StandardDeviceConfig(self.config_path).data if self.config_path else StandardDeviceConfig().data + try: + return StandardDeviceConfig(self.config_path).data if self.config_path else StandardDeviceConfig().data + except (AttributeError, TypeError): + raise ValueError('Invalid nested configuration shape') from None + + @contextmanager + def legacy_operation(self): + with self.lock: + if self.closed or not self.hardware_lock.acquire(blocking=False): + raise RuntimeError('A station operation is already running or service is closed') + try: + yield + finally: + with self.lock: + self.hardware_lock.release() + if self.closed: + self.owner.close() def catalog(self): snapshot = self._snapshot() @@ -111,6 +140,8 @@ def on_result(result): self.active_id = None self.active_started = None self.hardware_lock.release() + if self.closed: + self.owner.close() def _finish(self, job, status, started): job.update(status=status, finished_at=utc_now(), duration_ms=round((time.monotonic() - started) * 1000, 2)) @@ -120,8 +151,8 @@ def _finish(self, job, status, started): def get(self, job_id): return self.store.get(job_id) - def list_jobs(self, limit=20, offset=0, status='', query=''): - return self.store.list(limit, offset, status, query) + def list_jobs(self, limit=20, offset=0, status='', query='', sort='newest'): + return self.store.list(limit, offset, status, query, sort) def cancel(self, job_id): with self.lock: @@ -189,3 +220,5 @@ def close(self): self._finish(job, 'interrupted', self.active_started or time.monotonic()) self.closed = True self.store.close() + if not self.hardware_lock.locked(): + self.owner.close() diff --git a/inspection/store.py b/inspection/store.py index fa39cdc..47be18a 100644 --- a/inspection/store.py +++ b/inspection/store.py @@ -33,18 +33,21 @@ def update(self, job): if not cursor.rowcount: raise KeyError('Job not found') - def list(self, limit=20, offset=0, status='', query=''): + def list(self, limit=20, offset=0, status='', query='', sort='newest'): if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100 or not isinstance(offset, int) or not 0 <= offset <= 1000000: raise ValueError('Invalid pagination') if status and status not in STATUSES: raise ValueError('Invalid status') if not isinstance(query, str) or len(query) > 128: raise ValueError('Invalid query') + if sort not in ('newest', 'oldest'): + raise ValueError('Invalid sort') + direction = 'DESC' if sort == 'newest' else 'ASC' with self.lock: - rows = self.connection.execute('SELECT record FROM jobs ORDER BY created_at DESC, rowid DESC').fetchall() + rows = self.connection.execute('SELECT record FROM jobs ORDER BY created_at ' + direction + ', rowid ' + direction).fetchall() jobs = [json.loads(row[0]) for row in rows] jobs = [job for job in jobs if (not status or job['status'] == status) and (not query or query.casefold() in json.dumps(job['metadata'], ensure_ascii=False).casefold() or query.casefold() in job['id'].casefold())] - return {'jobs': jobs[offset:offset + limit], 'total': len(jobs), 'limit': limit, 'offset': offset} + return {'jobs': jobs[offset:offset + limit], 'total': len(jobs), 'limit': limit, 'offset': offset, 'sort': sort} def all(self): with self.lock: diff --git a/legacy_web.py b/legacy_web.py index a78ffb1..b60fd7a 100644 --- a/legacy_web.py +++ b/legacy_web.py @@ -25,6 +25,7 @@ from core.config_manager import StandardDeviceConfig from core.generic_detector import DEFAULT_TCP_PORTS, GenericDetector +from inspection.domain import config_hash REPORT_DIR = os.path.join(BASE_DIR, "report") WEB_INDEX_PATH = os.path.join(BASE_DIR, "web", "index.html") @@ -66,13 +67,22 @@ def connected_devices(data: dict): return [item for item in data.get("devices", []) or [] if item.get("status") in CONNECTED_STATUSES] -def cache_scan_devices(devices): - SCAN_STATE["devices"] = { +def scan_state(config, state=None): + state = SCAN_STATE if state is None else state + fingerprint = config_hash(config.data) + if state.get('config_hash') != fingerprint: + state.update(devices={}, updated_at='', config_hash=fingerprint) + return state + + +def cache_scan_devices(devices, state=None): + state = SCAN_STATE if state is None else state + state["devices"] = { str(item.get("device_id")): copy.deepcopy(item) for item in devices if item.get("device_id") } - SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") + state["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") def same_interface(connection_type: str, left: str, right: str) -> bool: @@ -83,10 +93,11 @@ def same_interface(connection_type: str, left: str, right: str) -> bool: return left == right -def cache_interface_devices(connection_type: str, interface: str, devices): +def cache_interface_devices(connection_type: str, interface: str, devices, state=None): + state = SCAN_STATE if state is None else state retained = { device_id: item - for device_id, item in SCAN_STATE.get("devices", {}).items() + for device_id, item in state.get("devices", {}).items() if not ( str(item.get("connection_type") or "") == connection_type and same_interface(connection_type, item.get("interface", ""), interface) @@ -96,17 +107,28 @@ def cache_interface_devices(connection_type: str, interface: str, devices): device_id = str(item.get("device_id") or "") if device_id: retained[device_id] = copy.deepcopy(item) - SCAN_STATE["devices"] = retained - SCAN_STATE["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") + state["devices"] = retained + state["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") -def selected_catalog(device_ids): +def selected_catalog(device_ids, config=None, state=None): + config = config if config is not None else StandardDeviceConfig() + state = scan_state(config, state) interfaces = { device_id: str(item.get("interface") or "") - for device_id, item in SCAN_STATE.get("devices", {}).items() + for device_id, item in state.get("devices", {}).items() if item.get("interface") } - return StandardDeviceConfig().selected(device_ids, interfaces) + # A child using its parent's carrier must stay inherited. Pinning only the + # child's interface would turn it into an independent, incomplete carrier. + carrier_fields = ('interface', 'serial_config', 'network_config', 'pci_slot', 'pci_match', 'provided_interfaces') + for parent in config.raw_devices(): + for child in parent.get('child_devices', []) or []: + if not any(child.get(key) for key in carrier_fields): + discovered = interfaces.pop(str(child.get('device_id')), '') + if discovered: + interfaces.setdefault(str(parent.get('device_id')), discovered) + return config.selected(device_ids, interfaces) def update_interface_counts(data: dict): @@ -122,9 +144,9 @@ def update_interface_counts(data: dict): }) -def prepare_ui_result(data: dict, mode: str, connected_only: bool = False) -> dict: +def prepare_ui_result(data: dict, mode: str, connected_only: bool = False, config=None) -> dict: result = copy.deepcopy(data) - catalog_count = len(StandardDeviceConfig().all_configured_devices()) + catalog_count = len((config if config is not None else StandardDeviceConfig()).all_configured_devices()) devices = result.get("devices", []) or [] if connected_only: devices = [item for item in devices if item.get("status") in CONNECTED_STATUSES] @@ -223,14 +245,18 @@ def preserve_scan_interfaces(result: dict, scan_raw: dict): return result -def scan_catalog(payload: dict) -> dict: - raw = detector_from_payload(payload).run().to_dict() +def scan_catalog(payload: dict, config=None, state=None) -> dict: + config = config if config is not None else StandardDeviceConfig() + state = scan_state(config, state) + raw = detector_from_payload(payload, config=config).run().to_dict() discovered = connected_devices(raw) - cache_scan_devices(discovered) - return prepare_ui_result(raw, "scan", connected_only=True) + cache_scan_devices(discovered, state) + return prepare_ui_result(raw, "scan", connected_only=True, config=config) -def scan_interface(payload: dict) -> dict: +def scan_interface(payload: dict, config=None, state=None) -> dict: + catalog = config if config is not None else StandardDeviceConfig() + state = scan_state(catalog, state) connection_type = str(payload.get("interface_type") or "").strip().lower() interface = str(payload.get("interface") or "").strip() if connection_type not in ("serial", "network", "pci"): @@ -238,7 +264,7 @@ def scan_interface(payload: dict) -> dict: if not interface: raise ValueError("缺少接口名称") - config = StandardDeviceConfig().for_interface(connection_type, interface) + config = catalog.for_interface(connection_type, interface) if config is None: now = time.strftime("%Y-%m-%d %H:%M:%S") raw = { @@ -278,40 +304,44 @@ def scan_interface(payload: dict) -> dict: item for item in items if key == target_key and same_interface(connection_type, item.get("name", ""), interface) ] - cache_interface_devices(connection_type, interface, devices) - result = prepare_ui_result(raw, "scan", connected_only=True) + cache_interface_devices(connection_type, interface, devices, state) + result = prepare_ui_result(raw, "scan", connected_only=True, config=catalog) result["interface_type"] = connection_type result["interface_name"] = interface return result -def test_all_discovered(payload: dict) -> dict: - scan_raw = detector_from_payload(payload).run().to_dict() +def test_all_discovered(payload: dict, config=None, state=None) -> dict: + catalog = config if config is not None else StandardDeviceConfig() + state = scan_state(catalog, state) + scan_raw = detector_from_payload(payload, config=catalog).run().to_dict() discovered = connected_devices(scan_raw) - cache_scan_devices(discovered) + cache_scan_devices(discovered, state) device_ids = [str(item.get("device_id")) for item in discovered if item.get("device_id")] if not device_ids: - result = prepare_ui_result(scan_raw, "test_all", connected_only=True) + result = prepare_ui_result(scan_raw, "test_all", connected_only=True, config=catalog) else: - config = selected_catalog(device_ids) + config = selected_catalog(device_ids, catalog, state) tested = detector_from_payload(payload, config=config).run().to_dict() - result = prepare_ui_result(tested, "test_all") + result = prepare_ui_result(tested, "test_all", config=catalog) preserve_scan_interfaces(result, scan_raw) result["summary"]["scan_connected_count"] = len(discovered) - keep_limit = StandardDeviceConfig().settings().get("max_report_count", REPORT_KEEP_LIMIT) + keep_limit = catalog.settings().get("max_report_count", REPORT_KEEP_LIMIT) save_report(result, keep_limit) return result -def test_single_device(payload: dict) -> dict: +def test_single_device(payload: dict, config=None, state=None) -> dict: + catalog = config if config is not None else StandardDeviceConfig() + state = scan_state(catalog, state) device_id = str(payload.get("device_id") or "").strip() if not device_id: raise ValueError("缺少device_id") - if device_id not in SCAN_STATE.get("devices", {}): + if device_id not in state.get("devices", {}): raise ValueError("设备不在当前扫描结果中,请先扫描设备") - config = selected_catalog([device_id]) + config = selected_catalog([device_id], catalog, state) tested = detector_from_payload(payload, config=config).run().to_dict() - result = prepare_ui_result(tested, "single") + result = prepare_ui_result(tested, "single", config=catalog) target = next((item for item in result.get("devices", []) if str(item.get("device_id")) == device_id), None) if target is None: raise ValueError("未生成该设备的测试结果") diff --git a/tests/test_backend_fixes.py b/tests/test_backend_fixes.py new file mode 100644 index 0000000..072f176 --- /dev/null +++ b/tests/test_backend_fixes.py @@ -0,0 +1,379 @@ +"""Review regressions: synthetic configuration, Fake adapters, loopback only.""" +import copy +import json +import re +import shutil +import subprocess +import sys +import threading + +import pytest + +from inspection.adapters import DemoAdapter, LiveAdapter, demo_config +from inspection.comparison import compare_jobs +from inspection.http import make_server +from inspection.reports import export_job +from inspection.service import InspectionService +from test_http import api, requester +from test_workbench import BlockingAdapter, wait_finished + + +def test_second_process_cannot_recover_or_probe_owned_database(tmp_path): + adapter = BlockingAdapter() + path = tmp_path / 'jobs.db' + svc = InspectionService(path, demo=True, adapter=adapter) + try: + job = svc.create({}) + assert adapter.started.wait(1) + code = """ +import sys +from inspection.service import InspectionService +class Forbidden: + def run(self, *args): + raise AssertionError('second adapter called') +try: + svc = InspectionService(sys.argv[1], demo=sys.argv[2] == 'demo', adapter=Forbidden()) +except RuntimeError: + sys.exit(23) +svc.close() +""" + for mode in ('demo', 'live'): + result = subprocess.run([sys.executable, '-c', code, str(path), mode], timeout=5) + assert result.returncode == 23 + assert svc.get(job['id'])['status'] == 'running' + server = make_server('127.0.0.1', 0, svc) + try: + result = subprocess.run([sys.executable, 'web_app.py', '--demo', '--data-dir', str(tmp_path), '--port', str(server.server_port)], capture_output=True, timeout=5) + assert result.returncode == 1 + assert svc.get(job['id'])['status'] == 'running' + finally: + server.server_close() + finally: + svc.close() + + +def test_crashed_owner_releases_lease_and_recovers(tmp_path): + path = tmp_path / 'jobs.db' + code = """ +import os, sys, threading +from inspection.service import InspectionService +class Blocker: + def run(self, *args): + threading.Event().wait(10) +svc=InspectionService(sys.argv[1], demo=True, adapter=Blocker()) +job=svc.create({}) +print(job['id'], flush=True) +os._exit(0) +""" + result = subprocess.run([sys.executable, '-c', code, str(path)], capture_output=True, text=True, timeout=5, check=True) + svc = InspectionService(path, demo=True) + try: + assert svc.get(result.stdout.strip())['status'] == 'interrupted' + finally: + svc.close() + + +def test_stubborn_worker_keeps_database_owned_after_bounded_close(tmp_path): + class Stubborn(BlockingAdapter): + def run(self, *args): + self.started.set() + self.release.wait(8) + adapter = Stubborn() + path = tmp_path / 'jobs.db' + svc = InspectionService(path, demo=True, adapter=adapter) + svc.create({}) + assert adapter.started.wait(1) + svc.close() + try: + with pytest.raises(RuntimeError): + InspectionService(path, demo=True) + finally: + adapter.release.set() + svc.worker.join(2) + other = InspectionService(path, demo=True) + other.close() + + +@pytest.mark.parametrize('field,value,reason', [('station_id', 'UNIT-B', 'Station'), ('station_id', '', 'identity'), ('station_id', ' ', 'identity'), ('batch', 'LOT-B', 'Batch')]) +def test_recovery_requires_same_unambiguous_identity(tmp_path, field, value, reason): + svc = InspectionService(tmp_path / 'jobs.db', demo=True) + try: + baseline = wait_finished(svc, svc.create({'scenario': 'faults', 'station_id': 'UNIT-A', 'batch': 'LOT-A'})['id']) + payload = {'station_id': 'UNIT-A', 'batch': 'LOT-A', field: value} + current = wait_finished(svc, svc.create(payload)['id']) + diff = compare_jobs(baseline, current) + assert not diff['comparable'] and not diff['whole_unit_recovered'] + assert any(reason.lower() in r.lower() for r in diff['reasons']) + assert diff['recovered'] == ['DEMO_PLC'] + finally: + svc.close() + + +def test_same_identity_full_rerun_and_empty_batch_can_recover(tmp_path): + svc = InspectionService(tmp_path / 'jobs.db', demo=True) + try: + baseline = wait_finished(svc, svc.create({'scenario': 'faults', 'station_id': 'UNIT-A'})['id']) + current = wait_finished(svc, svc.create({'station_id': 'UNIT-A'})['id']) + assert compare_jobs(baseline, current)['whole_unit_recovered'] + missing = copy.deepcopy(current) + missing['metadata']['station_id'] = '' + baseline['metadata']['station_id'] = '' + assert not compare_jobs(baseline, missing)['comparable'] + finally: + svc.close() + + +@pytest.mark.parametrize('scenario', ['healthy', 'faults', 'timeout', 'crc', 'missing']) +def test_demo_scenario_outcomes_do_not_change_with_selected_subset(scenario): + full = [] + adapter = DemoAdapter() + config = demo_config() + adapter.run(config, ['DEMO_RELAY', 'DEMO_PLC', 'DEMO_PCI'], scenario, lambda: False, full.append) + for expected in full: + subset = [] + adapter.run(config, [expected['device_id']], scenario, lambda: False, subset.append) + assert subset[0] == expected + + +def test_same_scenario_retest_remains_failed(tmp_path): + svc = InspectionService(tmp_path / 'jobs.db', demo=True) + try: + first = wait_finished(svc, svc.create({'scenario': 'faults', 'station_id': 'UNIT-A'})['id']) + second = wait_finished(svc, svc.retest(first['id'], {})['id']) + assert second['results'][0]['verdict'] == 'FAIL' + third = wait_finished(svc, svc.retest(second['id'], {'scenario': 'healthy'})['id']) + assert third['results'][0]['verdict'] == 'PASS' + finally: + svc.close() + + +@pytest.mark.parametrize('statuses', [('有响应', '有响应'), ('异常', '有响应'), ('有响应', '异常')]) +def test_all_probe_and_parent_evidence_survives_storage_and_exports(tmp_path, monkeypatch, statuses): + import core.generic_detector + config = demo_config() + parent = config['devices'][1] + child = copy.deepcopy(config['devices'][0]) + child['device_id'] = 'CHILD' + parent['child_devices'] = [child] + probes = [{'device_id': 'CHILD', 'status': status, 'request': 'request%d ', request('GET', '/legacy')[1].decode()).group(1) + node = shutil.which('node') + if not node: + pytest.skip('Node required to execute legacy page JavaScript') + code = """ +const vm = require('node:vm'); +const script = JSON.parse(process.argv[1]); +const base = process.argv[2]; +const values = new Map([['qldc.token', 'session-secret']]); +const element = {addEventListener(){}, querySelectorAll(){return []}}; +const document = {getElementById(){return element}, addEventListener(){}}; +const context = {document, sessionStorage:{getItem:k=>values.get(k),setItem:(k,v)=>values.set(k,v)}, fetch:(url,opts)=>{if(url.includes('secret'))throw Error('credential in URL');return fetch(base+url,opts)}}; +vm.createContext(context); +vm.runInContext(script, context); +(async()=>{ + for(const url of ['/api/scan','/api/test-device'])await vm.runInContext(`postJson('${url}', {device_id:'FAKE'})`,context); + values.set('qldc.token','wrong'); + let rejected=false; + try{await vm.runInContext("postJson('/api/scan')",context)}catch{rejected=true} + if(!rejected)throw Error('wrong token accepted'); +})().catch(e=>{console.error(e.message);process.exitCode=1}); +""" + result = subprocess.run([node, '-e', code, json.dumps(script), 'http://127.0.0.1:%d' % server.server_port], capture_output=True, text=True, timeout=8) + assert result.returncode == 0, result.stderr + + +def test_history_sort_has_stable_ties_and_pagination_over_http(api): + svc, server, request = api + jobs = [wait_finished(svc, svc.create({'batch': 'sort-fixture'})['id']) for _ in range(3)] + # Equal timestamps exercise rowid tie ordering after persisted reopen. + for job in jobs: + svc.store.connection.execute('UPDATE jobs SET created_at=? WHERE id=?', ('2026-10-07T00:00:00.000Z', job['id'])) + svc.store.connection.commit() + from inspection.store import SQLiteStore + db_path = svc.store.connection.execute('PRAGMA database_list').fetchone()[2] + svc.store.close() + svc.store = SQLiteStore(db_path) + ids = [job['id'] for job in jobs] + for sort, expected in [('oldest', ids), ('newest', list(reversed(ids)))]: + got = [request('GET', '/api/jobs?sort=%s&limit=1&offset=%d&q=sort-fixture' % (sort, offset))[1]['jobs'][0]['id'] for offset in range(3)] + assert got == expected + assert svc.list_jobs(sort=sort)['jobs'][0]['id'] == expected[0] + assert request('GET', '/api/jobs')[1]['jobs'][0]['id'] == ids[-1] + assert request('GET', '/api/jobs?sort=arbitrary')[0] == 400 + with pytest.raises(ValueError): + svc.list_jobs(sort='arbitrary') + + +def test_core_tags_every_probe_at_exact_parent_child_boundaries(monkeypatch): + from core.config_manager import StandardDeviceConfig + from core.generic_detector import GenericDetector + config = demo_config() + config['devices'] = [config['devices'][1]] + config['devices'][0]['child_devices'] = [{'device_id': 'CHILD', 'device_name': 'child', 'device_type': 'plc', 'connection_type': 'network', 'protocol': {'type': 'modbus_tcp'}}] + detector = GenericDetector(config=StandardDeviceConfig(data=config)) + monkeypatch.setattr(detector, '_configured_interface_items', lambda raw: ([], [], [])) + def device(raw, phase): + detector.connection_tests.extend([{'request': 'first', 'status': '异常'}, {'request': 'second', 'status': '有响应'}]) + return {'device_id': raw['device_id'], 'status': '正常'} + def child(raw, parent, result): + detector.connection_tests.append({'request': 'child', 'status': '有响应'}) + return {'device_id': raw['device_id'], 'status': '正常'} + monkeypatch.setattr(detector, '_test_configured_device', device) + monkeypatch.setattr(detector, '_test_configured_child_device', child) + raw = detector.run().to_dict() + assert [probe['device_id'] for probe in raw['connection_tests']] == ['DEMO_PLC', 'DEMO_PLC', 'CHILD'] + assert raw['connection_tests'][2]['parent_device_id'] == 'DEMO_PLC' + + +def test_inflight_legacy_operation_keeps_lease_until_it_returns(tmp_path): + path = tmp_path / 'jobs.db' + svc = InspectionService(path, demo=True) + started, release = threading.Event(), threading.Event() + def operation(): + with svc.legacy_operation(): + started.set() + release.wait(3) + thread = threading.Thread(target=operation) + thread.start() + assert started.wait(1) + svc.close() + try: + with pytest.raises(RuntimeError): + InspectionService(path, demo=True) + finally: + release.set() + thread.join(1) + svc = InspectionService(path, demo=True) + svc.close() + + +def test_legacy_operation_releases_lock_before_response_is_sent(api, monkeypatch): + import legacy_web + from inspection.http import Handler + svc, server, request = api + svc.demo = False + monkeypatch.setattr(svc, '_snapshot', demo_config) + monkeypatch.setattr(legacy_web, 'scan_catalog', lambda *args, **kwargs: {'ok': True, 'operation_done': True}) + original_json = Handler._json + def checked_json(handler, data, status=200): + if data.get('operation_done'): + assert not svc.hardware_lock.locked(), 'response sent while operation lock held' + return original_json(handler, data, status) + monkeypatch.setattr(Handler, '_json', checked_json) + status, body = request('POST', '/api/scan', {}) + assert status == 200 and body['operation_done'] diff --git a/tests/test_http.py b/tests/test_http.py index 6a1da7c..52b9db6 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -123,7 +123,9 @@ def fail(): def test_legacy_live_calls_share_acceptance_lock_and_are_explicit(api, monkeypatch): import legacy_web svc, server, request = api - monkeypatch.setattr(legacy_web, 'scan_catalog', lambda payload: {'ok': True, 'devices': [], 'mode': 'scan'}) + monkeypatch.setattr(legacy_web, 'scan_catalog', lambda payload, **kwargs: {'ok': True, 'devices': [], 'mode': 'scan'}) + from inspection.adapters import demo_config + monkeypatch.setattr(svc, '_snapshot', demo_config) svc.demo = False svc.mode = 'live' svc.hardware_lock.acquire() @@ -133,4 +135,4 @@ def test_legacy_live_calls_share_acceptance_lock_and_are_explicit(api, monkeypat svc.hardware_lock.release() status, body = request('POST', '/api/scan', {}) assert status == 200 and body['legacy'] - assert legacy_web.RUN_LOCK is svc.hardware_lock + assert not svc.hardware_lock.locked() diff --git a/tests/test_protocol_regressions.py b/tests/test_protocol_regressions.py index f62f585..93c6a12 100644 --- a/tests/test_protocol_regressions.py +++ b/tests/test_protocol_regressions.py @@ -114,7 +114,9 @@ def serve(): server.close() thread = threading.Thread(target=serve, daemon=True) thread.start() - detector = GenericDetector() + from core.config_manager import StandardDeviceConfig + from inspection.adapters import demo_config + detector = GenericDetector(config=StandardDeviceConfig(data=demo_config())) protocol = {'type': 'modbus_tcp', 'test_registers': [{'address': 0, 'count': 1}]} if modbus else {'type': 'fixture_tcp', 'probes': [{'request_text': 'PING', 'response_match': {'exact_text': 'PONG DEVICE-01'}}]} parent = {'network_config': {'transport': 'tcp', 'device_role': 'server'}} profile = detector._network_profile({'protocol': protocol}, parent) @@ -124,3 +126,103 @@ def serve(): assert raw.hex(' ').upper() in result['response'] finally: thread.join(2) + + +def incoming_detector(): + from core.config_manager import StandardDeviceConfig + from core.generic_detector import GenericDetector + from inspection.adapters import demo_config + detector = GenericDetector(config=StandardDeviceConfig(data=demo_config())) + detector._local_ipv4_for_iface = lambda iface: '127.0.0.1' + return detector + + +def test_incoming_tcp_split_loopback_frame_and_matching_reply(): + detector = incoming_detector() + port_socket = socket.socket() + port_socket.bind(('127.0.0.1', 0)) + port = port_socket.getsockname()[1] + port_socket.close() + protocol = {'type': 'fixture_tcp', 'response_match': {'exact_text': 'PONG'}, 'reply_text': 'ACK'} + profile = detector._network_profile({'protocol': protocol}, {'network_config': {'transport': 'tcp', 'device_role': 'client'}}) + replies = [] + errors = [] + def client(): + try: + deadline = time.monotonic() + 2 + while True: + try: + conn = socket.create_connection(('127.0.0.1', port), timeout=.2) + break + except OSError: + if time.monotonic() >= deadline: + raise + time.sleep(.01) + with conn: + conn.sendall(b'PO') + time.sleep(.035) + conn.sendall(b'NG') + replies.append(conn.recv(128)) + except Exception as exc: + errors.append(exc) + thread = threading.Thread(target=client, daemon=True) + thread.start() + try: + result = detector._test_tcp_client_configured('', '127.0.0.1', port, .4, protocol, profile) + finally: + thread.join(2) + assert result['ok'], result + assert '50 4F 4E 47' in result['response'] + assert replies == [b'ACK'] and not errors + + +@pytest.mark.parametrize('fragments,rule,expected_ip,ok,reply,reply_error', [ + ([b'PO', b'NG'], {'exact_text': 'PONG'}, '127.0.0.1', True, [b'ACK'], False), + ([b'PO', socket.timeout()], {'exact_text': 'PONG'}, '127.0.0.1', False, [], False), + ([b'PO', ConnectionResetError()], {'exact_text': 'PONG'}, '127.0.0.1', False, [], False), + ([b'PO', b'NG', b''], {}, '127.0.0.1', True, [b'ACK'], False), + ([b'PO', socket.timeout()], {}, '127.0.0.1', True, [b'ACK'], False), + ([b'PONG'], {'exact_text': 'PONG'}, '127.0.0.2', False, [], False), + ([b'PONG'], {'exact_text': 'PONG'}, '127.0.0.1', False, [b'ACK'], True), +]) +def test_incoming_tcp_partial_evidence_deadline_source_and_reply(monkeypatch, fragments, rule, expected_ip, ok, reply, reply_error): + detector = incoming_detector() + class Connection(FragmentSocket): + def __init__(self): + super().__init__(fragments) + self.sent = [] + self.timeouts = [] + def settimeout(self, value): + self.timeouts.append(value) + def getsockname(self): + return ('127.0.0.1', 123) + def sendall(self, data): + self.sent.append(data) + if reply_error: + raise ConnectionResetError('reply reset') + def close(self): + pass + conn = Connection() + class Listener: + def settimeout(self, value): + pass + def setsockopt(self, *args): + pass + def bind(self, address): + assert address == ('127.0.0.1', 123) + def listen(self, count): + pass + def accept(self): + return conn, ('127.0.0.1', 42) + def close(self): + pass + monkeypatch.setattr(socket, 'socket', lambda *args: Listener()) + protocol = {'type': 'fixture_tcp', 'request_text': 'read', 'response_match': rule, 'reply_text': 'ACK'} + profile = detector._network_profile({'protocol': protocol}, {'network_config': {'device_role': 'client'}}) + result = detector._test_tcp_client_configured('', expected_ip, 123, .1, protocol, profile) + assert result['ok'] == ok + assert '50 4F' in result['response'] + if len(fragments) > 1 and fragments[1] == b'NG': + assert '50 4F 4E 47' in result['response'] + assert conn.sent == reply + assert all(0 < t <= .1 for t in conn.timeouts) diff --git a/tests/test_workbench.py b/tests/test_workbench.py index efb9037..b4b3218 100644 --- a/tests/test_workbench.py +++ b/tests/test_workbench.py @@ -42,7 +42,7 @@ def test_missing_device_cannot_pass(service): def test_snapshot_and_retest_scope(service): - job = wait_finished(service, service.create({'scenario': 'faults'})['id']) + job = wait_finished(service, service.create({'scenario': 'faults', 'station_id': 'UNIT-A'})['id']) broken = [r['device_id'] for r in job['results'] if r['verdict'] != 'PASS'] assert broken and len(broken) < len(job['results']) with pytest.raises(ValueError): @@ -260,7 +260,7 @@ def test_duplicate_retest_input_is_validation_error(service): def test_failed_only_retest_never_claims_whole_unit_recovery(service): - original = wait_finished(service, service.create({'scenario': 'missing', 'device_ids': ['DEMO_RELAY']})['id']) + original = wait_finished(service, service.create({'scenario': 'missing', 'station_id': 'UNIT-A', 'device_ids': ['DEMO_RELAY']})['id']) child = wait_finished(service, service.retest(original['id'], {'scenario': 'healthy'})['id']) comparison = compare_jobs(original, child) assert comparison['comparable'] and comparison['partial_scope'] diff --git a/web/legacy.html b/web/legacy.html index 977b98d..756fc83 100644 --- a/web/legacy.html +++ b/web/legacy.html @@ -304,10 +304,12 @@

工控机设备检测

+
+
等待扫描 @@ -377,9 +379,12 @@ } async function postJson(url, body = {}) { + const headers = { "Content-Type": "application/json" }; + const token = sessionStorage.getItem('qldc.token') || ''; + if (token) headers.Authorization = `Bearer ${token}`; const response = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify(body), }); const data = await response.json(); @@ -604,6 +609,18 @@ } } + document.getElementById("credentialsBtn").addEventListener("click", () => { + document.getElementById("accessToken").value = sessionStorage.getItem('qldc.token') || ''; + document.getElementById("credentials").showModal(); + }); + document.getElementById("credentialsCancel").addEventListener("click", () => document.getElementById("credentials").close()); + document.getElementById("credentialsForm").addEventListener("submit", event => { + event.preventDefault(); + sessionStorage.setItem('qldc.token', document.getElementById("accessToken").value); + document.getElementById("accessToken").value = ''; + document.getElementById("credentials").close(); + setStatus("访问令牌已保存,可继续扫描", "ok"); + }); document.getElementById("scanBtn").addEventListener("click", () => { runOperation("/api/scan", "正在扫描已知设备", "扫描完成"); }); From 8872651c64b87f532d8e543199fac3994788ef1b Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:21:33 +0800 Subject: [PATCH 07/15] feat: add responsive acceptance dashboard and repair evidence workflow --- tests/browser_workflow.cjs | 111 +++++++ web/assets/app.js | 52 +++ web/assets/style.css | 3 + web/index.html | 628 +------------------------------------ 4 files changed, 172 insertions(+), 622 deletions(-) create mode 100644 tests/browser_workflow.cjs create mode 100644 web/assets/app.js create mode 100644 web/assets/style.css diff --git a/tests/browser_workflow.cjs b/tests/browser_workflow.cjs new file mode 100644 index 0000000..be6224d --- /dev/null +++ b/tests/browser_workflow.cjs @@ -0,0 +1,111 @@ +/* Hardware-free browser acceptance. Use an existing Playwright installation. + * NODE_PATH=/path/to/node_modules node tests/browser_workflow.cjs http://127.0.0.1:18318 + * Start the server with --demo and a disposable --data-dir first. + */ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +(async () => { + const url = process.argv[2] || 'http://127.0.0.1:18318'; + const browser = await chromium.launch({ headless: true, + ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) }); + const page = await browser.newPage({ viewport: { width: 1440, height: 980 } }); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + try { + await page.goto(url); + if (process.env.QLDC_BROWSER_TOKEN) { + await page.getByRole('button', { name: '访问凭证', exact: true }).click(); + await page.getByLabel('访问令牌').fill(process.env.QLDC_BROWSER_TOKEN); + await page.getByRole('button', { name: '保存并连接' }).click(); + } + await page.getByRole('button', { name: '配置校验', exact: true }).click(); + await page.getByRole('button', { name: '载入当前配置' }).click(); + await page.getByRole('button', { name: '校验配置', exact: true }).click(); + await page.locator('#validation').filter({ hasText: '"valid": true' }).waitFor(); + await page.getByRole('button', { name: '设备清单', exact: true }).click(); + await page.getByText('DEMO_PLC', { exact: true }).waitFor(); + await page.getByRole('button', { name: '新建验收', exact: true }).click({ timeout: 3000 }); + await page.getByLabel('工位 / 设备编号').fill('DEMO-FAT-01'); + await page.getByLabel('批次编号').fill('DEMO-2026-10'); + await page.getByLabel('操作者').fill('演示工程师'); + await page.getByLabel('演示场景').selectOption('faults'); + await page.getByRole('button', { name: '创建验收任务', exact: true }).click(); + await page.getByTestId('task-state').filter({ hasText: '已完成' }).waitFor(); + await page.getByRole('button', { name: '查看证据' }).first().click(); + await page.getByRole('dialog', { name: '设备测试证据' }).waitFor(); + assert.ok((await page.getByRole('dialog').innerText()).includes('SIMULATED')); + await page.getByRole('button', { name: '关闭证据' }).click(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: '导出 JSON' }).click(); + const download = await downloadPromise; + const saved = await download.path(); + const report = JSON.parse(await fs.readFile(saved, 'utf8')); + assert.equal(report.mode, 'demo'); + assert.ok(report.summary.failed > 0); + const baselineId = report.id; + await page.getByLabel('复测场景').selectOption('healthy'); + await page.getByRole('button', { name: '复测未通过设备', exact: true }).click(); + await page.getByTestId('task-state').filter({ hasText: '已完成' }).waitFor(); + await page.getByRole('button', { name: '与原任务比较', exact: true }).click(); + await page.getByTestId('comparison').waitFor(); + assert.ok((await page.getByTestId('comparison').innerText()).includes('恢复')); + assert.ok((await page.getByTestId('comparison').innerText()).includes('部分范围')); + await fs.mkdir('output/playwright', { recursive: true }); + await page.evaluate(() => scrollTo(0, 0)); + await page.screenshot({ path: 'output/playwright/desktop.png', fullPage: true }); + const htmlDownload = page.waitForEvent('download'); + await page.getByRole('button', { name: '导出 HTML' }).click(); + const html = await htmlDownload; + const htmlPath = await html.path(); + const htmlReport = await fs.readFile(htmlPath, 'utf8'); + assert.ok(htmlReport.includes('仅选定设备')); + assert.ok(htmlReport.includes('SIMULATED')); + await fs.writeFile('output/playwright/report.html', htmlReport); + const reportPage = await browser.newPage({ viewport: { width: 1200, height: 900 } }); + await reportPage.setContent(htmlReport); + await reportPage.getByRole('heading', { name: '工控设备验收报告', exact: true }).waitFor(); + await reportPage.screenshot({ path: 'output/playwright/report.png', fullPage: true }); + await reportPage.close(); + await page.getByRole('button', { name: '工位总览', exact: true }).click(); + await page.getByText('DEMO-FAT-01').first().waitFor(); + await page.evaluate(() => scrollTo(0, 0)); + await page.screenshot({ path: 'output/playwright/overview.png', fullPage: true }); + await page.getByRole('button', { name: '任务历史', exact: true }).click(); + await page.reload(); + await page.getByRole('button', { name: '任务历史', exact: true }).click(); + await page.getByText('DEMO-FAT-01').first().waitFor(); + assert.ok((await page.locator('body').innerText()).includes('DEMO-2026-10')); + await page.getByLabel('时间排序').selectOption('oldest'); + await page.getByRole('button', { name: '筛选', exact: true }).click(); + await page.getByLabel('时间排序').filter({ has: page.locator('option[value="oldest"][selected]') }).waitFor(); + const ordered = await page.evaluate(async () => { + const token = sessionStorage.getItem('qldc.token'); + return (await fetch('/api/jobs?sort=oldest&limit=20', { headers: token ? { Authorization: `Bearer ${token}` } : {} })).json(); + }); + assert.equal(await page.locator('[data-action="open"]').first().getAttribute('data-id'), ordered.jobs[0].id); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: 'output/playwright/mobile.png', fullPage: true }); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + // A browser-only fixture verifies structured probe/carrier evidence and escaping. + await page.route('**/api/jobs/' + baselineId, async route => { + const response = await route.fetch(); + const payload = await response.json(); + payload.job.results[0].attempts = [{ request: '', response: 'UI-PROBE-RESPONSE' }]; + payload.job.results[0].supporting_checks = [{ device_id: 'UI-CARRIER', attempts: [{ response: 'UI-CARRIER-RESPONSE' }] }]; + await route.fulfill({ response, json: payload }); + }); + await page.locator(`[data-action="open"][data-id="${baselineId}"]`).click(); + await page.getByRole('button', { name: '查看证据' }).first().click(); + await page.getByText('全部探测记录 (1)', { exact: true }).click(); + await page.getByText('载体检查证据 (1)', { exact: true }).click(); + assert.ok((await page.getByRole('dialog', { name: '设备测试证据' }).innerText()).includes('UI-PROBE-RESPONSE')); + assert.ok((await page.getByRole('dialog', { name: '设备测试证据' }).innerText()).includes('UI-CARRIER-RESPONSE')); + assert.equal(await page.locator('#evidence-body script').count(), 0); + assert.equal(await page.evaluate(() => window.badInjected), undefined); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ result: 'PASS', baselineId, consoleErrors: errors, screenshots: ['desktop.png', 'mobile.png'] })); + } finally { await browser.close(); } +})().catch(err => { console.error(err); process.exitCode = 1; }); diff --git a/web/assets/app.js b/web/assets/app.js new file mode 100644 index 0000000..1c2b706 --- /dev/null +++ b/web/assets/app.js @@ -0,0 +1,52 @@ +'use strict'; +const $=s=>document.querySelector(s); +const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const statuses={queued:'等待执行',running:'执行中',cancelling:'正在取消',completed:'已完成',cancelled:'已取消',failed:'执行故障',interrupted:'已中断'}; +const verdicts={PASS:'通过',FAIL:'失败',REVIEW:'待确认',NOT_RUN:'未执行'}; +const scenarios={healthy:'全部正常',faults:'混合故障',timeout:'响应超时',crc:'CRC 错误',missing:'设备缺失'}; +const scopes={communication_path:'通信路径验证',pci_passive:'PCI 被动检查'}; +const faults={TIMEOUT:'响应超时',CRC:'CRC 校验失败',MISSING:'应检设备未响应',CARRIER:'载体通信失败',RESPONSE_MISMATCH:'响应不匹配',UNAVAILABLE:'接口或设备不可用',NOT_RUN:'尚未执行'}; +const S={catalog:null,job:null,view:'overview',offset:0,filter:'',sort:'newest',query:'',timer:null,token:sessionStorage.getItem('qldc.token')||''}; +const done=s=>['completed','cancelled','failed','interrupted'].includes(s); +const date=v=>v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'; +const id=v=>String(v||'').slice(0,8).toUpperCase(); +const pill=(v,label)=>`${esc(label||verdicts[v]||statuses[v]||v)}`; +function notice(text,error=false){$('#notice').hidden=!text;$('#notice').className=error?'error':'';$('#notice').textContent=text;} +async function api(url,method='GET',body){const headers={};if(S.token)headers.Authorization=`Bearer ${S.token}`;if(body!==undefined)headers['Content-Type']='application/json';const r=await fetch(url,{method,headers,body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});const d=await r.json();if(!r.ok||d.ok===false)throw new Error(d.error||`请求失败 (${r.status})`);return d;} +const heading=(title,description,actions='')=>`
FIELD ACCEPTANCE / 工位验收

${esc(title)}

${esc(description)}

${actions}
`; +const metric=(name,value,description,kind='')=>`
${esc(name)}
${esc(value)}${esc(description)}
`; +const empty=(title,text)=>`
${esc(title)}${esc(text)}
`; +const demoNote=()=>S.catalog.mode==='demo'?'
SIMULATED · 当前为离线演示,使用合成设备与报文,不连接现场硬件。
':''; +const device=d=>`
${d.connection_type==='serial'?'↔':d.connection_type==='pci'?'◫':'⌁'}
${esc(d.name)}${esc(d.device_id)}
`; +function jobTable(jobs){return `
${jobs.map(j=>``).join('')}
工位 / 批次任务 / 创建时间执行状态验收结果操作
${esc(j.metadata.station_id||'未命名工位')}${esc(j.metadata.batch||'无批次')} · ${esc(j.metadata.operator||'未记录操作者')}${id(j.id)}${date(j.created_at)}${pill(j.status)}${pill(j.summary.verdict)}${j.summary.passed} / ${j.summary.expected} 通过
`;} +function setView(view){clearTimeout(S.timer);S.view=view;notice('');$('#page-name').textContent=({overview:'工位总览',new:'新建验收',catalog:'设备清单',history:'任务历史',task:'任务详情',config:'配置校验'})[view];document.querySelectorAll('nav [data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===view));} +async function navigate(view){setView(view);$('#main').innerHTML=empty('正在读取工位数据','');try{if(view==='overview')await overview();if(view==='new')newJob();if(view==='catalog')catalog();if(view==='history')await history();if(view==='config')config();}catch(e){notice(e.message,true);$('#main').innerHTML=empty('暂时无法读取工位','检查服务和访问凭证后重试。');}} +async function overview(){const [stats,list]=await Promise.all([api('/api/insights'),api('/api/jobs?limit=5')]);const counts={serial:0,network:0,pci:0};S.catalog.devices.forEach(d=>counts[d.connection_type]++);const m=stats[S.catalog.mode]; +$('#main').innerHTML=heading('让每一次验收,有据可查。','从应检清单到协议证据,再到交付后的复测对比。所有统计来自当前工位保存的任务记录。')+ +`
${S.catalog.mode==='demo'?'SIMULATED / OFFLINE DEMO':'LIVE / LOCAL STATION'}

${S.catalog.mode==='demo'?'先演示一轮故障,再验证恢复。':'新一轮验收,从完整设备清单开始。'}

配置中应存在的设备都会进入验收结果。未响应设备保留失败记录,复测后可检查恢复、退化与证据变化。

`+ +`
${metric('应检设备',S.catalog.devices.length,'当前配置中的完整清单')}${metric('已完成任务',m.completed,'当前模式的已完成验收')}${metric('通过任务',m.passed,'所有选定设备均通过','good')}${metric('失败任务',m.failed,'至少一项设备验收失败','bad')}
`+ +`

最近验收

${list.jobs.length?jobTable(list.jobs):empty('等待第一份验收记录','创建任务后,这里会展示工位、批次和结果。')}

验收流程

EVIDENCE FIRST
${[['完整清单','未发现、超时或无响应设备也保留结果。'],['可追溯证据','接口、请求和响应与本次配置快照一起保存。'],['复测与比较','检查配置和范围,区分部分复测与整套验收。']].map(([t,p],i)=>`
0${i+1}
${t}

${p}

`).join('')}

设备组成

按配置统计
${[['串口 / RS-485',counts.serial],['网络 / TCP · UDP',counts.network],['PCI / 被动状态',counts.pci]].map(([n,v])=>`
${n}${v}
`).join('')}
通信响应证明当前通信路径可用;PCI 被动状态不代表完整业务功能或电气动作正常。

当前配置

SHA-256

${esc(S.catalog.config_hash)}

任务创建时固定配置快照,历史结果不会随配置修改而改变。

`;} +function newJob(){$('#main').innerHTML=heading('新建现场验收','记录工位与批次,按当前配置检查全部应检设备。')+demoNote()+`

验收信息

${S.catalog.devices.length} DEVICES
${S.catalog.mode==='demo'?``:''}
同一工位同一时间执行一个任务。

本次应检

${S.catalog.devices.map(d=>`
${esc(d.name)}${esc(scopes[d.scope])}
${esc(d.connection_type.toUpperCase())}
`).join('')}
未响应设备保留为失败项;取消后未执行的设备单独标记。
`;} +function catalog(){$('#main').innerHTML=heading('应检设备清单','这份清单决定验收覆盖范围。设备无需先被扫描发现,才进入验收结果。','')+demoNote()+`

${S.catalog.devices.length} 个应检设备

当前配置
${S.catalog.devices.map(d=>``).join('')}
设备 / 标识接口类型接口 / 槽位协议验收范围
${device(d)}${esc(d.connection_type.toUpperCase())}${esc(d.interface||'按配置匹配')}${esc(d.protocol||'被动检查')}${esc(scopes[d.scope])}
配置摘要 ${esc(S.catalog.config_hash)}
${S.catalog.mode==='live'?'':''}`;} +async function history(){const d=await api(`/api/jobs?limit=20&offset=${S.offset}&sort=${S.sort}&status=${encodeURIComponent(S.filter)}&query=${encodeURIComponent(S.query)}`);$('#main').innerHTML=heading('任务历史','按工位、批次或操作者查找验收记录,选择两次任务查看恢复与退化。','')+`
${d.jobs.length?jobTable(d.jobs):empty('未找到任务','调整筛选条件或创建新任务。')}
共 ${d.total} 个任务

两次任务比较

${[['baseline','基线任务'],['current','当前任务']].map(([key,label])=>``).join('')}
`;} +function config(){$('#main').innerHTML=heading('校验设备配置','检查设备标识、接口参数和只读验收规则。校验不会修改工位当前配置。')+`

配置内容

VALIDATE ONLY
`;} +async function openJob(jobId){setView('task');S.job=null;$('#main').innerHTML=empty('正在读取任务','');S.job=(await api(`/api/jobs/${encodeURIComponent(jobId)}`)).job;task();poll(jobId);} +function poll(jobId){clearTimeout(S.timer);if(!S.job||done(S.job.status)||S.view!=='task')return;S.timer=setTimeout(async()=>{try{const d=await api(`/api/jobs/${jobId}`);if(S.view!=='task'||S.job?.id!==jobId)return;S.job=d.job;task();poll(jobId);}catch(e){notice(e.message,true);}},650);} +function task(){const j=S.job,s=j.summary,finished=done(j.status),pending=j.results.some(r=>r.verdict!=='PASS'),pct=j.progress.total?Math.round(j.progress.completed/j.progress.total*100):0; +$('#main').innerHTML=heading(j.metadata.station_id||'验收任务',`任务 ${id(j.id)} · ${j.parent_job_id?'指定设备复测':'设备验收'}`,`${finished?'':''}`)+ +`
${j.mode==='demo'?'SIMULATED / 合成验收记录':'LIVE / 现场验收记录'}

${j.parent_job_id?'复测验收':'工位验收'} · ${esc(j.metadata.batch||'无批次')}

操作者 ${esc(j.metadata.operator||'未记录')}创建 ${date(j.created_at)}执行 ${(j.duration_ms/1000).toFixed(2)} s
${pill(j.status)}
设备进度 ${j.progress.completed} / ${j.progress.total}${pct}%
${j.metadata.notes?`

${esc(j.metadata.notes)}

`:''}
本次配置摘要 ${esc(j.config_hash)}
`+ +(j.parent_job_id?'
本任务只复测原任务未通过设备。部分复测通过不代表整套设备验收通过。
':'')+ +`
${metric('应检设备',s.expected,'本次选定的验收范围')}${metric('通过',s.passed,'保留对应通信 / 被动证据','good')}${metric('失败 / 待确认',s.failed+s.review,'需要排查或现场确认','bad')}${metric('未执行',s.not_run,'等待执行或提前结束','warn')}

设备验收结果

${pill(s.verdict,'本次范围 '+verdicts[s.verdict])}
${j.results.map((r,i)=>``).join('')}
设备接口 / 协议验收结果判定依据耗时操作
${device(r)}${esc(r.interface||r.connection_type)}${esc(r.protocol||'被动检查')}${pill(r.verdict)}${esc(r.verdict==='PASS'?scopes[r.scope]:faults[r.fault_code]||r.fault_code)}${esc(r.summary)}${Number(r.duration_ms).toFixed(1)} ms
`+ +`

复测与交付

${finished&&pending?`
${j.mode==='demo'?``:''}
`:finished?'

已完成当前选定范围检查,证据与配置快照已保留。

':'

执行结束后可复测或导出。取消在当前设备组结束后生效。

'}${j.parent_job_id&&finished?'
':''}
${['HTML','JSON','CSV'].map(f=>``).join('')}
报告保留验收范围、报文证据与判定。模拟报告始终标记 SIMULATED。

任务记录

${j.events.map(e=>`
${esc(e.message)}
`).join('')}
`;} +function structuredEvidence(result) { + const attempts = result.attempts || [], supporting = result.supporting_checks || []; + return (attempts.length ? `
全部探测记录 (${attempts.length})
${esc(JSON.stringify(attempts, null, 2))}
` : '') + + (supporting.length ? `
载体检查证据 (${supporting.length})

载体检查支持本次设备判定,不扩大选定设备范围。

${esc(JSON.stringify(supporting, null, 2))}
` : ''); +} +function evidence(index){const r=S.job.results[index];$('#evidence-body').innerHTML=`
${pill(r.verdict)}${r.simulated?'SIMULATED':''}

${esc(r.name)}

${[['设备标识',r.device_id],['验收范围',scopes[r.scope]],['接口',r.interface||r.connection_type],['协议',r.protocol||'PCI 被动检查'],['判定依据',r.summary],['耗时',`${r.duration_ms} ms`]].map(([k,v])=>`
${k}
${esc(v)}
`).join('')}
${esc(r.request||'无报文 / 被动检查')}
${esc(r.response||'无响应记录')}
${structuredEvidence(r)}${r.verdict!=='PASS'?`
排查建议
${esc(r.suggestion)}
基于规则的建议,需要现场确认。
`:''}
此项结果只覆盖 ${esc(scopes[r.scope])},不覆盖电气动作或完整业务功能。
`;$('#evidence').showModal();} +async function compare(baseline,current){const c=(await api(`/api/compare?baseline=${encodeURIComponent(baseline)}¤t=${encodeURIComponent(current)}`)).comparison;$('#comparison-host').innerHTML=`

复测结果比较

${id(baseline)} → ${id(current)}
${!c.comparable?`
不满足可比条件:${esc(c.reasons.join(' · '))}。以下差异仅供查看。
`:c.partial_scope?'
部分范围比较:只检查共同设备,未宣称整套设备已恢复。
':'
模式、配置和选定设备范围一致,可比较验收状态。
'}
${[['恢复',c.recovered],['退化',c.regressed],['仍失败',c.still_failed],['未变化',c.unchanged]].map(([n,ids])=>`
${c.comparable?'':'观察到的'}${n}${ids.length}
`).join('')}
${[['恢复',c.recovered],['退化',c.regressed],['新增',c.added],[c.partial_scope?'未纳入本次复测':'移除',c.removed],['其他变化',c.changed]].filter(([,v])=>v.length).map(([n,ids])=>`
${n}${ids.map(v=>`${esc(v)}`).join('')}
`).join('')}
`;$('#comparison-host').scrollIntoView({behavior:'smooth'});} +async function download(format){const headers=S.token?{Authorization:`Bearer ${S.token}`}:{},r=await fetch(`/api/jobs/${S.job.id}/export?format=${format}`,{headers});if(!r.ok)throw new Error('报告导出失败');const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=`acceptance-${id(S.job.id)}.${format}`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),3000);} +document.addEventListener('click',async event=>{const b=event.target.closest('button');if(!b||b.disabled)return;try{if(b.dataset.view)return await navigate(b.dataset.view);switch(b.dataset.action){case 'open':await openJob(b.dataset.id);break;case 'evidence':evidence(Number(b.dataset.index));break;case 'close-evidence':$('#evidence').close();break;case 'credentials':$('#credentials').showModal();break;case 'close-credentials':$('#credentials').close();break;case 'filter':S.query=$('#query').value;S.filter=$('#status').value;S.sort=$('#sort').value;S.offset=0;await history();break;case 'prev':S.offset=Math.max(0,S.offset-20);await history();break;case 'next':S.offset+=20;await history();break;case 'cancel':await api(`/api/jobs/${S.job.id}/cancel`,'POST',{});await openJob(S.job.id);break;case 'retest':{b.disabled=true;const body={};if($('#retest-scenario'))body.scenario=$('#retest-scenario').value;const d=await api(`/api/jobs/${S.job.id}/retest`,'POST',body);await openJob(d.job.id);break;}case 'compare-parent':await compare(S.job.parent_job_id,S.job.id);break;case 'export':await download(b.dataset.format);break;case 'load-config':$('textarea[name="config"]').value=JSON.stringify(S.catalog.config_snapshot,null,2);break;}}catch(e){notice(e.message,true);if(b.isConnected)b.disabled=false;}}); +document.addEventListener('submit',async event=>{event.preventDefault();const form=event.target,b=form.querySelector('[type="submit"]');try{if(b)b.disabled=true;const v=Object.fromEntries(new FormData(form));if(form.id==='job-form')await openJob((await api('/api/jobs','POST',v)).job.id);if(form.id==='compare-form'){if(v.baseline===v.current)throw new Error('请选择两个不同任务');await compare(v.baseline,v.current);}if(form.id==='config-form'){const d=await api('/api/config/validate','POST',{config:JSON.parse(v.config)});$('#validation').hidden=false;$('#validation').textContent=JSON.stringify(d,null,2);}if(form.id==='credentials-form'){S.token=v.token;sessionStorage.setItem('qldc.token',S.token);$('#credentials').close();await boot();}}catch(e){notice(e.message,true);}finally{if(b?.isConnected)b.disabled=false;}}); +async function boot(){try{S.catalog=await api('/api/catalog');$('#station-mode').textContent=S.catalog.mode==='demo'?'离线演示工位':'本地现场工位';$('#mode-badge').textContent=S.catalog.mode==='demo'?'SIMULATED / DEMO':'LIVE / LOCAL';await navigate('overview');}catch(e){notice(e.message,true);$('#main').innerHTML=empty('连接工位后开始验收','服务不可用或需要访问凭证,请通过右上角输入令牌。');}} +boot(); diff --git a/web/assets/style.css b/web/assets/style.css new file mode 100644 index 0000000..12083a6 --- /dev/null +++ b/web/assets/style.css @@ -0,0 +1,3 @@ +:root{--nav:#142b38;--ink:#1a3442;--muted:#6b8190;--line:#dfe7eb;--accent:#087d79;--bg:#f3f6f8;--red:#b8413b;--mono:Consolas,monospace}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.6 "Segoe UI","Microsoft YaHei",sans-serif}button,input,textarea,select{font:inherit}button{cursor:pointer;border:0}button:disabled{opacity:.5;cursor:wait}a{color:var(--accent);text-decoration:none}h1,h2,h3,p{margin-top:0}h1{font-size:30px;line-height:1.3;letter-spacing:-.8px;margin-bottom:12px}h2{font-size:19px;margin-bottom:12px}h3{font-size:14px}small{font-size:11px}.muted{color:var(--muted)}.mono{font-family:var(--mono);font-size:11px;overflow-wrap:anywhere}.eyebrow{display:block;font:10px var(--mono);letter-spacing:2px;color:var(--accent);margin-bottom:10px}aside{width:234px;position:fixed;inset:0 auto 0 0;background:var(--nav);padding:28px 18px;display:flex;flex-direction:column;color:#a8c0cb}.brand{display:flex;gap:11px;align-items:center;color:#fff;font-weight:700;font-size:14px;letter-spacing:1px;margin:0 8px 46px}.brand b{position:relative;width:42px;height:42px;display:grid;place-items:center;border:1px solid #557582;border-radius:9px;background:#1b3b4b;font-size:19px;letter-spacing:-1px}.brand b span{color:#68dbb9;position:absolute;right:3px;top:-7px}.brand small{display:block;font-size:8px;letter-spacing:2px;color:#87a5b5}.nav-label{font-size:10px;letter-spacing:2px;padding:0 14px;margin-bottom:12px;color:#6c8a9b}nav button{display:block;width:100%;text-align:left;padding:13px 14px;border-radius:6px;background:none;color:#c2d2db;margin-bottom:5px;font-size:13px}nav button:hover{background:#234454}nav button.active{background:#244b58;color:#90dfc7}.station{margin-top:auto;border-top:1px solid #2e4754;padding:20px 10px 0;font-size:11px}.station i{display:inline-block;width:7px;height:7px;background:#5dd3b1;border-radius:50%;margin-right:8px}.station small{display:block;margin-top:7px;font-size:9px;color:#7894a3}.workspace{margin-left:234px}header{height:72px;padding:0 35px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}header span{color:var(--muted)}header strong{font-weight:500}header code{font:9px var(--mono);letter-spacing:1px;background:#edf8f4;border:1px solid #bfded3;color:#397b65;padding:4px 8px;border-radius:4px}.actions{display:flex;gap:9px;flex-wrap:wrap;align-items:center}.text{color:var(--muted);background:none;font-size:11px;padding:5px}.primary,.secondary,.danger{display:inline-flex;align-items:center;justify-content:center;min-height:39px;padding:9px 16px;border-radius:5px;font-size:12px;font-weight:600;white-space:nowrap}.primary{background:var(--accent);color:white}.primary:hover{background:#05635f}.secondary{background:white;border:1px solid #ccd9df;color:#476572}.danger{background:#fff0ee;border:1px solid #efcbc5;color:var(--red)}.link{background:transparent;color:var(--accent);font-size:11px;padding:5px 0;white-space:nowrap}main{max-width:1500px;padding:34px 35px;min-height:calc(100vh - 132px);margin:auto}.heading{display:flex;justify-content:space-between;gap:22px;align-items:flex-start;margin-bottom:26px}.heading p{color:var(--muted);font-size:13px;max-width:700px;margin:0}.heading .actions{align-self:center}.hero{background:linear-gradient(110deg,#173a4a,#23525a);border-radius:9px;color:white;padding:30px;display:flex;gap:30px;justify-content:space-between;margin-bottom:25px}.hero h2{font-size:24px;margin-bottom:9px}.hero p{color:#b7d3d9;font-size:12px;margin:0;max-width:650px}.hero .eyebrow{color:#85e0c1}.hero .primary{align-self:center;background:#b5ead5;color:#1b5349}.tag{display:inline-block;font:9px var(--mono);letter-spacing:2px;border:1px solid #51737b;padding:4px 8px;border-radius:4px;color:#b9dfd8;margin-bottom:18px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:26px}.metric{border:1px solid var(--line);border-radius:8px;background:white;padding:20px;position:relative}.metric-label{font-size:12px;color:var(--muted)}.metric strong{display:block;font:33px/1.4 var(--mono);letter-spacing:-1px;margin:8px 0 4px}.metric small{font-size:10px;color:#8295a1}.metric:after{content:'';position:absolute;right:18px;top:23px;width:6px;height:6px;border-radius:50%;background:#8faeba}.metric.good:after{background:#38a979}.metric.bad:after{background:#ce625b}.metric.warn:after{background:#cea04a}.columns{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(270px,1fr);gap:21px}.card{background:white;border:1px solid var(--line);border-radius:8px;margin-bottom:22px;min-width:0}.card-head{padding:18px 21px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}.card-head h2,.card-head h3{margin:0;font-size:15px}.card-head small{color:var(--muted)}.pad{padding:23px}.note{background:#eef6f6;border-left:3px solid var(--accent);padding:12px 15px;color:#477275;border-radius:0 4px 4px 0;font-size:12px;margin:18px 0;overflow-wrap:anywhere}.note.warn{background:#fff6e5;border-color:#cba040;color:#8a672c}.empty{padding:40px 24px;text-align:center;color:var(--muted);font-size:12px}.empty strong{display:block;font-size:15px;color:#486574;margin-bottom:7px}.empty button{margin-top:14px}.table-wrap{overflow:auto;max-width:100%}table{border-collapse:collapse;width:100%;text-align:left}th{font-size:10px;font-weight:600;color:#7b909b;background:#f8fafb;padding:12px 17px;white-space:nowrap;border-bottom:1px solid var(--line)}td{padding:15px 17px;font-size:12px;border-bottom:1px solid #edf1f3;vertical-align:middle}td small{display:block;color:var(--muted);font-size:10px;margin-top:3px}tbody tr:last-child td{border-bottom:0}.device{display:flex;align-items:center;gap:10px;min-width:190px}.symbol{flex-shrink:0;width:33px;height:33px;border-radius:7px;display:grid;place-items:center;background:#edf4f7;color:#7697a5;font-size:18px}.pill{display:inline-flex;align-items:center;gap:5px;font-size:10px;white-space:nowrap;padding:3px 9px;border-radius:4px;background:#edf2f5;color:#698190}.pill:before{content:'';width:4px;height:4px;border-radius:50%;background:currentColor}.pill.PASS,.pill.completed{background:#e7f4ed;color:#26744e}.pill.FAIL,.pill.failed{background:#fbece9;color:#b8413b}.pill.REVIEW,.pill.cancelling{background:#fff2dc;color:#986a19}.pill.running,.pill.queued{background:#e6f1f8;color:#387f9a}.pill.NOT_RUN,.pill.cancelled,.pill.interrupted{background:#edf1f4;color:#778b97}.row{display:flex;justify-content:space-between;align-items:center;padding:13px 0;border-bottom:1px solid #edf1f3;gap:14px}.row:last-child{border-bottom:0}.row strong{font-size:12px}.row span{font:20px var(--mono);color:#527c8b}.row small{display:block;color:var(--muted)}.step{display:flex;gap:14px;margin-bottom:18px}.step>span{width:27px;height:27px;flex-shrink:0;display:grid;place-items:center;border-radius:50%;background:#edf6f4;color:var(--accent);border:1px solid #d7e9e2;font:11px var(--mono)}.step strong{font-size:12px}.step p{font-size:11px;color:var(--muted);margin:4px 0 0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:19px}label{display:block;font-size:12px;font-weight:600;color:#466573}input,select,textarea{width:100%;min-width:0;margin-top:7px;padding:10px 12px;border:1px solid #cad8de;border-radius:5px;color:var(--ink);background:white;font-weight:400}textarea{resize:vertical;min-height:105px}input:focus,textarea:focus,select:focus{outline:2px solid #6baba5;outline-offset:1px}.full{grid-column:span 2}.form-actions{margin-top:20px;justify-content:flex-end}.form-foot{padding:18px 23px;border-top:1px solid var(--line);display:flex;gap:16px;align-items:center;justify-content:space-between}.form-foot small{color:var(--muted)}.filters{display:flex;align-items:flex-end;gap:12px;padding:16px 21px;flex-wrap:wrap;border-bottom:1px solid var(--line)}.filters label{font-size:10px}.filters input{width:220px}.filters select{width:150px}.pager{padding:15px 21px;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);gap:12px}.task-top{display:flex;justify-content:space-between;gap:16px}.task-top h2{margin-bottom:4px}.task-info{display:flex;gap:15px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-top:15px}.progress{background:#e6eef0;height:6px;border-radius:6px;overflow:hidden;margin:20px 0 8px}.progress>div{height:100%;background:var(--accent);transition:width .3s}.progress-label{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}.hash{background:#f9fbfc;border-top:1px solid var(--line);padding:12px 21px;font-size:10px;color:#7b909c;overflow-wrap:anywhere}.event{display:flex;gap:12px;font-size:11px;padding:9px 0;overflow-wrap:anywhere}.event time{font-family:var(--mono);color:#91a0aa;white-space:nowrap}.compare-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.compare-tile{background:#f3f7f8;padding:17px;border-radius:6px}.compare-tile span{font-size:11px;color:var(--muted);display:block}.compare-tile strong{display:block;font:27px var(--mono);color:var(--accent);margin-top:7px}.compare-list{display:flex;gap:9px;flex-wrap:wrap;margin-top:15px;font-size:11px}.compare-list code{background:#edf4f5;padding:4px 7px;border-radius:4px;overflow-wrap:anywhere}.retake{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap}.retake label{width:180px;font-size:10px}#notice{margin:18px 35px 0;border:1px solid #ebd7b1;background:#fff6e7;color:#885f22;padding:12px 16px;border-radius:6px;overflow-wrap:anywhere}#notice.error{border-color:#eccbc6;background:#fff0ed;color:#a63b32}dialog{padding:0;width:min(680px,calc(100vw - 30px));border:1px solid var(--line);border-radius:10px;color:var(--ink);max-height:90vh;overflow:auto;box-shadow:0 20px 60px #142b3840}dialog::backdrop{background:#0f2c3e70;backdrop-filter:blur(2px)}dialog .card-head h2{font-size:19px}.evidence-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px}.evidence-grid dt{font-size:10px;color:var(--muted)}.evidence-grid dd{font-size:12px;margin:4px 0 0;overflow-wrap:anywhere}pre{font:11px/1.8 var(--mono);background:#15313f;color:#c6e3e8;padding:16px;border-radius:6px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:230px;overflow:auto}footer{padding:18px 35px;border-top:1px solid var(--line);font-size:10px;color:#8a9da8;display:flex;justify-content:space-between;gap:12px} +@media(max-width:1100px){aside{width:210px}.workspace{margin-left:210px}main{padding:27px 23px}header{padding:0 23px}.columns{grid-template-columns:1fr}.metrics{gap:10px}.metric{padding:17px}.metric strong{font-size:29px}} +@media(max-width:760px){aside{position:relative;width:100%;padding:15px 16px 9px}.brand{margin:0 0 14px;font-size:12px}.brand b{width:33px;height:33px;font-size:16px}.nav-label,.station{display:none}nav{display:flex;overflow-x:auto;gap:4px}nav button{width:auto;white-space:nowrap;flex-shrink:0;padding:8px 9px;font-size:11px;margin:0}.workspace{margin-left:0}header{height:58px;padding:0 17px;font-size:11px}header>div>span{display:none}header code{font-size:8px;letter-spacing:0}header .actions{gap:6px}.text{font-size:10px}main{padding:23px 16px}h1{font-size:25px}.heading{display:block;margin-bottom:22px}.heading p{font-size:12px}.heading .actions{margin-top:15px}.hero{padding:22px;display:block}.hero h2{font-size:21px}.hero .primary{margin-top:20px}.hero p{font-size:11px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:20px}.metric{padding:16px}.metric strong{font-size:28px}.metric small{font-size:9px}.metric-label{font-size:11px}.columns{gap:0}.card-head{padding:15px 16px}.pad{padding:17px}.form-grid{grid-template-columns:1fr;gap:15px}.full{grid-column:span 1}.form-foot{padding:17px;display:block}.form-foot .primary{width:100%;margin-top:14px}.filters{padding:15px;gap:10px}.filters label{flex:1}.filters input,.filters select{width:100%}td,th{padding:12px}.task-top{display:block}.task-top>.pill,.task-top>[data-testid]{display:inline-block;margin-top:12px}.task-info{gap:8px}.hash{padding:12px 16px}.event{display:block}.event time{display:block;margin-bottom:3px}.evidence-grid{grid-template-columns:1fr}.compare-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.compare-tile{padding:13px}.retake label{flex:1;min-width:140px}.retake .primary{width:100%}footer{padding:17px;display:block}footer span{display:block;margin-top:4px}#notice{margin:14px 16px 0;font-size:12px}.pager{padding:15px}} diff --git a/web/index.html b/web/index.html index 977b98d..0716fa5 100644 --- a/web/index.html +++ b/web/index.html @@ -1,624 +1,8 @@ - - - - - 工控机设备检测 - - +QL DeviceCheck · 现场验收工作台 -
-
-

工控机设备检测

-
- - -
-
- -
- 等待扫描 -
- -
-
-
-
串

串口

-
0 个设备
-
-
尚未扫描
-
- -
-
-
网

网口

-
0 个设备
-
-
尚未扫描
-
- -
-
-
PCI

PCI

-
0 个设备
-
-
尚未扫描
-
- -
-
-
报

测试报告

-
-
-
尚未生成报告
-
-
-
-
- - - - + +
验收中心 / 工位总览
CONNECTING
正在读取工位数据…
QL DeviceCheck 现场记录保存在当前工位 · v3.0
+
PROTOCOL EVIDENCE

设备测试证据

+

连接受保护的工位

令牌仅用于本次浏览器会话,不写入报告。

+ From e0dc7fcd990d5420d28e11a1da4a3f5d1ec538de Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:23:59 +0800 Subject: [PATCH 08/15] docs: document field acceptance workflows and verified release delivery --- CHANGELOG.md | 13 ++++ README.md | 72 +++++++++++------- README_zh.md | 72 +++++++++++------- docs/API.md | 45 +++++++++++ docs/ARCHITECTURE.md | 53 +++++++++++++ docs/DEMO.md | 26 +++++++ docs/DEPLOYMENT.md | 32 ++++++++ docs/VALIDATION.md | 48 ++++++++++++ docs/screenshots/mobile.png | Bin 0 -> 88823 bytes docs/screenshots/overview.png | Bin 0 -> 196465 bytes docs/screenshots/report.png | Bin 0 -> 129116 bytes docs/screenshots/retest.png | Bin 0 -> 111033 bytes .../2026-10-07-field-acceptance-workbench.md | 6 +- 13 files changed, 310 insertions(+), 57 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEMO.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/VALIDATION.md create mode 100644 docs/screenshots/mobile.png create mode 100644 docs/screenshots/overview.png create mode 100644 docs/screenshots/report.png create mode 100644 docs/screenshots/retest.png diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f642f8b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 3.0 — 2026-10-07 + +- Add a configuration-driven acceptance workbench with station/batch/operator metadata, async progress and cooperative cancellation. +- Persist task snapshots, results and events in SQLite; mark unfinished tasks interrupted on restart without hardware replay. +- Keep expected missing devices as failures; separate execution status from inspection verdict. +- Add failed-only retest lineage, scope-aware comparison, HTML/JSON/CSV evidence reports and separate demo/live metrics. +- Add a hardware-independent synthetic demo, local responsive browser assets and a compatibility UI at `/legacy`. +- Assemble fragmented configured active TCP responses; validate Modbus byte counts, quantities and frame identity. +- Add API and protocol regressions, a demo browser workflow, source/artifact dependency provenance and pinned offline CI. + +The project owner confirmed the project was tested and is usable. This upgrade's automated checks and release-package validation are recorded in [validation](docs/VALIDATION.md). diff --git a/README.md b/README.md index 9fc38a9..e32698c 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,65 @@ -# Industrial Device Check +# QLDeviceCheck · Field Acceptance Workbench -[简体中文](README_zh.md) · [Configuration guide](config/CONFIG_GUIDE.md) · [MIT License](LICENSE) +[简体中文](README_zh.md) · [Demo](docs/DEMO.md) · [Architecture](docs/ARCHITECTURE.md) · [API](docs/API.md) · [Validation](docs/VALIDATION.md) · [MIT](LICENSE) -A Linux Web UI for discovering known industrial devices, checking protocol responses, and generating inspection reports. Configuration-driven matching connects serial, network, and PCI interfaces to the equipment being tested. +A local acceptance station for industrial device vendors and system integrators. Turn an expected equipment list into a traceable inspection: record what was tested, preserve protocol evidence, and compare results after repairs. -## Capabilities +![Synthetic demo of the acceptance workbench](docs/screenshots/overview.png) -| Interface | Checks | +## A workflow for delivery and repair + +| Field problem | Workbench behavior | | --- | --- | -| Serial | Modbus RTU read functions 01–04; configured custom requests and response matching | -| Network | Modbus TCP, TCP connectivity, custom TCP/UDP probes | -| PCI | Passive inspection of link state, driver binding, and associated interface nodes | +| A disconnected device disappears from a discovery list | Every selected configured device gets a result; missing devices remain failures. | +| A report cannot be tied to the configuration used | Each task keeps the full configuration snapshot, SHA-256, station/unit ID, batch, operator and timestamps. | +| Rechecking a repair loses the original evidence | Failed-only retests retain their parent task and original snapshot. Comparison shows recovery, regression and scope limits. | +| A demonstration needs field hardware | `--demo` uses an independent synthetic catalog, visibly stamps reports `SIMULATED`, and separates demo/live statistics. | + +The intended position is a lightweight Linux field acceptance workflow with local evidence ownership. The project owner has confirmed the project was tested and is usable. See the [design and positioning rationale](docs/superpowers/specs/2026-10-07-field-acceptance-design.md) and [verification records](docs/VALIDATION.md). -The workflow supports full scans, individual interface scans, and device retests. Reports are generated in HTML, TXT, and JSON. The `via_device` configuration models devices reached through a shared PLC or gateway connection. +## Try the demo -## Run +For a ready-to-install bundle, download the Windows x64 or Linux x86_64 package from [Releases](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/releases). Bundles include offline runtime dependencies. Install CPython 3.10+ first; then double-click `Start-Demo.cmd` on Windows or run `bash run_demo.sh` on Linux. [Release guide](docs/RELEASE.md). -Requirements: Linux, Python 3.7+, and permission to access the selected interfaces. +Python **3.10+**. The demo and offline checks also run on Windows. ```bash -bash setup_linux.sh -bash run_web.sh +python -m venv .venv +source .venv/bin/activate +python -m pip install --require-hashes -r requirements-runtime.lock +python web_app.py --demo --data-dir data/demo ``` -Open . +On PowerShell, activate with `.\.venv\Scripts\Activate.ps1`. Open [localhost:8080](http://127.0.0.1:8080), create a **mixed faults** task, inspect its evidence, then retest with **healthy**. [Demo walkthrough](docs/DEMO.md). -## Configure and verify - -Define equipment in [config/device_list.json](config/device_list.json), using [device_list_examples.json](config/device_list_examples.json) and the [configuration guide](config/CONFIG_GUIDE.md). +## Field mode ```bash -python3 validate_config.py -python3 self_test.py +bash setup_linux.sh +bash run_web.sh --config config/device_list.json --data-dir data/live ``` -Configuration validation and the internal self-test do not access field devices. +Review the target equipment and custom request bytes before executing a live task. Built-in acceptance rejects write-labelled operations and Modbus functions outside 01–04. PCI checks are passive. Communication checks do not establish electrical performance or complete business functionality. -## Implementation +| Interface | Existing inspection capabilities | +| --- | --- | +| Serial | Modbus RTU reads 01–04; configured custom request/response matching | +| Network | Modbus TCP, TCP connectivity, custom TCP/UDP checks | +| PCI | Link/driver/interface inspection, including configured child devices | -- [web_app.py](web_app.py): HTTP API, task coordination, and report generation. -- [core/config_manager.py](core/config_manager.py): configuration loading and validation. -- [core/generic_detector.py](core/generic_detector.py): discovery and inspection workflow. -- [core/protocol_engine.py](core/protocol_engine.py): protocol requests and response validation. -- [web/index.html](web/index.html): browser interface. +The original scan UI remains at `/legacy` in live mode, sharing the station execution lock. Its reports remain separate from acceptance history. See [configuration](config/CONFIG_GUIDE.md) and [deployment](docs/DEPLOYMENT.md) for operational boundaries. -## Device access +## Engineering + +The new `inspection/` domain, service, store, adapters, HTTP and report modules isolate the browser workflow from the existing protocol core. SQLite persists task state and evidence; restart marks unfinished work interrupted without replaying hardware operations. A single process owns each station database. + +The browser uses local assets without a CDN or frontend framework. Default binding is `127.0.0.1`; network binding requires `QLDC_ACCESS_TOKEN`. The service includes bounded inputs, Host/Origin checks and escaped exports. It does not include multi-user roles or a TLS terminator. + +```bash +python -m pip install --require-hashes -r requirements.lock +python validate_config.py +python self_test.py +python -m pytest -q +``` -Built-in Modbus checks use read-only function codes; PCI checks are passive. Custom requests can have different semantics and must be reviewed for the target equipment. Use an isolated test environment before field testing, and keep the Web UI on a trusted network. +[Validation](docs/VALIDATION.md) records tested paths and remaining hardware risks. [Dependencies](docs/DEPENDENCIES.md) records pinned releases, source commits, licenses and artifact hashes. CI covers offline checks on Windows/Linux with Python 3.10/3.13. diff --git a/README_zh.md b/README_zh.md index 54fe538..57aaa80 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,49 +1,65 @@ -# 工业设备检测系统 +# QLDeviceCheck · 工控设备验收工作台 -[English](README.md) · [配置指南](config/CONFIG_GUIDE.md) · [MIT 许可证](LICENSE) +[English](README.md) · [演示](docs/DEMO.md) · [架构](docs/ARCHITECTURE.md) · [API](docs/API.md) · [验证记录](docs/VALIDATION.md) · [MIT](LICENSE) -面向 Linux 的设备发现与协议连通性检测工具,通过 Web 界面完成扫描、测试和报告生成。系统根据配置匹配串口、网络和 PCI 接口与已知设备,支持按接口、设备或全量执行检查。 +面向工控设备厂商和系统集成商的本地验收工位。以应检设备清单为起点,记录本次检查范围、协议证据与交付信息,修复后保留原始记录并比较复测结果。 -## 检测能力 +![验收工作台合成演示](docs/screenshots/overview.png) -| 接口 | 检查内容 | +## 围绕交付与维修组织功能 + +| 现场问题 | 工作台的处理方式 | | --- | --- | -| 串口 | Modbus RTU 只读功能码 01–04;自定义请求与响应匹配 | -| 网络 | Modbus TCP、TCP 连通性、自定义 TCP/UDP 探测 | -| PCI | 被动检查链路状态、驱动绑定及关联接口节点 | +| 设备断开后从扫描列表消失 | 每个选定的配置设备都有结果,缺失设备保留失败记录。 | +| 报告无法对应当时的配置 | 每次任务保存完整配置快照、SHA-256、工位或设备编号、批次、操作者和时间。 | +| 修复后重跑覆盖原有证据 | 失败项复测关联原任务并沿用原配置;比较恢复、退化及覆盖范围。 | +| 演示依赖现场设备 | `--demo` 使用独立合成清单,报告标注 `SIMULATED`,模拟与真实任务分别统计。 | -支持全量扫描、单接口扫描和单设备复测,生成 HTML、TXT、JSON 报告。`via_device` 配置用于表示通过 PLC 或网关共享连接的下级设备。 +产品定位是轻量的 Linux 现场验收流程与本地证据管理。项目方已确认项目经过测试、可以使用;[设计文档](docs/superpowers/specs/2026-10-07-field-acceptance-design.md)说明定位依据,[验证记录](docs/VALIDATION.md)记录本次升级的检查结果。 -## 运行 +## 体验演示 -需要 Linux、Python 3.7+ 及对应接口的访问权限。 +直接安装可从 [Releases](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/releases) 下载 Windows x64 或 Linux x86_64 包,内含离线运行依赖。先安装 CPython 3.10+,Windows 双击 `Start-Demo.cmd`,Linux 运行 `bash run_demo.sh`。[Release 使用说明](docs/RELEASE.md)。 + +需要 Python **3.10+**;Windows 也可运行演示及离线测试。 ```bash -bash setup_linux.sh -bash run_web.sh +python -m venv .venv +source .venv/bin/activate +python -m pip install --require-hashes -r requirements-runtime.lock +python web_app.py --demo --data-dir data/demo ``` -浏览器访问 。 +PowerShell 使用 `.\.venv\Scripts\Activate.ps1` 激活环境。浏览器打开 [localhost:8080](http://127.0.0.1:8080),创建“混合故障”任务、查看报文证据,再选择“全部正常”复测。[完整演示步骤](docs/DEMO.md)。 -## 配置与验证 - -在 [config/device_list.json](config/device_list.json) 中定义设备,参考 [device_list_examples.json](config/device_list_examples.json) 和 [配置指南](config/CONFIG_GUIDE.md)。 +## 现场运行 ```bash -python3 validate_config.py -python3 self_test.py +bash setup_linux.sh +bash run_web.sh --config config/device_list.json --data-dir data/live ``` -配置校验与内部自测不会访问现场设备。 +执行真实任务前审核目标设备及自定义报文。内置验收拒绝标注为写操作的配置及 01–04 以外的 Modbus 功能码,PCI 检查采用被动方式。通信检查结果不代表电气性能或完整业务功能通过。 -## 实现 +| 接口 | 现有检测能力 | +| --- | --- | +| 串口 | Modbus RTU 只读功能码 01–04;自定义请求与响应匹配 | +| 网络 | Modbus TCP、TCP 连通性、自定义 TCP/UDP 检查 | +| PCI | 链路、驱动、接口节点检查,支持配置下级设备 | + +真实模式保留 `/legacy` 原扫描界面,与验收任务共用工位执行锁;其报告单独存放,不计入新验收历史。现场接入参考[配置指南](config/CONFIG_GUIDE.md)和[部署说明](docs/DEPLOYMENT.md)。 + +## 工程实现 -- [web_app.py](web_app.py):HTTP API、检测任务协调与报告生成。 -- [core/config_manager.py](core/config_manager.py):配置加载与校验。 -- [core/generic_detector.py](core/generic_detector.py):设备发现与检测流程。 -- [core/protocol_engine.py](core/protocol_engine.py):协议请求与响应校验。 -- [web/index.html](web/index.html):浏览器界面。 +新增 `inspection/` 领域、服务、存储、适配器、HTTP 与报告模块,将浏览器验收流程和已有协议核心分开。SQLite 持久化任务与证据;重启后将未完成任务标记为中断,不自动重放硬件操作。每个工位数据库只允许一个服务进程使用。 -## 设备访问 +前端使用本地资源,无 CDN 或前端框架。默认监听 `127.0.0.1`,网络监听必须设置 `QLDC_ACCESS_TOKEN`;提供输入限制、Host/Origin 校验及导出转义。当前未实现多用户权限和 TLS 终止。 + +```bash +python -m pip install --require-hashes -r requirements.lock +python validate_config.py +python self_test.py +python -m pytest -q +``` -内置 Modbus 检查使用只读功能码,PCI 检查采用被动方式。自定义报文的行为取决于设备协议,使用前应单独审核。现场测试前先在隔离环境验证,并将 Web 界面限制在可信网络内。 +[验证记录](docs/VALIDATION.md)列明已测路径与未覆盖的真实硬件风险;[依赖记录](docs/DEPENDENCIES.md)提供固定版本、来源 commit、许可证及制品哈希。CI 在 Windows/Linux、Python 3.10/3.13 运行离线检查。 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6c48551 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,45 @@ +# API v3 / 本地验收接口 + +Base URL defaults to `http://127.0.0.1:8080`. JSON is UTF-8. When `QLDC_ACCESS_TOKEN` is set, send `Authorization: Bearer ` for `/api/*` and `/report/*`; never place credentials in query strings. Browser requests must use the station's own origin. POST bodies are JSON objects with a 64 KiB maximum. + +| Method | Route | Behavior | +| --- | --- | --- | +| GET | `/api/health` | Service mode, version, active job and persistence status | +| GET | `/api/catalog` | Current expected devices, full configuration, SHA-256 and available scenarios | +| POST | `/api/config/validate` | Validate `{ "config": {...} }` without saving or running it | +| POST | `/api/jobs` | Create a task; returns 202 and `{ok,job}` | +| GET | `/api/jobs` | List `{ok,jobs,total,limit,offset}`; `limit` 1–100, `offset` 0–1,000,000, optional `status`, `query` and `sort=newest/oldest` | +| GET | `/api/jobs/{id}` | Full persisted task record | +| POST | `/api/jobs/{id}/cancel` | Request cooperative cancellation; finished tasks remain unchanged | +| POST | `/api/jobs/{id}/retest` | Create non-passing-device retest using original snapshot; returns 202 | +| GET | `/api/jobs/{id}/export?format=html` | Download HTML, JSON or CSV with evidence and snapshot | +| GET | `/api/compare?baseline={id}¤t={id}` | Scope-aware result difference, comparability reasons and coverage flags | +| GET | `/api/insights` | Separate `demo` and `live` counts; passed/failed count completed tasks only | + +Example task payload: + +```json +{ + "station_id": "DEMO-FAT-01", + "batch": "DEMO-2026-10", + "operator": "Demo engineer", + "notes": "Synthetic walkthrough", + "scenario": "faults" +} +``` + +Omit `device_ids` to include the complete catalog; provide an array of known unique IDs to select a subset. Metadata strings have 128-character limits, except notes at 2,000. `scenario` is `healthy`, `faults`, `timeout`, `crc` or `missing` in demo; live accepts only `healthy` (the scenario label does not predetermine actual device results). Metadata may alternatively be nested under `metadata`. + +Retest accepts `scenario`, `operator`, `notes`, and optional non-passing `device_ids`; it cannot change unit/batch, widen scope or switch mode. An empty eligible set is rejected. The returned task has `parent_job_id`. + +## Record semantics + +Task IDs are 32 lowercase hex characters. Times are ISO 8601 UTC. Status: `queued`, `running`, `cancelling`, `completed`, `cancelled`, `failed`, `interrupted`. Execution completion is independent of inspection success: `completed` can have a `FAIL` summary. Each result includes device/interface/protocol identity, scope, verdict, fault code, summary, rule-based suggestion, request, response, duration, `simulated`, structured `attempts` and `supporting_checks` for parent-carrier evidence. + +`communication_path` describes the configured connectivity/protocol check. `pci_passive` describes passive PCI observations. Neither establishes full device functionality. A partial retest's successful summary only covers its selected devices. Comparable repair conclusions require matching nonempty station/unit IDs and matching batch, mode, configuration and evidence scope. Differences remain visible as observations when those conditions fail. Compare flags and report coverage must be considered before drawing wider conclusions. + +Errors use `{ "ok": false, "error": "..." }`: 400 invalid fields/configuration, 401 token required, 403 origin/host or mode restriction, 404 missing resource, 409 station busy/service conflict, 413 oversized input, 500 unexpected failure. Errors omit exception traces and private paths. Do not automatically replay a hardware POST after an ambiguous connection failure; first inspect current task/history. + +## Legacy interfaces + +`/api/scan`, `/api/scan-interface`, `/api/run`, `/api/test-device`, `/api/standards` retain the original live interface and return `legacy: true`. They are disabled in demo. `/legacy` is the compatibility UI; `/report/` serves existing report files. Legacy operations share the acceptance execution lock but do not create new SQLite acceptance records. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..652719c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,53 @@ +# Architecture / 架构 + +## Data and execution flow + +```mermaid +flowchart LR + Browser[Local browser assets] --> HTTP[inspection.http] + HTTP --> Service[InspectionService] + Service --> Domain[Catalog / verdicts / immutable snapshot] + Service --> Store[(SQLite task records)] + Service --> Demo[DemoAdapter: synthetic only] + Service --> Live[LiveAdapter] + Live --> Core[Existing GenericDetector / protocol core] + HTTP --> Reports[HTML / JSON / CSV] + HTTP --> Compare[Scope-aware comparison] + HTTP --> Legacy[Legacy API + shared hardware lock] +``` + +`web_app.py` parses station options and owns service shutdown. `inspection/http.py` serves an allowlisted set of assets and handles API access checks. `domain.py` defines validation and verdict aggregation. `service.py` owns the worker, state transitions and snapshot-based retests. `store.py` commits whole job records atomically in SQLite WAL mode. Adapters normalize results from either synthetic devices or the existing detector. Reports and comparisons operate on stored records without accessing hardware. + +## State and evidence + +```mermaid +stateDiagram-v2 + [*] --> queued + queued --> running + queued --> cancelling + running --> cancelling + running --> completed + running --> failed + cancelling --> cancelled + queued --> interrupted: restart / shutdown + running --> interrupted: restart / shutdown + cancelling --> interrupted: restart / shutdown +``` + +Each selected configured device starts `NOT_RUN`. The adapter preserves missing devices as `FAIL`; task summary aggregates `PASS`, `FAIL`, `REVIEW`, `NOT_RUN`. A cancelled, failed or interrupted task cannot be classified as fully passed even when previously emitted results passed. Progress advances after device results; live carrier/child groups execute together. Cancellation waits for a group boundary and does not forcibly interrupt a serial/socket call. + +Task creation deep-copies configuration, hashes canonical JSON and commits it with metadata before execution. Results and events persist after each update. Retests use that original full snapshot and allow only original non-passing device IDs. Parent IDs retain the evidence chain. Structured attempts preserve every attributed protocol probe, including earlier failed attempts; child-only results retain parent-carrier supporting checks without adding the carrier to the selected result scope. The SHA-256 identifies configuration content; it is not a digital signature or tamper-proof audit log. + +Comparisons check unit/station identity, batch, mode, configuration hash and selected/evidence scope. Related failed-only retests can compare shared devices, while declaring partial coverage. Communication-path and passive PCI checks are separate from electrical or business acceptance. + +## Ownership and compatibility + +One process owns one database, enforced by an OS-held ownership lease acquired before recovery. A second service cannot recover or execute against an owned database; process exit releases the lease. New tasks and legacy calls also share a station mutex. Do not operate the same physical station through different data directories: database ownership does not provide hardware scheduling across separate databases. + +The original `web_app.py` implementation is preserved as `legacy_web.py`; the old UI is served at `/legacy`. Legacy reports under `report/` remain independent of SQLite history. The configured active TCP path now assembles fragmented responses with bounded reads; Modbus validation checks frame length, byte counts, identity and expected quantity. + +## Local deployment choices + +The design uses Python's standard-library HTTP server, threads and SQLite to keep a single-station deployment small. This is intended for a trusted local workstation, not a multi-tenant Internet service. A network-bound station requires a bearer token; managed TLS, user roles, retention, signed reports, distributed scheduling and production service supervision would be separate work. + +Demo mode constructs an independent catalog and never imports the real detector through the normal service/API workflow. Scenarios and exports are explicitly marked synthetic. Hardware operations in tests use Fake adapters or loopback sockets. diff --git a/docs/DEMO.md b/docs/DEMO.md new file mode 100644 index 0000000..5f210c3 --- /dev/null +++ b/docs/DEMO.md @@ -0,0 +1,26 @@ +# Demo / 演示验收与修复闭环 + +No hardware is needed. Start with `python web_app.py --demo --data-dir data/demo`, then open [localhost:8080](http://127.0.0.1:8080). Keep demo/live data directories separate for a clear operating workflow; statistics are also separated by stored mode. + +1. Open **新建验收**, enter `DEMO-FAT-01`, batch `DEMO-2026-10`, and an operator. Select **混合故障**. +2. Create the task. Three synthetic devices are expected; one PLC has a synthetic timeout and stays in the result list. +3. Open **查看证据**. Request/response, fault category and a rule-based suggestion remain available. `SIMULATED` means the bytes and verdicts were generated for the demonstration. +4. Export HTML, JSON or CSV. The report includes metadata, the configuration snapshot and coverage. HTML can be opened offline or printed. +5. Select **全部正常**, then **复测未通过设备**. Only the failed device is selected; original successful devices are not silently marked as rechecked. +6. Choose **与原任务比较**. The shared device shows recovery; the page explicitly declares partial scope. This does not establish whole-unit recovery. +7. Refresh the browser and open **任务历史**. Both tasks and their evidence persist. Search by station, batch or operator; select oldest/newest ordering. +8. In **配置校验**, load the current configuration and validate it. This does not change the station configuration or execute device checks. + +Additional scenarios: **响应超时**, **CRC 错误**, **设备缺失**, **全部正常**. These are deterministic demonstrations rather than real protocol measurements. Demo mode refuses legacy hardware APIs and does not load the real detector. + +![Synthetic retest comparison](screenshots/retest.png) + +## Browser regression script + +`tests/browser_workflow.cjs` uses an already available Playwright installation and a disposable running demo server. It creates a fault task, inspects evidence, downloads a report, retests, compares, reloads history, validates configuration and checks the 390 px layout. It fails on page script errors. It is optional and does not install browser binaries or add a production dependency. + +```bash +NODE_PATH=/path/to/node_modules PLAYWRIGHT_CHANNEL=chrome node tests/browser_workflow.cjs http://127.0.0.1:18318 +``` + +Screenshots in this repository show synthetic demo data only. They are not records from an industrial customer or device. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..2e60569 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,32 @@ +# Deployment / 工位部署 + +## Local station + +Use Python 3.10+ and a dedicated virtual environment. On Linux run `bash setup_linux.sh`; it installs only pinned runtime dependencies with artifact hash verification. `bash run_web.sh --config /path/device_list.json --data-dir /path/station-data` starts a live station bound to `127.0.0.1:8080`. Add `--demo` to use only synthetic devices; demo ignores the live device configuration. + +Use an absolute data directory when launching through a service manager. Task records and evidence live in `jobs.db`, with SQLite WAL/SHM companions while running. Stop the service before making a file-copy backup, or use SQLite's supported backup mechanism. Back up configuration separately. Protect the directory using OS permissions: records can contain unit IDs, operator notes and network endpoints. Retention and automatic deletion are not implemented. + +**Run exactly one process per physical station.** The database has an exclusive OS-held ownership lease: a second process fails before modifying the first process's jobs. Separate database files do not coordinate concurrent access to the same hardware. The `.owner` companion file may remain after shutdown; ownership depends on the OS lock, not the presence of that file. + +Restart marks unfinished jobs `interrupted`, retains evidence and does not resume hardware operations. Cancellation waits for the current device/carrier group. Device timeout settings bound most calls; service shutdown waits briefly, marks remaining work interrupted and rejects late result updates. Validate driver behavior and timeouts on the actual station before relying on unattended operation. + +## Network access + +Network binding requires `QLDC_ACCESS_TOKEN`: + +```bash +export QLDC_ACCESS_TOKEN='' +bash run_web.sh --host 0.0.0.0 --port 8080 --data-dir /path/station-data +``` + +Enter the token with **访问凭证** in the browser. It is kept in tab session storage, not a URL. CLI clients use a bearer header. The built-in server provides HTTP, not encrypted transport: use network access only inside a controlled station environment with suitable transport protection. Reverse proxies must preserve the service Host/Origin behavior; arbitrary hostname rewriting or TLS termination requires deliberate configuration and validation. This release has no user roles, multi-tenant isolation or public hosting workflow. + +## Device scope + +Review [configuration examples](../device_list_examples.json) and [the guide](../config/CONFIG_GUIDE.md). Acceptance creates results for all selected expected devices, not only discovered devices. Legacy scan remains available for diagnosis, but discovery success does not establish acceptance coverage. + +Use an isolated test station before real equipment. Verify serial permissions, network routes, installed PCI tools/drivers, carrier/child addressing and response matching. Acceptance rejects explicit writes and Modbus functions outside 01–04; arbitrary custom bytes still require protocol review. PCI evidence is passive; register reads and response patterns prove only the configured communication condition. Electrical actions and business behavior need their own validated procedures. + +## Compatibility + +The old UI is at `/legacy` in live mode. It shares the station lock, produces original reports in `report/`, and is excluded from new task statistics. The original helper `preserve_scan_interfaces` remains available for the offline self-test. Use the new workflow for persistent, configuration-bound evidence and repair comparisons. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..58477df --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,48 @@ +# Validation / 验证记录 + +On 2026-10-07 the project owner confirmed the project had already been tested and could be used. The details of that earlier test are owner-provided context. This upgrade's reproducible automated validation uses synthetic adapters, temporary databases and loopback sockets. No real industrial device was operated by the upgrade automation. + +## Reproduce + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install --require-hashes -r requirements.lock +python validate_config.py +python self_test.py +python -m coverage run --source=inspection,core.protocol_engine,core.transport -m pytest -q +python -m coverage report --show-missing +python -m compileall -q inspection core web_app.py legacy_web.py +node --check web/assets/app.js +``` + +See [DEMO.md](DEMO.md) for the optional browser workflow. CI runs the Python offline checks on `ubuntu-latest` and `windows-latest`, Python 3.10/3.13; inspect the linked PR checks for actual remote outcomes. + +## What is exercised + +| Area | Evidence | +| --- | --- | +| Coverage and verdicts | Missing expected devices, unfinished tasks, adapter failure and non-passing retests | +| State and persistence | Concurrent creation, lock conflicts, cancellation boundaries, restart recovery, bounded shutdown, no late passes | +| Configuration | Snapshot survives file changes; original snapshot reused in retest; explicit write rejection | +| HTTP | Real ephemeral loopback requests, task lifecycle, history/search, comparison/export, malformed payloads, caps, traversal, Host/Origin/token checks | +| Demo isolation | Subprocess assertion that the normal demo workflow never imports the hardware detector or legacy server | +| Protocol | Fragmented configured active TCP, EOF, timeout/reset with partial evidence, Modbus CRC/truncation/identity/byte counts/quantity and write refusal | +| Browser | Fault task, evidence dialog, report download, repair retest, partial comparison, persisted history, configuration validation, desktop/390 px layout and page script errors | + +## Recorded results — 2026-10-07 + +- Clean hash-locked environment: CPython 3.13.0, Windows 11 build 26200, SQLite 3.45.3. `pip install --require-hashes -r requirements.lock` and dependency consistency check passed. +- Pytest: **85 passed**, independent clean-environment run completed in 19.90 seconds. Includes cross-process ownership, configuration-injected legacy requests, stable demo outcomes, identity-aware comparisons, every-probe/carrier evidence, incoming/outgoing TCP framing and response/lock release timing. +- Line coverage: **91%** for the new `inspection/` modules (693 statements, 62 uncovered); **85%** for the combined inspection/protocol-engine/transport selection (941 statements, 140 uncovered). These percentages do not cover the complete legacy detector. Windows run leaves POSIX locking branches uncovered locally; the CI matrix exercises POSIX separately. +- Original self-test: **23 offline checks passed**. Configuration validation: 5 root devices, 1 child, writes disabled. Python compile checks, JavaScript syntax checks and shell/PowerShell parsing passed. +- Browser: Node 24.19.0, Playwright 1.62.1, installed Chrome 155.0.8059.40. Desktop and 390 px workflow passed with **zero page script errors**. Structured evidence rendering used an explicitly labelled browser-only fixture to verify escaped content; screenshots show normal synthetic demo records. +- Release candidate `8872651`: Windows package extraction, fresh offline runtime installation with `PIP_NO_INDEX=1`, launcher argument forwarding, actual localhost server, fault task, export, healthy failed-only retest, scope comparison and restart/history preservation passed. Final bundles are rebuilt from the final tagged commit and checked again before upload. + +## Scope and limits + +Coverage is collected for `inspection/`, `core.protocol_engine` and `core.transport`, not the entire legacy detector or repository. The original offline self-test adds loopback checks but does not establish field hardware compatibility. Browser tests are separate from the Python CI job and use an existing local browser runtime. + +This automated suite does not measure serial driver behavior, electrical performance, sustained station load, power-loss filesystems or custom vendor protocols. The earlier usage-test confirmation is separate from these automated results. OS-level device exclusivity across different station data directories is not provided. + +Reports describe configured communication/passive checks; certification and customer performance claims are outside this test record. See the Release notes and PR checks for the final uploaded revision and remote CI results. diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..0b9d28cad0402c363e3c9b5b4173aee877cafd15 GIT binary patch literal 88823 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�uJ81+i?6AKAL-Bif@mG$c=AUR!-6 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..3fd281b8ef4959ccedc433df8816db3db42b1a8a GIT binary patch literal 196465 zcmeFYXH-*5_%6<|o}+?|B3(tK2`ET!DouI^sZo*Mi4Y(JR0I?$(joNTiS(XG@4XX9 z=%I&}1QJO896jg!?p^oG{r3NI_gYyiJDELu-uIn(=9%Z6`Kqm{Onrsv3KbO1Ut5KhK^0^6Ib=PDOQtN=4zhzE|>A3iSsq12RQUEaQ$s$M?NT`8)5v=)THT z=&sadF8^%O4YQe!F-+Pv9>m2v@~LGS&dimG>dIeXwp*64mI(`fV)a=6GYK(BNCpo= zh<=CztBq58$pL~NR)#P;D1imT2|%`;=_*r?`&2z#?VEA8&zkV-diI|FkAJ^Y#Q^8$ z|GRlscbSIfzq=Ul|NHCz(i;O0|MlA4j2Jf38&bw8Cz@S?v-iT=#YZZI#+*5BVUpv# zX=-;bxVrvZ^yPyP&{9zorO5R3B+jmiH#yMz{1f;6v4SY}aNamaBdpP`+vf~NR+g&K zk&Uq;qb%$G9!xTa$}P@w#&Hp+M@tz|QKw*npQ*mWzf}%AsAivmd5PH@l_=e%p+$dp zmTymW+IV{ZhJvw#c%cDoxYEEn@QMvqknB7Nckz?!}qL z73EHjvXX1I%fdNNvjA>)sKo2y|W+8rH__}`8Gj^Ck?=Ys-+PrDcR$%tnBy>f#>#8HrFg^ zbZ%qhHkA3BfJ1#LJ%|Qt&qN4k)trKvsk*ip#wu~6s|m6dYvJ;Hl!iunPNk8^z{&R~=QyE)XT zYRePc)Z$AUNFW#^dp%qfWJB<4wcRnT5u3-`iyxKvh3Eyy`jr%IY$YJ&!@Tyolk{qQ z<>Nh)?VW4gJ9>g)cYizo*>slpLp%U~=ioxd;bdvwGMi~1zbbYMe3iysxn`pTTPFr$ zTscIZtbHcb4s(PxAVjlE+(V?q-f`dLOtC|nGY%0XSRbI8&mzA{A=hyHg`#F%QRrEZ ziT11VmH0#3Fuycx6I)u)KHl)NFjvg3CRDNx)_Ah!$8S-4G^}MWE1Dhd_p?xI!WZlkA&4AAyy4Y_dx`^EE4J+Dohgo4Y;1b%Cw!z7S5*DhxHbut7tcs73F@ z=!FcIlATI&yvd}xH8Er1i<;W@vCyZ6WHE;Yj zy@xT+Ptqsz?YQdu+|dj`xMbX{->0?5*`%JofJPoa(nMm|%%G(n6DWul4R8&9>;ieM zAZs>$xY0SX>2U|0QrWx?hP`G}kvs&4t)e7SEQEbNb^E^Eu!z^>`peUGOjE5$dtobc zx%9Lkxm0uGxTm0Om7AOIiW)w}sG^i=#a@I*6J(_mcD&8S^eH3kid)&zbeZWT+0!YJF;^!9-=^i( zrC+Xa-EEEYr)&bQMw}=Z)SuYBX2`kfhAxk1H_nK-W$QsWJr%r9Bm$Ng#C+Cvs=4R~ zlV7>+eKs}aHnl)`9Lr2j7=p;45^xN^;b*JS#!i5217*`lOi#|Y;Ye##?nz!Kk_S<4 zfE+sP5QFK7o!|l`dJA+@l;UaXh=ue9r$QF{)7jG=*t(?`!=R43!V}&K(k@%dM&aPJ zA|(}1krhw(OGK?x$nU+OQ41Kqqkf=~DC%%wsiKHtF`YTJxOqKK9c`%U;S<^b*XwWV zUtf0(BwiRU!*6sNu~v(grtR)OU14AGUfw?s!y>kxAjKBmj+?FgJdoPLbb!<$-XGb9 z(`;$km9L!^S*d?wW%1Y;M*@7A;^cHR3zL~>XteQT`qE4rUyA?2tI}aWwd++4<2P9* z;$oR;a2rFf$H`40mlgJ8w`A^Yv&?)bOLCM`@*#8vfHR6mqUtkc7NAq_3Un6@e zpCZoxic0=u!YamT?hH@z=7Lizn4g*08_~mwt*l_}S~5>_Wn#ZqLNA zt45@az@-ceUCKD<^F)_pv#nSx%DzGi{32%NDDV9V5bp>?3bE^aNtk&!MSFb)E#nSh z-AuU687n12_HWu`J6c;zn%g0~&Ok}*h7cC?$ zrJq+qN@`}pOt8*=$w!5CGjb;y<@f02Ld$}+BZfI3NaKbIg~%Wq1o|DBS5Fk6p7X&5CoQxDKrgZ(cGR_bwMO?_u7wn<;8f+?X$w^q z2e8f@aJ#>%{!l&f(8CT}Z=o6F5=Z}ZTPERLzR#OU8g_pEbo@+LRI9^8vAAZ;M7~~! zw|*_9a{%XOcCrxP=|3K$SqNa*l63QS!G$q;m{vt?Dt?TR-ZuD~X-F397P(AF@xSy5dpip)fK1HAOl|+ml{tUjA7x+uSzKjX<4Ph_D*GucfwWn zn#ZIO7A`hu?q_5K@9{fmwo0Mc`m>1OvN{D+ z-oWL`52uTW8vaKI9uf?cF)h}9Z4o4uYqw<}AbIYn$uTcs);Mm`-|t+%7fW52YDBi>cwJ{Hy37i+d{OdSqg zF52*rcA4xkh=Kd#;JHRgLIOh*H^xtBJNjzs0+H7}?hG7pe43b_BH%>JZ@(KxMGH0Z z_s=y(Y#2-B_kIf@T+iHWg$<8L^=nZ#5Yo%kKs0P)-{S^S{H^4`9cq)4hD^7$JO}QG zer7W*mUy1uoZc?1*ew7lC!ewdDCZ3CT>KTGx4fy7#Tbt)hbGcdp$k|nf2m9_VAF!# ztZeb4JatGIHIX(pO*6&RV5$mY--@}2hSWKoAl4PuRb~_t`PoGkf!=3CM zh7qlX8oiWn1?p3PmsOHJe!bUr#9`aGEiu-Z%jZ`9Se7AC|Mdx!wbh@3kR!hMVD)kl zovAx9mgWiqH`#b&_GkC_c$@_q{k+|-Pgvbu?v6#e3pl**#d@^p}yr=2gZlUG_{}+yAg?%RRk2$Ov$nHELXmC0qx9HAT#h`Lrb& zi5b>VmiA&B@&k_un1y!srYbczgKQf#q@Z4y_1I^lm|yWSp9T0d|5}7JL-Eviw{C;w zK~xT`uM2r(Nn}NAzN6`#5+R({#NEuPYAo}A$SVG#DyB%c)A5erTf5;RY>QZ`pSu(8 zd@z&T?b022T`|-#Aau;fmld1CHsiQKQ#v|mhhbYx_@dfc1iMEeZ)?{>ma+v7^!cW zx4=DOgH7_x?&bUpDC*lDF$^`$31uUAkr9h0B~HS@F5BZ*jgv~1nu5^4PIw(i^VOV# zSCRn(QL+5%>eX_ z#%gA^7Iq8eHucPdUw36HcwB|=GWxGXwX3q zVYh4E!c$ck1(OH&felEx-Pc1s40)oDX5%I1?a`2pL%->1Y>MY8MdNj+!eJwhfL9?o zP*RcP9q{e)ouFs~W={y{$2P~3gWuzf*K0@BY~Xd7VwOO(ZoQ5)7>Fs&$Q6^gK7tl3 zNM1A@NpmwCW>k;>y6VFW0#9G%81fE#h6I3t7EbvZ)3uK`-(+%~dY&%LxRkp1-V@n5 zvf?<46D5Qx>Y;b${)hWodR3;}dbt_q(914Y&6u6VrtrfbPB+m0VzsU7n z7<)5wA#Ph3nEK$gW$9yI3uFcfT@P$|vsz>hDs>N^G?6%6u^T%TIW>$b^^za+2*OGa z%H;M&9L_xL_o0hc#aXnb1jC5$OoRFAuhLAceig4apK#+n_-ZWF+5|-KaphB6=OF7yA2 z7?sUq8!Ob4ryC(`d5JVLe6Ni({P`e9KWO?)GR_!hx1}?Q$*EMPz*2Gqw|=}~k`ZBy zy6fReT$JK^^w5b~XPdTlE32P$a8rT?!G!N?mG{*w|~^wMWzOh;EKfs1SVBDIs9;B&Ne|Mv@_QNlJRa{Y_!DZxEr9 zA=aLi&9p{3eN?tOra+P1!@VamXON~bo=Qz-lUk*x!Y>#_2ICK8W^pIhWx~1fS zF%C5foAslPXug_A$)6d)loQ$!Yq+hToO4u#N1+JQ;=&;C{KIqVCXMUgi7rdedjYIg zHtrK@w1RK+$V)#HH$+APC%NXc|M45(ZkWT^H0Y?oB_i%Ei?THLgq~ymE8Q}#k2jKo zZnA_NynW3vIiVzTRc^Cx1q~@r%Y2XyXNof(!VfXH;F1QgZ%W=YBlr#c<5k1#n-BCJ zQu`MLSNqz9sqOGufa1$MOr3uk=b~{h2oY=z4*~(UmL&C%mn=N!q-dlLE8U2f_D(C| zZGq13<~&m>3+LI`sd z9F`)$8OfoR*3`2;x7+L$f8}^UYuGyvZG{bm79P{8)6lu(-F--nupjwuR6~9aHY(Cw zuR&{{`!i)fIz`S_&lh4jt`?Cg{k!0wT0*QWlVZ|n@(-&yK7269s6^&1 zB!4FN0C(Zvil}Zt-<38Gr_)$C{nL4>w+?IRsUn#1;xDsi?uY3C0_}fkp zok{W(8mEM2Rz5dV81b z)7jG3@I>x6r~A9k;g{6`0Z-YLec}U6KclA)4FBgvEm5;PyrQsoAn$ha;m~H_r07sq ze`!ih1_~g28OIat&CL(nnOrz0;{I+a^LiY+p(TI)HD{pf0(z3JfV~hzbc(PZADe<( z0p(`iv5YK!uM9aoW?RGB+_vfNPt2c~A-q(wvs}@k=N{S<}Xy-M=1k@q!Ke zk6iQEO3*n3I);(ziwAN@C%+=I)s%3&C2mnpV&HuR_NPcIU#Kq(J@-^_=#T4&N<0eS_6hO?;y5(O7-Y zDe#bN_VYQ~=l7+q8#+vb=kxi5qz61a^Jun5nI;@2OH;NL3uo=RcG2&pDZ%{AVhl+P z$s<1VSpOsg(pt3!GlEY9RBV{{{B%RkSew3ZexhL&ERLVp&%|SpbvgnSeho#JEKKxN@|{~RF(hN-IU`yBjot_0gi(=C zt=-HZf`33%i&hR;)a8++g?k?e#vZ}Cr?HIWPZO9qSf{B6KeHxHq|p`wvAT6?y+O*N zkm?%a_&&|bePJilx$y0cH14jHCtPThV2N7rC9awilHk>eW4~cW%@5vy+JFd?CL1EA_IXO;oo*D4rWl7Q&Ffh+9T8FTP zYB^JoQ;YpGB!aR`azfs3b!MG}V+^y?!dY&5iRs#l*iCUa8PZwnHI;(MZ=P+2An$;J zzQs8~^^ZNVs}Pc!o{)){C1SeCi!3H0X+BvmRG|^WnewOSzs;f26#k2?`Cf1N0~OVm z-JC5d5spw&&t#UyuFLZl-2X>t12bf#L5 z(YJ^jPDs++SI;m3=eT+&wQdQ_WHvS=Vko=?t64d)_PxqpnY;kpFpS5uied_AOHw33 zjA>hE^!b$ku8W3=Sb4Z2N|< zo#~M*q`gicuMv5nlbxrIn@c7xKYC)2?bKD2)mW@|KOno3?a0+3b1qF<0>|gs`_&R` zICwm1-#q{AprN`L=-4}pv2n?&Fr{G{mCCYQY4`42YwI}v2Bn4*>(NE95Q$_89>JB*Tbum>=%l5w=*E6zBEQ&8$ZkK6G6gY4E;tj7ypIyyHw<0~sGV}N{4 zU4vY^YMEYh?*Ae4E%>EQauawnJ5}o+>EhpdehNlikP?UU^GbT}E0~xtao${c-=l)i zApW@dZxO2Gdk@I`yrz>bN8>Q2GpY6x=f&TG_O1Vl_!XQf-a0-VwpjG0Ez#3L6;BB7uCX_P?{q!V#vA2dO1Q*HL1^!6_{{)HgL^+q~Wz{U&pxfM04t3d2G$-~)_1b-zQUP+MV|Vw0*G zcg-eag9eqTE>k480T^l)_W;U3NY+A{Y2}o6#OZ2gBIV?edApSw;r-tIVFS{H*xzr& z6FK8ib0PyfcBx?A?>56BmFfID1~|Qw#n#6! z2z(g5pyWz*BgmZMveyA=aiMWJY3(u3@I*EFZguE(R-ZC6zzy?Hz^(&m0Ni@r;>z+6 zgCzsReK60o_K~9(W|5K4K&x%9C022DLJ!Q=F)~~od<68uHos{l)W8C%t8MT6? z3~dE@l`r&<7$Cv#*EIfGKsFM@cAE0X`_trNoSfE^Csz;9xih4#yl*Bs&K6qzVfXH( z$rhgZ;MA%g<-KN|m9SwpmM7ZJ(SLq_QwLz3E1F%!LD6jIcoOrQet&p({_kFmTN7;Z*1{OTZO$ z5GGxB>MLrG3Lo}=E+{s`p_-Cn{h4QdPOVM?j=r8UR~MI{-<><==nHz$|9m?<^rUz< zi0Ec5-&O`An~V^ri7|hMaIFke^KVAJ-Jtf4-gp;Ls3A3pWsOPc->IxP+Ek_-7ce=@ zz>YGKKz^pPKRgSKLJsor`I01AqM@6sFIcC@@gK~7z$z|I^=D^Y7+@L5{LGGzrw4fZ&fJD~UMwNf+<>f_3qug|0WlJ0yhMiw9j!oit)i z%WRTm5WgaXuZO{sXoa)0QSjTIOFCiIMrKm$(S$)cDZHcC5C0Wad$DX9&LG(kK}$&L zhIzN6hW{Y)7`fH254(;vv7g#bU3Bh2IB@Z2q@Zt=00=rYTdQgoenL1l>*`(<7neU# z_LSJG-ma*p&Kv@w*Q;>;2X0=oV{qP6HWj-)DyKR@tI@O%K~+W}u*#bpD5g_^O)x;+!$j zqyyA+ay{Xcx>5pn#gji`Re@nne533jEE~#z5dLlgQ2ZAlsQv~BDK*OJNf*D|4rP7I z5a#PfNjG(oL7yxRl{;7%mnaGOzz`D_sY^))dZr~hilC+OEcS+>sUvdmpy27}5&J6t zYKj${gO+C1IhW*8Z})xm!tok*@C2E_7A3SMW)zxy>|3Pg+k@CRb#6X9tFoZ}iGw*g z4Q*~M^RvjtEc*n^7LWPiW!0MQGhxNB+GNS^0ZnjpJbkMZt;R;P62YJP)O#e06OU0& zBaVF;Kf%Rw9KR8nAQ{HlkxulE(~JRGM9qZgYsfUCJIN7;(CNAqeB4}~F*2}nXoBJQC^5CMl z_%n(Bg?LE*C%Bxwc7^S%xBq)-???bySUd#k7obW$mB%6yDR2E*-(^{tNH%|cZ1dmZ z`WA~ z==@t#x*ncG{DpRt-sXm~>i7a(LHtUkTujmRX>FOC4D3E~J&&TsdIbvAf@^H5ix3Xo zBvQfc@y+*}RQ(@;?}5%cA*VBS^)|UT7?FfW6eJ+tHdu7SV^r~SLgd|l9Ztn9mwb~Z z99p@#IY-UJSGM?xm4`trle$okLnLcBlC@4}#BL1Uya35jV2*2q`4tN=#k+a6gDZ^w z44*4|y{FhDPz3*(+VLOV{IK7`xG+Ce_SPGx2@MFqyP(ZW%z-myiG?=FHI_K{kT$#r zVBB#|z!tWV;=D1}2(`?TY_ojcHu|sCRP%lFp{}k!SOlgFnjjCI$klFP0Z0zbJJV&} z@;J?sY5oaXI&hgTHKr?kthe6kpIskk7WMGhBnJe9#A5`s(T*c)L-7E;#@FlG|Jphq z|142ToC#NtE3eLfJsOzt`RQ4d4R!BN6)Q09Sf*Dlpv z0@znvGo~NbEgmnanDhE89!jSj{?p;@zinPi<~iflZ>j!=Tua*g&%)!G#Q(WH?!*31 zm4DuFkGDlLbEV=Jf^=%aE)K6d<Nw1CEoqm?n9tTwO-j^4I$*OK9@IQOq!99Wgg_ckCk{ zmq+4J9b13naUc(5v`;V`6TT_stk)k(&ni_lur*Ny&O20nzp5?XKLe>g+?KUoC( zlAbPp4ZPU-H7?9AeRvEbpmh`LwfJ%Y9`$yqb=ZH+Bc_H3E;pY2kfg3pbcv@P2zuMyuTrJs0r=@h1IiIP6O`q*@ zQ}M@Y~XHw>b099=I*KGZ`b_?dM?~~%0`I*A@@~~3Q1fu+i{Wc zO02uTJ75p{+1|n%(3k-&>6j)jb&CS>UKa*5Ck7@j|H1fm%~c>l#BbJKvO)sBSQY&6 zkyNHXxB2eACBm6xW;D>O*Z4Tz?e=@b&V5yBE|3N!0e8X9F=dXjeyyf8$8}j!6AKv3{T|cG$kItB94C8cm~$y#p&#JeMRR5~ zQ~$H$@#NO8ex(n8-*{)3w;4cJE8syICpP ztCdED#BPRGmR%mBglk6hHc zc0A6^e(jo!QQ!)xF|kg0>$aifkH><84(P*)=|rpY;;!|FB-G%}`r4_-Fp@AQ=gs_k z?7McHJL8?hU5&WN$ZuGvnXRvM-r?H4%vu(f_N&->8{#F0moG|Wv2Z!w%|5fB^cTpz z!9&x1QeDS*+9R*kRF&=iS+?FJ=R{5>9I=TMVIk+9)D|(?{*7&WP+fCfbY~k=)R-zW zaYwIW0f&U;z4ATtRfE?(;Ro+p!MCydC<)B^7N#dQVCm;-BQ-e2|LWHBn_w}4J}J$Z zC^w!8Ku%#nm2Z#)OTj{ZG-g7z1pN7}lDSq}s8N0l)WqQ1c%iRoj)fnxC^P080xGq@ zj`I7-%ddorA62a+s^b7TzhUg?BGs9hHw(VIp|eg`d9EMFGF|guT)(`;LM-(X^P`Ns z05^ZETv)m^AxGOgleuv&tV)+xk8$**+Q^?!Clj>tvk{ru3ab4xn-;<#BE? z@W&8^Z`=ZT;_j(0c@1O{)`bUkVd?gp>uvqlRzvHT4^UG&uutD zlEoPVLKv;_H*})^sHpRP$_f@;B*>~QM*G^|IORe8P&^QRWk6s;JRKWgv|BMWsh(1OPqWt|MicE z(nr-OuhIO$m`V35I}LMQ)Ie1X#)9Rs$I)$I%oZzJ1T*{w_)xTVZ-BJ3J$xUh?lUO( z%l4P&{)ULFjOO4V6sUE~24J!_Q`j_};WQY|1Eg99&Mx5vVrd z$-*We=0+uAC)B;d7%xPku;Y0yy@yaMJ?R>XG%)a6OsWLyn14hpn@IIj6+Q4ifzDX( zrqHNfT%)vO8%fdgN>zDYV^`@mbi`@v;JPzn&Lk=85HU(3^f$P*jDoc9HdwTa_yw8*hsij=IH_g8WX++ zJvclCrk3iL7$BcF5zXW0gt9J~{TAL4fBxMy5*8^}(5A*k7$^Dq@hj5xw(Zh2{;ggW z@-<}02t%L1gTlDECmb$T@I9*>dyk@2%Wa>1+p-aV{^Znj|D5QkMM`NHjDYor0 zkONu{1&SngneXDW6GOcv=X?WJ=YG$_#9YY~EbEL80pWObcd}7k74hUL*ywB>{QHbR zUi4}n_feVzDLqQGceTw8xvpJ1&p6SL;VSJvyZd}VI%SNWrHa5)kbG;4hTHt`A!#ux zl+WG0ypB_PAK^C_Ic^u0A(!L2j=h?6&j^EvYcPH2bFz+LDenBXEW zbXdEeF13%h35o4;vzM{QPo=JJS(+?tp~ST0Sg`<=Zbr(d#NfK?C$Ud7OJoU*=c(~L zsVQa<%h7t!wQn@yJE-+k!-g0b51tGPA>3cF)xOkXopL=4g&wcZ#;@sQa<0 z=QE6}CJAK9Z$bzX352O@&bY8o{8F*PLd+qCu!@1PN;`EI+pl4tj3bbO-@ZnSt=0o_ zM_QD-U0uf!J?DcP>M=Gy%UZGo--6ghIhKCh6VVQ>y zjL;atr>s2d0iBKZes%W*2j7oYHi6VjB=oEW-THDRrYp^v|Jf(vM{e7h7vJ2fD4I|y z(f+Hb?qe7C&f}a_{d@!Cy>Qk!fbT}zU&IyNgAQeMeW{^I6)xIQPSMBQp}C#fpV>ub zjb6}ay+_1*DGltX9WA_WS7L&EEu zZF-6EdQZcZ8fIoay(&6!O?R8FdheHc{^}+=SxdkGKAGKp%(DEbtk(N!ap#%6%h)yJ zu(j{#QvF@j);Y35k_+L73sdC#MU(aJtR#^eiLK}az~(Snu=vTuq<-q5b#bG! zuT8V*3w5qfEg_SaWOjKt698z^bPDTs1e$@491~{|kgwDcMnJeNm0SdpiCeWE(@Wq4 z2QZuDYeLp!egQzoK7y1x)&tl>3&s^6uh(zRKmqJ>@4}<=OZi8%U)7GD6xs!CY^?*) zYaC;cXA^L5KH?EvDOgqKstf_6E1G-iGJ*I<;x(n-msUST=3Y?!CQy)dnob@jCd9 zi&{?scTqARN30n}G{Ok4|FWQe62CEgnK=jVN8C(Sjpf^d5{Hvkt1C4jGr7T%XZYVJ zvU(Juy53(Q71sOvsLyWiU8T#)XUKNp*(dG7(M0$i<=7Z~%P*uzCOYmnTPa9D3sfN3`uT7(bS&8cd*^JZIK#xntAH zv`T%~`x}F7!nTCQ{IjpPR~EJI3oJWQPG2X|BxQ${fs)D6exWhS0ABOMHW5MJ($co! zlj;b{h?f6hzdvcXCZeY4NrO#85KpW;y`B%k2;0SM#K~njq9$CW^B@rDZZzu-t3O=t z9>gr0Ty=fA7H1IW@_5)4ekFdPhyY+B7!U0!UsZrL$XL`nE$xdq#1)^+dg{{B7i&}O zw6l1(bA=;RoeAEnyK=M4J9J%Xa<8R+D?dL4zujSFu`y`-RDba1 z01AU+<4fx`LktCt6C1p(b}GdRUf^RIpDd&X^PC15Q4BxsUqvMsz$|^mKZmOgMSbfCa{mEoieGEl2hv>|H{jTYHKErzr2CC90PZJ} z;=2R1-=Arx+|tBV%eWN3{Oe&wDINc-+MjG*x*1DYpL%JAqJ!04LCNm4{Tez6g6A!o z;wkXFt9kRI)Yf)R;{BV9)J%RNmWQ?e`8)!?{uXYYh_PILshf6#+sud=RSAZhDYNy> zCK^lo)hP;?jOfm*SIn=%ZH;^CtF?%v3S25B&_5BrFgblScjiE^ymZrGl}v4qTz7|7 zj2(TKc&iT6dI+zLU3{41b0u;w`n#15)i?ZC4XF1C989qM+4<6Gdt3M|6{Yf}9M zU3Kj@(^YGPeZ5Gmi_E9dzzo2&N z?p9?K3j-6v!^gcYKVK+D!hGkv=7J9XsJb4@=wLliHqn*dBXMd5)CymQV|_+3k%5A% zsQpsc8_JzJX-#51RURv~TgoMGuBOxjERNYtpaB2sAGPT8u|Cah9#9o@CjR;0Gw z?4X+%i1&CecvC-F4%{X)8t)bMY6vwc^_)FUR~$4e;~@I#faNn=a+@Ach1GeJ!WiqE zVkrQG@DNJ;dTQ>|nVK^4>6W<3_avkFr&8mpMl5Orv>?|KrgifdB>739t$F3kV|FBw1~QWgXs#&fmGN=UTdtkPlV=cU9%5xc7TeEa5(u+IGFdr*;FY>S0pfXbW6F`GP6 zN`gHN6Y3xlOpWLZf^DC>>FwI{In-LCpd~VO$-=8Dc(HHZ;x=+82%Pq(#k==s+tn_X za+>SvGQWH8fIjXh(slQj>%MbQryvTh^w&W(G-d-0OFr4c;ElhDzS8Nua(A0J2e#C^ zRpI1JfdhH=z5o;-yj}SW7ku;M^56dWDR}!#v;C5?*nS&!>|n8{qRJlzOY`xM6#4N1 zqU^YW&mw#JN1ldHv3i53XGv|v49>L2;v;(J8;)+b?9KWMWeE%P9H%T96P8Q62m7jw zy!+UM8c2%*5%!}pnx!m+7R+1K#67t?p{}H9n(!g)w}+1rJe( zapPIwM|mN?oa9$(p3_jevD00qgJHOHr)K@PwJr?o?2OTZoqz8U{%U8*s+ghe6F*l| zRhoVu%|suqnunh_?ZRW@XxaaiJo$NZcmgUB(FDy>k_E}(860a)?%A!6Us>%leRWs0 zrR-c+nE;9=yu5{EprBc}cxv&hOCVnT&G@Z7HB#P6%P|xQTJ@*DdLuc)lVxGPnxx7I zN$YVCJ*@^?WB9I9=!BHu(jRfov?qgpA7B=oJPuX#gJ7va@>q!rd#9xh*&!=2mSaST zW>_wl@zzc(IxUZAc;$dx<|x4o%XyqWOuW8+Q4K-LAft(VP4d?2<` zU6^?ieSr#N((iQJuL1Gk(?8MzE`>8P@&-1DidUPB9e2cd&pe9F^iHFn6_Y;Ok58+w+HH$0Clf`YL#MfU zWTX;PbMX?bBMh3Nm!i2#Gj#^%W`u#KZL5|tEG(T@tu>*TJ1yEj-aYq2Bg=hr$7u(e zR>*{~Ec-*~e*C>eeNFKn*2kj4s8Tz%@}}pXIOW=|`qtY1=1|zZ6$yAnbW}?u9V`sK zB2!eJ^S+)^zylF}y!Oi@^O!%*AVT3SpjmXX+Qg}oQU+jgndau5(p(9M_Urvd-v`d8 zY=-)$8{!YLLfd8e0Dhf78ADXnO9F(Yn-v6pa_~A4Y7%eH(p~$w!aO=uvqVV9L{vkg z0P5qqBRugnG{*pm--;B{1IMfGg{S1ioVK0#OUGyWyb@Jsd?yXg`&)wJyUd7aC}>}1 z5Y>rJg~>iCw5dn`8E+YQmAw{|6S%|q@@z{rrQy@%v^!#ru}AACQ@J91_}Dw_GuAP& zptA#5W558sgm-1Fc#D3)qE*_f-g4_oXXC^z0`W@R$qm8Xh0GVK+pa(r_#EJcO5Sc$ z@Ez=>0&+OR4wDPjS z+CxDFgCRyTjzfPdk-S=lq$?o3x^!`Zi*R7?x70@_M<-Jat7RMa?ze?V!n!6ujI9%XmZT%M2yc4Ai#n=s>% z(~PBz)XbAEm5UP7dnF>98UymeX;;g-Y_Umsmq~Z8snYw^c85+j7D8BH+I7Btt6H53 zlApqT$)2dl^c1FRX_EfpoZm(C^6XX%zCW=&`R*J{ujM5H+kH^t#Z{9&_7w+jTFYFuhI4~ zJ@!dmxmb%8)Iz6wrWh4#TD5k6szD>)`9FcO5mkW|_N(*>hWT*dLahz1oe4@qluzrs!tYLuy57=Pgi0){F(7ZM z0k^h0uacjXfp(IN#b)={`sailj02wky%uI0lk)7%T;4m~vozp+0^|#4c+_*-=tcWH z9j%zjO|h%C4m)G8elH)`CX|nSlcd(^prp9J=H{)qIR zTKxUV#yQ(WY_%QnJ5QR6>$O>J zRs6A0XN^_{K&%*oK8+R=1{7GzpeRrCY;vn;*SbrXi&9gCV7^NpJjwO2A<1+Xa%K`mlO_n?#Yl&s#eDY&`l6zUnA7 z15$XZQ9j^IXBkA>OPl3Qmhio@*Pr&Yggr7*v3j;bFQ4jAT|uB-%`QkuOOh5)2GQnJ z*1GJQYNXXtJ}TnvZ!&VQF;0I`dAq6P!1zs5Mv`mWgOArq3ST^*{Mc#~o>Ry4GRM%l*yT7OiW#}5CpWG%s=wxi{ZP&d|334OFF@3rl&5=XC1!k#Eu41_SPn3W%8QxV zLdmT1ma>l&8%gBZO!N0#{pElt6$y9Or&xU7xKklwSn9cz+Gf-`Oz`;A zPr(?a^Riv6KXJr~4QX;&Wh-ss214~}6(Dv%Mlh|`wP12GR>+x&u%qEtoN*(vr)q|V z$)*(EtSMcfU4f}(u-Lxosxv+l0hP-tsLr%e9bk9v72-Fp(=@TV-}(LXSg*219cKVO z?rVVjJGGnfn2vn5d-sqyJs3vvPDWF=RM8KVVBg| zYswBnp@T9YmqAy{JG^-WUtCl5RbWlEC0Ypi)TYOhUqnu)B($1(JXV3%Kw~`qrUv_C z%IF{Z^!m2o&M)*p^4xBnvJ9Z&Y#!4&z@UG)_GD8|iPh@xH8ID>44ih*IpJ`^+LwI& z;}d8>uHN0M%-6x5tjJvF>28~!#dXvnOvVHC?Qs&R}>UmG`3yfTSTW< z)!W%f3XaQ}{4>ay;Ex#Q!2OTT+eQr&9Xg8j0h&`D-J2aV9{(P^q ziC{Qb!_`Dyn0VW6XhdM9G;AG?7P9^7x8I*-&y+~&ydQ9w6+eWBaAh_f#Z!j2fql>E zhxgom^=P_7ay?XDdcd^Sgf+{p+a>6l-BuNg;1USlxVr{OXbA2Q92yBO!5xCrI0S1vc+da=f@X^Q{r7$|^E@w8 z4?NIaeX8o5v-a9+OHGsQ+0+U;l5r0{qJ0lvXwY!G&j|T@0N~H7cp0an=*UNzS;ci- z`0BdjM{9hwmZOA<7k)S!@Si`w6rE~IweWZ39)DLcG~`Ogifr2+ot^)uEdlTgP9J0O z&}66k_}gdRT+R}cFDZu=dN(L);Lip_Hg(oiIq7qPDEO*>QFz+?@jo8>F`P?co9*vi zzJ-RP?E9a{+uzr5*%Y-}BzzLiN7oyCWg1{)H}BkyKf<2lSKydC5?mb8;Aid2uQ@g( z+U17cQI*@SVVCt!X*Y1b!0pYVj8lG=rxo&B3m4`ra%*IvVh7bO=$F$%)ohVZ7|`2H z+oh~#vOH#*1IGS$+^?x$brT=siFaj|Vk}Y7W!0&;W`+8dUZ^GzxSwI)Lh=a@t2=Jj zvcCZ#t))26MZtQOgr}!#ZwPvekB1$1|C?_-${N46sw(SLtKu1=dH$}-8qHw*2A6k! zt81#uao=2ap~x{r%YZ~uhHr)c@@%%<+Ntn+tJU&BYWl)c4aG{mHk?5V(66yc;Eso2@Xkd-~s*TaMJsI*(Ti^cGs8COjB9Ze(=JzDQauU zLDlGYiG%09pM3eQ>_u71^%$MoX(p_t!q2C;>JPV0Lvcw(kDACyw9Nk953L`o^#5j5 ze6^M?St@xUz1`Xu#ne;_;Aq2F!I#0MMj8gV2G?R5w!HzR%U;qYUi=>R_mFEQx4vr! z$)3Y-N?{Of_qf_qY=3?@_U_5r>gxFcUZy<@;lGbSHJX%k704s~H*tE6ipkdLpF4T* zAd&9BjdUI$7Vnb=|2YOl{~vv1-rsEf!u9l@(zREqu6%zzkciI~_Ha!oyl&tVJG|Ua z{k$z|<%c)k>^YN0xNMM2H&Ayy&c6yi7pS(kHq z-3EpG7#MGPY&GNdLwWU9#B&wN*7YNbVwz9yVl3LFMfdGO#~K~x6X9)9ur|+{*ZGD^ z^tcmWhQ3{4`2;EmQ5<+2Xue!%j(yd`evoS7Y0`AHs+77mlI<#cZ=r%wXleWmo z*n_u9>P>z5=&{(6KsaT(3pXA1<{9?0p}S?b@v)R&`kuO`nCLb&yq&C-;%rNNa z(9`B>rQ5ChG83O(JJD`fY$~nox%-fE2{KHg5=qxaDzjC6)w!&=KjfSi*P6(}ENnT* zCW?NJqe+7ESD`CGIQN)RCXPnH`y>G)*T*1ES`EMW}(7Jg1**X0jO+JQ*9#>@~XSC6R;^r%7wJ94D=X zRvj(`FBdL__bzrF7pMY$G}kK1pl4xEPkD53IFmh7qj=4ZOgrhUy@_ihQwE511Q zU+0xok;^8z1n4yx_KEcoWZmw0Ci2+rrPhhQJt;op*xD@E@1y{%*i zhUKOZbdNeF%Aidux#J0rS7iRZjwC*qZw1Ywp7Uw zSy*hh-r5im6W@jPX$@BX`FT5>WfgN3L9lPVRH0q>q*og7EwulzT>^Wt#-;i^Sv5g% z_h-vx#*mNGJdd+M#K$wm$1$AK)lBFW-vB=cbnTnwq}<8?^0i?Ul(MQvwZ%7=T{e1 zeTAoz{`)VTw9A6QVJgjVMe5Ry>LqTfA?N2D8w_>_)92G|%(~^T3*o!P&iRF@$u=_x z9*5yjv5f;w!Cj55%k@F*uEr01+_jQJgOK_9>yuA6f3G56W9ugZ7=Qy0}qdq1d1G z)bp;{jTIB5F7@-G^AXP(WwiCF>MWu&cU*3Cm%E?W%_@}W1e}bdXs=yGZ7v~U{GJoC zQ6tO6SihX+m^04iCd?MyO&;IdsGrEP|?C4WrmB&@9sCQDV*~JP?#{ zosi$J&bdds*gn@GE&YL5beD@oXck`sPj){4DRtZUG)YylcU>Nn240V^YA!oFwPC2s z@*RoWJ$JZ*4L{lb?CinO5=hq4D`rPKCyo2U1k*IvL*d(d&NA1+gW51|!nuU^;RNq; ze(BBf7d#Y<-i#Pld!vl!%Dde_`g^ThGU{WLlbE<|%#Jk1Z%!tHnLKyx(_NV5RzVc< z8At0RaYb~UT1Ao9QO>R|;qSy*hlS7Td1vqGXFOcVQbWgf(#vo-4{a?`r>M8Puby4} z1jD4>bH0O(S^B2ySE?lWD3TF(N8`kK<>`(5Rdo_^Wwiu zX`m?`Lxe`~ep!b`?rA9(`c3V^on>af;An{z%jm|=DiAC)XqB43=G`@X(rwr#{Y?T4 zt3mkl*_kp3sF3}dVu|o^{?@3!&crHWQ<#0!v(|r>5Q@hf0`gvhC?P# zW^dVW$d#ig&+!ZH{fw$+g-vuGuYd2x*C;W4jux7o&>zDz9X9yrIs*G!&Jw#4)$MlW zmD;6wTgbrKW2;NvT}I__`!p4^u}?RWXTG{i^#JOty7PX@7Ea%7m_y##=w!__?`NjA zSSpReb$55*PbkWf>wVh`Gy=a?*6?f|hrlM=&kwSw^~M}jFRU%a(0Ba`FJ?^@w#U;f z=^Zp~xNOwxJAeu39y>W@8Y1d&D3^@uJL*%niEgu;!4auF3=X4vRN*3GlzLpGLS|!g zuJo5fIC?ODsK0cHCynsl&xaQ0s&_4e>-x57g5GIjp)v;ymD(DA*sRV_t2v~XH;lJ> z$s%(dUh_TrG13Gc*D2GHezysbb5_Tcv~IyX9DZx zSa=eaB{oEm=c<(fMj5ZA<#B>tHKVaN>`oVmBAy9-@7^c!)cp^S>k>MQOPS2wF7dE^ zV#pvHz0+gMmlfs=O*R3x=-cVHZOhWW?8F<5N)&}jYUsIFeP3B|JJWPnv%fIvj+Cl( z1Rt_BA#@2|UfHM+`l3chT5N4Jkcmqt#cA$|YT_^ZNfXd`J0+*9$?zr8abyhZ=JU>7 zyibGNFBmVv<5XO2j)qrBRJ+! z4}5J&l^Hkb#W^XXR#uM>;n>tW(vkN00+C4eN*aoCM3P5YAv|)eYnjK3<|6MulR2sQG^{|rh)+Gm+T+@fUKAqC zKQK}uFVi63twyUWIJ4ci=bb+<>HN*5H1z~$+pk_+aWCM`)H6cSh4I1p?Nj|D7C)YE zK6f#DXQWgGMMS?zq%O6|T5?F+9gd`2pS^)u`6ZWW=X*Uqsd2I51`|#285xPK2C}tT zaS4N|2vS1cl|O!qP3HEKxV-D9B=aw8ygT+3)s{{Q&4Xft@ek?>XLBmPDFG&14xgV! zVie<8s68sbV!}>^;noIySnn0urMI70EW1iS2`2k}h5It?%{kt|>B;a4Jp^gpp0BxtYw&aOjhV{)}v{(UykvPjmneryg3c~*GX+yJLEPz z`|~&vvC}m zVcsF@26sutsq6Q^)ID&5ge`SC@AiaEiu#Tf8@8D$OxG}LcctY~id3^^U;X$Epz^2W zDiX+5*JDiv+wu3F3}b&n!xIYwOGg)k&4>IIU=*p7>fd%AatSMrDdqv?|7HAmMMd0` z>9r&CGP-aZwl~cc$%48DN9>#a0qJAQJy`1v;~;~*$1$s`vr9ZO{5<)w!Ep7J;`psE%4I``lf#88w3Z5gB($VZ*CF~w zKZa0=W9Y3-oni}59wec^@x72voqgON;?D&r!d%0$WZ9migB0U}qEL#hx@j5E*?e~T zHh&=8S2F6#H0^GSHT|}}F;Ne~Rk~kNdZl|(TpQFw6DAS4`t!|~nXXT+T{2E)$J6T0 zt&<|B{=Kco40|PPgJDvF8=#1@iYgjgRqV8e(3u)`(TOh#s?PHIxFzRY-lr6@>lroO zK8~;y<=k?1VekD!_{@j&cGb@cyLCEaY!S%6T|GJK<92#&?^iE%#)=;Fpr1Y*D7&Ni zv4p&we~Vr7o9$2(t;mpNbEUQx^7-;QDf_JW%qH=0sl%bNiZ!@CW|vt0)@G3JOt#|+ zlTY_;VwHSEO#P;Y%>-Zda*s*digV0rU1q)PtD|f}(*W~waHmSI4k@uAnvKRv`4iqL zhSRh<_0$Wp9l}#o?b*mj!=!=^-;-d;Gqk5ITBojs6Le>cDWYf!-)wjR=8@p-fCFy% z+2c=ol9HQSeo!JxD9sfbex3$8i&GJ7?KqOG-aSr5Dx*=JKhz4DAh@U@ZlNyIsm3lL zn;7QGzT!fMr42Wr3CEf|wpm(WWm@XcW+**VJ*%tM93w5wKJxL>E?nj*Q!z?eCaw!t zAMeVIrtKN(@j?jcM}8NX?`{=x%$=c&&WwBly=bwhBqCx*sYSc%YMnkl;1~PN*ozH2 ze;rfiZIpkO(?CBu>=K>#j5iQ|ks@GRSVU|0IOK)JC-glRI!`S8PKScmAy^GoldvK` z1A|&#qfougpA{6Y2U=XwX96vy=O4SW=GP%6osgGLWwsPHJ6^4Q<)Gunw*+ zjaiGGgk=joTrNk5*pCAJAFuXax;Jz=c;uk_@}DK4Y2xh{jNdRBSa$TE=oq_+XzHYa?s$b}uN^jVE>0NFTVPT4`6&7=u*uTR&E#ud$ddS4>*b4v&j!;pz6)rVuhTdiB4Xk zJ}5bJua=HM;dG8LAO#+yX+-4ieN{4(&ql_TK-&F$YFY-%af>_LtYQ%uwAyQyGqh)0-7s`?8*UbOO*DVt(8 zu0ex`7dDpmh@dL!Vn*BWCcNnw;vZarbxJ=rP+O0je&XR6-fGfQ!IpL)!ElAe-j-F`|~^CiTNF(wf24pO$wa@OB=Yi9crlq2%P8T->- z0dY*ruq|(@H?hoihs$^2k$!*QmaBb!?H2W=37Anq-A*39+V**6dZoTv_sm)355er? zXpl#ExDZiiWr+CClcYqZKxkw}=^;s<^yjgQi6zpkZ!gL|zbGxXt=rw!WLDRy`E^@Z z)TqsAT&nOI_T;%bDOhhp;G-Q8TMEiYXhg9HN#|P|EC&t=ZKv#-bhrMMs-2t|uNAKD zds`S6TE$5778Utx`r-#lNxo1u zclX)lft=>#*RKb?g3*glJ0fKd33RMCi^MF@Fgu-^u*jSu^g$|W$iZWS^giB&uCfr` z8a5MlZ9%on*!B9kw^3i)sJ+DFzCR@C#Qz|c1KV%0&x8>#rnzL#7oJx$9Vgd>`H3o= z+7Ig1;%hAmFAPqOs+qQ#F_eC{tRdSQD(THEX#d(0RuK5kl^Exg&1R{Qr%=2T&BF1|4l1@ zI$CfNyymyFBo|Xp!3#TN6l(UF?5~qCn0>RLnf7PxYo{XbSa4NvyOF51^N3noI#GX2 zny_u+$yog*Ty_j$jSbE!aA@6+W0D+sp?@wnDQ2&CR_f?Z=e46 z0S6bJMia-}*tV?I5G^_)PWS^z4t{&BxlEE0?0&)-+fzpCbGJJb%Z&Yma^56g^8c*C1+dSe5b5%sz0k zP+bOMDx(p-V`Dz=b|wP~Rel(9{W!M{*O6$cWoIfvh%V)6OnL&ELn#U*+PaB?U^Gw|0g(sUh-R&L~$sZBB`lkCP zm1IS<=dmr8HHljVefQiE+KR&AdF?F87}tL7Kp`sW9vJ!CVN5-@{WBfUR@5G2z}Y1b z`l-gv&HKExt=zQQORRr)U0<1-DM9fv*X+l97fzMIe;Z-oN?t-bt8SdT-*z_Pe-z`* zD`+kNtqT#~{Pydk#Hi#Is8xxw)wa*z)gj59$WPs@amx7(E%UM{rC-1omm9&%{p^FQ zSsnD>rz#|$2Ja?+2i^T0K|G7Yxqc5X!z34VmAU;3wqZpPk&_p63zxSRB9o4w)Y6bq zh^P|*ClEksv9?XNILzU699H$1^E_A_KHD8mtjM#(EMSTzbx_4t(`U8^v4pUo)!Upb z`IPbf)7j-ghGG2H>KBkv54UviC*{^9UQVenVNY&9;walm{y$bXzZcb_h8>4_1?tCU z31`Pe=@;scR9_e!kA!w6duBHJFO+FZhM~6_C2`sN-$gDz!kA&M4uVxiy!@5?GY|F(1wE!npUCc5)=2&oZS47heEL4;z~*;#yL*{Dmcm=Y zoUQ;no34??M|-yS0{QOUVVS1+g4Kw1^{<@HZ<+!9kxg5<{|U(64<;ve-{t7(`x9h+ zQp7)Tp$CjHGy)F<4&JrC+YI>huH9y-z^`{DQ@}fZ4N4vFC+S-li#C8OU+w*+UTXz+ zO^z;6?$fmDN>5+v&ClbW=hU9sTaDvM4NjKmelr4gbZq^xIm)R9Z_4Du{t0Fi#4P!PNk zU_&A{MCYLfo}CBAU3*$P7#Lb#sjz=KqP;Mz#I=38Vk`2EXMj+#$b+M=rq^^7-7-__ z;zsX)&2FYfqu=3RVdBRF8lXi0{UwdSj6IW=%}MQ_3f)S6%`4G9o7%?l(`|aXBI0fN zxuc-Rx6hsHFgop}B&sO}wJnB>8*tyHBoZVcW9(0QP~qNRzOPR}YC>C*1@+r-tyOhW zU%{`S%HUnvB15`C?XU?%3tOfI*d>{6Sjr1b-+c09xpLr+j3gZdYd9n>fNFTwgJ|mg z!e)$Vo1_$ZKE`&WaTV(nO2dz{1O;rv8fC3>zFUoJL>a)%)hlrypOW-dk||Y#Ug$g1 z;J>#tp{=+d#ph#+*ZzSqwehgN45YPjGdK?oJYYAO5|05iXCh57L+kG!(uuzMR>QDP zTK!#eLxyMGUe|Fh#Y=U^uR~N+RL|t7o%38qp^C3C$L=$1fqAP&@CCZB3C^w-PitjQ zgKPwL*ug}&f0()3U*z@S+8)klR?-zZ=5~o(GsdB$J@%ynqt7D_CFrx|uBC|7+%2p* z0xw}Ri1s>6o;TVu&IYjj!q}ip-n4RoYXN)wS>nO`17SE%(L(ry@noo7sQ@og1>2M9 z(v%@Kw!Fu}^rZFSn`P~Sa;vw@lf=13DIPm0d}fh=l|Sw(hpJfjy&dR*9*6F4(H&ZC z_rqQkc*0vA(aoikJ6@BVkmE-Q_=gs$@r~BujGpHXJ2r?V;2*&;;At4$%6IjF-dpa3$Z^1NI}7k5Sc zg-#_djOAhgt8HIIMl)%osE=#Hc(^%29?n@nURXY=55BrpmMa0#5XemaWE3t0J?hFX z1zoZ{)vYu|zvPx~Vy)Q!?K0JY^|@1uhVHk&R*pn4ag1pEurn!8xKH_5MLEmDmp`-# zg-g6X^6P8pkhVV9T&(s_f`Ri(S{D4aj7W-fif0UP1MY(wi(6|7;^WX9uiIhiDfBq(#`F?6b@=JfJx z_g#dwrPb&@J(*4w93^gGSMV_O!Tf?ouLrMIgu5U>=4-q=BD2;8e3~P5@Y%c zF7dEg$^d#x{l>T<>SW{hpvy-al<+~k5mk?@#T74`@k~Ofl_pj;>5w5L5jKSw{t+GJ zn!Fb&8(0&wAAX98z2)7(G$=q+f1?x(Pr>kBLI#RT1ZC;0P9Y>c?H0v4G~isG^kd>4 zXnWNnxEE8D*(9lQQ&_kfZgC^MKob%?%V30=jp})24{gb4>)~8%ifsh8XJB-~U}r;A zkM%eYZ=5GI6yvMGqSAmZhXkXm^M^yTii)A{3u34^VU)Y}cZ%efqY!HFpp?l1RM&2t zNOE~!i7{dXNyzw2V)L)v%hVdCFylD22Z5JfmxJuol`T2GY@$9N>)Ic!*9h~(>N|^R zCd>#^?Kk>!iB%jaE;CG>IAqDKVYvp=CUkf%%1O6;6Kq|7sM6Ha^07%(WrnEC+Ag-I z9{rc8ZLpblf1LouuF)4~nyCcKwT$lr-DObo{=*V2#x~NCLAu%`qap@6#cJKkheM+V z(H{P&r6>5EixC4j4U#l3;#o%LWmUJI5H-Wr{7pj1nKP?rDPa*mA&F&;Mf(yqu@jjg zAi88wugPOD2v;2*IIe};UVvE)mGo3vw9ftl;c+jdqr+hC{4s&(`7v4!P4<5GR8XP> za{+q9kq`2dT&E@mtDAvc3aF7S2d8#u200ru2yZO(awyUv`DJc!pFE|e z3x)kwU*Q4lP|ai>8fH2PxXBQ_kc~-7BaaWk{qtOl^ZX_}jC2xk9|;S6Q&3M&d1uN| zcD40Srw;M%TDZ~z!#HJ=JsoRn3U3hwnTGIy`6xGLX{Yg?>jC1+rX_OYGv+sKf1nHV|stk*dIcJ_aWt`Y%M)j;|+#MRcaD{Ttp;! z29_$<#3XsZG8H<<^RjNt*vUpsMsS*Qc-@;;$ggspFy8Pz$j}=&XDKxnxbOLH&^O~n zWRGQo9AwJgU8bTF$4Gd`<~neuv&yfe&;VY+5e9ZO6a7>Uio%GXBog}o7$A4^{s<-} zV<5EFS*F*4Y6@%F3q9&l$8@Envn*PXc@xvZLJ{3as~q}cY3<@1Ovi#b+s)cawJTn~ zFEA@3uz=wUr*ZK8*3`K1-5fj2GlH4(rDW0aOC&q3OJClnOkVT3^TrVSM9i^f-6#8p z___+iB4%OMN3`2#0&6l)T!MP~mbQ+Wgomn4d{RW+3ANz4)fhU_$F{1byjI#XbAzKZ z2&Qk1yhRJl@gUIvqzeS|X;?#2U07Je=s~#er%Vk`6Q2g!LeSY2o-};J{SU1?&zJWt zxO+ZQCJ%WBlE^AMf7&|zUOtyYKxM@kGeA*YKfff&?o1)#_9&Nn^P$_xIXnY(es%?q zhLkm<1IaA{mgru=O$OPp!1MMLD_H7yU{EG24un>i9QOkVc6ZC9xah=a=($ga+V)7w z^Z!yr`Xo=Jkp@_n;j%OL3ofw-^6P0-4TvBg0YIynf50ShPA8FnfQ!~@sbjr*^;Zb# zgU27gefCrjm9hf%v3h2lN4es77!y?C zJ$O21u1%-g%y@zx;P`%=>LQ_m9OkM*kT-COX$6Cr085h!9`jJ{k=gBhxu#hqWSBUX zmD`t4Ds}>?wP>aTHjT%|8Ps2-nf(jm_HiTpIy>ol-Q)RAbmKQ9?G$Ced)b!+mJDWc zYZx@ipiL_X>($`{I*iEs3iTKBr6%2@AAX%gw}_-wdG(_>#)a7u$b1S+^;6w*=eA!d zoVTf=3x>~>hJ<(hQuR(z2&IK&QnmOLnRz-UWwq}Aj86YZLs4^;MD{|#-<9%_a1`ra zsXU!K7^}iiel3aYm%YwoikA3@Q((+E6 z+~hf|?kIJo0uWsTXvD3z9$g~b6&yVdq4i*(W-nEwCCcoyQEgDM0`DTTqIm`88p-sM zkkudY_HMcNkH)z4;qUh7UJC6zQbBfWcXibqJ;Pam-dgdE z=I=VHUN1%q5MJHgVRA}@zf5{=tClI^9v}Y%$S?{Dc5g%b?eT=_nPlnav&)v`YdtKi z>4iufPhJ-f#s%j*aMFPQg^h5kbN}=}S$WEcA(eMSP ziK{8)KILX{8tpLQpFk*A7@{_l?7;IJn?z+$8gF`Xyl|d@AEUa_k*NZ2InyPrRZX!> z-L@ww=``W-`=49JAsdp)J^}XI|7Zd5PxiDi)rv)+raO`K8IpXcFm1tCMh?7{*K0F~ z#!**yBwGu|ZCa~&N@)_SV({~iWB6zdeup8VCObB0j~2w5GHk}#6I0&Nsl)l!t_ur6 z$ehmi%Y=wL>8dQ=8Gng5KPoLGLme--qZ5{-Rk=9 zx#)T9>KSL6a(Z-9P?O^yVAi&OL#BZn)D<*mXztg+A*vUT&)@pPq92ZO(%3&GUs`sX zWt&Of*nA!1JKAA6^xSU=%;!L_Og+}@=iu@w&rrR><=5T>`U@AWuhXSU9@ZaOg=HBz zTS9gx;xnk4d};$D)J*+ZILHgktj1HZV_bj@IAxqN{u%0G9s7`%F;3MuUL}TLJNfh* z!ZJo%&I{Zpo*NF9cQI$k2N{^WgG72CkYt8J+zeGBA^BfTl@K5U+ZCtggs!AaPeF7U z4=?f`=z^z>JaXRwsLHfAQi9y{miw(Av;IJxb3%ZAU-O~NVXgj%#?l9_VCtpdS;T^f z;W_Lu`yqIjIl}sY1h@`IG->l~gJ^ZY3mNVZhfN|*72`mkISwd;nT2Uat$#-9UCX#c z?f+~vy0jU0xV8iT2c3I@>_XPaU#4Oo{4vwf$2eF&6Q_|z3DXKI4?mM5ZI0%Qu00(! zz;oqz)^Q3pXB*8>v2dY$TewCQu`g zh;%q^^x2H`)qa>ATMQu=^a$dra)#1<%G0uBySVw>e-G8hT)c9or`#XUnXzQ`H2WZl zWX>vJh#DX5$qXf`$zrWb{3N`;0=iA-1>sR2NK1RR{tYoqi`<;!zfjVO%!jhj=77SQ zKwDg}_o5sVkVFGWB8}}hKOQ9Waqoz}MFs>fs6Jz3j&~p%NlfUlQ9VGnQw~KHeU#GA zxUqHSkwkgB1SC8MFkO&fGiyR!1VXEf)ngOes2GE3NtarMoo$Yu2esVc3eH9vY;6y- zVWnygX!R%!>?srM<4`Ad+l(*mJayX!Xm%#@G-j!@nKhrm9r6C2z{EYxotiE!b*09LmykKCk~P&s;SRjwDAH$ z?BdB1#40@Vj%u6SH+ssrVy0Z!(_g}hVrGx82am^(S--R15)B9S=DdY-u80Ri zBC8KU>A2UOESPi1ak3Yd`!D3-K4jd=c;-t{v^J_I+TRSP%aNugc6 ze`?gTC1mj|4nZ5?-157Bf?8bXoKu^i0~Rl~$acY5D7WVn4H76U$?{I9jJ`1~jc2uv zTxX8AtvfN}muXzf(0ANsA-D;$%UtBaus`QN2cqp+&h>u1m^a5WU8W%sVx{NysM*WX z-Cxde55mhaE)!T#lFy~cRMN)?79VKz9%wFq3@<|+Rz3j7CxggP+s}%j!q1Rgu1XNR znTiMCUAo@BOmoY*aH6-~K^yo&?hM?QN$V2`C4b`FMqZ-COew4J!Ot4-jY$Mkyc49> z9-rFmTqzV3^|r|hjFs9HFsS5BrvzUne69y?r|K<-9*y%v5UZqsP(2xtU-oE7e`@D> z=2{REu$Wr#Pf%%;vyE!B8eDnF*?xsM2dYC<1np+ESe!nWo}g@68^w4X53*nCErqsC znQb4bq=uR$gC0qopaW`67}7ct@(b|#=n!PTfG^LBolF{RcyI(A{s&isRu6MmE`AHf zW{K*J#*x`s-Lxm}^94tpzK${j;FV~sxr(3U0-cV9{FGFVX4ZmhjqKoD;^s3zi)SvI z`se+$aTmMuK9R{!0Ib4pkrq`UJhw5dBJmt+Yinl5ZN|;r3>aiFw3EjdN!zbBRsN9IcadhTNLp0 zGqfXifH+3aqvyim2{JHQF4ESi2jIA8K>+RJk^AtO5hZTjYi~qq>m)Ucz^f(&oZ)ie zb}}9o+hKD;T8+}l;3K{ zL9qOqMVO2sOz{%Qp7{r4K_U54Rva5gDr$nw%uyS^Z;1=Y5;L4D4ls(LVQm2}n~c)q z2!FR52&RTd#Y*;fUaP&)XpO(wa8J*b|H8#JD|xYOGlu!^L5v5Gb=94y~(+g&MKQa2!s^=Rt*ru z2VmTUB3WlIqWQ{3CM>XzCdBheeW|Axn5!wFsmAB7N>~-pA8>7A#lPmg$t4b74E(-c zArU8OqiPZV-9^cNno1k2NA-+X?wt2aGQp$$4@!Z6W>K9DGIsvuBo^_kT#>-vP1mdw%L zKBhlndg@IROxj7m{7Fva@rB z1xoD6^A^oP5^+C6k>$`nCn{b>REO_puh~_`bPv_Fc`;@9LjXrIiuD10 zuq1xjDm!scpuuku-I*;3Qk}`M_fu{|R4*? z4L{_7Jrv!h*KjEDiOeYaxU$n^ZXh7hR1U1Lf6FWeMwhYK@kpSpDi4g6qRC&IxJTWx z3zaI6`!%rGvsh(3S*K(gTOzy^N5&Xz7PTyiw7< zW0(AE8?BH9IWAj(^S4T@d+a_v6Ru!&$TPbZ8|}*^rq0Hcw}8gmqj~O_@SAuHcT0&w zd(F2w(I!jW;wzW-=cQ$;NH+1Ga}`jnym*tNhGE{H6nmK$9%Y+z*Bud+ZV|s!Uc_&A zk^ChAp3&@TJ5F33y!y?wg=a^IwG)!43$Fg2TG**--k<$d629>n8XpX)l{NF-Q9f@E z;9?4X1MlX_&ju0e28|=yJ2Lda3RPu(-P&wp_vR+k2eTAPQb1}heikQ4nKo;zO5}b( z(vXVz7M0%pFNJ;pc%6g<~M zRdc(k*(}*W01Ox_!Y3q!55VC^*;Mmls7uWizm2>3sg-Ug#nGTur0P&l$&Yn8B zi!s1$7_V(&Ndb2syu=Clg_-cTztOjTNZre|Ep8e^aM5|AC!P|Oo&&JUl<_-CIiiCqk-HT4;c}EMC0-G<@7_YLdOBE4{v;H zBkK*FOp*AOfLg-O9805&OTrKUyG+ibmCimr+LgDEREE#|VEkAO<0(2K)QI_%2;7Nk z%o;ne?HjZbyAnhKGJ8ADqpSs7AX8%ef)lN@B^l>avP$xmyC3!N4sTK4jr{(|3Q58N|jE!tfq)&$DTHf+)RVZR48Za zRJ)5-Qaqta>JJI&Ew4DwbL|}? zP^s~rm9J>-DU~E&z#@P3Tn6nx1SOAciYLC44jQVWM~aGt=d?`&%VWTxf}8x=sMtBv z{zE*ARN#FxuIYFoff&!?((33qusp zz5lq;DUd@F^#NTK*!NOzz&`h^nontgPy!CHg$%zgoAIekGHmZZtZ%_SF;h+p;SXcG z0IlMUFp;LmegzdTk)5A1oHIGI@{rm_)x|cpN<|I~NkVdbVr`MJ>?wzi#X$i4kF(0> zK%GCUgd)R3rV!4&&bF$gyGWl(KS`YDQL*8r2e5U+oTg-99$*sy@4JKng44BS{ND*d zB5loVD-{(6Vx#$af4Xf%h7F`Fu*TQhbCw5EN5q;)L|!KLniL7fo=h zU?%K&rdyN-M3?{5FQ8FHu{X%#*|9x$6W4bft|pFh)(|FwRFP^}ntCD`vJA4pxiZ&i z!&cGIsAL#cY|t|$S~GVm$+bpXlGdK%S(h|?CA&DrIsqZAo)s}GEZ-*u?XZ!9L3PWV zwz01VSlz>VAo&77tSRK4Amfn&j}A{BK8hLV_;Fv=^kiTq?wva!6z+ivPC2DefZH8} z7eM!(GP!jomV?~i_7*}D0W=JMg0dfR!o!R8uQ7GU9k7yCeYp z4M`$AM6eoCJ$U`0L>;_9uFHCSRJE3a3bHXT)>1@Q;P$J?K<3#u1d6hWow5wnUF-K( zhcr#CpT4l1g+1e`$GAC`c%}S12rWBUT@$Pas;hy0%3~DyyrA8ZB zDDafwiBG{0bI&y3g}>T>Or~zkxohc}l;@Qw+q05A!OQq;kn;_k%0I$o%ef8?DA42& zVY^8>Y5>~b(`cfTi$YmptGC0gq+#NjzaJR^tiRUXQm4YH83WLvFv>drdSzwCmss9- zV4f_`aTFI7(V-!UI^Tmho5V zQ5$^H6vw0nNkmqJmZ4HPUctq33`7TXADHkmVuqD=(N=Vofx^BayxWdfmB*dc0bzG= zx%tJcMc;T z0qE%>c@uo>3wUg5_;UH6^4K52D>$B*@WW_^0)_`z*vWif91%* z)uq4?LZlUk9;!)68f)p9PZ*tH85OTp7F@zsl?!blzFgR)l)V;&V(lr0wn+TGPz7YI z^8i$3CzK5wE(s`F>@8orV8et*5K_P0>(T zv6bw2`|NQD&+AR|LqzrZDfwqIybz!2#kH#(ymS0^^AOCK-|`uw10Xe2@|IdyKzwBI=z;4 zlx$MH?utyfnC3|DB{#Aq+&;`-xxQ@l>raBeWfnxV;ba(s>1uus)Wd~;eMx5&{sg6i zUfWWTIp1}d%OTDAQ;QY#8p3o9|DqB^kE}})NK8qndYw=c_u_6iK6m8uf?3!ZJINdk zrRd29PU~_qdLIH3t(cSai?_oA#qIT*>spWVCB5==0HaG3%rx;bYEx)T1Va+dbp(aF z0aa5*--FzzmN|37w!w=>&O>3D;fq8A5S(b@A#xw>Km&Zci3KriFlc8~67GVsR*jZ~ zYfIPLZAONCqa=yRhvME?tSYbO+(R`|0z5BfiM@o(In3q+1pEe2IgN5QsJkAn1QK0n zr?ndI`wU%Cr49kXyPRxSLWiPn_dg4(fPHpGKSRUS;QLH3N}$>GDjv1)wQI|#V7-r+ zdC<4Coe(AJzw?oGFxUmRNJ35uxO2YYJ1kO|%TKk(Ue?jz(VNxQdPu0Js8&l9V{YI0 z`g}t*E=Azy$PN`wHRAFNa-L)a@UaO>#_OZvLQR>3`)V9`q;j@JA!N*J{nV}ECWE1ujRCu>QD68V~YJpH4p34DThH#>( z!L+4^ZH|xnrdPyO{Ues{VPd002$v8lENqz<|~^!E3cM zT0>TQ@f!F3%`DG%;>|>=>n#l|T7Pg+%>O~I#U5`CRy5)RF3U!j*k56i^!TRg{Oqzs z&7yZylDdhWsdD2>4t5rjxQABTa$BPtm;Hyvq{nc+IE%`Tc1L ztt>&5iNtT61?kl~Ijo1IqR|hEda8BY>HFUF1aE|EL^^3#t9&nps(q4PL0;9pEgmrB zP2omZ+d$iJgC+73N*lxKRA|A2`uYfAK4twg<$6%B=ffNz$-%4&+AH&vxcNSWcBHWS z1@lE3?{EwH0FdSIq5ujkdpi-_48IJ9__k`LtY%n@TdD-9%T~jmQ^u#L%E5s(1uE|I zFwIOew!=8vLJ(OqZ2de~Y8)ZV5+y!9~dan;u9+y{fL(lZ7(|Gx|vtb z5E4nu2EE4ns{xF=tAb{0vh0-4J$<_H@aSL-Zn&mjH{SuS${=?!># zKnyx9C-kLcKmW0{s)e2T44;lW$51aywqcAj*}FrC9*{1_3Istu!HI$6khu-qXaXZupF%);!ehK6Z?7$YepF<%v8 zHnL|#x-7#`a22~C21tsrwk=$Wg=+9M;SU<{cr~!J;B)Kobvr0k7<;6CGtEf{&S_2# zw33Ea%{|r*a8dvgoP0QUZ(HRf%YU=J2)4hTYcpuhdjPJczhK95PKpaGkx|_|ekN%P z$mDBhkPpuC_bGG^ag8X`R7JzCrIuzyES%_5yoX%tr>|;&5 z-~XcSJ%gg!-)~O?G{J5_f;Ks4#BPFQu*pfuAUWsQ)Da7=RyU?*Bf83%K|a!Qk=qq zZU<8(#r^WP9i8Umz@*q}bc}4*ZIW=p2|NrJ$&!v3F#!YrVnUnHE6Ex=?+roQFE_G- z_z=;caeR{7l2sg~BN&X6h0OelI#ithhI;tvv6FQrxs?0OOFbYDgXvx|~lzO}o-cir20%f1#@O zQGyFI%cYXsJXtz{14FkSCC$4ZY#aw27rm?cVn*8oEL9})=k~l}D^%uz6@Mx|uyhW= zW3)YA`eVCYE_f(xQlET{gA*6 z!_M>nDs8X&n4i&_bb9yLpsKrhDzpWNmuc*>+w}iOlkC@`QHN25z88Ie%wxIl4G-ZI zV1gpmlRs}uZxjQs;f+1E3Vfu?2149I5}z{3jL$zTQ49KMQSb>7^;l#`du0}34EZ-~ z*1-RLmVe|-H2y&}2oy1IY2CHv>laVW0tB!X0E-sw%M7?h%jd=GrktiL==0$4gIs?; zN^A9>0wu4UpZ|nCf@bubSWmaX6s5SM+?@JGqK54Ce^e) zpXi(HnBe14>f`;NgJq>pW}nom-fL?<@7g^cg1rYhrn&-IY`iE5A^w`KSauCrjZh+p zd6P~SkQeHb{Lqa-cAA?^`TwUm^4wW3)2}PtvzyZaH_Fd2?LCb#^Ts@A$78r$?dp9E z)1B8V6YRfLZEaT$^S`%Y+H25j!u*Dx`=*9rqaUCmhgV9{W}X5DC%*HQU)K)-=gccM znF(9VWAmJB&y<4^QY~g!`BHE-L6H8Nw(_}IY8-FGZ;kXqUhALU!!#9fM{Pr^v}&E9 zOik5{@!dF>>XQR40)lTJXdVMV&d^?j8jAEq6yAi1739zrc zVMUT5HR{73&}A~?KQroS#4b>B=Mh>T2AYclc=nilf~^kg1h+g>pv9kPto>oV^RcX- zsVp&7*YGb-f+}e$I_!WrX|Z`NfQzCRICH(o0jdl@c!c!W-tsy@6{^c#%RP>UKkp-R ztsf&vWV9OzcT0}%q=zHjH1C<0)1hS5g^cl_`a)|#G!iO5douwg+03^9^po$NYB5`dhmx+qL)4jC+*meY0?1lr7U!S5|I!jU$ zkq0JX{xFk*siu^)4#lugg*)52O);_F8Dnw34pomp^LFPW8V&{Bk|RKG zu>fENi=_XP+|`rSNh!|7tDUnQB-bVz$O$+QhT<+5RPk#jaMubSqxo->FS}g%7xD(Mo@| zqj)t@?<;`Ov3eCbFERKx6>+zRZC)0uq81%<^id9q`#2w9CaeH!nOOyBNW}qOt3n}+ z!o&?wG$kr7PkiiX3_wCSoq33W1=Vm*m^4S13=c)&3x2k=MfN!d@w~Qw5F>UP$PY(j zEJ*V#qL;il{9I*qPJGjUV~db*lpu71PRH}_2X%uYkU)5R-e{evQ*e>|XwGsEcTYT5 zL?$BQGL(1?loB-{Upd|^+VQC&6;g3G)ta{-kYc??^w?pW6_b{^U$m7n>3&^N>hKX+R}GXBuygMAB2#p#25% z-GhW9IaZP_-`Y=-YJ@@FIwW1U{QRZX?KU5sOGbK%X>I3m=pyL1%lEKvDTQ zkkK0V>~Y^d$c+W{PM*c595OVZ)ZAGoOwc!5yXcn>xi4PD=G@&u7i>_W+e7;>)>+J1 zqRbi3U8>Eybg7XFnM7;40%ul$VQSF{8E!`7nLe2(6~r0qsY{FUmTUP;6tELMm+j$y zSml|Mgmr$DtL_HG)qm#Ag@UaO&DMs181|J@Y_(fb!TPSL=gjVUsmAUFS3+1|6Nkw; z45y;KJ*D70g;-8G{ACMgstrcBSbJysx(TAwg%{GXHS=$9T|vi2kY zu%gt*pY;*d%gn@U6jX5A5g#$Y$p;R16L!Tj6+PtK!)^*Hz;FxU>V^W*af~z0U>}7W z8oPI*%$@!Q4}A`C6&v`!3a!;+Zph64w=(OPX*xXqRHh+@TTkhlnL>;_b>HacUFGaY z+UFglrC72UrX3&my_9#DzmDz5yM#VsLbu*=4drngjxPx2d3fGax3EFi&qGP5 zlHFojHzdHIUa-HBWUI#X6}^ZnqsVF|BFp*=Vp;8ccoSeB3R3!z3ZRRuwZO3)%iy%W z&619MeCiij74y72RonvHjaUa7!6S4&)_V1PJsFZz#QLP3`81s?rZ#P>?*#d_)oka< zW-1p~_Y{Akak!v*7454<%Y6xIOLPl=t<$+C@0t;?Yg`SI?IjdU0vfygVz|FyX|l^~A- zV%Uz}_JJ^;&jUqrzz8!i(=E!L0TE(>G;1>W?Z)swfwLa50k9Jw?N`d*lKC11YIv>N zxqou+?n@B?=+r-J1X0gWLrNyx(W!5u9k`#U%nP`F{15ax6dA|VH;x;Vr!v_1<4a*X zNlG~_NJ6~9i}A8$?9W3v(Y8c%(_X5;VR>ESl2nZ-aSKp=oPH4FZ9&oUOo7+!^LAX| z@UUH-CtXWTb&+3h;T)~8;&RIC18S913Mvb44TXJE{FJLeK3*r16>JTnL8ZEqK! zPY2D3!2prz%ltc7HgtXgA5hx-#tc)-;rWPs*o{Kg!;xF991~U+cBB9w=B+jd2l4k< zX>(li3sP;J)qS=PPV9i(We8~Dor+z12H~g&6-nFPb$mIiL%%EL zrR{wEh8n~3hqNDP)g8>U7u=KG-w`SIBJm58ouZf1$rvI{=l8u{bF&tE-c@zv^k?-5 zNZ|$a8@A9kKB}()@o!V0bNYn-P9qyzSR2jYuXM{WYfMpDn#mv_Brd|y>0XWJKeUX*u>B{m=Rk{d(M zv>tI9zgjW|zE!m^mc(24X>BEb>&oj?gEr^aL6*vt(VwHevV}q=kl63bsoA0w?#nj` z;f*Vr)tN%ODfRG&O4q+zYJYEMm&wL#4_a3|984_0(;zRo!czy9r|DhS$KnLj$ z-XmTG2sKe8n(1DKRv7ST7y(FKY4=Bp+Lbwk^Z$Zo$Ti-+>!m2`RjuK512X`PiJo}s zJkSOZBvm*A5;e@C`?HEk2;OLJ2{q}YPbD|n^5JvTdgPo;dl&oD8^Ir5KYZ}NK{G@h zUGg?DwYTRr-hDxM2W3AaLl+rqp8jA*PvGZv;KA2d6IGN4J2vcES5D%*R8oVoS(BJ- zs{?_x_=kI~qh3cR1vu%K;j%6^rCOtF(}U^AtftSsCFE{$RnLizYC@0SQ7#&}{u&kY ztu0)pd8Du$qE!k%5}-b7z4{VNeNlPQdU~!S``K%OmUa5~bdSLF_M%$^N0x@-R^6d$ z(QIcatN%r5etk5Dws?ab^H{4EW7l!gP=#Jo{{58qB-%UKTAzQl>}FLKGh;@?_V%nq zVV<&Eb4E>8n1$H&^vhn&o&B6ApS;fR8Kb>4O3fy7>Yvm6oQ6DH`Y}<0!1?}XkD?`- zEvrJs2vgnv<j&@vaagY{lQirWUenhv$&V`cgK@04d>2p zKR&O%fqtG?bi{f_DSCWXG&_*8N1=~5!zfrkNGs|aN@4m5I&VPs=>Adv>*K7qbEqM2 zKAG<{)@#15U5`@srLhF!P#K;&RP)YHSHdtC!(9d11(wb02aJ|>FUr4$=I<^XI%*^3 z@>ZAV`W+wZ2gZLqII^0>EgL&nwY#)_B>eKcNNk(-O}J2a1f^-`#2Y87v2g#HlhGxN zU(X^Jrgm4tn4s|>f9glN#|7wsB8$dM*D2s-Tg8{H=VC!p(^gl>yvtgm zvPpBZ?5wbOm6u*Pi!A^?RQeI9<@^}E@-gWIRMFwZb&=1ul`tiJ8I^VVZRblFxsqpz ze)aA&$Lxj1+REqWRI|Q9fub#(J!AqZ%q?kzX(%^+AtE1<4WOpWq?7mc*uMq6bfXUM zNY*@5QVKs9Jb6;moz|H*Ks7MQX>D-EQ>;>wAtI0ZMAKW^4r_j+i-LbkWdtaF9_kXr zbFclo8SyW1A-j3MU*_d-90P+E`IftNx8-AquvE%n5MR&NQpsEd@g8>~NCpI%Fif*o z-Q)vC9%T`z)Lbz?V^_G05lY(wFcBQ!DS@EgA_RUOf``UbfdIUq)itlFP2ukB+3*T( z8~1F+Q1CrA_SETER>0iDTtvMQP_|%y!viGJciMhb(;yP0n$q&k8tFBSd$U^$s?UaL z9fpZ?n;lM;)y{!~H0R>r;3}#j#l!wx%qN>MeYY2h{x!kWoYfyvePh!vHP`$J2dia6 zyerz(>aI$fYGie3Ghl0iF_kPwN>A~d4nsq~hzx~VC205j9Dvhk@0{2YI<}qe-WF0Z z4)`fI+?uLylBtZLAN0gyBMtcY86DhAi%F_%VaY0-JcEqPj&74%so1Z7UgaNDKYv;O z^P67Fhv9t$?PIgUTnCP>BjLSTJ#!P`q@)1F0AaP`d5(mBjiC@~-zgwAU3#9{>i@w* z!2e<=*n_24+&;o>ZakEj_t)Ct71^CPZlG2$F3os;N zw8DIBbFZj301~Ms&Z1R+$(9z8#romv+NyY75yF&^EFdiqM5e{lntEWw&d6P_<>IW> zNK*!61F;LgcL7g{jeyYx7XA?+uPVzS<7Xj2F=~v?`frKUx?5^dqF)=e$jCompMQ(1 z80BCeH{VQRqpBia21514EbteCP!+?ru+8d|NkeIfq7#JY44~F~{pa7V0>R^ZL?BIt zMvW&jEWbW>zWLt&EqPSy@1}?B0^$zX;w5WUn@oSU>)NvF0#TP9WzD_%HG(BC-<48A=-gj!98C!dHb+UN05`bPhGvIH<(E zwWjR{9N0BS&P4V)ju}IpXIw{kYG{WKrY?d`DCU6=c4(?K(ViwrT)bxL_2IXY;=*YD z`I)&>PTrrIGEzG&!+v@F8+(tyGeoPhsru~j{2^LJM*8fr{bj4)QFo*VrS(Wch3i(= z7VS*^EG$krtU);^qrYzOqKnY=H@G()dxnN^k7EF%OfnXV&N2Qyk>p|X#$iSi?dg4f zIqr54M9iNrOS~(wRsWgkY8kVMw$y3fx+2Gz^{Yj{6gsP_WoGu4{*-bMRg8J z{U;k|J9VuKb)yITtnWP2SjPi6gTKqhM@Vap3!GNg6OWUyHu=@=Hx7$h(4Z%!nEigL zb{4dMIh&5WaZh;g6lATKrfn^O0*}<7l$E1QRE4M{KEbnvCNR@=U5~b}X=smtlqZ|d z&s<+Jg7f{&JOsD>U*77m-|4;(SC`#LrC&QmZA_fxLuK?0?D-dh;wg=P6Gu_|HvAnT z!WM^d8}I{)TdJpX2kKuL9hi7S`qoqEe~jhM0gxQHFGg-k(tczr>*;@b4!op~fR;m3 z{V=y(1Ut2uU5eUQKAR`Kf{sd{@@%*aoJXSS%y^XvPE1P$q>sx6toU1DPwR;>o zpN@yy+Lk{HNEnneP3(W-a96!pIilIPe9KzNTOjhS}#yHeW7ZT0#*R^4(M+bJIHmt_3&*IMwQw9H9&+ey*2 z<;E#xHjS5kp9&}DdNzfr$}t!jXw{;6_3nw*^s^-Zh&7jVh)t%b%n>PACM+UpBqqrGxVMFtnJ@ zQdHz^c4&>QLtw|g6Jf8DHM<#0lFs2Dll1-BmCI*a0r3eHA4J$?_NQ9yPUjS{%Z4jY zZo7u-HFNTTl`S0|XB|0$0}UrycS(sCvloXFo}xaNzq%Fu%YKaaSzBM(%qfy4=)QL_ zhtLVP$dW6c@8>@QJNQ@pH+47y z=Co8_8KiyOzW0nPsb4B_K0-|5)8W^o0=D((c2mcV`>rhUgM{&XC34ssZ2ucd0vT2A6E@1YK{#QXnB-V4JKGjEvI6(OLuXF__ zwn~E)_lGT@x51h%TC5!UnR`y*t1Bb2F3M_zO*b2-Y(sz2kaXKWn?XKs$PD0rpQZsYfgzZ**>P_PSrBPQQMYa|I&(pab<3-ZUoyl)eLQC~k8=muwwd>yA z#Bn|uXB)wDamfVn2%l5cCcknfQ$nr3i(1}j&d^gJ!$5awUwoUm+4vDj|MJ7=cf5Uf zJJ{(Juz6%hy6q#6JP+INTit<1p4FK73?>p3jKM~Ai9!a&fQfSctoR=Ii5mX2Ql>lL zsE0KgcY1^Z%Q#3^$4qnqI+5@`zTo=ph>_gb!#-_PtEoIBR77LD2851GWx(g^#-Hkm>veCA zt5?<_Q1{iV_$mEtA6qrW?p{3p@o)S;1Gp>f)=uJ_95c!6vE1adf$s}W18_HSgpf>e?J|AuhO`Jx?|M|wGTG2?2n z`;wT=nIOha_dI&`_(NcSL^7dKS>qF{0kTHc@U+NPs&>;u7u^P{TAxt(ul&Co!%pxL zH{YIMbAt9s$zBbYJZ{0>h-ut>3bHk>C$#)8E)E88ajXNG0kP$#Px>nx2M$-~vrhda zXdAvay1FQltv`EsgT%Sr;Y547gwc4CSa$Q2tT#K?$jN15oXOQ)(SwzSUDh?9?nt_l zLhU(`Y;acdIXY{`Es=D_^~`_|)hC%@%9Vn}A}JtdS-wXs}hwspoq(W|)AZ4if;(-^4695&awyxHh#U@Lz993Xx`UmJhtC>87|gQUdn!7#=Y!nddwgs5pTUg;WXM?Uk$H1! zq>$oa<1gQya=ABgFPQb79xId zh%_7m?}B!q-p#wtC$9{wu^5`wO@3R9u)f)LaWjtPX`tXgZFaCpo}&i%f#O2~?{jX* zUlRawDgC$Oa%OuO4y1^rve_rjVO`;6Saxy2zN7&=3j`pWILV}}`>`sh1P#=?l^$DF z#HO@%inJrGNHwD;WS6ylR^Y2=9Ny2;FnJ3B2)qPkQE=I_*@E<_?-9y_TFQLJRNs9TW7*H}l3GjkKH-K@Y^ z!}{7tqo_PcJgcKyPP$Dc%=^%nD?9^QZo*a~8aWHJ|J%5KLrIC|X(5-`60NvQf3#Cn^;AFjsFq zCnvr^riEJnfYA0r+K*`9SvP}Zn$4ve02pjxe(}JB4H7zPSnCPXUZ`@}bg_%O!?!(U z{sO`RP&qVP0o=?+C?27;%)|XgX8`A;8<)nijguK6aBXI+I+V1H1Ih)$iT#o{yP3|A z)oq9Sv?jfDcyFTasA@Ag#*bR*EPR=VF?s1faBI=7+ij}QH*7TTK3sJs%oO$DSoSAN z29{#MbQs}YjMnyt1O@MCSOdu!&;=gPEVxtu%&^`+XX-HH+de}@nga)uO$4=7f+Zj8Tb2&-1Z}2xU?)4c|ucA zVK!xmwu8lc-b0^dr5%{OxOAb2YTBP~Ss%_+RLC=EWqI(L$^6W~7HPINAb$8wavmp! z>dOD%Vh{ie`0X???`skkyMlN#G>X@y!jz`>79r(}*q#M~aZU;YMzN3hsgXJ{0*>1MlK7hPb zrnj0#HNTNVRa2i&7>ZJW|3Ik9LFnKC^%S;a;0~4gXGQ`H7NBl2u3f!9km<|@_gUY6 z+S>dk7tyjWDu)DOpjgl=$vJHvd*6K3koBot@>pF|bH(a7;8>3MAgKVJo%Oz(i2SBD za8oYWPSR;CSwt37uRq_32PPxBUZ4TA-T+j%rELC>lhHcKK06(=q&5_}3$u-nEn4`d9i#~jGKBMyz3#Ve%et(C&l9QA$D z$H}UBqs%k!_8J$Q#}L=Fbc7^uR)!=19Xgsf6-=8AI3HSgfs(n%kbbYg`T%2wF_SMf z72!^u0Bt(!Aqpz% z1kjZv^%I)X{rM16>POgNa6sj~{kN6EYYmB^gGBamOAB=Yw7nFHw30gUYaP(5VLP5C zQAb&HORXX?qHfd6CVXrhek|+C#2KKUCAm}!Z4$jID0O8DFozsa2`qduwHz1G65l-n zNN+lTFQpF%&@)XCd~@qoV9%^FZ@)-+cg4uHif3ot$l2a*fN!RSIFn9hPh{`>%@P3~v|P&6t2 zmT>n^?rH#waR@KuM z2IETr>JMsce8NqU880q-3`8Z_-m^JivsC(~M1z{BXYMv(G1|sO^FCx7dfo%@xw704 z{DB>f?NHEyT+iYp!ip2Q$}j)qhFVUt=+b_f%pL_Y%o`^)FmUkinrP1}zhRc|*BpkI zN77&eN)TAimT6G8D4Zzrc8j(J<)KRx0d&Qbtz&@uv&EOlFIa^89!G{38JV`N4w)6d zd?sjcFB<1&F`KVNnxu{D*;E$S6FPWAC>w>`Zij7LMEBojN;n5B0;mf{RDH-2@hZBh z3m#1o*{ZVKAlIUHz=jMV$;JJzlJUSQ!JI=2H4k|LD1gktXinlBy&7_@Qz3&E2FT(t ztr1?Xu(O!CypE{jw^*E-a@)4G>Z6CsQC`3ffIVu$vqZN=WMD-QE?SDYMQr1uG*!@A zkipcyaJib~R51-nsQ+6(C;WRJu-GuOLR##bgAK6y>kxlI?A!bMQpp8Ox`UYuoMd(o zf>;_3mdYHlS$RmTE0Z-LnSJ>eRX*WwhBpl8F;LP1R&lfj>f5XT!24;T3z6j%8A@($ z6@)0dZX20BSqIC}{KjJgWCTlx0?{YYubFZc2V5gs#zX zW8q^2zG40cz-t~~n5_6_dm2Du0bSEkAZg|sMgXhWCOOsSye{}|)V%mk*W_{gJ{i#Bns*jL4t$S*=`7u%d!uRL2$S1vs6kJrEQ@DdjIvlJX2M@nx9*gKq9i{rovSSjr!{MZHzdGwa%H%5@8_#3DNv$uh z5mM1>-R(!M+bM;o@E)zAcEbzkM8RVMlxAJQfYKdQCUG>%v_Jz{ywwX2pC~jKSsoH3 zAm)qe09xXg`!IPjFbnM^7#h`{bsJUt3nouuLNqB`U+{rbm)i;)WHm%*Ve*A86^5(% z265>2TpSZch4W}=dBhc;7-ZfpZWq>`kpCXVsH7WN9OXTZEL2i7`t~h2Se_>V{c56Z zIu4)nWlschIN-g2;ZllZqJUxRedR@Wp_@7W(e3bOqY-js>vRiM%8CC{%MFN_wJgVUA%p9|`4zS{&>o zX1yvfA>s)`T=fw!g3!@;-Qr5%U2h8L=~fOLs$7RjgtK8%c|h%bo~@ZQpopmUr^s6u zKeipLKQfM1?tetkmOOjfPB&vtq!?9g?P9^MbX0}@0fZc*}Iaf0CheB9Z1U={_sH~O)agcs_r;Mp*Vn4uGx_)XJkBUfh=k~(y;Yr$p ztrXs|X|l)+YQ{%Le;Hu6qnI#oN_-;IWJ?K;wj0Wez#>sc)-}Q!aL3qc(?_w4eq5inOMI@IT2VbA3e=l9M80N*Lr{DD> zhUoMX(oKXFom@GoQ@Cn`Czc;W@6dv(OPGE6>d=^HDv3O1KjWiSekMNX)%@?$ZI_e> zAe1{j+1yTnI>+HYT;76QeOuxxA&CpLcA@gIBX=Y?q65VeIFZN!{D594QeQgaDxLKU z9n>kZi|&P492UQnoziCv7IOa^kF!$SOpS9;A_E!6Jk~pX;P2CL`(N+Fe+6LnDLp(w zMG068O51y)+6CutTRcFSK0+zsIkIl|IbGWZ9EQGbrz14PK4l^p*vWZyw*)utJ{Y9M zzdA&NTEuA3;uOGNs3&^-y*Wv}b;Za(Mje`B=(TxmPG7(rWL%bcD35~j?oK?Eh}9VK zxTJv-a^Onuc%RZ3M@WehwrJ~8#}_2e6r&QDqc^a`=os7yUfRfCt|`Lppqj6}zm zXmo0~GZqHo)4$BZM14#}iE%_1@kbLm53$)*-&obeQ{caevY%Zc(1?U~RNqGBcIMW5 zbehEJKKo*5BBxkY$t9R7h=`yEcMVhBPw}u+qm!pZAv;%o*ajk0VI}a;Y{ouZ5WS6s zEO1$@9-ubyp|Eh8kF+>SSSUIH3&aB#)}?GLl$z*gBvF!N7(za>T{W+HwFg?*3oEfT zyaU3!=(M6LY;-vmg&&S}({)zg_S!LlfxO7u>97i{-obl3u;q0?1d;DoY|UVq-_@F2 z;thaDfY1Dkp)jnp~yMx|c+#wYaay5~8y z5wN#$m)RanI18m(nppi5LrWQ_5<$H+{=!-9fg5zl5g)YABHdiPu3{DAkLtVU)lz>l zrEPUO+b}tII%FHSh>dYQbDpaem0u`5Zc%e5a%uenexbJ5b@IHYmqdnE`Takbi)WPYF z4{f_OA`y_3ZgD?w0yqZMB>=o$aPdJ!pUBV@s3(~{P>XtzS3}Eeo?xCtT~k;jZ*`{$ z>3MWK7OMF-Yu>G@li@@d8^$ICVY<&4>C51-)!$r2XbRi;!z%=eh^E%)rUM`@B#}cG zLQSVr1P96j%3c2X^C?wZmf-|rOx2xYqVzq)LZGi`4i>jCX$cXAnk_|Zb7PBMRVsw}^?YI=>TZ`Ce)T&_@AkJ&puX7I(WD7B`1|3aI$LWQ-<6DC9g zCi9;v;3b<({j&jI2Pwcoaao#7BeI-olTX!b7%D5EY#b9M;|_-(;cU-t<)@>9$?d^o zHsCvoWLmt~#I@4#mEP@0c&*HH5>(KIEt_1%xA*@Xi?(K}-+av9b;clmf^zJUVNIG$ zoZvA{IPOIxg6goOge2_}s+p6!x%pJ+@S`$`IT4oNtxzzomWpyvGTWF|96NVtUXNm) z7P!m~UP4={7f)8HCCs)NnurV&s_V=R7U4pT?S7LB$&A7rK}_qw5&C9vcPw5~Y4#UG zW#(|r1Z5QPnL%!DL@qk&iH7a*MO>XEKVU#Cx|vd#Urs9#ff;e>iH~&r)AM z0>%4t9mvc7;(2GB6>m}mDr*`P#P#%Knne=r^wFT062}iDNX%4+b*qbq$*B}zxP7$3 zbHABUwL|Un#Gr_ek{nXdV0V~27&@`7C@0y%5y&f8_=&M;Ll8QA0KD1ui4#lHMXy7fsNlw+tOF;a3JL&5g^z_=;z*;Sd)RLM?w@+OZJ6lTy#N|gCCF*(g z1Xqk%hLBQ_a-A;Kb^OK95-p-_di3q97`xJmbmMm{1T2SM#l+TPP7@8?mWJ%u`9x}T z#EqR|hE?!*hAUMWonNn>>0~a$qd7ro^Z&@sQh~+hJ2h|45*%4;r7d9L8~&WqJQS!B zHowegUq4cnM3fbjq`CZ3qFjIKt17f@XMa2D$SGem4j)wN6j#gxVmzZX*X03sHcO_w zd5F@kulY)6aSL_l&_aac%32m4ZeseyP!tr+g7<{GGiJLx;wdbn3 zVWqQyT@`~7nAS!l3=WE?jz^QuGq=+r2-zE@cq~X!n^h{EblY&K$eEfOMwbh^{#rqt zfFeQrIGjf5E=pMknGqRW-{ELoRZsU)v&-Zb_!%}B$~Q5?cczw6^;ioW>Y)xRStU?z zREk7YQF=B7@YXPbyP{E1T(JMV zz!JZ<$~{RodE6rSjWLo-TC#9G>i#1V*1Sw|XW?sjUk3LQE{n78KsKgFAxM*E2cj_J zto-QHj`S$y>TAnaLZp?BM1cZ^YSv5-(_a&hRx2{@$^|Jql8rJ{Q<02De#|nHOZb|N z0h^3;dE%m4E4di-f_zV*OvU#>X_aQt#WQMOa*0OoFamdIpEs#Xn9wk#g$VI0OfRyV zGKKo$shtwLbiz+4Q{;Y%J_->+l6DV@^}a*8o|7Hiw{c4Z92AA1i@bei^Fv6{%Vo%lud75=hT8f6H|} zi+(L)z?|J+CU?j4MCDBtq1$5QfH|$gDDXD8t?USg9(gt_d4G$3MyC`bvTS_4v8q%i zgef!hUQE!rmR+wDa6vLt<3x-FB$HYW|9z=NI8vQp!Yr>F2H0KsG~!W4pxiGDCwwsl zr1S%LL5C=}NMX|SU=4)qZ#B(3G~OeJe!xjho)p8;WQW1)B(BW^iFcUg21O96imAL~ zi+E!vm$^G>YkjyJ@C%QfS=FNL>qG~_=4&4KnvBW-pF?tTmxZOQ$6wdy??F0cs?Om~ z3ex&n;3-o8Hig}iXL;a#Y^tiN1vgcF6sZcxV!inl$spDA8`+9TBjwkSdGh2AZ*hrj ziOQZJHkUocvF&RFbGx$H2#!fqAK6bvnT!GYBJ4A$@@$DJ4c+uDf#pOP?xzX)sq4Ek z$z_&3J+BdlWKsCfI(%${jZ5-}n4pDh3V9vzMJXBik3h`xD}3HXB?do9k$k}5H;NaT3+z_QCR={Lg| zaD8?DkzT@^NG8QKY60KAjgnh=`CLBT>|Rl`zj{zStKFmo6WgXg?ba@>>eSc=IrJpC z&c$TqO0W!VwPHa%7MC>W@fTfvkvcXnErR4i@$eYG;T3dnm8ObeWJU1nPn_}kXtKc0 z0eUf{g}PaJqgvpimMvOh*%CfeIdYWt{W7x-I>xfy(FZ7&d3wgQ^FOR#bHg5U$sO5% z@%w^agml?1d=zH10w~S7gWoDpTfPqybj4yIviX%+7U<wUKZb5L6k(M5V=a$oqd0bC57FUr=^}$!0fOSEeH;Ckk9k-j?RMj!%7E1gu zj;hnVt39EEZY)F$q`+1@7a(gkVs>pC*lwRO9mch@J!|z-!1+&M<9|Kuq%kVDf-d3E zy7#iq+!|lsngZ4e$^f=+&yqz`oOx;OLMLQJ$n+t4H+0%X)dIr0_~=vA1WdhGbfod| zJ?z3`sw9RHo;rBE1heKFl9D90SKdL+u{CN>N>U3sjZfsyd`#FGh9n8LWojG2_c zKbXck{<{W-n8RudW(PDnbE8B|_m%hv6oEH|){O$5`o@#}E%W&roiruL`hhy|YXsw3 zO@F*-|0;0CDZ9#iCHQ`xv1 znrU}mIkPlS;*+cHH(f<}79Jd~z5YCxQBOMARL03tmpnYS`&Q+AUylt*6vw^MKGaNT z=cmI^YZInKW^m{>$CP&@wFmT1n->V@ zD_#Fb?=#Gt`N)?mcP?OlrzRBuZMZ`Bp>GhBl_o@_;4V29Je%9Hwb%Im?NK6(AiPXP zuq^VH?sGelFXqP8NZ1Cipr(kP32;u5fe`I<9eSfCB9c5VAUR-{O#Pc0^H4aHZ)(DU zHTD%A{1>N$7GDt6pbPMR;hAF=!3kr#vQ|(#1IKV#vS;&MQvD1VkJDJUd1pmn0Q$nM zLD5}Q`*iIHG_cWTE<|P_RqE5z?z?0T9bMLmYQo0^Q(`%?MJRPX#U`Q6^$pp5W8MoP z`z8w)xKm{Jg%T9aR@Ex>(KB56sSH6n=+OdYx>8)oKZhTIoopxr>b}?ZZ&Fw|#u|nd zZO5az#KuwcyHVH_lP zi_`)<2A$u50dk%m1w9qgvVd!(6N6se>MAjazinv(3rl=J$_pxGvZr|Dv^WJ#RFVrZ zhz0~?90h0trzD$IBeCA>Y?m)X!~v{G^15V`&Q#W}Vp z(GxKKZgCtp$cHQu-*wNHgSI=mXfrzPU2r>Bn!+0dnHLedM3PG_>c&L6=dk31y*A@F(&6U_Sucd1y2;$G5sNrO4GOZc5im?pSc-J;1|gC)d$ZdEg>LJE z8JP9VRBd!@vt>fOLt!>`>Vh;Vy0Si#7N#p}N${?a(v&yB^W*{P+?iGi?f=Z)zh2U> zgDd$w=Qn5M3aI>}c74j0Xo|1-SpAm@R4KU&`pcBWg+tpnjq?ZOSnc5IwzAZDuQmG> zmQ!&Q2!pJ^z9gQt+ZMf^+dGM8H1DW$dz|w0oyfB>`H@oN4qJT>H4vQ z!tbQPOIAn25qW|LBb(z>oSxxZwc|BFLL|+}mGIt(6i{0{Ss@kMWGgioSw?qs366lQa(~lB*$-AYF3_yFx-+mYV{;m)f8Zm z#ys#03&m}(Zgb^25+T+iQ8Ij>SLK%8!);=q2rg6*LVu3klZk!*cSE;HDs`cWNHD_F zSP`l$PQX+BHMf1@3~JG=I2LFJV~cu)2a{jzMr0j4H5ciOv7y59p{nhoy9}rn!+_=j z3B3HD@j*2kt@fpOOqjdS+@I3gydFvb2j**P5TYv=@^GSHdB~N@y|ZxbMqpBG=IBP| zx?HEdq_VJ}xw&xvIF%MN=WK3~nf((3i1^!By*+9iM4MQMly)JREcx1Vi6Bb*FWB1H{b@7R&DEA-J63bKKu*e;xw4vkhL;N6Nh z<-S+x_(n6xb#yr)5o9reVElH)r6GndoSZkNpi(v=L% zyhaO{KBAxUM0!rh74dPRBOU<&%2zriqb`#;bzl!}txynA6_AC6A5)8^4HI9)*%7VpjnsK-mv@ zuwK-w*MpVs)s+_mI3oJ0jh#%fi6WLeU$-|RM;g`dPCjX$v96_-9+V(4jVts$i#DeG zZ$!OyU)0h1{jD@ecY}moHu$5V+!a&R3noIpoIc9!6-zPg%Al&sFXp+RTbCy-RpwZbfb4{xRYx zw$IsmB@jDyT+SHOFEdT`RCB(7%W1Q-Nfb-Nhz%5YgE_(R;TPReu`$yk1vTI~jklVD z6mc^17|__t>rl}Y^Ka-m%Be0$R+tCrXI{~pP$0x#B;!gMdHlw=3C%uwZrOYrN2h{D zK702&6_m3N?4g#}7+Uzbc{$>Rk+vzA0l3*i0x zz6QW}hpjX~HbMv#j}hc6bvEH6w@&XRPj%iu#!9Znrv7X^Xz~pB$t>^?$7RVEO&h(> ztZaZTpI%R__IN=0bk|Cq`pA6p4TXv}@r?%{@`*!GjZ*x$U$mFn25qL23E9rg9G9sb zaM%1Ph1)ELl)(0F>ql9UpJab7PTgr(Qq{huJ{|y4+VVaXw1wGsxb;eFiU2{E%gHF-{`)6D>%A5-O0h?{l8)z^HSQG%|EXF;dzDZa{6P zaX4mCYsHD?48XJr)1%0%7XV4=KYS-}Q3Ap&HD{9EB?dG7Rru=& z&4qnbx&{0l+lFR-LV1yy{q79>HN2||vyDgdf&c34%_T+6hp>|bacT7^g?6OWycoAo zg;LJKp09}&8mMexEXV9W-7d1L#N4r33Fu7*%C&HQu~v<>FsR>9>zpvlT=>Z{o21Zh zs_Pc3$oMLWyG}Z&VTXr#3Smy!YRjA9na6?MY>7wl6QtQKcoWIQ&{8r5aU$5*2!R^L zM>UOYSbr=L>zM<@q>(I98M9GFF4MO7n@0rA#(U6`7t5EA{13&Dd1*!XpG*<9|19)K zJMq3`m{acrMEB)XkJ!6Kxe6^;pz!_Nbmx7ImO$Wr4fu%x)b7p4_5BGfn8cX;H{qQ| ziN@u|V)@r;5(!Oca+FxX-dLYT5g`pynBOG@3r-`Pk+MlCU?ujpIVJfY`Z1)l*3Vi! z=G0p^ji(Q^4b7RM)%Qd4blr#I_b#*dZ^t;KE^cz#POe%)1HJl<*0mq|h?1UPjG^a1 z*Ca~#(d$iu-Q2I8$A2F;2_8s%C(os#>ty;;w=1#sfDn+Elp@shJ+Kw&hRINJdkbL@{G;J@5z)q+nb_31<%A_@u3c+}P zT)ff#)M+xRQ-5t1?_KU#^U04vG(u&3kb3N?ifph}8=#=p*9B}k_G)a&tR>uc4Gjhl zZ}$JXh|$--Mj=O0cP1M?%me&#$RQB|fbZt&jf~PicE9(~Gv!}!g(D?l&&-@+C;sC**pKD?Iqnl% z{#8FQSdou>=k=Tq6G~Ub(ICR4-}<6)#^g!<532Vc8#1c5Q8940Z41f&-gw!vm_ z0fGU`6ucP8I;R3Ys!9ZsoxAEkt@<3H=7Lf6}6w!@N@Q}i3iMDcs{fsF!>ho zU-M@CP}uqX(&d#v$oCf_&LK%GX1V{~GTmycz-m0m!c^A!A8~UGedE7YJwzl)u0iL0 z%%SVeNjM(ByoX{No%73J9^*h)GZCUu8{f(=_X{_PLM@*`$BPoI{C&{pmgA+G;2Dmq z@>HIuYrC2D8@dj<=PeCmEsPplMQwTdC%_%9+i4sWmix(|e| znC@Foo$)|Bq0k3LGoJ6K(S<&-)N+JZPl>dC-U*Jolk6tt_70#ksI%tnU>lvNMJR#R z0n>(pP;B*0Hp-drD{oo_*+p_iT>mHyPg+;~+%o^!pHJszbk^bH`XweH7P0-ati`hJ zEDNK8OKM)bvgx5b|Ds?ay*1cf-b-+~+0E4bmSAn)Mnarv8$8m}_;8^awdtw5dVpSf z%oeGYC>p6fTsm6J_fxHS2Fc1dOkadLY;E|-_3k7oa&3YhMT2z{~Y3zCLt1o!1Sq}i-;1-*>5QMRJ+T{0hV;d&L;$7n9*RKzA2!rlq%LW3jnJW#n(nOoYlp~Z zWuc`1@@l&#m3?vj95Ud@JWs{zx>?3uo%=|ov(55_b$!B)af@tfc zJqN<7ehh+k)dZFE49XizW~Yl3F)}u`VhYy@xzJ<}S}mJQALg@(j7#0NGyR3bnnK=p z3A*)&O&wf2B_HL6(x9DsMTDeUhqwNW*3Cw`s0_a#d7T_XqVp3u{dV`2C)^>L-O0;l zrBJbEA>Dm#eZR=CpY{GBhFNF9OjVp!w||f#4>-yCF#Xt zJ-Vg*E_=aG8BP&lwB`#VKsTeAg6rHp4chI`nuPiD{9RBzjfTT4H~Ws zXTv&XbYM~wvM|YR{_?9lJ%{De_B<1?9mo0%D*HueD)ZnjhtaCfh{0NL5&QeY)~%)` z4p7x9_c^ClLb(b2K=`=GiDfOzeJIcLuD_myr|c|%h;Zhin@-9q@WntD-sLC@FKV}k zY8F$(`a?pvsIo`2SeGvnGvLqQQy$W~X+O-jQjH&oZy`HN93EkY=gW-;%flA>1y{n2 z_CC%O6L>Z}-Q}kP_r2Y4|L3-W->TNC8XX1F53jJr8T%S51~e1L@>BO(@bm7n9T8{6o0-?h>Pk*CKIyvwaN|lT!KLq zx7Yf;p>dd@txj@X)cc^>APIP}-)A$uH3VEeP&fJmknt!Z=67a9?C1Cn}K!9ox`YPYxPy}82~%h4}}7Vwj%w%lctsWv;_Ke|EbSkJ9Z3E>t)g+=l!fV z#Mt4R<_eA4tG-{p$Z7bu7-}Cv#p-o6TnsvAb^E>mfDfKUJiH`WX1aBEiCyG3?Oc=fAv>WM#BJA7Jm$be9BmIOy;Q5ydPrLCyAd zj#`6O(%oT)K<6@0hQzK!XR#s^w$-b(;3a(S89Tr@|&fQ0TWHeYdtJy>%q_;=LJ{9<3TR7vwh3DKJaqC z+q^wvSj$;*=Z8$8KjAwjvv+cxXys^M|3-J;WI)Tv4QivDoW6 zhHt8EKPfQ9s*WrQ2ha*)u6SB5GxM50=fW+ep5*L0KB!tjK(W$%{FO_SguqTN3b%}{ zMFb0nurD?EvFms0;hY9G&u9(4KZVIWg->$kx==r9}Gm`up)CMBwt^jAhc3 zhH5`g@22iJvbAn`#@A=tw{Aw5S~zOE%Fib{aCf8PdUG}*VqQGJ6C7>#d%xdhgLZrq zAahh04{;5K3axiPybM;+xpoMU`Lw#kDUn#k9sRlMNq?dH6f1MWixGLIYp>jkmH!VX zIrYEh+XV%+BtZp*pb`Ul^G)X}P~~INmj_b38`rl|c|W8oL%R*@x0mHRJ75kMNO(`_ zYqL+oT1ikBJy@u47q5j))$64C3~-@r2b^Q8s#<#zRm28@&#nabzj~rmOONconHe#c z=Z*%Z`0the>L@hlNEk~oT@BR8F!%16qS@Zd=`3;m)N;;~A^bctb<_R30jsO>QT6F) z9q79)5Y}?T?~lA5;7$Jn+U+@&jrh{qe6uOvTi4c^tlZcM_WX!G)Xf~)>UtP0!Xxa9 z1BFdBwX|ff`SgU#XU&)E4|0tR2O>k2c01A`9bMpGZYRr-HKRM@mH{z+@Y|c{AN00F zqXfp)@Oaw^yRYBQe%1T=NkDG3E>w#^tu4Q@E`n9oT&8P3vdK#}`>q~!YUHi%JEf`g zy7=9ve7i;BnI;=+51w;#;ehu4n4WdX7NcwT&b{PrD9n1?d&*l6n&`tZ=Ex5B@U8*! z#QKCbnVAY|6kDo5DA1+ySXlvAM2|L5VZUV$eJ#pma&3>sz0~K;_R1X=U#>3rx*csi z(BMy&#seQnx4DU?Ri4?Uh@wIxl1#+>fa=j!rUUVu+xK1~JOzl(FVXSc2kN zuUS`&;)n$#hFfKP0WZSnC}^n7gAe@{Kb8%-+E&)1>w!>3>|-#p%=a69mW2>6hspN=s--u^k$ed8u7MLivj z2KYGy8&P6)su?0>V$iMM9C^V8uNzRYeiL4rzZ1vs^Fc0xVBWFn*thzC zKR2Ou%&*MDYF?0O+M&n625jdFiQebBtbNY}LoZIC<5fTNQ7vJ;TN=|uvco#mbJA)` zJ%6n0+|=%a!M2)gf!man87aK!V9&WY;UuSPNYge8m;O>o?y$h|H#L$Mu%`&D z)y`smvmrb>-E-`;VPB{M^C=NkG1sJSLMZQi8J5PFt8>b5&Pp*Lz%W(U%g7aw7j)+S2W3K&q9j>|3r_C3v39iV4bi{05M=l7WuxA+m z{f%TYSB^hL#GK-uUgkmTeCI8za59QZ;fu*F`;IZvU^}kjCHABYEqL2}fiu47Sd1YP zr~H>O*Txx9+hg{|WZednYTygwx6%xW&(6WF{NFu8Z0>D6ryg+FLmD1(Y}U?5_V;qu zX}zS%`Fp$ip6HbavJB?~z2}S)J9b8M_e@w8I#Oth5~L;ckWmKY|2w#kXHWoI1-j7d zm4?2}qsO8CVZO0P5AS&Uv54*k%7Q`I-45mO&DIwWSqqzA;pmWjeER;$rd-Hz?IJAl zl>4i_=>140^p0-*L8o;ju|D=KtuCN6hq|b40h#R`X6(2S`S7Q}mG^%<;7Me{*T#5H zrmY=%@ZJwe^DEbrf78EN9qgRx5W#Ogrg*{*yNR46g%{R8yQ?C;@%uZHFd51}Ir*npsdB`a5Id=u>=9_NN2XR1xjvhveBzrwC_ja?5%Q)2(>{$IOJrrM^SFaeOX7%(J)zp|JmR21u`U_HFWE5PgSVo#(y zX=CSQV0-a5m2AVK>ZpG?otRpqR|Fbz38ee(cK;D#thceICs^%U;vQDbD_`~Ip5$%8 zEH~7!L~aNV6uJN^`UsrlnU|=0>dJ@;Nrvv2yZR4bN;I^K*Cz|+08SGltFMw=1KiC+ zeIsy&3?U;DS|UnvoFcQ6%0Ol9Y#mf;d2vDyWV8qj_WS&;*0>?_3(=S>Et@oqUE%0W z>hk?|d)jG|w7rjDyl-F5M1+XgK%4lodUZ$fALvxVr7do;YD)ib5Heve!QcQTPMJst zz#n`UE~K(#G$6HmzTEd1sWbOqG6_Iw={9PSI}4nC^m8Hvc3_Hx%^>7JiKX?O|E?%p z`cpO1rn`ES7zcKJR26TuN5dyG5A4yS#@>8PU*<%xwtJK!Q2L!50&uJweRw8<4E6Am z&6-~biUJwCr~0$OZ>{hrq{?imKYw0-ahBWYj4rGaIO7rreGgb&m8!3~?Q7^Dp6DXW zf4wtTgH9Ed=c^@p2Yv^|3u&xkQ%hR9iO^KbFY<_TBe-cNebntBnI^WHV4ca7I}mS2 z=|GoW1j|S2;C_1+*zf8N_p6>&i?|P7fwCKL%I$XipB*eIX>;KpcKzix*>~0C) zxsS1Gt+mV6xt=!MQTGphI-CMR+SPKkP7BzDR(taR`rMyqU40sS&*=1_)6+vq)S`t! zsF3cCbw^Lwy}~TgXZ_mvyyt=#+BbG?f4cD~y0k7zQypZv;2PgGzF_TOr1_D)Vxs3$ zsuX$i6_&rr$-(p{7FjV(TW z8D0_d^AQ3ND}RDtD&c4n+PS?dZGN+J&`|n$gk_MP2~6Lc6E6)iAwUJnASk5D2hLXt zAEp7(Q+5&3*@U{;1?pf^^J&pun$1>aR3KX(`0Y~4#S4)V5-|B|w2SwkgD;vq!qD}U zCI4(ICad#7ljg~i7vasaEov}8fE&-y(OloX%yah)SHbqc=Ity%vev8w9U>E1sg|~= zOQz;i@~Fe16W;DS8M4%I;XEZ->ZcOy8@BCg^`JYxB(c3c@ph~!@4LkP-BrB5Fugr1 z+OoqVM24L6e_OC#J2&;^5UOy31@dyhc_=QOvZ5gd;G>*%lvQ1{(kwODoe-l?{DbD&KP zdIqWa#EIEs$a)QEFK9&?=( z7f>iOQT01G0Jn5a)9;54h~NGGMIiqze0G*x3^zv4-`A_4%UPPU;m%SE&8QWhVb~a`bxjB-;S7+w+CXSJBk3c*(DwcbVoVvLO=&n`*veSYD&6e48xibQ~t_<@7C6`PM7<+(9PdNZUl7LO)Bbn%DfuqBg-EktV8 zOW^1Q{tAeO`xD6Esb#+S2ty5j`(yBmmxJ(^jt6$ZlA8gT=BJyy4&yQ3!8Wet`hsRZ zJ!U*Q|5+)mFGY_z(^&!Uf$!gfHNWFnx~EcqYHeu)LIhb5yR2;ulxB6oMxekdb2TmJr=ev&D|9D~i_B@OQnMTay51>wzC^j=Nmn800=68-L`uEPTtFpHB# zu!5cXPd?+aq)h%)-+BCTN98 zJ0ugMtTyc7N+6g;H)|c_sdC;_tNq{vcN=Pov+ESLwyk|7Q=Iw0(M>SPDRH?LFV(Rg ztx|WK5uI#^vux@{`bYjuDH-AlYM%V{QR9DJfN0>V_+^r_{6;Kq)Wj;O%hLPOO%Sb= zL9^}H{#;Y|wwPQq^&@tclxcug6g!<)(8cgJaE ztvw@y5?X~z%)7y6kj(;UXI7nTc@CtLDascVdI3XDczLw6TOT5pm0!!7fmMep@KvWm zpS5g;@7D{@D~(8vC)*aGs6CLOjIIXm*8ObUF8^DgsLrLHTlVuh@Kr{tt&z4W^|PZV z)K%)~X7cL1%fRcjDpIYBcAd}3`xn)u(J~DAz(I8LBQ76iB4(&7XQr{K(bVke?@wkQ zZQ!|KO)cx74*3qcB-3j$kV#sV`Znc9$&Hch=fl!0bx73N$?8UBuJBr`+nHA3n#t_; z8Y*f;OE>YFPPumdjHeZ&VW-Jq!RWULE8D_Iy266dIf5$U$!-E6`pUhYnf_J@_)lPI z#RcK_Z)Q`lw|9B^xaBO>C&}u?+cJan4JK&1+H6Ugg@rPZnDN(RlqL?&QDcR1>{N^+ zO4`3z1C+&a2@_~+&ZrnaM=v1qej#N7o%>RK9OYZ_^6K;4OMEaAoe@5JD+DE@yO28B zEz-q@Vcg7wgZAKvt3KGRfc?&R@pZXI0*qydrZ-2O|hWG>8gHQe*o=aaDBB7k!c*QgyUZsLtEY%SAwFyk6 zQ1r__LRg-6qX@6i*nT)HC)ieM3aZf^O9IC0bN+G60@tjBdJC~SUhe#@HlfYdDJuPN zl#te&Xw>*Vv_AoCQKVj_gbV>oi_P9>y&Nv{WCZvk8G`E^W)W{nU5(~yG|A3){$>o- zYX4j|ZTFEnjm;a4)tL=D`&Hu3R|bLVxa=tHqSbP}rHIfpta9zP#f*x~hzt!8;KvK5 zNk_g`v!?3>AgF91e|_7?>;=l5^#<#8@zs}dok=!U&R!C#!%JbZ3BdiF>lJ*O2gHxT z)L2n_|C!l8sT0u#ejz`kG5eSn2XTLM0o!a+Z*#r>SIykuBXk0_@ays0eTOVIZA6;J zLw3T&9)Ny8Coj3sBSyN@SuUg;e0*T*`rG_!)wRh%k{0Rq82vQn%@ip~>|W7HEct^> zc7X_$%AiU{p&kbc55Wh4pt}Tpu2BxMmi_gaG*JDxvcdw~_2;_W=PWNgUVS%%llJ7w zm>0~}87tml`cYE`v~Tk!7`_C=+3%$DYLW=wR0yzU4n#3CBx7NsmMBOWOAjh|@Tz}f zJ2>{G?p?^(0+hZXEuZNBiddy-G>;*Iz-DC@pnbCens={(+FDiC8@V8E=+Nj^@`@R)G<#Ze=p)hwRW>1+9XXA-~pt!2?!>0=u{c3 z4ic!BTi%ur@|cAV)-MR0f?rD|be1fI#{1byZA{&lbYFp6QwAO-`r~&W@gM&{4pir%0Xu<;*qc-@5k~p5*t*1V=%#7niuh z4)8By)Nq~mB?Aot^WYd29`Vkfjo7a5{>bf50i(=@CgX=kLfv{j?N(OZF&j*>GW5Jt zq;$02x!x}cM+VONpP$m~mS>0rxO+phqeq+QP=2D(P@%dWcKAvR?O}#ZnEk4&zop(+ zX*KM6PcxjjQ4Ug+p@d$a->p9=<~x9STu62?jximz*_lp~$s1tvwB zme#a6?wkJOAbTpEQan!E%ID3plaUa=&$-vmNGHqS2@JZ1wIq1uNQNFlJ#M$ps~+4I zGigv?Jv9ewe>fEKz567mA@yhfb&t4#m&;nGg{P{^uh0|v`=dQyG&*3w#9>yi62Lt#a}k_oy9K z+5~CMAaWE`pm~fm;XlqG&fNyre|30cr6ws_U%{OIGT6=6JW;~TJ@gsMkxG-`DTi@} z`NIv3(cB2t43mr5P+XwwUl)Ro4eqk!Gxtua;G@16x{Zy;L>OsA#%csDNlT@1soH(H zMS~cap_3P-ofTgX1v9l;a3`<`0${o8^$C8>blE= zlI3hLOOC^O*s|U!-i-6p3P+VvjnO~X!AvBd z-)l5#s36$=Y9{j;Sn-c+%@nZQ`ApPa@wKjeBsFzWukSp+h)`Q#f%ZTgwJ#*5z+*GM z7ly4=lBQL08K_G7#hC9-s7BNLvH+U&+nWxO^)$UL1VIKgJf5EyD??lsdVH}?mfNu9 zFOUjkXCxQryA5>!B|ceF5x9xjq`|cIzsnRa)bkHFc%tzN6=ZN$7&ol9v4}yF zz-;=E(&%R?-YIC5s-)yYnNNn^Ltd5I9L@dv?8TbO>l+b~dF=vayyRSODr2qlN~g@- z^<@v;)7KTH^+*`!qNMynEkQ~xkj#1*g<7s3i&Av+;VQj+YZC%72wn7i|09X+Wqzc^ ztj`CNW|hyoho8p^)kd+UbfXo;+#otN(@))pUS>8vFNDNpIPHPKjQC)YxixN zCL=YW@DJGTye~j#YaoO202y18J8x!T6mmZya|H^ywXT-ikl5Fo_(jXX|{YGFJr`v7%L}@JG zbJ**K^zrThr6k2;B4$q$5GmQ2i3H$^bcoJJGh7?P zpRb>bokpHV6tDiqDc@2YXf=ec{307<$DvnfL)83bbLT8ca>4&!+Dc6qzi@xjlWxtj zr}ntjdSY$7?|61@!}VfD-r`kNUW&X8kLy$F0cNF7FubH1T}5Nce$L`c2kCxV)bdkk zcIeO+OW>^u+uRfdQ+Z?dgUnDfSq`fDQycgxGhww~;&^Oty+sH?;pgvlrTF3tP5>Ij zlTPW@TfEl#pVET{IGM8Ko=Ehe)j1p>@$cu_6iPz&OQH9_z)>3A3w%}hi;4TVsAjYx zXgXuP2D98eTCbEcml)7TFtpI$_Kfs#j6gOPKuqxN7w~SbmMOVu5)FTQ^W&--8nn^h z-+$j}^n*TQi^7nk8SD=FQMzKMzED5(@u70XP=~riZn1M+N@2~Qy`>Z;s~T73Hv%Hk zPoBRBvfcgB0rw>7*QSC?Kcpc<`YQi3#dDbay$q3rOf`j{mHJ76&UyAQsP3^Y41$}Z zw<<;P=W~K%6qGo7yh8VAxT7HwvghOb@-%g$C{LPE=B-u})VzII66Rf)yM7ZqjzSP3 zdN1;y5cV((L*P4bK6YXD_2!$T2f3HEKX8K2Q&o+SjaM^NnpXmVpYL1OKBS}-GmN~} zuBFz7kXyWW|1OC}L4iZ$(K#JEeEWK46qo3~S?koT3kRC@j30Yms?~noYXmc z;h4FlWzQ@S_HYqc_)d~Y{OjI((<|Q{SoaIb>CyVl!KK6-G)VvoApy>>v1>XiKNWv? zh9*mr3;HXiqGd>pX$gNIh)YKR1Ro2ed!Bi-{`f>IwcNO)wZm32-AW~kGI8v~lWp2~ zBn&na5|vtL{{0kd)Od6$%dr`XcUo}6aCrCw)krUqN-EUBqLhw(FJ!ArB(xjG>{E0) z#?cqdai*edw#;{NWLsUhvt=ti6igj-M2&F#c!^@nICF_w_7#x}LwKO5RPg0yDW8~buyL!G z_kc*8JgRTDPRWwq1l@BjEDe3Ai`k;~po=GyGxPaL6fBuDy)Wdy^ZOmXyzDY*cbhXw zZbthb6L);ex!X677(QBqHG^XhqA2GdDNtQg8woL` zwZWU67a4Z13exhD+8#yk|9TJ%c_WL?!fU$BVBC+@6_FEXI_sc)P|Ntqf+`@+Yl`q4 z!@e>t!Os7@fPr4{7lWv4^D&sj{d7EYhBN}3)=cFP>y+L7VvR|p-nHG04S0|yi+m%R zNcV(A?CMEwp6~Y5^#gKmzO+SyD*bVOPK4)va^NzI1|@8pEz$W`=ppdz8z%H~Am9Dx zFHTT_y2NAasViKv+HlN^*C&Ir5>#mNA=xWUU&a;Y%|7fF|1IlY_U?Fed1oYvv^G>3vqzwoBb(ttuZ=UADsc*%`TPAfC5k=@4vl+Kyi zRj9hQDb+*@Kgda~J!h=FP~zlyTt(f?+k;{2QCiga+1&d;p8-xK^)L+deq% zhHRfZviaHVgkCNe*T7HKjE6>xD2-Ps+Q~?6y@Ew;AT=gcp0c5{pEq+ODt+wf_zRnZ zi^Wqq_Uo$)w)zDCy{+#;A6_RHfJOB7=5JPsWY4+vTOpTBU2k^wXcPYOw@;J7LoCm7 zd^tb*T@jYg6%3uAUmtG}>7TqW&ko*m^)p1tAp4}ZNzmM9SZh4u6045NCxAiq4y(X{ zmt1-0Miht)&6DX6{Zv???*c7~WFr43d6Z!k9Y!dj$JoaP&0C2#R%r~M+=#BXdbD#( z&bR&Q_=^Dz)x_o%5MVi8`7Rgr6=pQPIxoimiCp_?zvXZ3qKwH}J@m??R=krw??$=xp! z=iUD%m->=5UAS%k@@7q=zSZn0nuLNZ9Zqv1{OqF}yjiPxiH$ovUGt6g?@zvrF$@^- zsw0NcFk+2c9J*{ME%^+;Ohwz0fr;c&-kziJFx60fGJ;Mi)&MHIv=fEK8lj zPAJwer57o=<|U40j(*>O9(ej@DJP7fl^m&W)VPbJ+mmNJ9p_2+?=wOlJtm?l)~EB+ zOX*z{@49(|m9`zm9gudf&=P{peTOJ2$}E~$kCjSEw*+_a5(AFjhoCu?Z6r^XpRNTo z(ezB6LQLEN_m}sHY`$vQB#zGoop%ph%f2G+7e89gJ3?QcWH&2N80qtsy1U05fGX{brN0_1Phe?5ppbuAUlM$rO{IYyl%#HTss>O*?5$_X`SoLX3*Oj zLqE#nOIXEx5Yq>chiQ_Ja7(HG+|+7Tp2^i?U_}19EA@h&mDsazIg^lkPj-IP&+$NV zb_hP=08VO}(TBBTzO_7IQQXDVGz#C)H9+G<2x%A`BTX8iqlL%)k9*1gMe*#Gyr#gm zY%?QbD)gr(u_0DWOk&S^Qd{?0F{N08(}8W21n*)vs1ttW&)GEXOVz_~yd$`cEbu)O z#mdc-kJeut;vtuai0+~Wn0|ukKg~rHgjRp;zw;{or!wYURuvOv7X-z zy}9lqF#bst1-grq`pshOJNtNx=$HwaWoie(>SUsMi7tH*Dbcrt@@qGdKLSlmxcrF3 zTAfY>_KMJOD@Hm=?9w6bIRuj%f%DA!Pc0`e)+y@o9wI_xm=3Ac>P@z&Yz9)RjwWc> z<#e1_fQQ|%-vVolG6Io=q(K}3d%rUbInO<+GC~gDxMYg&HE^JTwsllv>@Ct{@Q+Z{ zUe2;J$s8uH%=e9-oe=T^>&jDksiQF&dz(%+8FzXtyE zzRs6d&$A1Ok~sjM=gN9;3VxPurSUyopeZMfDypN`;cD@>r{kCj9?6D{v5Uu%PkF-= zo8UFzve^#v^+5foldoeh#~#H5xHq^1!i`)sI)4Mj$`QYxl|GCnjR^Un#TC_c0r#>G z1-pNR$)ehc);xCShCW{Is0}4$kfb)9bk&@7zH1@Q)_`T2R=g)!Gp*YgQxWT~^bvSk zQ<|!7@i}O)TrcDL@xy30ztj;cZ1Y|@vA-`6mqt=^Gy5u$%IRolzAHUr#?u4T{9@;# z>JoDK0Dt)5TdUC4`7LQ3E?dH~ej9QiVsXWY(>rK$*c1cnxR{U-~K*CvhHbhpN_mEmJ5z}7xiH-qu8fAQsm`mXII#X_vr1= z4@U?qn`3fM}*hL(u#zLAPFG+?hIF7FmM3HC=$v|mNU zV#Q(73|7gD+>5!PYK4Ykjs>GivZ#mz8KNu1`U94Vtr!6i+knl2*Or?-1G#{Yl>+$x z(oxC*j|k{0KDhO-K`bMo6fVD#4K*hP`!_E3INz7~g7-8QU5&~`R%6O|HPkp1C3y1C z3&?G&OHUAnMrB!%TO5?mTzp={kjI z#TZlaj3j2D#PP&pt)8;D8C5BV2|c6I`9p&nTz&7E=Qa1WpIUyPu2Vzd&!-|n9M5|j z!m{$PB&TfF`?ax?BlNsubq@WmP>-26Nl9)Q?;nJ!0_50!lZRwGj^vRFJwJ!6^Qd2D zR@XfHBWPyMF!OaQzYZA`6Jl#(fu!GgYMSg0gGsd7I7lyU2-+*wtOU}2*DZ{@Qc|o_ z=e+*b%1|fcBg}@!NdP)~^d>h~(&JWIDvo}&pm#mB8cm9ZXX&1+(Qk+CaEuLXX8>{h zd-hFK>WvhMv&moU;n5FLz9xkCioU1%F^nSS8z{0u>8e3~F`m0>g;HHfhah`zJ17c1 z62VNdk(t3%M9!Q+ZJFNnrD?!J^0oBTWQ~+rQ&DtT$d;KacIrv}p%JJPx|JP9L&yEV zYd1HpdR7z(l(fzRJlu^%WVyj!yka)Q#QZO|eRCJ{u9-j|{Ua9EISY2>?~>ySYSzzJ z9=4ls?j+#*N5=r%0N>s}Ri?k{!7)2cVu$ufmX1Sy*~X(ndXJWkM@f!dJSE4P>K-DVpNAUs?< z*P0ipFFjwyB+ds%KCS1n2AV!h@9#*6Z>TlU2B0R~3R0BBzW;e3Kiiu97_tq(wwyLk zM&Dtg7)Tg^!A~rRCj=(qI7>k7QK%*(_3yUGj%4VGT}d`3jtEK0VW{YTf8d7OS`SrnzAoLIyx`T=c;?2hkzy6;HbHl zg>h2MlQb!`b{f9MFA#WjP?NG?h^ofWyK7$^r@px%<7TGXfgXVO#Zy-3OajpMB(-O-o4jIMCJq(aS^R3K?|Iu@?8d9az9(Im8$we+@u9ph?xWT9 z^SdU;bWb`S!^LB@8o=&|zt_un=6+haevxAv0+*FsZM3|cHZWR6YU(J~RaK9-GD6Io zF9T|y=?t%*f}>QCxOBQL3aO>pe)~S}{?^Ls1a$s}_C5&g82^;t$hrUZI!?yIAYNT0 zXnx2kot6`M#~NIMfKr~fxbf<32u|qLTs4JR0rm(J#RqR)qj7Z=%iZUcYT0DNjC%pJ z*lQQ6yS6G&!v$OsmXE`l9JZj#;WBx9p1Ucqyo&Jnr!xjYBmwc}S`exCRthYTqf0gfe!*BA%x?sztiDgU^k4+d|t! zTd>>dI(1=t9NQa%-+YfJ8hdxV;UsgH=beS%^DJ%TE zb^!uOQk#4Y1L+*z{84Gs zv;jk9QL}_;L|+EPvR1vRlS8XAu6{!YX9K;)g!=OQ%M4TxN1K*j8Q79IVE?O1P;$_n zGCYxJUc7ewCOvV4O#u9Ga`zlphV-*T?zfviFXoha=yLZ*(wO&urkZH70kv$SWgW9_>~SRmy5nh6P+ceyr|^{Z*Sv{ z8S5e14<~)rVzDPSinj`el*yBirgAEGaGgpAfX7BnOw7chfyKMpH2qh0{tHHae^p%x#~qHty#7!whFd+8kugnNcT?oytPyP0dU+ zMlKOOGz-WUYd0lNoqm0>)d?Xh-R4WR1m^?shTYY@o7|}+-7gV#y+u0Vbo^NOgEe=x zcunn16*EWGC5DQ-;u^J1VntiwM8WUoN;Hc`8O{gQqTVM2y6p&%-2?^Nk8N0wYLf6c zC!5&~v)*Sq~SVo|+(s2dJ8QWr}pw$*HDc^u=&Yz!) zg1oa8rxAU&yWVg?Xs(8hbODiUV6aq&@OxM@cvhu`Ad7)os#C0G+;G6Z4 zKnADQq>fsF0u9$M(NVkR_3y@5UE2jeg(=lT5;m0y zfSn&=sYj+1=p7>9Vg&3f`{(r+0^6*C?ID6Mi+NR*;Vl2rUoSCIuMx9Zfc~;wG;zg)8EO>Hz+d6h2yBO-d~Aa z<>5?LYZp>>!x7n`IWC$yw~;{A@5j0JE3jf|8dt|aS+4(&th4Zn^56Ql3JeGX0)o<@ zAl==Kl+qyr(jg!XLr4xSDJcy~mvjt`Fmwn=4c#$AcfGggcg}OpTJImgEEX&_`}^5@ zUze|HYWp4a8tWrNwqcjD&5wid?l_m~=_p?$<2GQzXA_ZgeB+fgSLj``uq|1U@1E(^wf% z&$#icJTHFVW1FT8*;Rnb)Z6I(pxgL`O~tp-DaFJ-0lHzZg#7bR>xNbi z4608d7m$aMQV~w_c8&$r-`Ufnbaj~k%uU-vMd#_CvE_Sq1=DfnD_}g6y<=?Ys24)s zbC;l{rmAqxfrh7CSRYOBYdipwX0I!;t!Q`jBT(?9w(&23B`Fmr#{#VJ{C)X2BMy5k zLSZ$We^C&z+#pZ9eHwkc?#&6J*ZNE2w5L8%8q|AnA@PPyzrnQls z#|{l#chJK&=Bw*P@r7om=S(W#B=BvOJ2e{$F8&^Wo8==zdD*D2&NDpJ_MMfF>Iv?1 zUk~|{YA0&*zTfv!rmb&K7iU*$_zTT^aTG*}8Y4?s>u-H9*^BuH+&)mu!L~0kRAX-R z;}F4u^H;5TeodPL-m{Co`WY@4C9{29zGf@9LsMcw#x*m?_Fpd!DN!OG6c^Gt@c<$XW_DPrK7wT%UB8cLY1@b~ ziBsMC(B?c5IOOPp%(G&o?wTBH%?LQznBG{{cz$Jmyqi_yX<&!`Mk4pwJj)`xkEwY# zwq}Tt`{&8Q<@v=69Wv$fR<5&R8N-Dwh;4AjNDQm%ZmZYL&8uWw=lb$=QT2n#Ow~J= zO&o`|qw^6baQlk5Huyy52i8A}$7P8faP8 z+Zn%CGNfnCEaR}WQX@5EE!x=PM)&^CHhAi;@5g`9XmcqDVai!Pwm!SpR1@J_{wnsI z#WFxj^FU*7gvrLi3jqv=w~Aazh`#?iQo>CtJaSKYq%L=M+BKOl4E!Z=FxJyX)sS_R z@B~#pIYA)Id0Frd${y%6RJYTZ<3oj^#u`m6l7E2gj4OME z7#tmmU>Nzie5$^Bu2XTtA`!Ft4;+=!!w(6-QCkcfK|mN6s|SGRBYGQ?t$cL#uJV5C zl21v7s|}GmK#3ULhw*>&m7#=OR(bqbBHr|JkHuy|kbFWI`}>So+_dT}7sW=p_rI>n z| zOYI@{%M9AgixuQsk*KEr#yZb3Y*f9GD%H(e*E!CuERzD&;c@v#-;Z*TfZoSiw1zE& zd;wqcXt)PY;ydsBnB_bjhjtAnPfSNRTuP1SVOgP`Iu$P4$uMyNVudm7GLXRN(Ec4Q zrQysfJK?JhcezCEnpdGxCiLV{9g*u4M!W;W@WXT(iI|9vpt|ub?d+=eql*nsRm_nRyb<1XH$G^mqFCUku0&eIr(eF*AXy zYLrQ!3KAc8B3?ysr5M1BudOLQdE1L8VEqOEUSVj43kkBfRvOZ!#N$4YmQ@yLj9jVo z;rKrIDVkM%pUsjnF_m3+3D;VZC16rfPq* zdzA4jN_y{@Ex_~@DHdOgG)%#>89tg|WPcO)W7ove%{K&4Co`k)NN5s?xrAl5Bxy%> zc1a`v&Q;+8A(i&)>X9QH{)9Pun9hHp`Pc&Jnog_vj$c?YugNPZ99N5u_|t<#$6DoI z2T%yTKF|+3r+v9Va%(2p&?K|^=f}3bKBFg-wOM4Z&gB(9nPtH4rtp`Uu zXUVc4z}J;wP(D(0kh3XrlHPxZrVsl$c@pc~ES>;?p)Af#3;d9PO>W(vi^LK$HW2x{ zE8G6>jqK0L?--zUYRz9Xzf$9tNR_}KMc#iD3subO3lT`L`&t8z`B%mj2HuCj9#M>x z7(>DoJs%j``wj}iJd-_fsQ;W8N&qQ%27RXsVlNB=`QZ;GQ+_?qqJWSsWv*OKOz^+P08CPR0MPbI;+kHDgsuvV4f^y6GoYut@(D1| ziC`uNh`nJ|8B|a*L(+eRe^O?(Rg{0o!@ILxzrO--z=T5*+Ti!Y603jP!1401p6Ef1 z2uf{mJ||w)JwuwG7ulrgsNlU8=R59`dm3ecA5EcN@BRHl=o=ZmfrkUU&4Io=PE2eE zLX6zhBx*;`(y^;@CU{w&%FWdj^-2wRx)pNiE@yekWV57avx{hv96l`8Wz@~3uNRC) zGTcb7PLi6qM!3q=zLorhSBiYN_-14+7W(o_8!cNP4bEiZx-8vRs8W>ySSAiPI6s29 z{TWm}B+QmOn;$#kL`HKRn4}V-eh%VL$4DV1KFhpglUaGpsqmdhDZWJ6=3jV8F44DO z8zJjd-YdgjlpE4+<1b%GWy9dM7WQEYjG>gi2v2kxU$zM?7j&e_2~Xg02Z2bu8{prI&3i0)z=&G}c^LjM_O( z;g2Q42m!<{Z3Os`QvM$hFmbpP^r0k|z{*F{*y5SNOGbV$>VX zw_rrrp;sjcoS)}X1it?!pPtAWrlh{hSQ)F4z}%hI?pm@>AYWCQl~VYq3R~(riZ9Cl zy>~w_3tVujH4IkqWM@IpOA}kk1D*zP>AY~A%Fs0|V%i~WrWZFRH5u(}TqJ#6??K5L z^@emPLetnn67bQewxp_WKqoUZFfHt%Q3RwG+EESF@a*$@Iui2)anLCR5jY>=_Z5t~ zgq!f;7|Q}K3+jL|+HODBvQZF@RsByK`OLLbXjXt2Hpb1R_%SYSPX z(?+h^Nu!7h4XnD!It9Xnu5Xh!Ujr=NLXbC_-82L`3Pp9*zp_#8hbi^B!H2`QQR`Uj&)GA->yYG- zb|omc-7h+r@1z8E84=A45NYN}z8VUvGEg5F6EOrkBP^((3cCKTfA_+8f~1tzJLQ}S zAX;OQe}xaDAcz+@#F-9c=9J6~;`(+7fUn-@>X7jE@O1veKq$IvZDf7x zqDfqz;3Tn1QH$=ON%#+MKKzAAn*?F*hj%9y9kc_~`5+G1fjJ$+3x;h}0BFine7_YcE zhP?z1vc8{iDSS1FT_>|uh(gMOt0YW&r1HOg?EnlFHA~!}aC{S$QNJB71tM^+Fo1sq zxNw2jstAKMZK^l`wjmbG61J`Z_3Ry!8Gab+ zt{o;Bz}Eyo)lma@J|9hD)gzHKc+gie|29z^rAOy*B;T5GtMORff47_X?qmSq8}J<3 zMOk$j8J7OqC!h^2YD*8eoUmUo=>7RX6v0SWIH1BzXsrt-DgO3Gz4^@!hn9xbG>zk( z@ydj5b>rj0p@3SkMS24~A;zi;5shb9+}T%H|EqGSyGWJBG|&mA(JZ_A=plEf-&L!G z*^Tf&{lEGPHd(d+YrJ4smH3Rd#Gfe)Vgd?cuR-`^UXm(TL><8|$B3^)N?^bj!A8zC zDPTnaNkJ=C^`f&5w|Zd~``V2>A6tzFRrnzQzj&sf@n2x_rMEK0jug+yTXQD3ND_o- zZjW}<%*s0@mO)bF!eEVm8+wX4qF=$+pZfNpgT6b;1OI~)(>Pn7qk|(>X?`eZ#oba& zM1tjXN*>t2;~s$y@4+G2>Tg4K$T8%!RVLD{4ZbgzrE60PxJ~@l?8rW&Zhgp0LIk)j zI89?yiT*Kl)V-=<(c|Y2#EMQ0wR<2|Kx3vhXDzju-WaYF_#b_bihM>Wu}c{w-8m-T zk2>kp06_jm9q8|6;P}y5%Tb2E8?riz{(dpD?E(r-gdnzs7GNUx$ z|08>N^IHUp))%-UX+-rRHorbYI|$H9EZqAESvme9d$P+t#3Q)ISZQ>ez@_j&o{X=A zhqozWy{XsH8N5P$N^&wsT-p7%4r>{Hj7sJP&5R!jlKdblQhU41TmKS4V6mr6^lEri zNHDOR&;);g0^+nvLr0UVpXV_P2w{4p+_U&Vw+3T^Sm9iZh1g;1)4GpH*6NfY6hsN# zE)3zE3N^>7#L#Dd*ZE#N1}P$LG*gTS#8kO^@)Z9=U)$ARbb$K%k`3Hm$Cj=q3{+l6 ziOp;_Oguc6ll$ZN&klh+1DS-zSjuHn>?;oR$D%F&1&1jm_=4&3FbYiaqZ|0D2hc)(gF2u{RE>=P%5Jyk;cXTVx&|@lmK^Y-c;9!aHi1+3DJ5jcTha*F4o@8w_eF$h~dzyWYsVFzy z!f}knOBWg?CoGb?usX`4((9c!uX2gewHgFk-kpi{1M})OE=AE%Vy-Rb1$S#yoM^5b z;p>{0b_LaDE5KRRv5qV+i%5GA&>>U8jUS8?aeC|wx0-aTgh_EZ$koAVegU+7z}Lfa zSM3n?j+#@hMi%^1Pdi4@uQ|MzMdXJ65mTlQ8VNM@DCHCIk$}z23HZsCd70XR`z7*4 z@d6W(pM}_qf$Nii6?5&}X5zUb_XNk#@BNBgy>M~BMhCRoBfNlaBFTJ9LXp{OQSz}V z{%(IeMk1qGmc{+{q+~5^c&eY0z|LO=@jp%W=&;FJB>+H-Sit|wh<`{xB1^I6^q2fu zlkR6p_1{9AYaa@~ZPHrH?tPFW+p*W4JdUjqs&@9Y)8O<%-#J2q=qSICSM(s(c#-x% zgI4eQ36WlL(hJ%=g(g!tBQL>b9)WL8gm4tDbQhD72BVYtHj}1}V{qVltNcki>#>r0 z<6rU5s|eR_?bqsDyxmi}p}?}CR)L*%Ku+pVNSG#I(*c!IGxu^C*jx%sS!La@DELz8&0CTmOs28e4P8}ghI%Tpfxd+= zzjSAhkt8(cVl#?^1IM)VPhm&k#8c!3r^A6`IP?D)H@avreo|rvN)=z3(~lF6y&p%-zVh&_;|T%yW`=flnu<}zSX+NwgzR6uQP z&(^7R+|aKjC{8I1{6i=mgiR>906G=5nk}xx3gqZ$rCbUZ4Pd&VY6nT? zNBDXmTs0{lVMg3{88DL;4d|=u67g$#La6U2MS9NOpCK0ICA?fI&i9IF!E-|resWfe zB#msK)mn~7Xl#!+x))`*B0UJYb5kw*e;sY`)7uO3J%?Y;L{?~2=v|~rDa=*yvf(A; z7$!z(zNx6CwT56B&;m5oERa$!@{byhK>U$=R5$?=>0>oni;&o8S0(Qg{%+7jt^IpN zl4KIObDtS!vluQo@G&%&2%019)(z0a;!QZ$xKWHfhLt92pXvZFEjupQbZ*(PhN--$ zD+2?>)>!&aoAgD>k}p!~0|hxEkq%U((V)P-63v{-1Om@4u^$SiphJpG@ijw$L^p3{ zO`aJ18*rm29Rh8@TYfxdGwR8fJ>~}GNU8jXf0;XgTSFcI=vhlFD~CxLL`V)y{E-D} zGz%i2v>V-wTHBR)WYrOF!N~M)M(Yt`soY--b;MS+cWqiLisTWzS>8Sog3?+(`4JqE z7}t^fbU%PjokB7xeirYbRh|`ooG`MahP3W~07gbIRxSF|;l0zDd};zytNx6N1FTKx ziME`D)fpL-<8Yb|5-^#qP2DO9*f^E{?>ij$iBV=IcV~1E0G&A}Ao&|1 zLD@yW!0FQ-6SZYvENTp_-}Dq#MRYLYk%5)|3VE)2IJf%SEx=cK;w>XfVLVhFw*|&< zQ;9E1?4fx`05_-)KB`ouzy$sQuW}Ed|E-BXIsns|AjnK`oDjDor-QPba74e7*880m z{MY6ZzMM*=)az}t*ex#WCExPOkcnxP8roF9PQYas%VphV0nQh_pD>oqk~5zGMG<85+Tir=zeN%2g*_jb1IqL|M(N)Ihg{1E zny`1^uD7qN>Ha^7LohMpFH}H+@59l(fRh2Kb-?2Q5Y4m2JMDmWRiFt?J3Wa6FgZvT zWC3XDU)ff16#qFu}hTo~5^F|A|iiHoJ_@uK((EaZ!@J(ZENqpoQp^0-Y|VPdA|H zeESfp-thffPc|ps{}#qWsz1DEl7uxq98qGiLFZAwoQcbrq%rsjn58tGiYLm3vz3A( zipQ9fL=&4=16F9H%hc;+(q)kWk+@{RvzY#b;!re~+)V{3*{9L!Zh5phhTA$;bfQpb zZsDT=F})zA;`+Bi%>Ni7L`CC= zLy(-v&L9}cE1fggH>7h|f6ClOfd%ACeP?<69m*7W#c^M~w_DK{( zfHDVPAtR&MQ7KVVYXbpJ#q`{JLd|`lT16e99wB z#-od0HSs(QJ>bcbaE=$76eqg6`s;eax76%s2$Qk67O*#sjNDhOmdg7pXF$zkb?!pC z%bdAuyAmU!l^{k1+mz^xmWHjIMTA#aH%K{P@m`JT=gX3u?8`V)OaWB@Fz@`Jm(ty; zA_hJ7!UCY|G=Q`$DAmy_N-7SA1nlFOsVr-3gWSp)d+y+Z$&3*@8( zdFZX*v8r?h-}dkmuo3pR3W#BeqGy19wC;}~KRfKgmcGtsWW5^}+fCKVZQlO4e<=_R z?i-nJca9a@65SPbh(-3Y=1p@k=UQ|H(`U;j2I!CfcHh|wm?f4h70BqN&D2MesaIEZ zUHs_8I7Qd|_;IhgGdpC!3@~KeOlJ6)c_i~j`(Qx_-TQR32sq!WO#nG_c9$Vw2m1-M z^Q$&^#87S^Tmf#ct21Vz=}rM7(500UNrK;mN$hka)YwAA=ozQS`wSnMgha}aJ7z-Y z=e?&~DIe}h!=;MEA94H#zLG1><4%L1MTxwbIUzlvXh3*vt<4ojd_!KJbRE@JFn8Tu z9Cl&ISUD6fnssJ$5C=#530t(MQ~K&6kDI-z9ek{}Rx6nKu;dCRDo}ST+dX1rZCux8 z6|!GL8=UHyGWze33yLgQMitA4quqPGr!ZA(%nH1;7OCIF-><}a>Y-C1LzgLruJ?2m zxM`D1BQ(s&JP=+gO$BJr)H+*^>M$=q?e)uuK4(q9uykIHkOUtD7!;Q zVd;i_Fsl=pMysltXw>J{5gz@@$s-i$sT%v|lTuA*^P$)C`3MjWXn1Z}`}^dQdZLcR zidIw|k|%FvR(!C9!0*XyTWLQtq{oFE;s>6HQhPuuD-6gNP0%%3dGcjREywxZbt#DUWt4p1*$~PwB zKaFm1(mJHr`Bi`WxTnWpRT8vda{6$oCedV_+$|GvW4!pdoC{O6tMNixe+RLLnBI^| zPw?nENt8|it*Ug>Ol;pxL)Qh|(|-PLd#Rqb;9`r~=3kaPAM@MjXEK=b3Dl1O7{j$! zsLIL#$NgVFU+=nl?{x@UaZ{?0*Ofi?HBeSbeVV~tL&4j$(r<#}4vLp!__FYv=#8KG zoyw;X&t0^5=F)uuNQvWo(d#ai_*CkhO?oUx+kHjiKc{K1w^4VK+gH~6)dG!|A(Z5u z*}Yv$ua>Nf)6_$4vRXMiKR#$Q3_Wi(crcb(3+-v8O4^Gkr%m(aK~&<^>|JkN+R6{k z$*LI-#*@2dPVX7tE0(MQQS$1ag{LEiQ+{{u4eJ3jL(JQ*4XyAs?a}P`wB9xCOCNZR z`fM-of$)}qyZj}Dd)GO~X;vFJCrI16lzmybD)gU)b zKvjq7-czIL!51=~6eo*Ju45{VF}Z>1VK~nfrqP26nJ&=C={k%@%LOszddMcM$Ke_N zQr|G+fD-9vK^jj>3<#N42O<~WtVI-&{GgwFZOS=F)A^xuhZx%cO`1i6Qz86gUdT^j z=p#Q7u=0^rlo9sMG^7P-B!eH+XhP4qD3F&{Z$3=FS_cNDSSoijayF zA%%L}F>OaM*u8uRxiey{Ac;PB&r^opBW?RSB&8$NE9!^mG(S2KtCKz=ZlWHE#jX~PE}sRzffgV6Bx9$pODenwduNNAS!kAuXGKuve?8PJokDofH7-3ZQ-mBda%#tQy8x$JvB8&llJ zo@ENpJEd#7=u~(raj8M8DaLyJW+wl2u;H%Gieg_r2F{K+f8%vPtBX#wk9A&yaMx)F zr^u+qIIOK)#pXR;>U&y$RyA`vv!I>+;S57DfeevarouI0?tBR3GX)y6@1FJEC4q~8 zFnjZGd@778sc*Afe@o!O*G&&>S?|V?Gd*HgtDn1ZpIpXDqcppQT*_-}nu?&Dwl(>2d+DRQ<3g;Xs~6`1QdH*>51>aM)eg z?#7!&72WewG5Rt>nDM-p^LXc7+7YITZw+}Qq8))*aXqu{PMWz-3->+Lr31 z{W_6|h0c7B*Sh#@g-||xzHK4p(0-8%&0$6y6-6rch#@$BVE!OvH*<6}hPrL3fi8s# z@i=#Np!bx@Q_}U9bQ~I zdhZK4=0gSg^P6h(T429|#a{tk_wP&pao^dES^2w{!>s%Qmt)3s16P%XMN*?8mbqg0 ze~Y8- zW{A5*JCGZb{J_~ZRx`+m!-q;Azo-I-`zy6KODmmq%dKK^^a~MNEpB;^(#57KyoBcc zrzr5I^9JAgXAJPJ^$y^5#An{XTXB5%gRe{YA@8yZ7(_mVU|QMJ-@R^kY|{DrSAE$U z(!TA;1#`a9+^2`HkKLH&OiWWQUC7f{9DKbpbu+4|lF1tyFhu?Mb1~X!QZ0eU91W9` z{&4Pv(19`-9>(~_v=v9b$D98H3D|Wl6!Wumfl&5WcZnB6Od5-UldnmH@=(hP{oNtr z^7M-TBY3Wa*Y=T->SfAxZ&86i3ySnMcJ+-($@}QiZBffc{lK32`~^dDch+x1C?oF1 zV_Ql4-Q9BF`x&~7GTHHo|2Q&MFZj`5rVOKW`XoL`*d~F;;Qn^a*xl=x4f)q+qssp zXOP6ueFCcqo2`b30ZgisHLrQNd*SUQ4(buU#o0JoPSk;MvbB{$hi=H`k5hvzTYj2x=DhVZF2M)wlYc zNr_m^jHb(p1?tI)5YK4xZ`hCvb@4~0YX(m{T-qVK7_IxSr5DRu7zKCE^~X$KyE2ox@1SDB`%LY2i#nw`{pwakM37QMy{NB~tKv zNuc-4Ppq7Gneat>M`oY0fjs3t4>P6eVSz@b%0lBZB>sd$TQ(dN~{O3sl@iWXR z5+mORGiV%dj<;hd>^znERNwv7)G+zELaWuFOKNuUc&oqCXWl)-8)L$n-_o5Y^LJ(~ zZ;)vrUjqC~Lz_AVc}4^z){-Iv(^l%(}r3X3SAGGr`Tv8*2~ z@%UC`w2?+B-}n4Z37?saoW=`O_6=<=(&vU)l?Ts8Kbc;`X2q9D`de-;6UhX?pk@X= zYzZa-2q%_Q9OfSx@%#0{vU|RDk0<5}{tfDuLj-`qefsVTE#~;cY56Xe)}mD|td58W z27)-zC|&`RzIl8;JnRzD&$Z}P*uF|KdR-4))XW=m-9akkKorTN4dyN}0f*G)U;{Z* zN`nbS2lr@gseGBb)hsX=>7?sYo>-?XQMXUPSD-5T? z7dNkqH+M)W?p&__G=rj8%I+FEsa@*)JeT*STwR+!59~cmv%-wom=|d}%`qw0mm)hv zv@pB0v@kD=?O{UZF^ka;N!@h)SgAQ743CFbjZtWu@Hv|g&HKCicY+_K81Rb@YCpu! zGuH9!382odkFwZ3a7EAUY$0-r6@3F^ARV~o>ZjcU_k77+{bE0{+9?F(f@&EkrX1aO zs?FukS_YEB0gH8eJ?YmmF7*Y<<9NMA? z%;4h4$g>r8^W#WH_4KwkV_#s)b1pcmm475=f!AF(uzmP>Q$yZSM5D^E{5{c$u|R&Pg%fgWC2aDQTiK?ZH={<_o{P_Ok34T z<03U;ronX6Tgi^hG5^UvEU3tDcTYS|5_E0WgKa;Z71~G2P$9{-tjdp4b! z6{2>Ue@M5?T1>wte)LH+w3FgbVcjrk6PriK%TkQTLPtFqJ_1F@ymf)J>8^wmxnceK zc-ANLyX4Em`A<~HD~Rx-k45>_RjY2_|7iV6(Qn(a1j|a!)2GZ@Vc_S z1}#t&2As6=(;h0{B|q|%&|sT>I{%yw{#L*7PdXE3(ivsV_pDth64;v*uG+o(El7n! zph$&FI#X(sAE9fPM<6j^A3i+$5$Qee_p+WvzhhZ!{-@>_XN(bh>^dKwS^{{Rqx|ak zUGHWTgAQ_!XPu2M_xEGFECElEXgW;#gLnfz&jaRGIg1NO2R2@Cimh48D64~~@maUG z*BiZVu=MVtPuC3aX(7gGk-85wDh9&l$o<+{;m(pymO6f{1L+oY(LSd0TJ}PCvyS3q zJHwBRGmQ6k9(2V}K$qHtN$g`HIFm>9w73j@6K6`uqQ4D)N&e|MF@T)O?X?8Io@MWe zmi$2+t>NQpK#Ix5f9q;GR9Km@$V4vzg+?fIMLdT3hFXs%1^@y+<%}CJ-jD`5F;w=$ z%tJ54!yd|q{^nNd1monF+isvxD!_MN%GC>)D$az263WtgXy+> zmx?}n>?qh<9~)C4_c6D?leG0`)u*EcHuh~J93~55-}TWxpG+=?+V5VCg)};jSUc$V z&;C($OSw-VA(?N>AK2vEl;E>jT;?gsTouMKXag66`TCIT<(2p2F~hA@^55611+KiX zCif1Z)tE!jI-d=RzVmUBZ3t-%M)@Au&Sr%2@TIkt2@?C1QIMnWlsRMQ=;OTb`5+mm z(*mx9@5z+Bc(H)Z2l!9i3l_n%<^ld@*|H`4T#Zyl%pU@Xn~SmBW$H;cF(&cLj+i7F zOM~Wl!;|HjN|rR#|j4$>N33q z2KTF_D8JN4Z?^;*>G2OSg;anDd5!n2?tEWpLCu-}Tn-lPy-BP+jNMb2x3 z7c}tBZ*VTNqom=lrmF5Q>a^eV2a##QYu%n(uuhZq*eEodznczicJ4G&F@-wZ+Rbu0 z5Tn(7Uv(6kYWeBh`6co7y{B5*^S1GIVx4#pi(oFjV)^)5cdJW%(>H-V(Z)u?G*{8* ze3S+2Rw$*Qhf~^ucV;siW}Cd{$FtRzw~Y|-(E`u)fk%lGj=pa9*PB9n`~3@+@ttnt zZ5)1E*Db`xs^{Usjkvq~?79tIhVNGdZ<)(o4H!OzurNeBRv?N?mmIOZzO3>}DVzu` zdPlx2af3y<>Sj<}uXX#PT1g7W>`I#gyTp7}lavQKH#=77USFay80s#^Klk={a%IHm zPu<3H!b2;lwD)W`qTKfb5Pj4S9j0({fa+z@L6pgGwgXn9>}6q{;dxPyOxh*O%brN8 z=$O??6`pjFB3;X?#x(Z^aPWqN=or(nvRE@E7r&L`PYVZL^bq?!o=E7!Y~~3OT#hqzp|sZQc2%|YgoORUMa4>aeoj8SdrWDfJ8gqC zN4FySJC&giHo;jaNfW8je)Q>@e)5*K)Z^2DfIMojM5#CA>TNOg!eOMJGD-|t&g^ng zi;ED+gzT?A(Z2Ums^}Ae{yIjvp_mMg-tU_5q=jCSv1j)2kKJrhEo?WtZO%6uZ)CKW zH}mhcJ!oIQ&(vZB4CF1&V`Vvf6e2jFPtq2~?c zAEdE|mmWBGw|t+LO5@8+ofwAQ#d&unQI6Ps^BO7qgX~=$5&hlmZ3*`ouo(B+*{k|B zp6H!f=xrD3r?fTyGz~F?I!3%47P*{W4>;RBw>Iz`31M={9%+77wrOY3p;}n3QD=}i zdpzI5R$Fkwx+h|*G?n*THjsQV2Wb{s5u_sn(x(4Otgw##6LR@_lM13JXCG5OX*r_5 zP1o$?p&LPO!mHrlK0MO}110mZa3o+6_h&IoJ1+&fAAJ#UPi?phv-f+hnO7*z%Zg`f z%Tm8j?I$Nv<-GmJp>;o2H#j!xG}QpO8>2X%(q5(huq{Ya4#C!LyMBtyLt~wJLFp1| zm$uu$GfhgNojRNr9rNvAWUm*=)#({`RyDs-o`%(xuR>402`ix!6{@%n!`$6^VsfhE zV)sc%(oMJ~OQfa6aqfkYxT`gbmUZ>!e#|h_9(RMinE$8A;gJ10AySw{%4+8B_HLRd zdiy!krIll8s-WYmGmge#EQm<>Z*jYqS`RF2#01^a%RlTTmAQlpvE_WI5Ba@cn2enRKBEx918#BG$3(biOdXMpg1p_VqZuZdrFlunun@({Gz$RF@hID z4|(T2aI-%9;x<{6%1xWL%QUMUm-;4W*+iNs2HFk>?BaWZSc0>PyjG4R$lkCc_0*%P z@5CCp5Z$M*{`_kKi8q!$Ci`0yQ2NsLdW|0f%LELc@nmXBc))iD3g!A4CN+lXl%Ba& z6g4apJva2Fj^=#8{IZl(-4i$(?pWRp^JG_{2HWV^aJCTSPH9sR%wre1aw-6e-N?TP zq^rsv8s#&s^o{@voWH9mz%50;hA76u+X)xViMJ#M`8)M+K1%Ru5~t{0$nhfOd2`&{ z?%|TfB&Ce5C|AP#8XlpA&AF#n)%!Y*I|8D<78AlNIZ!NL!B>LO;#tLO=c93;vHwPOchdA`wU8pB=fe7Q`ZaJ zuK6%I3poJ(BtkRw>Q%x;I6y8kPIwks(k9866dlL|4 zxC7|qTbnY-GZ{xzPvm1{wqu`BOzp)NE#LE}Y#3tlOj&Ailur%eW!b#+xwXy+|HK^R ztT^v}NdH|b)`<^A-3}cqS0NXAMWYDEO=#Wk#G0S;uh;_09cQO;H~-{C?M&X?eGy8S zN^SZx!|naC<^9z2Q_8kZ+fCGSZDxvD!t&rBD$8&fxs@|4X75ePYDmpz57}4yhs8h7 z9gK`dA2CC`bH&c0CsX)a%N)i>@^QcI)45N$q&9@v?)tvSdy+igSwV4sfF0fMrDBup z(=y$HITAd}jOl4FiuGw{U05NYh5FNv6$g`sXwa1&*M?NvY-4%X`hmvTB{8-IM`@aJ zeFMi0@Mu}}Juwf)7kf?NLG-1MrAHJUTEKq=JjW3+6I&a&jq_ByVDj5}8Mq%K7Vk*e zGyk6|Q*d7UZ0U0Y?LZ)iTb+oPxe@;GBjl$yZGuSM$u7?O1tmy>)hA#ylh+e1KCa8R zCt7sb;cJH(aU8b5rQR^Xk^mF_Zc?CF9$#ob1zMNzxvfMSqTY4yj;lJ5nJkZ~^PhsL znv3}BL?h9l`MftMPsrw%kjNLxT#vSGJD)I@rPq8MU0XI}c|Kw9zMWIcQSLfBJtLo_ zG8->Yl{;mikC*z{5O<1{%XEPr&$vQIe-n36cq;2!U7W#n#~Ej-Z0ECLkUU)6vuLro zj0$Ky`YFq+M{d*m1IlTOFWH}XVuW6=C(Kat^0_C64rYxlwk$VU#GkK47j8}NZv@1@ z`{sj*jS(q@#Ui#wDn$$rnvSO69=V22 z2VQ}2G52>)TZuwwbBAy~h0jH&3L4`@489FmZxGNm7g6B7*P>*9uVMkP1YzpVB#{)D zhjVSOK;JKz+G(PG(E$6N%YQF-R(qt{RR^AHA;y9lG&!;UZwM0<<00EXai<_BwA2%( z(;M#HycKAb8X8VDPw)eE_jzD69K}V7I>f)*G!AC(HtZm9PX@N!*$;UH5!3({HQm1v z_Pj^&e3Ii#)UZ17sg;Cl1L9R;MWNp^7fZm8|6~Cg(bNM;zm6R5#DDhz}xwT1Ha!&-;EeJ8@_Yx_&ti>y8Z}RPxU+ZC=*h!=g8skttDsX^C2DH zzH+4mYGz;drr!5Nd+rai&i=l&ix{aO70d!ujct{-7Y3pyeU^L_62Stj<)0z@Yvfnp zGUwp}zxkEhCb!|GGM7$>Pl0G=$xBY(7L-|<&{x>!uC}}n=UksyK0n#)T;7i^^^q18 zN9$fNd-=Xm?6tU-Z`VY|rfUf9sC#E@f5@uz?Q4i-JTQs**}~~dcFK~0w5ZwpA~w!p zuV_382_2X$P1oHI+;<;&dcH?!eLjkxQnz$Xpy@^7NP%5Og;KlT?Y>O{!JCB6wLbDY zcvaI3;2q~zKZjAbj4SjO&m6mWN7ASXLVCMgzNt{zYr?SG%cFd(DzK`ZKI5&;b!27b3D$6xg_n+VlyGwWc6H&s0_KWfjFLb zmmWUlw;i16-?RpLjRN||X^Jxg_o5IR$7!qOoChbYI~dwI#_PL3EM%%27Q(b~VQ;9A zzJ?a^%hS#Jk0(mqtk0#(%}yQFH0tMi^Y1Erv$h&6?aNxuPkqy{?5N`APRC}|#P>(T z?i|n!SvifMn&*|yK<~+*Bv2}w2<7kPU4C9}(v2OAvB=jzB<|^i*hNJ*8KzkYQb9%K z;|-~iK9_M8KA8Iee(*CEq3xCjb-yp#rcv>2BlR%Y?pSv zRp*H!r5L789Ahu z@vtHC&e8o1g;m=YxPi~9Dp&CqV$k)%>10?zdIWq`uO0_amhNF0@vMIDmj!NLmULG> zg|258hmXulihbUj5qQQVXZ0Y&U5;qRL6UGP>hG=yR?WyF%#B5)roxi~!VSVe5QzM< zBdzOBlBM;pb3kB88h7*W4&o!wxcGGl4RyY~#XrZ0_%Z)xfJHFp?n~s!YZ?rxzx=oO zMAnDim8K&J#wp$%Vjs4tA-|HDeHe1j?Y_Ti=v4mJnVB=Zr+mp{_3&YcM%Ts++Pi~t zXN(K4OZnlvPAHsQ*XGT3NxTS5K9*|Xu4ZwZgPLbh?DL8JgXYDQS5No6!TUKKlB#18 zdFVF)aCsth-XZWrR73$E5yf!4oyu-T;W-`X{dy#8CV=f0>twZJX^Y#ku_P<=J1q7U zP=;1|%Ro`(=mt3p3@Qe;HDsd2gagRm;>TG>Ixj`9K~oTz_%P1rWUjP;iZLwV!=DYn zE9xj2N5bbUj`B^gmR7-To3%S?`1uv~6t<^R(K@yphf1blce> zuCKVgt&{Q!J7T)}SQCr4$9?k3!g<|m;e8CX#MSP>`sr);klKM9Brw=PJQYr=I{Yav zf;(~F@K~;kC8=eksuReCqvulRR+S3k21!M;*V`W-TcT&FIYmv{eUJwbc>(2}+Emfr z>!ua{i3m1+RJ||G6Uw$K4cctwe0MiLv1XrVSTM;F-V97zSTy90O1y1T0lvLdUY%0= ziZNzt=d5B^*td~ZBsy~VXX(D(In9V82Br+fX|3cDwLO~OqDP@w6lJK*n;rFv#l5l@ zE78dJ2>8!HT03)E9cF+qHgmvBvAjnRRw3yy+pV8^FEbQ#Q{0bg4-Y&#oS}0$XmxC=2DViZI?}v zyR4)^vb1NV_TqKxWv+Xu&S%8Jn~B$U>$~mOqT|?jrJem#r|g>-QqA3DxWW)&Z_PG& z#a0AuXW{-7rWfAs{8)-Q77VB_Rk1h>ReJ zfOL0Aw=_eybay}?;yPzA866+>mK0+DPOl5;kT`hPugzORa}|1qmqboJZ~x$x8l7x{3vTIll?{mi zdYiU%=HM(^E#Z_9&B1%*YaweI^iEXs$!*A&(RO z1s6i*B2Vr|$+#X`h>ck%e)kxZHF5bvYO?PIO-CY4Zll1uV{j<^#rqbfBbA*<#0N9g zh7Z~tQk#(9IQbnd_kA;OMX4g{AmAm>L~H~)XCyuX4pKWM;eHX469LaOc$}S@Tm8SD z_XzF|xdf^4f`NUDySarH>sK?YI4XjKM~7O@*Lv9<4<&L`E9`3k>*(T@6>{iPOb(%y zT#e6Sr{{()D$7x^{@Z~XB$a(GH28)YBa^5MhHVe`3_ifI0(bxJT?(;FEB%+27TU9h zjsR;Lp8ENd$;FA+I{@dE{&IVUz4hheb^K679ZIRUTY2{%YxY4m|v}(=$Rd-n;7%n z7ykPEG{c+SIW8Yy_VNjj?#82;RFgLKZiQ{aZr>&Uv5G*x3(!g5oFP6M+-gV5j?o7W6UwJ^6=!c`uSTAyc!jZta=APM(qu3gv#p7t_)pt#y82r*_ zoUv|#5@MF>`7JSb7E|T#X65?=s!RPQe|SCDIl?8NW8RIA%;5*3mcXc=o+sgG@NR5u z25M$D3%&1ktl*{Nl-a8pzjf7k6n1@rC~mSuRaLveGM4r!!C!N?40dI@w;r=ixv=%6 zp11aRZPGp-&w}@=h+h|_UCEBMW)tGO6Qd3GtZ1N z{-EaS?%7Gdx;utXs0Ov@)@U=F?!4sA2I(;jD)%<{3uPslh^*nc&%1-n_Eq;9RZ6 zu)hIVJFe~K!o3W8Gl!jxlFb@P zK`zWr6x{;WsuT=Qnx?Xg&$0tle?{-FPSZ)AmaHk$=^3r>TGz(FQYllGy~~L#E^VU{~;iv10LE4OR*kovX(E|wyn#g z)cYC2juu(K%pemH4cNqR@V`0KK!YlbA;*PKi+VohlgaqCkzupJ%gD*Cu?}-h;H)iQGVq*JwPfBSzK?o40K|->;$^Kc zi(ihqcq4&8{Ly-DCD8PFw%DoPTB~4!MRH|sL8BPh%f6Y+|8;dk);WJLDpTo%Ry>RU?^)Ia}Q)@JEsJ!CfX z+b0Mef5)iqVd~gzWPr86Q1Kl=r^5KwSxu1OC!L`)WLDti-*KRltN{_;ds(nUaP-xk zkYk6((?_ULe^H3S*dh62K-xz}OPfHG@6mH96-Lb z3~w&F6y|51wW&BOizHV8TgD!qX;|yZ)L2&&T$qAUFzSFI0Y~23(jp44v<2T%Eyse1 zn*$d&yIMMx?qALXqSd1Z;=3v1?gJYBuJVh2u&P!FA$#9P98$LEQkK*0YuER7S^d_) zvphh%OSgJw;_RS4d*Z;m1`dJn12?`iof?{%w41jR9)8YLb{kw+AJTaFtE=n4&?m0f zPu3l&CMRwl;anB={F<#boT!dj*YIsLTz(&xOyH*Zd)&G>o31f%21DJZm=}`VcJjG6 z%OrMZdb5b#v{y_&Nu6#5@!wj2WEV;Zu`%eDb3OGWHmj}jPnZSMp~Oq-Q`6&dtaUyy zfri1>bk22)k(`XHH+z9xbwwdB6FNa2M)M7W?ksC(bfQ+S&Mpp%Jrd6ZX!$LdYMzzy z)4V3?X}DWDq?44ie)?DJCx#oTu~Vpg|KYmhVRFY1y;R*{6r&sY z@*9@PbvMpT$K4jnJK-OAURpYIZ-NPok|N~-?9ju9U-h-TCq+XK;Muzvha&G(<#S}TU8>&&?0>#>}R5hdpgW?76X7z|yb5?{Qb#akgKm z-M!2j21tC^UVuQ&-3kgi;SfcB5C7ulL)!H^DKO=Dtb<>bLF1?cl^OWT3R%jK@2J&B zABQjK`b?CH!n*ZKUyZJoY`7z(d<37XqFhp~LI;k~V~G24C6&edlD_J<-Ny2?e|hA} zkB5JKsLWU5PmyVksWj_AX-<#5w)g7cw*I!J9@+ANn*OTt=E?$?!iQJ);i0e8?||)x+M` zCa3f-#^4VF_WvZVNo;?B*R9^!;3>lahQSCnSPs%^j25TF>HHr-2C$q%c1d|e8(tC4 zqy02h<-~udWD@rPP$ln#cN%kt9<8C5qT9oASLyjo;({n#!i15R-{oyXU+3^)f-k?M zy8LkJe=#iQ_F^zN(|v3Pz{dUjIh>3C`Sp;Q>RRw|ui_5@Uw}@7@!K@+!DKXo>Z_7k zE+4&?Qay(Ocr#Us1qN{)G9~h+aUOe3pVt)_QIqNn0|exkxclphLPHw!jqajDe! z(bQNL1YNm8_h5%n4LnwB-qXJ@Y*);Lf7vm%NNf-34(=B%;qc;idTeLx9^%oG^Cxw( z%!)dZtR$szdb=SX)?NJQ;U5DA!t-dy*FaYcsRT&DpcV<~4+7+Y!cjd&bfli!b? z>j7b-CUzwFM?Kc--I6%42{YNT3|`(bzI4y^@r!753; zS-3aWBe_5Kc}C_*wWhOdO3M`M{!i+PouOoXcE<{+7c-B!yQpQ3lc~008#nXb3jXo8 zr12)IgYXulb={lg$0wM60@7fXnY#=!g!Gym3e#2BO3ft^8vv6QWnr<4fM7FU0Ri|mQpM<|M*?&J)#nqYL}3bGOm-Zw>mSrWaA)!G#}^IR2>8O26khC_ zNI0<h7PB7u$ z8Fmih`v@Qlf<6Jr0#|^=WnX6TZzy>yZvOCz|v4DL>g@j>Pbp+ZdME#9&|*}h=K zVbn!ZX@vNl?(LYcLH6-hsqV zhJAi56GhoUfXF34w$OjHQ00a=iH^lI2L3@E73JzT8-J#;z5@UXt@L%zC_$9tJQ47r zCAGUOALxe``ZrSp$s=v_A7KzS0`qsaxUACzy)w|N{hpXnSQigjrvwLhvxXZmwr>HF zRC-vqEmR2yVLE4AY9#1$1NyV3$hu z?Qm}FZop%CK!`v$+dLXxBcXNg(fzgMBM^Z}Pu?@DAm3O@`jB9jVax};zr;buE)B`2 z4N`o%7A8c%yWfn#zvK5%7W8wJ_--FxihnF#0e$$2c9s2mrYengZ`hx<&4=l)TYf+J zlc;JsL-|f`!0Kca&gwRw{T*5TJu4vu77jm@(<`5;iDf25M%|g+h$iR9BwY6Jw^m$;DBg%XyhBOMuGMuc1A6~+n<|HtaQ{~kbNVmZxxyC^zG!Y zjXZV2kjiuue29vu{UiGxw(c?H1CUN4sI-Vag=?6y)PGQ|D@dkd>?k1V*#v93l|0~? zAbaK1S$=x$=1B1^vV!pv#x8B)cB<@R5|_yBxd&6E?G?&?@ebtmq_p|u->r30=>IneaU^tX$bE4 zMW!`M3jgi+P6HN$u;kS<7cz-46zeO!t+<5vDpqJQ&kuCxde zBSO3+3F&$KBU0&RH%WSCZW$@l+(7*e+IDZO_@UeSdn8>&u8@Ep1$MQPDy0X&HYgNY zG&6CjR>BcvEKNR{Gvf>^lKoVLANu7WWQ>fUk#nmjl#yYB>f?GCpxG^)_uCfa@o1-) z#qWivw?MhEWRp0k)X>{r#LyNu)5AB%Uy0bPabvqae`JpCb-42quwTRwO@U!6gnU|J zn2CVAJ%)uxGXBHj%JIm42d}V^J0x>SCICH2j;>b30-~)V4T71UfaeD7Ph!Y(F+c(u z2S$VxvL)hCbhnqpvZulpo}zWwTlEH z7V)VQj$KGX6;-Dm2_Kzf`_4lU3Hc68UGKv-GXSPzGy@nYR0ycosMy^@ov0+p3Z_Db zCSYY2)%pMQT@y=86XBo9lSc4XgHbxMn~T@awjzkrymHSeyvXDE0T&sK?Lhie5#D$AwNIo`B4^!2~u;uyT&fu0XSR zA>*ldzDl#`=QDoxq_gbbc!gBID|++9_d*^vdn*Os??{X`1D3*|0k+4WXr~u05RLs8 z=7pxJPp~$2AW?AYLgTopT>!P2 zOY%^VM`c;D*(QVu1P+}Qda>OhVFG0`4dDF8i2M27%OwDq&`Hk`8(t?QnmU|(4u!&* zf11QaP>GUQ+~TM~nA*ePEuIVjd+`*)^uj3|0YbbMZYDs);oM^r)mxGJSR4T7H}L}^ z>*jm=1qHf)MV?ucY)c|+$Z6lScfE+3J748yUV)$#c(myqWrMjkJZ&lzGGe&N(Sdwo&5C%jg=#C19tsV#&H4!;Ot$X~(I z`uHIfU4%eEZvvRBZLsMm^3*>`H&gW&os8>IwR`LpZ;PK z_g;5%s^E`*KA6M>hl(~=E8-(Y!{MLw3U=Ox-Mlvof$OAeLBPD>#mT|&xheBUK#{}z z0ggZMxBupuQy8+#d%F$tf%3{7@-Vo%L`8iOXc*LKr|(DyHum2uP$H`RS=ed8t$#yX zum`5UQ6fe`2&%$`SHF=HZvjI-GrD)j^F?oeqO}6^pBI<_5s;Gmv-xeed3q42BEKB= z0-l69VHMUJKy#N_^b9*F???@gmF~Ac`Mh7pIr~u7B z8c;07NQKsWci&Y}l-KsUFVQ0|5hu$|96Ce6g+s>t_ftQL1EmK~zNTei63#*JoU2)6dFJ2h9K8p%HhKe>5R1|CvrgsK18Ot^XHZ^Fah}Otg@0{@sQ7ryu(J zG0V77g15B(FLLSm3z<_9>e}*78iVyh1zx6GD~#hsPsw?FdH?xd&ht0Bn+e!TNEG z)Iv;u(rAbh%0%l0VW1SlVrBEwg)p&$;PJ~2$-?aDSn^lwVcIdEK}2yQaI-cfaqfDU z_V&eWefzwTc`d7Wn6k09ibKJvvu$u`lIB|1RLewv_p0hG>C-QKb$?1}>Cm9ldx@1J zpAD0DJ!$yTJ2-Xu7VIYc0IOy!wGQyW>rm+7L%;E88dBMSqJG29=779U;djcz7Nl6D zXFvEzuy)~seUGv<-k1H~0Es6F6Z=}3f7{dsqZvVw1^cQWnWUWpHy(DY>HARqRTz9p z1Pa}NzY^_d0Q_6J%4kzxRsblel~cf2n8oprUT)y?c5`V-y|-L1&>(CuaApoZ%Z!>P z^+WOR3k2P9YG4xER+_Qpr^KM=vqV##d}fgE^kzip9khr@SQcPs_NN5^5zgc=m;| zdW?Z_|B*;tuxp0f;f2+TQ0}5WA+c{3P(1*YiXSA1$6@iAB+ky9wBjMXH`71L_gf5Z z)Xq4ydy4jo4Wp8dr|qU$8SLNc_+Jd{R~lo=SD$^qU6QTxZ^$$78>x-KqkMKJIyv|? zRC7n{M@MT5jY(f7v)NPoWP{FwEqb6R_RD(NH5Q(9M}M-X`BnbQ+b@u_>rUU1lYl2O zz9ZSxEi1|@t26j712iN>d-uNv_4&-jbIMB>FzB+pGEcdY{Kq0Il*YqSu!=cz{eEZu zAA_u%j1|Azu|yh~Kh*RaxvTfSiX_84l0WYab7eTtLHwRfPh`r&IXh*_O<$I0!7?pc z)5Sp{MlT&j-@=X3Be=bL1{+IS=$(a`QSWX5q#NhAGAAJ*mkB!0MFJc?d;3lpq#2;! zGKqsp#Gc|gy=aet*FfX#w?pwh zV;lPY4-+u39jF1AUp^;@`~nC2DhoLdO7;tZgqQdr=Dx)p#;U4MpYwr!zHJeT86k18 z3ioGer+=N0^NpMN@x9}krdUoSyZ~Kc2i8TasU7~4yF+hS$X-2vL zZ5fD6OCi=kUa-A91`u8C@iBQyryjX>szh0hImZGtAUj z>g@);$Gt)bgj%9opx?e^)BDto_#o@tAf@-GoPVMoi4Nesnwul7LM!yj>SV1cyYiS0 z092;IUXx+Vz5B_Q`fqLv@V1^FeBq`Tn@RF8zfOIE9iP&$yp5Z{Sd;PfE;z)_7gL!K z4&*7bPj3cwO4r2iUe+>`RISf%Jh7r=&{0jgEBlJ&Wg}FUYQ!^CFu;A>wzi0k2KfJU zPOEv~ zG0rfzF5KOeIgO4{9tw4-Cc(fRiv)l_B-oBpU%^@wuI+iGLJf`wW}HjS_WT4%+#p2W z^i^9l`EJ#~7P{EcwA(nYkPd*E}+qKTB23= zZFceJ41Gt_)do9A|JVYvv^^|l4l#C@d@G(PZpD4}y4cf8ed&Si=_0ivt zzlIs)&!o%6=$d8_C?xSEk!}k=;xpTr!C}vayc?2XLVK`P46cbTI1@J&9!;DlGv|u0 z=Ei+Z`iA;F>$4B?OTnFbkJT7;S!i=*%cfq0gVXLi9y!U4n6g4X?!0|K{QVGM2t7hf zkCP(+Y#X{)2+foB+R_E%rF)+kh@_j0{&=En2)zTigFLLaJsKW5O%p+2^ja3c%Bjh5 zW0T3&nvbZx%t>iS);s`#GqIMQhXI#%3_Omylpe1-!AS+^*Zevs6t3{Cjh?C9I9Juc z#T@*{95Py7xB~fJx+C=(@sYIy08@Q`Be3w42Hdgm)C0WqHdKvw1hP{5;5!EfmhAXG z9}F^ZmaG3ilpIi3(cHu2fM)}f`F8poA+e5oH-f;&R?cXbt@%wYE3QYsC}tQ6r?IW~ z0{0{*@HaGWQSan!E8Xwj@ByQ7xfmdstsZDF$>X+|Nl0<&RjVl7c{&d0cSGa2nR)!v{m*|#ZgXU+;3g*RUP%4B#m?)4R!e|?gLEEb^orvglja^zk7JI8Fqw!aL)_ErNT*`V zhO}8D5SaAt>3}nQ@wOm+1R}KM(?1G5lOe!Qg&1Lp`n&z&6&`>*6g!q(K0E9qgbiAx1s0oXb4qC3kLG5D6}k=pC?hbr`D!A?uLH z%w=u$Y%HL@A_650NG6!YO-{l*{$D64}^iEF3& zP>kOvWYb6Tv*b{vJoLLamDs5Uok4n%CMIQ54J`P%8V9Wn`B*`anY}>XYy-d3Z#wHa z7+8J=h8Qe6*S>0wo%wmAZwDzXnlcaV9vAtMWxcjfL)if8NzK6Hc+2E~l;DNO1AtoO z^IMqg5O~auKfalycm^ZFHHwaa%@>ld`tj$>bmA{RQFhQpH@-if4^xy!$xXWHDV0C7 z9mZghBKr11$Z!?Or}Al%)B+NKb`b}84-7%_)rE>ND#WcnOL&A-gefpH`rgeA*~GQT z>|$>>AzFg5$?oS?GPc#|xc$G}#TT(@_iIsRsvlHwKAKPotS_}9VXaEnwAkC+*pDD& z`!BLxxMT7mGMh;-i0(5Qa9;9QNzo)X!1CYF1y?KLAsA@;wbZ?_qrAZY^%KB`w}Bb9 znT_K@nXE_J2tb>Zy*r&434PNV-!!tbY?t8#=Y2y;-Co5sI|x{14~#}d@DrB5(xDo_wJ{u+#z=v4Swha znY9!*O_}tjK5vPn|A>V%qklp(MPO~?$c5tC2rCr$T2m=hpE5un+8NQtyl3(~KeJ(W zmEApf!0eiH|J+58Q*Zkkl-)5G_XB& zG|hX2;yp^M80RBKz}Fl{E)U#9L72iv;5kLBhpMmJj{B93nia)FCf@l~Rqa4lPK2y= zwQoC(4py8g`>z#-?;v2;D7bu{FfyKM zd=>w2U1;(Wa$H(iU$U*;sQ98nhZ^d5blZ-!W&z!ZpI!iv|F1CXX-~uP z#61TqcP#hYDYecP`iYW3aZp1+K8u;^)tzp+?CQ-}4R6i(`y$A;l&38s>n91c1f$W@ zHp48w|I@<0zVxr)2xz|dXzg_<`0P|Bp%p8@vsU3V?Jy6=N#!!#ofZ=1%(Q{1Zv^3J z_weP`kh(7)W)4s9tUiri9xErCOX}H}-PqDR_vH_KHEVccG$XgynZLIC_KoG=fRIpV z6;*|YMb9Vd1pDJR+G|_Kpq2oNS zb0bx!oqE+@$f)&>z-&A2Mog06*OR!0dR(KTR#epqBlnl*SUw*XIvwz0A@;|faZc{B zEi9YVX?3##N?ASzDZW)s;LD1g>qz)82xc82dIpS$uKW%WBBz*0dB)Y22Nd8)vd+}d zo^AyOUDPJbOuDBB6`@K>hDZNS8~%!b_X-MPDyzdbNL0a8ZTwzPun<3U^iJbCZx^3IpfJK@T)qT=}FM~?40)A(Zz1ERup($Z(g6!@WONFZu|#* zXZn$TgpJ{m&)a(|%k;pNOL$n7tD07R?Z%4Zv4chAs^Ch`fk4jKDv#AS=^+Ii&nUz$>(-Wr zFKJK~O}6Vh3aW@uWv$jhwOYLW8MZ@c4FuVNUlx4-^$Z;v+kzA zvp#HOCGBDYDg@C=Gqbdb3#Vu_f#N1RhAtOs>B(oHG=GOIhb8`R+bJl)u2j{tS4$84yyRHlrh{3j3-qpda5gBBklI+ARaQG$)2s%^!!fP#b$r2I@u(=V%; ziy6<>PN}tOHxH}|CJ;h*`ZW5$0m`BC+4CsabjVLkfCABam3P=P=qY#|+d_ZlxMg>Q z8qfoSDrCX9@OnZ_HR2}h7&|-m!^fNY1Pyh=W{au*_&i+Nam#+;EJ2?QiVP__OLP&TVpXYj)clve z9syqK3iz&i+LuOdRR^1suO*C>dl_aXG>?klIj|W*Z$sWKxZ(7|ei^N+-spuB+3r^V z=n$ z@p43!3|FjIdVmFLy{b5YYn6Lo_fZFOPrK9BCILc5JL?$bv}y&eWf|^)9M^jBlOis) zcsr49qQ1LpT+#5e>N4on0}+B+F4Zh++Yp(2v!T5B>XZG_SmjXSUwczsUOPp2(PGH6~VC^H)2rYWXwfQrx5D$lq4LQRIu)F~Imc#QSs z9C`2^)wkJiKV#^+i>F!B4)B5I>kB0hq0#_vh$oqkvav9A)|(c{ zgAn^QO(Vr!7+dH}-Oa2gEozwDt1!K{X~RzQKB>KRI~z+z-9e5IY?V3(A;*X680)B-jxA1J`7ZmrUcx)W(;pm+R}OeQ zka8<*y7fJ2n`hI1GSabTNd2p*Rk2m?v(fCl;MKJC-^#whiid@I1kusaYubJ+^phNWC-C27$ydR!1}QJznC}^V8=fSlF={r`^bV>h2%HF#5Rfrb zuT#o8OV1oWPQ;i_r{bEWp+*?q={)Kkgt#~2X22#l?B3N5)^tzEElyW)UM2cf4W=U_ zv`2Oq8`0~V1aN`0Z_lz@fB);jhu_1j>jiw>^{l2RGa-Bric>UqhaGf>TRYdB(~_*$ zA@=TDr==sQUQSJBLjixOxZF-I2E~G{b-=Z;LmM~z#pJ6eQ5Q`j0ZxwV0}Ca1;W?gI zRi6(;LDXq6aFf)$zxfrx7X8&MlN-c{=$%K1q_KH6U1~&O(ZT_yfga%K>a;ty{<_X{ zts|)&aHF{Y^-jwyl4-xFkHc0Tt)j+4KV1@w*? z3N`9E2v9aMsJ*R?Q503ZzjtINo2P4+2ezxLYHj-)ed~C)*J;?Zhk4tkM``jLM?qox z{J9*AyV<)gQeF{y`dEKZm2R9x$$uyfPdP>`VYSLWE{lw!yA}rgor9tpx8;}%KH5F& zphvA{`8AFC>jqEMy^t{LzA^VOg@;f?k|y)a(!gEaZwERAyf7i6*>s4JmW!j8{|xCF z0hioW_31#K2H8z75J}u;g(MOw_?(e4M>`aenGM?r7uCLx@i#%hpv_bsQF#qSo9Jri zoUGmOcWDgvqDr0x$5Rdbbe96-?&T|Z&wCHdtL(vYgBAu|hDw)YNb@Uz?BnZ@02yT+ z^8o@U4I(FT!wRf>njbvfazKkfhhjBsn*;adv;YY4W%k$24Ve3%Dg8-4SDqziMJawv zWehwZpHh*f{?qGYNf1}sY?iy+Rv}~k?t`2?`s+vl^bTVKLiBkv(XiSJXSDvSwJu_Q zkBrR6jdc z^)3Ddh`WI#?!3qCFVyg{!;Dsh!L~lpB$pU$!*6>|E>J0p)sKan02n_PExsnqKoKa30?Z_FNpcXeBS`ScO z~5 ziUYsbU53n1HBOzCk#O}l=izm0W<%>N#B(tLyYoy7W*JXF>1o|=qPnLX6Q{PyVBN-= zjQ>sRQ)(L;1xOJ|)o$}KIb$otULn7Y<7}8>fgBVi5Zxjb6Chz)JY{B-wdeNPQ<$Lk zLC$O}BFD)h8XoI?{0!to{fJcr7l#1pqs}^aahl}NDwIXqZ$6S1#9#V1=KGK3Flj!$ ziD6%jdzky^%*7p8cp_s7u>v&$&J8l$dY* z1p&QJ7hJS;sw*w=D$}i_?0w_p1sYPe-(Ptids^;jrw7G~>cV%6E)O4i$}7@MnTb|1 zfw=O2yFe&*j52!yq@0R?|0Y3>^~L9^($}X__(3#3s(~ES>C4-I`wX^G3C)av@HOCS ziwV$pDYqj}VzjmbC1vq|4#5UJZy+fHS9o7j#@a0NzR2Azkjp(YF;N3#h)ZYS4~|`3 zXXq#}L=plOcd*=|e5pXGhTq85NeU@2h(8DlWTdyr4(lZ}%AN4VKo%_k-jRa6BT5*{)4;1L2weLtZ2uYPoget}Xpbdm<^)u@!!E8Nr8 zpZPZkq>d-9lhie(u8pu!1vL0x4IbBAti|uU7X3a5&}(xGb>uy%?TT)sm_FVt0Zh$P zwQ|ff@fQ7_d(WeNO#{wj31VOF?iW!f*W0+u51KW4CttM=e0mvYl7)kJREyjqKj;_Z z`6TCUAwyBo_?V-w!*Qd?FKUf*Uyp5vIWP3R1FmTv&^w#Fq$28KAY_>77~t(r+xb6r zHFLw8jfB{nK0lvCYjq&iGyDZ?RoP`_*L6cin`(Wus|w|NgMr(0J@ZXtlHN>;5%sTG z;L12Ikq+paLgkyKh-tY2`ExM6`OGfMW@5kOw_{Os>&)zS$oHw)sgrCf@h0EqKE_m* z$t0NdLu20j8MBi#t@5J&MFFnGAn;`wV-4{0CB&>lX7T(5Dp?bY5046B7o%g0az zi_=VLa*8Ole`_P;IzP+TZX!cp`N&m;QSK;d^h+Jb^_l>;iduTk@aAi6vT%h|8Q#jg zmp#Ya-ogdj&c<0S&u(A1R^e>3fL|TnVc9^F=~r9q3&IaQfidnbcWl7aAk;C^4k0gfq((2gZ9e@( zFK?l~xcq(Os&N#sm0zy7z++O@hdVhqPqKhKW?>-l z#w;MAcDUi&8|aM3Ue)4y6Eu2Z-!&@bvyL3EH1x9vU)}+hobHlOSZ@`=Ra-O~09Y-pCbY5H7!y#-f=+s+XB+%N(=V5nELw(0p9M$Aawsd2~bwTj+aT((^iFYx8T z)n9qAZ){y#yx3wOls`uKSnc80XuDyT zVhpolawDPQuAv}F@k%ok5QdpRs8Bj1{{8vNr zjEeI{r04c@wS%8c`|Ty^kHWx<>_H=tWZAOAjoxPGv)tG}lp%MyKD4xH`VWR{JywNO zF~zV9wi@5$6QbQJ|B?5c(aV`hq49EJu0UYc7pBs+xNGNKB(!VoX#*ElU_rR06HdiM z;Od0s!Lol*yW5CI7_ih3@I(sT-dIxJIt}99Upa<)8b?07;$_diTPO<#x!;WC)l|59 zc&fg+BKBH~60`$XdZQ{RB@vI~94MS|o;Y14N^msAARKBKQm`RH4WG}pTP*x+esHU(&T+pvuB2^g_vVMFNa-wouQp2y`cH7w|oufvnKGxEyAqvep4*S_@nQHoj;gXN$b=$cB4SMKAmb;Om3>5zRQ`zaIF4m z6-x_;KiH_GqHa$FVp_HT>h?N?6F|qG@I52>_1qu*)o+TS!0>0tn{pq2Om;T+6>s#( zk&g!LnHcF;*HP5I`hvwqMlpK9DPr;IB3`$LV?~#CoF=5d~i@Wn3R%}K?*oy&Luo+$@??c zg&J|m0Tsnu;G$$d^1y=EdP?#74oA_g{<1Nb1T)$G-xiqK2O8b<$hpdrJQ% z*s_b;Mu*r4c$Y`O7ofX;H?F9nM~p1!O{%v<)>=_*Z_gj3%KmrOSYbGt|DUha!5KmLGAmX*cd8=8b3u17!d)>&|y=?*vZw-cxu^!ADe z`9Dvi&MwaFjhEVle-e)e@^}hFlqrP?@GM4FE z(A^&<=CO%e?Up&M{^^f#beb^Ul&YvdT)PVG9ZzNvJtB)pkbbmIE>9_66nI7%#^#aB z8*BaL{iCOe6qYOli|AgL4HidwS4=`3ef@`qvlUaqX9LN#Fux6gLrEsi}M2jq!g68MdM zcJuJdc->0lE4H#Xu1E$QpOP1*3&R|yQ?q*&(^P(vidqx;PT?Qv=`jnf*nEBQg=Tn^ zzN8y)H=!LJ@0H9B+ahK4mZLs+cZy7&e9X0j=AO@|3kpAeXhlTuCU~uqPehFj`rz{|AAXobpYbt=+O8#w zNV<~`j4Ar`X{LKnS5ADf<;IuuN7n)tJ7cW!BA3`&L>*dMZA$(8!nGc^=Nf0yICb=; z2Us5TXtsXTCsa{Zf1by4H1ImyM6IPg-z~Y2fcEG(w9n$F@1QyV=RgORD=RKAMX*ZM zZyBMep)DubiKT`wpE~>>S-+I&>01~_7k0!HSFmt|t=KFke3^bf6qfwcr(4RfR`C_L zz6GRhHJvZ-gNL>ObK`L@m(_uHO6vULxpm(R=4Pw~t5<%e^fod;iH2ufM^-k7V>`3l z*aIQ<-0jQ2Mc$5u=e3j#Vp-LK_UpOy6@+^+BVAMDxGHmm5x62D>n}~-e41>?7tmqh z+>Fhj@yT~N)V?ecyTK>c6={;Fo`0f_neg#vcLfKYVeGFdt?VD0BL(tV*oky%G==f> zkx(8EWsk0{@|kS8S)0U%FIlG01${$&t^`7>2OFf5OpvrsB5Z`&aSbI`9rQD@^D{wj z1;6}E){#9TpY>#X6@a7C-#1)mJ66pecXIn+HrZjh*LwS9FJVs=KYK!9K=I&4_}A6k z^&&z>by{M)KO_yWA}8TjLmb~HOFs_(E|*HcyHbdqNro8Yd-S?>?Q@G$OZ$Io*dTvx zZB7=fZPwBLXpfydB7&H>W!+t50or5GP6rsJ1ezlxGESvq-qngRnO8ZvGSMQ$i}0fo0#`~ z*78xv%_I}{0UEe*IP+j}`1)}P!jn0H%G2LFvW!@j)Qy~cXY^^`hE~mowNh=+4Yck=dzOu(2Ut&&HQ4dD zPg{@OGL@xoVDagSFr!*#g=`UfP()irMLV^^l}hhxxQ1WpU3m(I=W06ahoT04a{qTriiVPZ8)xG(< zf4RI)>oW=aZ#HJH?DWi#W)xoA%E3?fXiphFIwYrsprO|c{)SfuTJ8Vg z?k%I@`kH^wCJ7;g;O-vW-62>AE{#iY3+_&^;1b;38Vl|a2=4CMxVzgu`91&V+RU0Y zcilTPcTKky7wJdWGkOM=W zi4TvYmDG-KGP=g|3zZ@g3Ia_YZePj#UCGuO2K0)^CDm0DINA79JSU5C@ z(!#kE`zQSpA@&6I5;okrEhYLxj`f>?pJy^2;(6+AryrLbbT#Gk-oRy*2zh<^VB1Fb z)L^n%W`6t!=jZ{c$ZEG@6;as`78y}3SrSuLB81j-@%J!ueMq%b9?vM=^z_#^rqR1- z&U@kq{=KeNQ)3#Z-bNNF??9FQ)?YB$N?Y#sU{Ti%{Q9(NNrxGh73!pG#d$pwH*&kQ z9!8n??N<+tP(e_HWmrJ@S)Rr|UcvTkzP4p@bL?loPuVYYwU5J*8KhxQr~lYcUc4_K zCRT>}ZKE>r=~y~^Mf?8#F?J>;Hf~MW1r2jybT*w%Swghg4KwYqRlI%;WtVnFM z-McN*D{6Zb(h*mdd^I;;`ZH)^F>Gpts3vj)Y+Kde1V$1tfjoHs$zsQvj`jxqcn=yb zh6v#hqUiZ%{^z}@h-dAb)2n}ClHc2a&HnRR1QvpS-h7LC1NYCHUjk?Z|GfEq z{r~1abso)`{aXtF{jdCK_je$|?TdZn_w}W8jCX6oPiH}ckWoah&KyIfwx`oHxLBwS z8c1{J{Hz_j_0dOH@5S!w>_Ri@z~&u7nAGj{Yat&ai%-8mV-k$tbkZ^uT{~P8Q@rS@ zq(El)KpcGb~!KF_;+p>8Ds)y&-qG`_gM z%q6#LoT*7Vi{77~pEV7XRXlFD(I7wBI<$huutwrHpR`DO>9cfz*ufG=SL~{DU4{_z zIZp^ZDI0VlvbHKJ>ni{ooj%&3s|A zMsdA0fZMwvZ%v-khmR4CTj%yN$_KaldZ+#!sO35n3!3A zmQc16mdA961r4vKn9D9EGNMXgeSN{6HS#A(&!WoCf5uFewx7=2cY5OkaK`Ww>t423 zSCj&G9t3_qWv(7l{wvS`3{{wFtkA}%b>klYjW4H5>jwMTb`lOuR=(iNfzPHz;7%^F z`-Ws3*P8_lAlicc`~Ror>E3FMv%B2oZN6Ba8?5qHDxceDh|Y^@Jd4c2bO&yp(8=TIpD-T~~w-?$GG4;PkqAwOu+ z^onmN@qkW{`f*Dde_C0^I(Sg?Vi8yWm9VrnG26~d%iQT^)P-OZJ(A6%{z8v#z0A{{ z0QO9)w*BWpr_1JxN};FwY}exu7>0|j3~kBvyBBwQIgptsV3^K-XSCG)s@-^-sOCDX z_2ueC^R(9T{2rcer@^sV$nI%V^ zx4qy6?tH%%@r1Do1-x#D!FDMFZ{6VSO}_`7?#tcNstSiPBk{fA^<3}D8g7CxLciF0 z`_Nq!)O}jOao-2CrPcKAP|CfwVlduKNaiK+&7W=pr135o5F^tMpK&39TT9hyn&?TyeQ1xJ&-x`F$1<_u%A zyp8(h`KtHL?c+~`SHdLJz#0CH!5wOV?eHtEE(UextH8#64O-%Fj6Bc3pV&FyzuELp z^sd$&w7!%c_TE^vKH7O)yFc9w5X`aRYI2;LRAj;Ml8Q3pztcU7~ z>nsO_g-Gese9x5?wqc4~^d^p9UiC8nCI+Sk37DGnKWo7_q)7dd4%lrdpjTCew$-}q z+Tb2`BZ$3?9OZlj zjd8KR{-Zb$Du~1G6124#IeH2h{Q{vxBbbgJ5T32XVsk}2?&$yZ{5trQ+`e+z(DWQp z&0emTy-e36$}uJE5pTvx+>9qCE*#uweC}fyn9rJy-)6}&GiOR=<+O^Cbc^SE?SH=U z^#%MAU)CEq+hLvZm0!Er(+j!8ZwvTi01JO(Vg$8v@72S8s@hC@*hpX^)_gi>;VpBR z>ncY5=cmzJs(d5uA2&K%oe~5QCF{LwcKnZs)edeFDxwD6-Ny4$-}r(8hwkeieC|ZA z@>(4N=j-FTWKuYJtW;c?aGifhzvp_n^2aN4t2_9E&7V$04xG>!2cXsP4t%fozC96d z6FhVpe5%-FNW!28)pS`QJU6K<-E1yS>%F&Yb3gE@SlZ|OjcA9U`&@R$ZvuSVQ5*>I zEO&wg^~YIOn>?W5563U6hlsC)=`NccIwGx*ZM=@A6Ibsn-G=uZ?%A&HgSu9E?f1Qq zO;=njaYWt%zn>5LKehtnM1=oq3I5+^;r*XlY{z(1z718_I{Ik-Y(Glc1!X3*+&VXI z$hx(aED9AEmoL}ZZ4{P2kET!=Cx;!+^*l!8m)CFI-BH%QGUKvB)vH}x>8prtKE^NC zX^lsn;vBfpW0gb)320u))&D{Ct(&&)%|hbCl}MZv1(~_@a<)D77d^!01T>AgpIz?u zQb|~-T_L)dnhE3wjAx%y(Cb^|5Zo>UnGLc1R_RsU>ha49qBL6v+wK_JGCpVMQ1}}+ z?Rn)b<4FI4k=R)K7*h`)csFYD4pxPu?+A7qvEf{pwTQ1J5NtMCAH8;EE*k7DN@y7^ z(k(NVKPDQL7I7ta@*B-To**a?X@h`AkgevK)>|OI#@1Sp4p1V-!~u zyDQ_j%{kL!V&mg=T)kr>X9=0stzRrj7hEC_u+f9t>Ed*4@aw9S&HRWp70Fp z$AV8m4@j#n!d-W4PMtdqt3*mKktgrY?eHu%6^6!ldIlR-Mld3RiHtnY+)b4vh`Eg_ z+zFpU?A$`ip_*NZJZih%#j9Nz-yZWyEb|0Ib|M=Bvzyq_1euO55vY_u8ZNcbU3^${nrMDlXZq%tzuryueS| zH2at%4Tfa0E$&W*-lCQ9yyeLq)$;rEnF$h=Gqk5N>hbzlI#!ojm?E(GL-CI#B5pqG zw(2E9Ry+xB2GgRF*t{D}Tj&aoG?a;de97nV$=r30vQ+75Q5+#%KH+Oz6;;h=VBZ&< zrdGRtif9i$Zx5>+-Hlb648bTQEKDLAynvct#9g5gun}WxV*2KK9cL6V(#S`~Q&o5f zFwx`FUMI*A91XNLBxs~7zt1w(#L=_Y)5$%`x16EM<7Yc@oTTJl`XuvKP8oG-NnBC+ zY>Sdd{jyYdMX;UXQTHtI^=gCGB|M?yw~gj2yBk4@imsev;_UbE&G{V2Q(Odm1{fJ= zX(T!MBg7h46R6qF3mElujYT2I0&L@pwXw>bQ>Ihi8F_)(wc6mo8F#CHZ=zALB|LE_?dV7v z_w(JseulQ2Q`sJROR5Hp--}vmOvq6KoY&|2Yt=uu2+^)muR;{Xqa})-^hP-;WYWta zdID1_*+MSmeERq6zq#*gnA>Vn$Qgv*27X&|_>?BJzPra=v$s_kUR&uhrcOFCrJ6B2 ziW!0b`qe2u5IsLNPQZwdT2$a~4YDNTjroiXo4=bj9g~Z1$Bv};{?<8+XezeUh;WYm za#HXbd?ow(#mP5}Q zp{vglMYJH=itI=STpCEap9itKNyh2+oP?c0F29DO)^;10!|f;O8nQN{d&6i&&Xr*L zjnac*t}cHf9)RNMg2A=XwN7`Fi0*00*t>Ae^#r8edC?=Px|1}?rP$nWv9CMM7L;WNNhA*^<*fGYJ~+&x=f^@($8ek2WP#Ia#+SKHAY)OoEa_B%Ck9dMul;B-g*F`FI3%)Wg zm6uQAjihl>ai>I*jHVd4V`8SC+)nT?k{h?IE=m%p-lSjob>+(G6+zUMDOWQQZj?$& zIvcLPDYWBrg-z0uliTDf#^PFuEtcpAEwi#Uv}aeK=+gS_g#<-x`KturNjd9}%uvcY zBOOq}-IOV6zH6TKQSPui{r7TiJ2|o zi>IA zZ;-cc1Aj*YyExy9Akfra9F+RD9M|FfzqNqzlRa#)u* zjC;Kr`-(S&fYt-&y|u4p*sR_xc{psPN^;^U^C{+6;q1#OrWQ2iOwz@?&!q17&N+2{ z`;)8JkK6+ec{;V)P99^Q=biE(>(-qIo`tbbFGyA_8abEs7&~YF_Gd_xccjP09ioA) z?P+7sgw+GxkEOfYugSVo65(;U$!quZtJK6o(9LEAUfFFn54}=u(!$tmjz!Xn zRltclNGtbaWJ&d2bAPGJ6r{ON@+p#DEpIhmNvB$QHj2CBmDDNZ5D+&_c2HY)m>eJY zi5quQf8DU*VP6R6HB8SDDYP;bX|M@7O;25XoV?diMVbohcOWB~jy)wA9+%oa19d8?v%B z3tOs?Z)nKkMavOu<3DLdajyWq0;u9$&)pkfrQvI0(}p2?h<8JcSy zZBwD}flIO2g){S8f={=S_tZQ$X#eVNbnG-sz?v z9nzSXY)%9!K5hz>3sG8r&%yb!p%29Nh)F1zk4oe2nGEU=DW$rCl~E|i^HTYgXPEOJ zdr8U&21H#lR~pVHKdb5BC(pzPu`WicAdR(~Y%Ysl4+h8uy~Nvr5P`J={nv{Aj2v#@ zMK-j&=bbDica0&~mTi;(H*NE(2!+EAavdP;0OK)Rcx5V9v_Ynzdj(&nfr!pjCeV1? zvUkNVWQ8KSDGN$ovz?JbjkMWut6o+Sj)5!A61WYo=qpAi2Mc!EX-U<}@vd!;MduYK z_eC! z1{H@WF&&EAb)ZY>u7KjGlFH+_Cn~c&%$z-A-#@L-*6R z`rSA>r0Mx7CLibkETdP9H)y}hf)qLOm}S_edDJITUMEhtKdPvbPcrT50bI>5n4o4qpCeLv zAfY!gd#jhEmKl*%ITy>YhJQj}adh6ZV#Z?6pG}fb0kIfj8XJ;|K8m@eHnb^+^QzO( zK;O{WO{Vk&x*I8h0)Dvo#OG`7CAT|lfdft-mi<;Fad^X>VU=O4wKN`;;?Y%ViOWxn ze&89JaaPLI${=CY<$hE;60gM~5S^cV7b=2|#yDlJ>?|13S^1X!4k-|gKu4}u$fN>% zX8fk}U8QXN+L()LFQx45yRx9_#Ry|BXZz37-q|PbLeCq&x{DUd_Rg|gY!gie8#CYABrF*eue#Zos1P72 zj9)G7_Xk^f`T02Xjb!5F8C7r_ir%g}ue>LW&OaL>fU^JLqLdcd=X2P+?h=Q2_ zdggeW_g@0_SBL+tfSTV-5fQJ1Cp8H^0)ozerb2kR|7L@R5A2?t)XH@+K`9h}#NLLz zLjgz>|9=xE3Elr9OlB|ciS)GF$Uy8;K_MToP)Pu?1@6CrLONjBD*)nV)Z05oDOecY z$hVIRIehUX^y@)NuLB{!JgaJVF_GMcJgi2zFi zWU~JS>V$2SbOVF)zSBI>O|0;CIzMzfzx2h&#b9Osdt_7wK%NEWMEC(vJFh42Z!MCu z#$q;LzsgHGV}?au{TIB4Yh|qZ-+0d^@HB+!65urKuP?$_xmNFUvCm++QU86E?K4d3 z|G>sRtF=GwPi)btwYAXly%}r$JK&7jn_VZ-(K=eU&;K0|#OVS|~$w5#c^?V~e#;BncMU3iFcu79os+Gd&) z&6LwVgLW4d9@J_yfMNLj2X}?dK@u>D3Ia7zxCJM25pK1&aa{>I9k=jb-Zbw?j7xCG zfX23J4=Rd;fO$9#YuO@`|MKbKW-IRv$d^%`=y*#+kLZ32-q#G_@8MM!9@VDg|5~UH zfRqjD=@QOWQ~=uBauENeo^@Cc>)*o()J|6vz5Km%njP=HyrjB248nqZt;PR24nHDF z8IbJuJuWWu7JmXjyZ^#m0fWSKdqpDuTI|4<3?^&QB34iFWxS7itffTHd%7B~;}uA_ zo?ITZkQe%%3Jug$$a#l(MJ!b7t4FQ+9wf{)rAh_*JID;)+u0~uT3}e9|0y}_p9B0a zPoN3!sI`&_Q1Y}dxW;DU@-TMa+w`m2CKxfi`vKZenIf%=m8#37q zb+Z$Kc5MaT0ORo;DdY9AI~E~DGLcXP@aSPu2dAOt=3`dPdW2V!2`ER9VVX!{ zxy!IjbENM-##>yrsMVX$r))kKoAVCS7MJ6EMA|YQiDJ-9pa9+G*L*!Yg9rGk`{w&j z@L)H_&f)}zw3=G-k7F)Y)x#3r!gk!`CR=NR<)D^XE-u$}$IH^v8Zj%Z3n8MWZ`(LK z>|6nYhiQR}g&AQ+-9dcOH#0nR(+JlBwqsNa85+|nyYtHDty&>z z`?Q%|D$4VWq1^4{#3BvkRQ~wGx1g-<$aXI0dCBVS7f1c12P&|Zx7T3d4c;DnGQ@Sm zcg3fxi|SfNRn$hac^5f1v6RRd&wCM<^zCD3lIA=;$H4a$jf&)g{l^+UDM0m4oFSFR zRG}p`Q7NtOAm9nUg{?+ES1lK6uNtJHYF?K; zi!w`Z>&k02BpMZ{=$43ca7|4SU{RvPx}0pEMgX21VW&x!jKxYLXZIE08wg9O4Yd{7 z{KX2fg?v;ps0jwNTFBg{30ezxq(mLVzi4sWWAQ;%K8d!+Ry$QXJ4jq<^N`wX1$48t z$#Z|%KG@;iKQzrR;o6}FfnJIadxW0bmpopguO4I@g_5AV3~bnw5#M?&HQfzh0Dx-G|;zVC&K$;4+h~!?|;cakGA0W^7)EHblhm zE-GHK?wKMO%4hdq{G-dN@O^($A8A;Xn0O-@@$6n?d3|VJo zK3g}Z(&GaEC}s(DZOGT*kl;PlT!8mT&hm9xYkSSm6WP>>V!E$jrV#x-qW`J^>_Wz#)HUNO^IG zK*x+jp$f~wEn@R@!fyw%Ia;pUAR4PiP)nk*C0R2;M!N`2g0->*opyVgjETV`%|>9m zb+XVD_VxLzm{+V==M`PV&*efsK*q@npj&ZQ_u)%ljQZ;L40@SLH;A?O%sfhsEaiTt z={UvnN&|cq2xR709p0lnh$XPK%OhYpPL8|>mg~e?A@3rO*5EeLy8%el$r+GqHrTX9L72pN3Vn+S7i;Wb-V264qh z^JzmLtm2b+EW=d#6&P5u)2WJJA}Nc%1*_&$N@BtnqrrqlN})iUX*1`m90E zcJUbs^KTq9JLYvXZ6P%>6oP3u&hAd@2M;xxRQIRu_yp_4$%7L3Wj5*sRw;fKN-Ft` z6A6@fuv}d!V5eBt!VqY;<$(R+>SYi*5cR$;x(F%WUkPKzJD(ciZ{d0=Ubaup+F>1{ zqzShcP=i{a)bOqrB`Ye&V2jIgU}#p)oU7W%$)vv~dN%HH5+I3mIF>rRTlSL#&fNw*>H{(3+gU{=_AQ0|BRQ3& zvProu<|uopYaV0~Q&X96byf~11%hK~oy4z}?wV(0d~5eg!D&hhAy-ra5&ZxeYik?c z@**FUg0)UBkiGvCVtuPmdCVU_sd@=Sj@}CKskF?ZeevEYYod+J0vbQUfExcCHh1Iu6*tX z?CLU8lMgI3J8nJl&gqy;n+J82Ws7I*;?szwj@?yScrI*U4d;8%sq$PWRl@E4w=efU z!}d>VrmcyK@TgJkyB_Kv-Bs_9dyu@1p4MEd8OogA31H@Y@YystM(f2GFln{re}D%s zQaYc@Gs#`26|&(Q95qws^iu>2OcQ{`^^wZck~8ya`O3NE%fZZ*wo9m$`~t(;>*7gx zA7`scY3@cXTT{RJ|KVmBk0rEY*>35(+;MY#KgMdfZBh)EzI!DpQNWuzt$?W>zRipQ z_WRQ@1r2h?2{naBx>Bof&9SIT<7c!OjeCncJg!CWlRRZu;ZX%%vrfnV{3W+uTiVXe`=f%Z)&znJEO0{>4#=$vQnQ2BpU0&JXUg#?qaNIn zFAUWp+~>=N6{!x=G3)nBf^xSc7&Ill63`fIwsH}Xp4!5^H4U2J(Ak{6$Q*ZAwAv4- zy3wNTP!qi(K~cUhHp-LEG~Hh-^?k-%v54L{UCY&{`@W6wdXpZ97hmSXf;5+TJE zDa{ZEOvB|=r^=#{VcKU7>1mySZXPA(#&hI+;yy0lb8Hrg54{ScqvEG|WXO0A z^6iOF`lYI9hx;6S0aIBpBl?8pJeslO5unN-UJKPXqKjnm($Ce~COx`;nW^mjj(D<)`tvigUG*yk~4mogH%Hbh!#VW)Q zj34jymQzF&feO`oLj%bIxw+_i6`OEFjf8~3&$8uA+O-X~U*wV`;UiB`6wNMilag|x zsI`%UIZBfC@CY#ZjjU%nZIibZ<|Bs875Dv%6e`^T7GLvPtgTGQdnQhzk_A{0C94tu z4K3TtqAXb16n>JbIWy(kbf!JDc_Da4qUmq2BWsi*ibMltHIW}3)NRk-G-aJ6MIxld z8lkaSRx(=3tmO0%+#8=i3#3Da2Z0tAzR#w0^Ge7}5h1ApyNJaRvtf(JsZsMHdon2M zWUTW3b#TQhQ&VBi62BX@5U-DR!PcGS$UZIBbKKe(xBLf{2kp+p{m1S`_sX%nOP)xo zao#F?8Vv-bt?m9rPWSO~QoC2e7uv{-QjXJ77K3J0B+O0~i2iqP#506+H6EBc=b>ywF9%6|0nG6nW!-X)P*;@7<1M{M6VN zBNf%KG;lAgN~Mx149d-S!n!u-kTr%ey&M#`5(M&5vAVlrpari(hm7B~CJcPFq2xyM zwjIx12zF8`=j5IVn=K$#h@BuJD@oz-+ON4c0yd%s;#|tgI@y{H(e>;JVv&Pb?)3-3 z3?~jo68l!3O&MuWi}B|p*jJ+ceD=1_xx1EO$AH~D0`ehk=>$Zkz}=`RUp5vh1r z8c;fA)`_ZVPoEgM5Csq+(|=V!B$~$WA;`(EQsY-kU!*Ma_-kKO(}LV*AmPK#-S=YG zz>>fVlRd*^;+C9Y4a=lI(*sS$U%@II+iDAJJ2kNsl9Y@3M1Nj`#&rIbSXQg~bRbUw z`rW4aLbopjs2MB(MYDt(6{5qjzcu8ftc`3w$%b)9#wTj#_I}pk&}(c0SCawrskKFs zt(l*}qqSb2p%D$hy6YW-1eI^Z0HavO0II~y5YY&H^}eO80Z|$!K$n3ubIH*OW`U3Y zzEb~p0W<&a2E+ho3A~PqA*eW0+0mg2&nlWm&i z2fLDpT;X!CL(8llR!eY%$VF;?@}JNXogVd4Ax)H9tMd%M1- z%rM&0>2Q9_<9?LzfK$R&Z1u^ewbf_piM$m3tdYry@|;RB41tuaSBQMp_j-`N)Gm9b zXS}Epw*#|xGg4rx#Z*%*ZXq-9`E_~G>fFx@qUxHnV}|yx;$8l$vV!_=+B)tcrjR@D z^5D?-r$X_7sBPB>*a01xoZj~CM9enT#C~1(9?&>bOo-|7$w2cn(wO+r|_7ECJMPbkE zW+8rj@wyj!kUQ|A#79e-QA)1CMNT#!f!uIBF4CZJR1;cYbeHqEdR`$$2+9q*ETai;RM4bhx(I_n$R#!-r0U zuPh&ry-{&VX;+wto|iW}78V3!nO%H2i@xxtdb3UQ!mBp62Q*87lNfJni7k#MvpZ4k zwlp;rtV#GzR?ZcS3AEqJT?z*9e!C)!A;B96G-D>z4o^o1`C3K*5MYF7Q?otU$(B73picQlU-Kq zb)3Xp+l5vY>JE&imM0?)g9u#GTfTZL$Wq;{pl;a^mHna^xvrQjpjXBjZ8OK{h*s;~ zA7yb1F>7myOkt64|1@)A@59AoR0zRh(LRnoiEC+W62t#2XAvZ<{E5o@CK=^UnS`h> zI#oVj@}Otr3-@84n3)t};}!mhWFLZcR9shZKwoK0DRt%EcAG3PsN8X3gOe75%%ZnY!U`4k)#|zTe=JNm8UQ%EOBQ zeo_=*UpeK7ia3sQk@kt{E#$pu16mSYHCIZG_-ubUjfFiptNaLWZf?JUFMcrj6u-yK zRH1kxJ<}sQa%)}1@0B=PANdgK05`>X8Sw>&{;L*M=*`sKqpj_)ma(!@S4o}Md;c@ejV4a?@@zzD0BNY}j__Cl}O%xmE`crs74c zBxLK)8f=%Z^kyb-N6^UP&l5?r4H}oEn(mKo)?i+D<0gOL)&5rY=SuZ?#D@y0731Gp z03j4-*K>8O4*{`v1j5Xh<;`q-oO?V3Yv=u95B_B>wwzjW#UEpfSv&c0#p#PJH&4lm z7zJe-)A%(OZs$!}d2~Cd1A6K~OUbL!B8^V~@|F7m*tBd~5jU;h!~6IR9DYZY)u$~u zxjyN!&|GXWBfrJvsh}vNnV#_e-6Kv|H?Vne4jR|E6Kct7rKictJEshHxnS=F0eu+0 z$xs{WLYKDNpVbN9?TQN~;4Ur^RGf^ftQW_6*-R)s(nr)XX0p*wzVJfo zr^!_GzekIlD~*Xy*Wf|$98g6~haA9)c8B?eGGLW^st-IbGj&2($mc)T$4d0zor-D3 z2*4H+MS~|oSefl4^-gJbqGdIaE5&MUDDHMd`V=$-vT|nm?`j_7xP4}*53*;gqxU}4 z))y`HHo>fVn`A5;kG471lwfT|ON9m*Y961=&BJXE+do8}&ra;_FTP|v*hKaD6y|&i zX2bUTQIx>_=_E6sOX}jb3)KMX8fETMiAE54A9ZpZd2zkx;m*cuI+77Clf?^urgNR1&00#PcC#-MIj7_XcfU%uFFoMhVBef;Y_#0i?*mKyz;AP?TINY#<}w8z0i) z@`p|@?&(S4mOcaib6CMw;Vs{^FCP%|_Gf>( zKn|IYQ}^7Nfflf+xrDfYmoq&!C3~G2g$hh5-%@_qQ_h)j&dfX0oH7eEHrj@ETOp1} zI6JvVFf%cPf7!WV^5L5c3N#?NB0k}G{~FJ#0uMYYhmlEyYUk*4=eu|%Q3`tmq(^y) z(FlImqNXq#tgQpp$=C1V7ecK}BL=0b{U2!WSI!%@Mw0B1_IYGC7>~ztDfjKgGk!w0 zFYzwn)YXZzK=^npwE8~XoSB*X0C&)QgSt&6s7~za<~+N~L_rbJFg}P;^9qkQS5czY zCrC~vSh+Yhm_qR&L3Xm*e#YDXC%8Z6DPuG^|ILq=PgD>rvK2H`WHWHYQdDScs!}a3>7~}O9lEjo#jcizbA}8~g)#pF&}F*)ac=%Qdi%rg z#geOoQ^42Sv^KM4TXM2mOw|R4g`<3;GjdetM{&z`-LbY4B;yg8YV{H+@zkH9Q{?ju zTw0HS_(5c4$ps2$8qYxVPcYzeH1_kOhb`zQ|6O!X-SGWa#<+4x#4sip=#%!iqG1;d z$sDG8@eLu>nlJB|K_E?_2x?4@pP1Op-L+7aa^)&y#v-f5m&VTKeV`@8>$eGmEmEtf@cx8R){Q=5ML0=uFC$@#Z|zIUEGG{>cF8&}h0a2#0)mn_ z-DSHO&m!i>+o%+?YpwS3A)Rh4+8!T5Juu^+I;Y?MF!Q^+-`V7Z@kv{_Zu_FTQlg}m zP3k4y{fw4;b!va+35`=lm&N7x4k8e4-Y#|3vh)%yyC$_;bS_`ybPO6>fZ|dN80Ty% z$CQ?j8e@H{{fvX^d01NiQMwr9zSoc&F$ z=H`;}Z?&He?L@nabIDvk+XilR)Y$8{F{niP$Br}p*2WhfO%dlm{rY0y)(bx7(bPY; z-`QZhk7Qm|Eu()i8e@oZRgoVL-Qu&X6^kU^N(yYbV`4&}+Y;o|)v1f;mayg5VuyZr z6P2;Deb-xUUK_RPDa290WGHfC&!}$OImUP=Hc^*dA2(hv-{>y9S04cd!Lv#!s^Ri< zrFhs=DhXKDgNY`k?cu?{O`+qa^+)BYh4tOlLMn$$c2;A-a33RyITRgrdY%c=#$E)h zUf79VQ#cDg)Y*b5c$jjfbSi~%r`x0HR55Mrp27!1rg#v@;HPH_a#MgNl#r~+2Mc+) z#HUQJ#mJ*NUL-~;PN9bacZ;D9n6rv(+pz676Z-~HwB|rr!Y7ez@?M1B8IgyyScK5c z`3uz7{;;5|R8}Dp)smb}bfxB4AVA7~(wcTH<&OzQq^dy3N`*j#<_CcECx8xvKw0MG zKqV5|Gsf88;G*mQ$+Eu#(tkoU;d-Y#V#APg~ zb1~Hp2vDpB(%k*zG|rd$a&G(`ZoVq#UB_+LmfRX4CP+|b=NT;t>q&^4I1&Z;72Vo2i!^G zi_hTQoqoPJw z#mMO!$!VeTcv|A0>B`Jf+M9tL@V70@BN{#tkWd!BQ3m@cIWkI|cWR4^pI8^~V=`g) zYxj))^zIM{`EI!)i3eT-jE& zAXyG8+>3X8MEOT7vEyG2Xz+_iWTit05_2$nnO*8tWc!=^$-KWP$;7@6p30MAZA8Oo z@+kFT1v1CQJgPet+Ctx)RUsv#dHpn>02zm|ctkJFV~m<1$5E1%Gf1ia^(@|+*Pn`cqo*rSX;KZ0F{@d8rfXSq_5G;Non(y*#+j0 zb5>(j1MiS}nwiq{WHkn5oU;VV7w?1?Y3-!4Ed!;Anh@E6@kGz~$6bXLC?*GB3I6}G z4}@vD39vrgQ)c5ENuxmok+{b%a*}lE>)pTR`J9dA)g1aAO^6LxRr^cD+EjTBpJJu! z-j^Apc;QaWs)qf%Gp9q&H_=LEoyI4ao@BB26UVjqT$og>Y&4PQuEP?@4XOdU+5xlw zTTrRGHExYMiEuS11a~-0PVQVlY@%vMS>@vvrnGs46pwr4##lCY;=? zq@!dSaoi0fv`w=+kz~eX{^{|zKe!u=b0ys(fQ&K>s7(0p{RaAl%;2)7>ql<2MLPa! zn1czzW|3H`tw;{JNaPN+kHaW>6!&ad^w*J%H>>#&{2Yzpp!6sCut+zif=U+1LfcPk z!T4fr(ELRZe7(4d#x1-a61=Q0YrWRO~k&+7t zV!>;se~4KeX3Ej_k|XVu&u0sIgDom)_*j|7q+opI|9eNm{}Bs7ssN3~$n2#p2XkRh ztn=p@^ZegikGZG1ozwH`awjaU%+O49pOPNrsOF*PbBbs)yc!U$bDtYWJ^uDTYhZu% zovc=;1z-kizL&?ZWrYIBF7R5O*!{6ez|iyoC~^Gf5C5aBk)lF?@9E6PSCo4@Z-q&# zRj`LjjIaBNDO3;%w9j1hWX#%4z=DJypy~*KQbeD;%n&F9@5L)P5=#?jJbz#Zv9_P6A@sT4SL@JRLrJs`O0V2V=$-$-sM<0MA$;d$`n0-<*)BuiYGN{$t9csv8*J7>%MhteNS*9AK_aunB*%Qsq~$okaMV zqQ!M(8buT;WEN9xbo>VmjfTyWnYg>jamT-Ch}y6@Dz?}_b3f~)LOmsgq9=g{V?){d zwB0=8=Fo&a1Xbz+A-x*<{zb7(j-y!dg}kmuyLsF_(aVZl9a7VhZv2m9EJk=_x%StQ zI(s8W4D~u4n_!LjrmyaM1RWSPhN1%%zk7tWZP0z!H?GKN04V zZz+%VtM1E718e;0$_988P7W2B76KFH5IYBN=@=X=^pnS5 zn}A0q^A;H)cxqzbd+?SS6O&Gqk(V8Jp!SYb(P`O=Ca|oM@dmOL`~9fd`I=R9O3F}J zpjf*{mh1p~Z~NO!9>Hi$+!8yhTcsIf=k|_{4$IT$r-H|)KnJFCj&Kd_@Dy`L_p=2@ z;zstYLHLIeI{18-vWH%B1l88evX@jJ`96Q2KHc-rNhrC`lx@(_zw4|c<3APQGM;HN zC>WS=^eGqzmPxm`%3{_Tx}BfsY-8{;ZGYWIUndW^1SjG4k5~=?uFAmv3jQO9kcZV} zTumB3^%ITQdd7=Jfj(*!1%XG+?r0^YO`9nZrAHx-;L8*4DiEcwX#$0!V|F!f zzDw!XnJVL*^~Y{pp<%gy59=>$sB@IjvkViFPo$8o!szmCCE%SJQjNt8-M^BDLQs_YvIE8{gf;=Rt8TfJqE@wrb(_4+EySR67+-#r#h-S!9ntp(7T>9IE3-9zt~ z%5{&^ke8#SDHCNGv$f^)9ax*kPv?2=CwRXhm9A%8qYSNOeJd61LOnFq9!H1BPdtAVoBd{q;%l_oukF^#?<73Us0 zN(cLtJ~!MW$h)$~?g|4n9@<~(p(b5jJ-zrL;rpWTEV)EitRKp=N6S6O?=q=q6BQUu zZ7T$GEJIz5Iv^q9balnYO^Lb#o>B)7x^?$vE7UCBJrE(Y%o$7ucM1OIFJA&cyj^<^ z*l$3NS<&8{d^54YJrusuW7=ZTQCa+Os@Wae!f+6vyVe=Mmc-5@j)9eoh7Kg$zz0$& zq*#QOw780`P-Adww^_Q1y#6gDAMMCYM~Qe6%+O6P;vboxVkb2kPEgo7&Z^N_Bu>^9 zOLM>wFH2v?I8v_u%Od&0Ya*wVf#%*3id{+@dCeKlW?XQ@sma+jURsKR-cxotPs^Zr zG%-!ri8N(@jaJ7AnAQj_ZH_pZQUbqL&2G69a+}b`!7DSLy5L4Sv}kx&!j;q&)xIVOwLmk6RdxM-)|~l>|3} z3Hm?zf&4y{)J!eC{WfTXad19hs`BM9e-F`R!BDyL`Fe@>>@wVD2Dh76hPaU4P46kc zUl9Z<7g$&WEzEbsqt^s6v%cR-w>^-xk;KQ1*|TJj{RDXvV2d%#tUOzPS3KQlzMw9T z)!)1;n2BncBLv^=iWS6}4!D3vuY@m-_bIR}3ppkwtgm>MYT?EZ30^MPDEahg?^BBt zEs{3$q`RV-=b!3d)T%C8?a7O?Q#Si_FMkFTkga8&uiofteFU%(OJLP^GvYhk?<+SL zCzZ<(ApW}*&3c;lan_s@yPf?cRybRA)#$MNp2ct^I!F9P6ROB=c>AaqI`L7^Dkoi0 ztCZzOl?D^Rs5dlYuHeVd;!v)d-l2e{CzsyesWXiCe|9NLBys)!NdPX6ykl9zzVK;c zmPxgrv8-s6@4GWMRRR03*7)d+zyC{Dj{Mh~hNg^!kKFsZgIbqA6BT=3SUn=QKI^qX z%R{?95?^P7dp}cKS^p|eTQj3d_l)T8>biU_DCD)7O<4l>mvYm)PJKWEP=tG&17A%O ziY?;$7JV7Ze+W0{0!ARuydF4h;QRUZdv)P}ucv!OuOlxiuu~bsBTQZc8U+H&KIm#K zN|OH`^7Gv%aD(e*J{NMb`)d5r?gzJ1WQ4YjXN+icC#T%)$8|u4A85b3r2cMg@GnH- zEZ=hQSRQ#W!7cP8;n=)WSHt^^7nZw!T;ys4}4+p$-* zs;FcrIovDar4U)@4r;4HyDxzZXUS2g zx=v7=(XHvdji%{3SEdKqqm!K54r!vwi(1p@cl#7a*#nfQ=_#~YXK}eLgUTBFR~*y7 zPW5yr@%|HUZy8iq_jP-chmeGj;1)DMa0~8`;O?%$-TfRSA;B%UySqCFg1fuByBy?T zy~*?byKlW+-M6~x-p(f~mB420v-etaj^7wlb?U9bq=sc2E-|$zR?L4#8epdyzGl z;J?W4gt_v&Arwrcy(J!&U)=L_PB*PD2LWB)ukRa=A@)9B%pgCVM*drE=_Nz@xC-;5 zGL*$Ej^Ajt&n(1Lnbovr#e9}xUv5oVjPJWsovF?_Soe3mK|O~tCXBzVja}K`y0w!n z3j*99fRO~wWxuE;^gkEozoGAmcu1k>cK#$rx;+Hu)r}^KNf8N7MGJ3@G&m6kG9{PY z_V!~ebpy!6U-prsxs-b^AVHQ=eQ7Vy3JrpC0{kyEV`~aV*Oa`OZX%#RSB6|{hc~`6 zwXKC~59(CT@9tT2vi=2Hu!)*$MQHkKg;^6`zJPPGe6EJd{kfuu9aI(09HY-zw(|v! zt?lB?d^vyNGTWvW230yPWoD5Y^rmNt4LsfVxA3Ite7iFs;43>o8*e%>RconNHAhaE z-<`_J8?*ry$z@d|L@;Jm-n)jBNR$UJmNFH1j-0Pn)Aqw<3g*vC?bvoQ>e}>a&>a@X zrS~i~yzQNk_0sNsQpX~E*r1{()!t~q5p@CN>6gz(@L8e#S??=NmK<{$(}+r8#+D3M z0rYKMMB|;atofD3EXv6=68-fZTbnrQ@KOaS5ff}5Fcb~)@_tMkG3*7I;P9bd?Jo5J zYWPipBN9s@bqrU;Ui~(p#&R_xiLRz^r?EpuR<^v``0Q9)R~uo}@@(j4lX`G0qbPDC|Mu zm~ARYouO_D#w4!pc%9{h{ME`Kl~@I_T!!Q%s>alx-t*Ob_hCG?R9Y>?Ceu+-wvQew zv_;qN!K=qVh#zhn`kNey0-TAzly=G779C_!hV9Ob@Mg%loV*1h4`rAIDs7>OM`ESA z=wyO6!3s%PUK5GH18_06Fx>L!{d8CBzLGR6M!Ys2Htx9bNUll2nLLrd(&V`kT8HA; zN_@K&L0|ST(~}nH_rrX{@O2@-o(F@VO&FH3w{Dc$QwM@8i1lK(ZW|nuJTLT+rh1#laKkPDc-lgS~Y-}u)%{4y1Z8xPmzVYCUG3+?WM z4%?(imWJ(^#f~t3k`5cStjmz2)}3G}L2y`C!}^nMb=8wkUJKvg0Q2FB{rz_JiO4m^ z*o47Tky#mT!M0+<70$_I_{hDzg8*ZdU7F7kbbst3xF}8}k1|dPSa&B105rSWlzyLY z^!4kn)Y|e*q_E&sFDP+bpm3Jl-ZU9$ed{!J6@F8e31e~*R@m=?pe|p1n;as^aDU#E z80=D=qqv~z&iVECr0yEixG`O`n(Wplvkdl4prUc?E@)uingdhqL6)1ok&sW1v@4y& z`l0Vcrt*i-_cewD#;nDgA)7C8vSl1cRL$nzkA-w5AQ5)NqtgykTL$7rG&!lQ$HT^F zcH-XdXgce?@)Rf89BVc)*|kJp;wZrQ6qudR$+B|8SQ^2i`(y0{i=F z->!lH^^!#5oUFpRByOFv>aRNnx8Bc+Az|vq@`Py&tnG~McMhVB@QF^ zxL8Yqi6E5?pfaBpEiIs=CZU6_qN3tTalty5I#CqK526Ame^=fUsMwxMxJ_S9AK~BH zvSXh4ll$M36ZG-Vbyutkcyo-S4v=DlSuOk3<=S(dyV`-obtm0t+XY$-FNggUOfh2D zy%yi&jZ{kmL`|fZdWl!iZL5qjybZZcW=b|XfU7hfOArkguPXA>TU4X z03nkgJ!dxe_J9jQ4P7lr2>RsEzn0rhH*HJ9NHl00Qu(>?rP!<=+iPK*Y5Cgb)x~Hr zN)*if$j8+t%Ps+d(+jaO8wX5hCX|m*eD(67tmySW%fZqr1e zTy_w27x*MQaQTKt_v?UsW)kglELEpuDj$hJ$OJLTjz#w+7z5b5BHh2p>G9b}EJ9<2 zv4MSiV#_53W(DPV&@%*2oE z3!d_~O^S)lXA32{&R@3A+L0yr#Ngpp-jmM5_X?Hcs?lLBSy~~1LP6=$zBT%`#Q{TK z;X%;v;b1eOZT;KST#|nQ8sjltQLA%hLwR=uz4x8RXemklgE-_fbcsT%c`cUB{gug1cERtzfavIE+@L=O$`^Uu| zv4oR)ze_4ye_j2h59o6pB;D|`-Kon)q5@547RRnbLdyxmWWx`{FTPLt?q|BPKh5-* zmU)mj!achq3Ix2^dFceFB+htOWRlZkhH)EV!0A>S|8^wccDPFbDb(uPZjk*}vQvVf_t0%x53;6p%;1!2636b* z6tCU!7bcB|k08GblYO@Y`D!MSFu%S9_8(yrEnY}R!Q}m@J-eNdSmJ2Vk+|-ec3tSc zWqiBzdiilRJc=^g<8o(5cWz;0(Uhz;iD}gFyM$}rkamyU239zVM|c9aZ*KlxDIFA;J+T;)sS{w)M79Sl=-SUnD^aXp zEaCGbCtb0mp}cf!{fR&;^oYuD&P6>RF+Q}p{bd(i+uMe@&q{&)x4Jc>A#)Na@es^seSuq1`fzXMLXfbxOeGVDh-nZ39Rpj(G~* zU-Hw@eDQgwGm^I)$GP07de z8P)C}p^81nLZBI}v3b*M)N&|0^r)d?vKe)DJA!0N)Fw_UeH9C)qK*jsxfhx$0vcD9@3N7f-L!s0U|_g^VLOg0V0~fksc2@cDq!c_7g^6<6SXE3 zqsLW3L}s%7nwb3PC!erq$6vlt4%(GIjko_t7n=B)hb$eflY>Iw;r$CIjT|0HAxdcs zYtbkBq}J^#W0sDWqc-a}MkBM;4u&$3v(?Pk=^ja}Dg@{S)DuX-%6qTqRY=}esqAyG zwXF6Dopp4=D3zx1H%iY{YcoMz10u}!r&T6J<m-4jN}X|lTu#iCQ&g% zS>z60CX`~UxW904a0u*d=~q)O>NFmre||t5Qw5B?sO~SfW&A+V`0n`>eWuCZ4%t~u zuz4Vx%xPkv;S$@X{wJX&=P{sejE^SXP1^CRQWki0fpn_hHkhgVLSu4hQ02n$Zls@i ze${SLGdy*xUa61)-Xa)hhdc3T^iZiJZU0hm6~u>mG2t?BX7Hj9O7Ve7I=E=wVa@!1d9@lhZ2l0Jb28`LGic!MS_FNXZK@>T9WLl zs&d{|wN#UD?LPsDle7{={SlFARc4A|%S8Tb@>>dwh(8h|oi#JF=cL=F=^e0{SR#{f z%Rw8t7F>h*>SLk?>l?o~)s^Kd%_6y;GU=;u*+0p`eMbW7-xB>#!izLwW++DX5NN8( z8SM{JkIg`BnIa&kGaqCpUcPiM3}c~iHvDxOh0I9*$&qT4D(+=NZlAc8_o zF&Co@@C4RWmlkrLmAELal%VoXZjM<3CZsfI4?Y~k@FFl+>By^onOK`ke^GL3&4TGA z+&iFW;iL(isfd(6ph|@^Ihq1)9|oo&`Z$F~sQ7xeQR{1dK6blvP7PdPVNSNm zPM(P=CO7jxcnLS&a9Q^WXIpE@WgL`XICy>|LX-EZx@^G%)CvW7O!5+moeO%fN2@5(xEJ%FE?o z|?A`aJo(A+T#B4fI>Fz?q56hHW9tXhDlwR)#h+oRXgC|=S&WZ0|e4gDtzg(Q| z=JC^1a-KRP3*iSQ8jbY2&xN0Whkf(tYq{?2Qgc4VvzaR%6f$s+o;3ozY^E~Ff&0^g zRN)oOPeLEeWOc3_PUrU+q7|-43Aai}{&$?qf*rfb8qJ3cZpmst5yI+rJb%BT;UE?^ z%?ydpaswyZB7~D0h8s0vb$Aze>|n9)a=XlcUlr#~i41Cw(;$|d_Q0Ypn;x8##D7<2 zE7mfe#UlX~D5cQdHCD-}3ne;Du)^B3W?t%mypASAx`o7Ml*q70n`%NxezmfKgj-AR z?Qb<`fml)&Tw@%hOyI_^E>fMuFz%6xT(bJd^ivQFz?rNSoWu5WSv zXWT#F-UR;NESyX#{O-3}%YfhiG6e9f?#OU5fTQ5=xBXx4!GF#Lcy>5^FD*LTkLeCh z^LE~K|sT?n%E!6p9a=?|%1HML}sb$=E*d4cnS1M!Cq3+XN#-N9;#-O*t z7*7D)jH94uD@6K$Mf1&V4IoUju@?gBGu+YNmgjDz@=)~uDN8>OfB7==KgiM||9fQV z7VRXJ71Vi{s}0YL-zcofTGE=V@#@PAyYsQ_v0=j%YgV4Szx@Kn_v544G|4A7ZD1vy zA@I>*zoF%dqTSij%zNdEM+r+wQntJJt1&>Io_I5L5JcD!9bH@}Ev4gQg4u0^WoF2i z=0L?bVH(yka4GfnZB6UzIxFRwYKZ{?BnlbtWk(6t^GsMDskP$VgouLQ0ga~avB@?O z<}TF~ZR$L;(8?u(kb}xNiOKLhsUe0y8tJ-$!Q~4}$^JYeQmbo0&|Be|FCwRbd+fDbd~n_e)RpTa5hoI%7iS zFoD~#KEefI>3HCFw=g_q89fv})!cBPJeKCG&f)?bz)$IZjTkm*f_`vKpVkV~nM1q` zIS5_r<0||%raZCR-hk5p z((DN$MAysTVzZE(4g_ppyI%Z@F6`XQlRf?q-kQJ9MZfmT881$GG;TxzEXO&a2;;pB zTy0MOu?CU}=*nIxCC!}PW51+T^&cj#R^jEmgcA*AWyZAKns?OSk?k@XcWa(tGf(#? zI4)@xHj6gdht*>PIXW5JLxSR z;lYqi+?|(svVnb5l<|}Ihc#nL;|`I+`eUF3p+50&bFWVd8q^~e2BKYs$Mu|CqR#Fy zsAGkPe+b+~6{$uQ47Pc(i4$TYX|K`iCL;$ux`y~oKG79&s3IpMk^t{zwPr=q$jHJA zoH&763yo^K)YN|K#Lsiq8%^QlnG?ZClOIgG0bFRB+chRL#9n0I)}l79o!^W{t)&qL z)and6kZ~$*ovP@sr1@&$sd@+Qh9d`7-ftDI<1+izE{w$%uUSZsWh=bgJXS2pGaIva z;UBCe#wXrB=FI2_S+a$X0>J&U9c&sJacl0RKWG2y868}ThJvFUkIkraWS zB50;@_PH=$Jf}TKr((W6GMS+bdrJ*E3)Q1zUY?3Brqxc+s#gR@RkDd;ZmN{wP=__j zQXeQ)Z9%m~RBFVTQ;r+MV^|A}BsK?xTn=f16iS@w`qZ2il~Zz!VFVK_J8^dVbGpE! zq|2_0l+QI8QAk%^Qv=Mm=;KXgOO6D9P=C9~1?k{xJ6oHF?LLX$mc*9aoVw<$iv_yI z^Yd-*H($0@Jgx-YOGCM?d;>7Qhz0b~sIyUON|Ey>a7jAHrQ;m6!}cKjfNRbfYkee40I;Syj}IMX*r^?aaAHonFNNDmYd!# zI5V{@(Jqu$?wi2Z7#*NP_pg8XppAGbAu3(lpJJ3lxV6l#N_|0`C4WRBsZhOst7XVp zxT9Y5{cwi?)nMrYSz&B{@;h5oG+2S({`j!f=w9?-oN2-c&$w>vI({K6@IGIC;qbh< zD%~i8j5fn&`hmtJre88T+lcpefBKyo4_>NamkMD}+PPCj2JO9SUmal;f#!vGdT6e8!M93dsAl<^;A&82O znVh0}fl(7(_)QPQ^JG)zk+kPMvDn)VHF~TA3F(?5%^)$KSA8FIUZkQ$IS%5>n(op|)~=rE$2mA# z+w{5-#aq-N>|(7e_ZM`)enH5Z0^9W!Muw?KiRi^c_qjF9Vzf;hjlm_IM(S^0S#C;X z-`=yD3~MT-(wdJ|d>Yst@Q~?WP#?}D&UJ5qer?*l@8WQB(HN!>UQAgZyYy>{@{XPh zUFlSPveDZN7v-i+Up&j|H1vk0p{S+>f^O4VX)%cPD=?;ya0H~X{oZnm*W^fwJN4av z@18!ccS{ve-eH`ML>5Cj52}_HmYntx+R_{oD9QJ27K&muRa2NZc=%z{EmU925DSbd zW$#HT`Oz5ya+<0n5aUvE91HoBFktsi2aAPFWz)jiQF@#Rm0b+(3(^@TR(c9y^fjL% zY0rmn*BVwW`FO$t&&_522FWjPZ0l4f6$AuSQl|4OQt2W&#vdA5GXMqyimOoaJD_z$ zF;G^o>Z7QZa!CerV(iONo%@o@4Az+1dZAxl96G$VUV^|x@ey16Q^B@3XGP;EB2!4_ z#x)qP58b3)IvBTFPaw6@>01Vi#8AQjAW7+f-Z~Z^Qgi-MutvC45&vAtT`mXoFmR69 zH5U?xcPO9JY>ck=7`@j(*^!L05GjEwrIp8B@+W|oj9YVWUya#YTk?FJyl+F%OGO1z zI_my9fBr5?6{v^-#B4tNglBnqzp|SXf3b40#V)P))6BOPjl2{0ot4GBIYbaG=c+LS za1!lJ?pi~M^lMQ-wY+?Tf4krlLHt;{oAHFX0t?Sr7hvR9Oz*e!xaPpo6)y?}af!ri z+~9Djj$J*8?~$S?QYS&%&kiY%1Fb<+e*=Z~-!$Bvg#F+s(Yf>=lTr<5eg&x32U@i$ zXh3ex-wYyl7}FMcg>k)@QC_AcHvRSBcu3i*d@j)mR0 zDfKQl@l|kNBCkh>#16^(#Y<_Bar`e#@5cZT%izrf+up}=F@ zoMU$qIJ&I9i1h5?gN*jCr=tev^}S!?+piA#0oy=4@EMNg@XB`P!XTdiz1(;tPC=1N zTz=_Jf513=EJmbR2|5KE#yuFfx(7V70}nr(nT@?U?OkhcJWuEhHOVOW?U`m08aZ1o zeP{XwUxkR@w59fm%Ei36z0|G5FG0;pm5r=r=CGD%KA@%%+IE9*t(CYdEMM7Mm?u=4-ba(f?rmbqC}PQl)Pa6P z6a$Av(ryKmF0+@P_*L;qRfok}jWe;qa~ISHY1+3%kxr>o2 zrCJ$}09%yB2yyS7yeqX5GX+pa_0yC$bz`!VQ=y3${u{xmv`^a;SuYy{srRH93R`>P zN`YJ-KDs;Kjky(!IdD&L>fs>YVin~zQP#x`xQdSW0}zOz6?^(~R^eKE@;do^CH!IX zHe=A{Uf096FZFW_5btxf9K2_sI&Epp3?C^A%5qlr`DpT3ZzWchUU(~4Mz3*WraLKV z+jx+^p?}q3fDGi41sxi430+#yg<*ejqSnx~)+ixAce|FrVodn0R)S5T84xlzaZ$;F zP1(1X4`QG(S4^=as?Yw!!PsnUP>fcABOaZn zx646>_eTxQ+@6cx^aU*5r@B{3^D81}KjU}}4lGB~KaS0E%?rgyjTiJCb|A@dy?wzK zlcTGr#P;dwJn?EY9v};5SsG-w$)vHQLc%e#f+RJrYFP}4&TD#p(R-}t!J3!a6-R_!vN>W~7M={epl9Zqk5v;b{EbgDNcj9blsrDI(7`dEWHw&&^mc&HY~7%02ih!4`4scYTc zRQ?fM050BQW-obw{s1rH^-?9(s20l-Z?IqG)&xI3bbe=|7I0?&qo49EflR=_+`eN> z+&@*woHS+IGnX*^KWzGQE}(k@IcM@bL)#eazl<&48P-!@t2M-|0#J~+{)#*K9gj@N z-7~OMAc&84vCT)sC>h>AoGCIA(4!||&OSC)fBPKnB?k>yrId4udLPvuSM>DPDvM%}!D`}ie` z*vqkcm-+d*`iA02A+TATxxPcSVQSX(2ZFsaYX1KU2%Q}IVNPc4Kp%YY8ND8-Y9t+B zBIwLqyh;GrRno&hRk*%1Z5fY#^d1I%f?0&`rGA7J(zgy1Sw+w1|BDE%DSXrxJ2QgWuyaY_u9NSEzrlh!j4Gv+x|cv zVtPF%HHj3OQM9YNr@Z-dT*e-Y+%-ifzAPXuWM_L?%FYadn^(#%051P6-Tx4Y0(7nD zpPyML1hlNAlxpYC)j3{^O>OrWrT#C2fTo`+QP4@gQmd1&j^?>8tis@1|{5D!aOSKbOH%&Tj-ND7bi z_e(3AhcVAgSy`O}Q+z|_wo(G$GAw8te{eS1Z*K_9Jm9*?lUe9EslV{6+#0Yj;p4j9 z9=V28f>z339~0e-rKJ@$^X&Xkzkac)xftEr$PZc%@Ol`;fEitvt}@B3e7gAOPPJ^# zYjdBRiFC6Tw}xwqGmnZ`aHT%jCwe8mbGX+%qTHg_< z@)fiXOcP;W8S;Ro+I>#tJrdJB!Z~4Syj$YX8u_OlWPx+rv!$iq@=*=vDlhK?*FO`O z+G-TH+ z*=l3ED-6-SbBHTd^)|L4rDtMu)F?dds;DNKB#3gX^t0_<$hdyvN+Gzs>Gq*48h3S5fM` z-E7uB(K9)mcU*V%`qIRqF0#u>-b0tZeVJ=VXC;O{*bA`f?ZoiSL>C^x8 z0_K^*nDJB{L>+vP#&a7?X{!(Y%tY4*eZ%yn1F?3ZYlK63bsA~yr9Jn6tWRIZl19d# z{Mo1%#gNRxyMOvVJ-`HD{wq#Cte!-kehc-x=(JwC|ALeBk{ARU4XUS|o?0=+F_ zwQ7EpCW%#_#A@$l7MU(gY^7#+l!tS=7k3tq>C4n62|4+8{VzfsK0Tnf+@8ujWctn+ zzxi-8b$s-CLE8ujJIuL~2!D?Y(QRyxT1&9AyDTGQS+pV~8oyMC_ z{}O*#o>*%$O&g$RiJ`JbRLh}P&io2it1VjF-m~}~VD5UQ*IZ|#8C{1*9%w!w+;-1q z%xs)xOk2EjnLGJ2&x9tjm)bw+ZIya!L9^TER;K=TpnIW@KTUwDqb@Xe?&GLLl)@{Y zpZ%xJbtcH$Yjr1FcWIMq-+<`LK8IER#7ZEl9-;cfeVC`K7dRU=ul58Zi6E~AF<-(jF? zs@5H4Z&-A`21GR1FsOu0V^gL7<=Fea&9429x*f|4ct? zr6BxTC*;G2sh)vVV38mC7QX_nnD&)V=!weUFN+QG57;r-bPUB(PC#!fH^w&Y;9e&( zFr?ThBgRZiFVRY(-J;dm2J7Hzi)bve3jiROD(cHQo2#wT+~?1G$_r?`fDJ$N`I9gH za8|XZk5(ufv~z2-{<6fWdsHWeWJ0NnA?svLGoivx?lq^42%am>)WUeZzoQ4XFBQxD z&1fzL*1Vk7E3TdlvbwYa9ODBhJO4ji2pm(i_Je}U-O?9a&5g2e2Ap&#do4pYFcQu6 zm5EO`s^EWSVFJ@L%^odg&3gQgi9lNLRRCx6LjPyKGbJ zyWU?-Rl)}17=r3>rouE%TPdYwk#CRs?|(j&PA@v$v1Dzb=G7fxxel=!lk%@X);7w` z1ukywM#CR(&JR@77=suEZifb5AA8dA9d=BeOzS{AU{OeVQW8nUGmczpn&C=1^ zsYUfg%40{-+ZGYru9Xl`Z{_9b{B+8@JQ>Frw!cE&4B!n<7mCK_Rdc_X;7?iziFe6U zPp;#+A0D(5+X_CNTo3_<*RP4T?Txc(k_s({x?=r~<>Lsf_T?J3pwoZ2-WziNI}STB zYq%UZ898i`X~RboE#vSymEy8+45;an(McXIs}wQ3Gs+~fk38`Z-dls!Bd)Fgi{JEB zEF+{8&0{2tA1W!co7FgzDzZ3AB5m*NtLXGAOA*jCWKB8c$8aUS$ZW!89je}QrVfAn zoYVILz4cmXqS2NAT^ET*d$_j&JrWCSK`xvf;>>T`wU@hq!|U91dQ^J?93#M&VHzw; z{(Euv5aT}nIFd!rw~E>@B_z)g()LCoxU&9OTa<*RKS!o{HZy%md0O5HP-Z+bLU(bH#MRs6bzGQ1#FpzVMb%|4mT zoNAuA=a}XO8^`TwEUxb2!pQ!Myo;nag*iX=N zK0cT`znYH|80Cjw<4jQVO`4puvl_wN0fsMVoZE5xC#+*XgQd-|8_{jbvi&gRyC~{P z{QV`z%WvR9zK=`;Ri0bpggF@)ixWPYBy0)W>K^xNUzD(5;S2X{bpYiE7@fZOBfTZ$ z(uKQnGE%G9MWq}`4F}TZP6cgEY;Q_|>~SPDbTRNpDQ{D-SD5C(H1^}*kW2jp(hvL* zpQSC(eCgML?fMwF6Byt)9!2klmv6r3j4q`{W$~OOJuv+P$E`Eh$m*~H8j$$rH^4c zQ3yBs3kDIBRf%z@md@L6dp~54KYx2j$(CJk1H^`(k0&Zo4wy86Qs#cwIXMbl(m}P< zYFnyvb#djAm=zlx-adl?RFZaox&bdDoY{wg3H>Bu^%!V?qVdFVgEw{(uM~mJx>9Q9 zI07&<_&G*U%I^2-_vcp{pemPKeBzW%Yx)S64)1$;K&Bb`Zw?>b{H7#4Tw^(4XL$be zedzDM2EO|L>ev2jtp{pg@V~(UaQ_Q1g8vC2kmb!?g7fT z)p~HKw)XSiUk~n!xs_G36LjA+j#2M+pH`paKbIFQ?WlcwF7*-zuIcXG2gZ}L`2%cw80EeFUNhjg;yUGw~Q`CevE45yr|t^e={oCLFKHyMRefkg^0{Wju`!|7 z%|23(X!zqQ^g3Jv1WW^>0S)+@Wmcjm-qZec08{IC)J1U3{-fry6pfuWHktmYZ8 zh@1OMsrhk?bOHkTk1#<_pknwdBDI0*IT61(xvRvS<5Qa}+CbTF80a}HM+FxB{- z%ZYo>wc~U{sLhZgmuLO;Xq~9{%B11wC1%55**<*IOrZYLI*P7q3zKR<-|n8P^+O7n zbR^R5cGVmUeg$;%^$eErlm-F+2;S$u_J2xG&qPB-CDHDD6CdPL15cTVj%dcly7@BF zf_^N(TT4igsg8n+V4#FZZhV^}hwTa)FeYDQo&KIn7=sBHPsUjApCHPAL^shX-UQ)_ zAV762S4>VC;B>#d@>-F@H@!MsIQ-C~(ds^}DWgpG5c;{r2g0rLyXQ$0Lh|Gs6=?p^ z8>PoQDuMzp_Y{R5c*Ndm>@+bfcRLBS4gW|99N6V~v&yb--D;;2d@%7K2yZD6PPFNF zUEs4m4c553WwN~ERQaQ&gp{;A+i_yPV3H{6ToyX}yGQroD#a)EH_?Dlr^zSVDuwVn z$!Vhx22ygl*3L!e0eBS%rL3y9T~HDkQ-Y2bjw>Md7=8`Mi)(4Lo3`EWus&}VO7J@q ziRE14+=|K+-Me!9wiqleDaT)!jj+9ABFzxnG6u7#zHgMmBGBaxTf=%;h#UM%O}P2o z!RQZzt(!4W@b#4?B48hSR{mVpfOYey+UdCN{?RSNyPBk%%tC$Hlr%K zS+Y92q2mEN|4?sH{xJOb>Vu`kAaz8nyb&wY zXwoq&;tPS4iqU&Syb%n&e{z}gJqtRzzMmM^!(viHTkoWUooM0Y~qsWhsvZdTR zJucd&W_FGl{moeXwA&`NJzY4&Yg&%htp?ac2o_-Aqz#Q#Vhk zJ@VFtdPqPQTHHKKCpu3*U@YHUVb%};X@4oYG9fG{a%~cQzVdTzvXA&tkS-(4bDjV6 z0{AYfb3$)uGqrWSe#ZS6T!?|}{O)p8)&1(c`$y<2=*e7oVVTYGQH$RLgnS`PKh7wfGS-G>)?6VaE)nJ5pKgdj zH@CbKTap|7VDzIx&MvK9AhF$5>PUafqq)YDwtBPtk98`pZ@jAl(@|u)=QP53LcS2{ z)~`9nH5tJh(olDM%Tg4}Hc_@M>ZO;mSeQ~HIelL?NiGZArDqo+ew$*8$NBIvRt&W| zmC*E#x(HNwRJS-OR4mJM;7vc~eL(a);Ap#kdPUAFohXa>6cpi9LppFH7GGBysc@?OEf4o^&G~QVm+vO z{Y`AP?@m7S%_ri?Oa*qmMZRJe-=3mk!w$2dqYMOUjMR~$>{=F?dq~_z1`m_*q!D67 zcj$++0FUHEp%_dXc3r=7K{uo=k>s@`3Y7p+Nc?xEzvw31H*qw)&F(OwSX#_?D>sZ@ zovra@6W4FUh<^FMY*EliAV6Q|>w^?jCID%6nwF0wa!G075!&G&FXPksT^06H&Ngp% z@pHqM+Yc}vXD9Aw9P|T6y-egt9R2;BT=y85TJs0{N}FBn-w;dd>z;=>D>iAqvAoG< znY{jNkO|f^4B#)Io0YrHm9~PNhIsdOwpf!~!4*U7W5cgaLSqx4u}e0_TIBu4&#qo;`+wuVZUY5?i?DYMU&6 zr83&GSLBoCKyPdInl{%&kUi^Ho{s-esBm}d9Z2l&fcx9ks}hetJLIM6?*IH%g(aBp z!=ahB2C=%r@=$J&+O4l9Tl>sYM6sr@#~5smYrj8=*2tls*+`2z5;Dz~%RO?LV_kSL zX0z-R6zgn)w@IdD@2iKI-9 zsMhrE4hmPx|D3*;Nt7V@gtZJ_-qTr}@6@t9UgJAaWy$xK*a)ec9&E~&XJiQ!=a|F? zjcAGUH6|R8v2JAj%FcDL?=jWF=Q7E|01()g$Vb_WF$u#-R7gJrA-E}0_M01X2=DG`%a2(l{o z(2v``weYi#*lsF_-u6Z7_VN6cg9sU(Z6Be`sbNThe7MnZ-l4wrc`XTV5dcy4a>~2% z5r0sU?w3$bNviG`im!Bv@PGY$gQabZ&h6IbcFFd|+YJ)GKzSth)q)SAV=ds{kjUr9 zVxXq-d=ia}3F&Bml)C7mu!Jt7;GwJDiEc(??erG-PhHViPEo94s!nJBqZz4N17=W5WAH1vEsW$r{qR>KqJ#XFbj?)cBl zAL5m2Vz>$lw%JGOH?=A-1sR%A?5}N$tSmC*DE*o(T+#_NXY24@PHVEbtc(t4q8r=8 zLofCld^$Sm>-iNGl@hY>c!cnxGb$soDH$2C8!vmues;}CRBu?m2ySwhAPbs^ya|j*|MDKd%?%5=`ElYs5dP+&xyae=WX= z%Mrw|JC%&umA;LZ?tZBd7iHFCinhY;Ax+>Ckrkq!u#i$$J!>j2^ykIrFPY4a6BZa% zl`C^~z)YxpO|Ktcp=?c32XTlAy~_q~&yk8t;~uf-Jt=`Oe>0XGSXe;MxK9vxAtMMi zuCq%R2NMgQC3<->?$B5)JZS{K=8Q>M{*xaWokbrfItHP-vbK;N{@`id`FTt%kwl+z z4t{Jbl?TSSL9I~*$4~FHY3grWIOFeeDR_w%c?~C;2^$}%8>F&D(#i&G9=)l7&|AxJ z|9@PYS9q1`-+yb$_JjTEqRtJEuXgK-_=vz}d;0Zt$dR?N%ya-&9IL0)y4>}$A_?5n zLjv1BU60hF#pJ4kK5*R1(yF*Ut8bpn(sFBDQc8<_I(9`%nsnbJoZhw?*)5VD}mp z$QEXkv0aM>KhSmBVCKC&r#X>v=UzMwyP-Kd`$}2q@hRK9JIU6ARjFliJ(^TuJsz)& z;t=e0#3M+IP40&Zd(1Y{OiJ{RTUPh@W4H~8R+9hFMMh&Ri}AQ$2Wpb3pHZ5>QUP7u zJ{+%+sHWD(@~X)O^li3CR=hJ`5oz{EmNE^)z5WxMugjbhAjG1`h)z17m9wA&-?@5dH;nXyVrVdqC+ciQ~( zK30^5UaR#r_N{Rs&!JG2(kw4GKZ{RL5Ypzm9JQJn=;0TZhufp?aLcsoc7&Ffu##a~ z-65|9MpE1_28iMENo1|-Y;%FjbN7Dg{R(<&WU*FMDKfntfkCHUei0|A&zI}1h zIpSY&K2vm^`A85u9(E|Yz!Tz#yYabwyHya=^K3*0{M2t#oca_MfATbXxt36LJ(4}* z@~Opbu>LSB1ism)qr!4GGeBA{+J{1@q3#H?6xkf25W2VZI^~Z znEXAAoCawQxh^9a+uOiu#hVIfgc2C`DswGxwT29_f zT`crOiX$%@;PhUYe_vg)JyCOaqH`bc`*jX}+Hg3m8RPcdP(4_x#Fbyy&sDOFXPqnH zI>dJ-!p5$7xh)9=M04f6#`X`yN})k4*+4W|?<&DP#ySoQ>cOBee@IhzpdZvE<+>FMgO zs;;$at#{Qz5kPqTlL(s#Olabcj`+PL`J=(k)1MPo;a)!2c53Q(GHP}}3DrBD36iDJsi-wyFKUrpJ`;_7i|<5X(>Lek*gd;F>knlAA?*b>v#I=H1qj!1_ElW|D1P78u*k{AG-3wiy~YnoIK}e(U1C zg}l!75Gys3-$%^os4J;b59fJ9Yn$#M=S85ZiAlzJYXyg5Xdkbf8MP{GoTWwm=!%`k zdC5;R&TzG}diwjsBL_S6>eeKYlCr))(xhdkr%LP?0OKFDkKDuSh3`(ytpE^EGr)(E3wuk^C{%9X3C0rFEjcg zC>t;r=->3HykVrbKn3)Rs<1Oe91GrR zh%_-5#6*M3p>F_Xbj~6}>m#u6T_nPOe;P|)9*dBPBA9_&J5d03KHm-A=UF??(nSGa zB$1)UasF1?7bVk^>@RjFNk#;dfBtYO{O&PkVQk?#%n^HAY~hvL-m^n{vU|&`TN7Kf zv1GpK#_FM!9wu7Wvrbf*bsZBsjuR89sq!$9f5ujW^duq~FRu-N(aR9xw@~X%9s4P# z3)v0OJSMM8Bk-}m*BoWpdENQ$37#CD@}0d8f;Gb5o3CNdm=MH)c!FOBJfOC#Se7uJ zq0R@e@QPAJGo^MgDy2(EkU3pfuh%YxOq0)*sfxf5FB;vdrSe)7+g0G(zb$GHYt@R; zCnw$^!d%0LS!{=AS-$S69(wS`)9x`}JCG*@@?;kk!rnkH-~1>~H^VaOfQh4v+ojY@ zN=RJUUwdXpHT{CGbT?c{KO#(4mNbr{8h1w5g4Um>FYAGxe)PPPK^Y2g#Q@>)@T^i8& z)+75H{@#7oPxghU+?Y^}Om#CgS`Y~SPg?*zv@U_}DswRlrg&&ZNuDGY4Hq)wBT~;a z9*T4aX+3trd#c!Qhitj&K1I|;)gQGC2Ig$UDbL&V&O%{Cglk~W{meHSk%8vv-)_bW zSGDA6=7&c^eZ@dlUgoisq$jjPFZa4oO%>~{h#@AmwxypJ*Dqk13r1i2^n)!-b-*y7 z$L^X(u^*~tr%OSd%-TB02OQ7+30F@6I!A(KIAI~|mU<@T;mlFOUDqNUTbO2F+94%S zAZ_9ddK#!iMfeSqw|CAz$a^bfa zk4{1wmcO`8D2W=rm8b+!+FV>vUf&g!8+CnJz)d_=mQhz7k-EcCMBPyLiXp!=Szr1y2Ujj*8RO=$xO0w65D7 zvXvPQ25uzIyxS#En{P+;$tyszKaG_bm>V@bS$>sxV%SBfdHDs(N?ATB^1gWG(XcOS z;Mt7r01qS>3W7o#hXYyG+|zs;py*f>mI8avMcm)j@=nVPP^DgM!4pU}Az{NoJ%q)O z9Cm2&=!2oCkg1cy5zM`;F;!CIOb9M&PD~xNQUFMca&Wkk11JWeA=CDZzNJ)!H9B3OiFkWv9CD0KL+>Dok9Wg18U3R$!u7 zUPYLU$UHD6J$pu&JUDk%o}KI$>WG4ffZ+?5x(Eo?FJ<|cC)~pL zSrlLwQ?22Y?N&7>nd*yvaCMmM8Cnw6OA?Q~d-*2GhQH<1XjytB(k7Be8N#^}9>Tp|so!M3|2Olmt*^Fd$Ybx+2(*A&Id zDa43xZj&F0D3Kwu+V&Fucy)`{7CD!IXR02RL}bWTuo6cQZ+ZV_W2yWolxp%@W>^~ zI;Vv4)<^Fk%+oK~Gw@*S>7N18l%^?|`wnu;)Ra}m&uSLg4%o`D`_7|uMN^g-2^oB^ zc2A6te5Y%E|d@`aY$Pmc<)Y zT5YW{p_xcLPSGttl0Q>j!P;+p+Rh8b4IWZTwE^$_syC(kuuq3M(*yE(U0m`58(-c= z2!zoA@85$XE|+3v<9B#Hwua(EK_DGo2tOmAe*Y#%_xhZ}(|tu`BlDiYRKK|wa*}No zyhRN3K6fz-++~S{hw{XPnD)oFw0DlATr)c?C$zLv{&Wz)a=^e;m6nP$O;MVoDwsj5 zq0rA0{PCq(D=2EkcjvqF_GhV0i7};WUD%xPCN+Bai!4og0u!k@GGV#2dlr%+4E;j3 z)uK^O+kAOkty=&ryJopqTyix_6XM)-!+oiDejS62ZSR>$sA8mRXkfpv@A`DP-uVS4OIc43Xl z?kTQf_hq|z=Vn(!%1^OF6xUm=$QnDGJf6i>-@5+l5vjk~Jxe!I>>H1NRXQr^Pghju z#r{eQ<&k3?`LreVODg#EK2X#q?Snmym|S<{aJn02Daq5Ayt7%;YOJ_czxelG>$Zto z?G#?QDV7fm;2C0SsMQRJ!|%j4au)}8J98+?>Z=RI5H3L@8MJQ9ry5!Kp*8FB&5%)J zvS>!y?nA;KGo`cl2t-={OfMLQEVU^jn${8&>(}o9HTsW%^xz(x)Asw>Rn<`C{Y1X2 z0L<74SSXf&C)P?y@^@LogJ1XVg>%W;WgFONLZi$A2SVqVEOnoFon)>Eo%cI&uPF_G zNAvBnRd)Y&n+|4U3TUO2Htl*f)xKqrBh&k#T%Pr$^U*aU6%v&|HA~d^ZibDin=%pU zagDGt5=|dF(fY=Fw13>-zAsizUFdofN{9B1z9$CC1oWo8K?H}s%ldilEcP>N6ERe? zq5D{EkmhJ^00kig=ieFp`@(3mi=SM&nb05u(&sXeH(Z*y0hQ(R9L=b9pH$N6`)+=m z^dLWh_5CJBks*@k8Jb+{y64}{mhJe*Mx=I(6HKu&unNSjQ&Riip*r|aaI@d2b#=Xq z1^eVgRT+>4=K8z#lN&Qv5*p!czXf-IE&Kv}^PLYi$Q2hUH)BoC4ou?1*Ks|Lrc}Nj zQa;z|@K0$I!h_*b-S*iccS-wgf~&dj=*}vcvc-3W<#ENM=7?DqetSf&b!-y%a$j~g zJoj&Mvk(Rj5=S>zuIskk>dstIBM@2s+gYf9H#9Atu+h1aRy|oI$7CX~D3N6cE>^s} z!C*WZkEaQleh&_O##kPPrdT+vexa+odWc17?Ea;@bm(XzZqVf5{U*C9@QwjLkIm0M zZ2-Cr2zT|l8!s#r%ha6Q-wqP#N9QN_^b0_xB3Hzt>g@!M%}GeeJ6ur%OPA<5yl#O= zax;QS$3#7e)_x$2T;6hf!V?LfYyVEbg}ijqfvJDoZ;W zu;M7&jRce&wpyn$-H;U>zLK|GQXU_fR{EIh+?ct@MAvHnIiYfs-S@axQN8z=@OoOi z%RrCb)#ufrlZ^G(bQAo6pXyX~%~B_)P@@RZ; zRol{aZjq?-*TY=+u6*J@mmK_f>=r^3@wQwJP*AtS(db&dLW6p%<$`RHY7qsZOsIS) zi}4VwU%kLYXd)g9UTS63L#1NkGbP88ja(SQB8_ldI20b`cbZ`xk2-X?6Qi(Esrvzr zsQX0TnxSfVB78#mt5!rsNavQAGC2a^k}4l)JpcaC{Giy^4uUvw-ki(Cx5rcF?g~vn zS50eFLjrhrt|WsiOWT20hJG{ni>j;bKBZldj@AsGQ)uPLh>qAojdO0h<#EGH`WE)7 zwTjxOyAi|1cAKld=2Fv)wVOiVgy*FhWR2R^{_uAP%?Y)BQWV1@hYCdzi7|z}j$pOh zKFsY}q`tLJ7IB~teMhJ-i)kH{WOL^+tW#@9F*e@yq?y7YJhYB!Xmv7p)-YkQOn4%K zieMcgjfDJbA_u2q!@^<~BetjycLtp)x0@MKJKLkLQFxe8SH8=}H0`^-`B?4!0=J$1 z@+|<Mmr^zbPjTrYqTzb>HH^(12V+lEwTF5+9O1P z%|+i;Jihy`gp|AV_rR^j)nn{-Mzly9KR)WnAs zu~{63eOvm}#zx0UA5!*XVjcr%^v!g+uKOf#>xCjMwxL;lY>>h3Z}njnS7z(`RE>+# z#;-r;uE@{=DD=pBVPxfx`yX#xntasOg|-JupLB)U-6uh%B^0+<^A#NV_17780a@CD zD_>i)O2prM+!Q-)inlwt%yJ_iTw?$@T0`D0G?^88OdlE~iz8#YtP;|`qA3r(RX!(y z+RX4CP^fq8hc@TA#oZGfNp`efga8c%(_+@GCD zSJr!QhzoY&@JSW>^EC}}2_rd^_GX(NnwHiQ2$CnY0ozwUa=r2Vw;w%_LIWmpxbAcL zlbMu@Q1Zz(6A$esX@r%uvL<|M|8SQSj{#O`-rHu}-1?1dDjtqge6Byw{AB0dWOu>n zW*4g*8k2tS>?f2(YO-d`!r);{DBM2asmN^G6dA$jF{|1jzL|=5;^t?nD>g&wh51!V z0MY*q+=x4wm0<24igd4MWSu>Pt~J;VRb~+EnT0ORMuDJ9JXRZCuVaM_KGTId$_8wO9u?-9r0*? zKT_~n(R*A@%hErf12xCKA4{wW-@5e=Y7OF*6liXa>uNfV%*aj8&}Qa?<*!cl)L(6R z5CE(4=`m+Lk8tJ~hr-*llStyQo zWC-dw)J6s|Gem+nE1RippL24NUouP@Yn z_K%|@z2sMF-FF>qhBxNG5qMb6*NUH0w8&>2s#+9X4hMr z6qVD6_Ulz4312}3#^f`aj?$?q^X~lbZPz>Y7xvE4Fkc|7g!sQS_;B}$?$8naI*%LH zaLgr~G`#yNJ145(ASebQB*!s=GX}6pTq{LiKvW{{!ujI}K+L4*u0WV{u*V|uM-4N2 z?)AHn;h9V}vjr2?A|{zA5s!K#ERrBEe3Mhx5DjAo*WCZk~j@!imIHEx4&xo10P~k(0kz4Q?y3%TbgSk~oWZZi4qMW!s+xc72*J^5@d*5a6 z#`4oOf#J}c@#C@gJ|ItPU(923p`#k>w*E`E#qE=7En7YL-GG%Ju)+6&r(@7(+2_p1 zCpBP7baPX5UU9mc+WJ=yikJGp1)TQ498U?9Ak94`*J$Y)kGBrXY}ek?I3X?sB_*Yb zQvIhbV1+FIS%gP_iJr?z=&>v}9k2RZ6i;>81X-4iSeP6_AguJ)eWlzs^^r6_SEsA=n{j=;e{y@5bPl}Rxk zLXfSgr{0bJMupXT&6)8d2Oc>jJXQSO$arq4&`v+;jYKQyK3)IM{H!jd2b*2di*ReR zhxylmk6Y=xdXC`((ar~xb}L^FDo8#C(!Lc^^&{BgCDhKJ{OCUDo_TLVeX+o)Q?%(! z%4@yWzm5 z98H#1`x(urRfFowllNJoWh~zFznsfThlQwv`t+)2unGth-H(_{X49&$dNNz?;3&i& z*1u!jpGa%m)6@MzlyMK_=He7|6jf|eU*W*1LdObN8N@-K5%}5e!N`*_xB``yk)Q$ zOEJIevb4y`@%Fa3pf3g0-;8(b-dptSi8;_Fqh^-;U=OWR4qlNdVT-a|d?N5Vv#q7{ zU~^QshU*k7Iu;-Y(Yq-V+dbw_Yn1xG8f~xC6$rKaKhwRc^-)Puf1J=O`-U45SlO;@ z*yLRt5B;%BlzJH{ctxYDzf#k_T-<7Sa-x3)+EwsVlD6@b+0?hjhEtmFcE>MKg$w@-8X}n77s zHKFC9(wfM;f;iJxeIPTn)6%@H0DQx62tw6NPYhSt(K}u)R-J`gbZ{d72`?2lvvyZs8RUg`R4tMYp>22L(#YUD3KGMVM zm(sQJ(myI>548SyPM1J@GPj3opLehP*L`Ldr53Q@6(QIgvy5V{%nY~9_k=DKxTJ{g zQ8ov}K+YLVzY@Rr0w=J0=Ix6&nyUBU8L1Ke82r}GbO$h_b4yrQ&8UcZdBbzxAiN$# zfmiTPsdD(o!uR+1&mLR~{6|R({cHI8r&v=I{7rfFPliW&C7B4X^(p@cy17^*Nes2>PFwdyp`j_k`Dd%<=ct{iiI7{Ii(ha!fz}EH*d**|IMKRf2spq^+Msl3E)GrV$cau;TBEA zmZJZ-y8r*ArZ(!g_9B-?=3ULb_jror$|TUUUQXtHmNe0XGyVCRn-yD`{?uYjDV*DG|z10a3B zBXGZ0=(-G`S1j~^Dc*kP2jPO^H@}0%jv|l@;0yS4(@g(jwMQ8X7Yq53NA0=MJ?(Td zbRis@3%*(0-{QYg+4%w!RHO+7Vz&0IJhv9ix4>Af79sidypSgd`>s>~EnX~g217+A zm%1|9TWGHslkam}OIToA=AF&BoFon!&#j`n_xi6!Wsp(!%X0T!h+v6KbS31O>xQ+3 zm*SVJ`gU~*?9L~KDW>`O0(4A~tVHx5gVcW|PyYqn2RnM#_j1c7$sw%KYfvB{>+ZB$ znY~S6szq3+Gm~SiKI1IH*PYp;B9<=?>b4Evc?aA=Be0LfQqF*x6e+xVc)C+~eTjK} zP1)&p`}X-5ER#rr4%=?j>y!DEQ@ae5cz>gG_{&zJNpT>23t;!`i1p9-Tcs!z9?uf; zI(zDqU8vBdsTv<2w_Qz{!p#_H6^f=oAfkAn-8hFiq}>0cc!9YG&lSHP#_!$xBK&|% zkJvEIGxAAv zgipO&R7ZHWmoXe;>`df_WjAL3+(%O%iDHJ}g2&Xz+>%B5Hj1$Di{lEt)_I4H+WYGz z^klY2x%3YR92nQKe;h&^y((pAOV&N9HCrqg*-`wAeD0Ro2ygO8(CXl$aLDd3`WOXO z>WI-n>TA1BR~Mw9EQj?N+jWHb)5C0iubJ2iRGdeVXgv3%cPohA*{e4f$Tjx-iE|q6 zsep$7*uh(**4a3R2eRcPM8AQ{tz458;^=PNQ0Z++@}66^4rIe9Y< z>4zThZd!D(V*9kfBU_;`*?o8a)=r_Xv>58=fV$1qnQlCK^uX1U$eSI~Qy%4tlQ?F9 zJOJQwtBt!=Y3^kJg(tJ!t=$4}Dy0Ndrfl&FRCx|SX)IkAntX_0OSx8U1EHPzRYS)9 zX(!w1;@bT-os1v0I)tvxixQ8wa>#IB;-376Lark7S4PyF*GiO+B8UCEKDO( z`0_njXKKcE-M0C48xbh!6n3iDgYf$s&=!#DDSP7ndYBM65XSY}c6i2rYzS3H_Op>A z{^6Kp$0yseC-s55E(?@jGV|&H<4wO&!1Ecpj3iDLYK*f?OZ$E&mdN8UZdYi99d#2=_y#Ell@>;tIa8ru#N{i=(XKbmVCQ$bAXS?k!EUgzu6sB# z;9?~Y_#~R-rs5%?@jX^{+2PJ6wLN7Vg~qvRLmWUGfgSl?BwH5$5=B)%rN0}dhItKH z(BBD_x|~XnE=h~>B+#IoLDf_d|7G<4zvhj;aljqLiz<2gVHxueb(w&D@S(!rrg@qr z)>_LLn2~Q%)qgKqS@HNW4%l_mlmD_Q+7_x@2Yx7p#|ptk0!P#QnoIdr_guw@v$o~z z>B(n-s2V3SD>Lv1_|KDB|458KaedR#%;cxeq^%(RR@a+L+M^bc=^-RyWVp)Hd6cXP z-=`8T_5!Uk2+cV#*xXM^fPZJw1I2%YpTXc3K>2lOmlGZQJ8}GfB*ULcUiZC_UY11o zSI-!KwCkngQmewV>Tka@GO zd=H(a!!8;f@%JI%xy#Er))V{BZ*~21{mwgo3PspN{(92q@uAl4E|0C#p9Q* zJ(%2+NQ1?S;kp8OwOeiPnL7uCZFOKPhp8+}`p`HIM>y@w_E>r))?4H^a8`f#2InvZ$G>zF z1|W9pT{O~BvM{T4uHVqoCwFu>4y-Fh#OZfZ+3fT-ER#BLeQ8EnU%;%_n?dXPe7e!3KQLDD-o!u1s z<&H4u@g_@c&8lWBySq_)k+Kj3^}1K`2=&#wP8)^gT*IDbvM4qnY3|b>_lP6hC$W4V zBOq4nA+;zWz{p4NwdpSuUo}L8zq%AgT+!%i-Ewc)iQ6r!GU3ORb99W7$|YT`PPxHP zw*<;@`UWoT*E61n{*L49|7i;lJ|VpMlOR{zL9j>;y!P}GpQR&UQO47s8Whjt3?Dqh zdmHQ@%XnQcB3YB%NH!Km9)D*$OS+6rzm6zjX8jj{6vgy$oQH_IF=GkAnDhz9<752h z`Mtf)k86du`-J#gAi&%#C?X=pyITFHnG-_kismmw=(GYe=VI|Xlh38@y;M~RMhpq! z-th4RstOCzds`~fJxpjb?zKB@gHn6PsaOczPuInj2=RTl3B3|K=zSev&B8I2%-5=ioPBXc=zxTk zQdiswfkj(Nh%JsKTL;9dqx(cZ830Ken|RxPn*%}aT$Hj}XuqXzh;2gF%cEct$eWpT zF5M(X`+SUmQ2KYcl5tXTtg{cF6TEogBF@9%=xcCGpl7(rj;Axa@9}Dl$O9$@!R2H# z1YS!13*pV0%~e=4$>KjCUkqpX|7=HE(nhNZMmCmldiO4BP4Q0XFzuZaYsawfh+|q( z_;DG%gARUt4D0(F1DuwkHe802yn@^JM*+wPviu5enWRBeAz>MdeP<&q-VQEgcE`lX zvixjr&SCs=FJr_#UV~s!zVcGcnC;nxX3m=>l4()=>^{po-jxuKj=aNnoBSq3-ciA% zg^8Jr!_l64{1@%?6nD3*@}PLw;~0D9%vms@eAtfxpymrPk%c=$^YsDz-K^;oFRVnr zQK&~#d?yUZ8XxiLEdNphzo7NO{c4Z0U?Nk-qw}sO54d*!y}rxBvV5`?#D}fx-F){`$*I4hfSFG#}&j4M4JP$hPbw z_?`t-MLEv;1trO7+MzGD?Sy)klQUEBj>ZPLB;iwN2T zw&zvzQN-XfGY*3nZlPZYox^}rcWZ|-l-^aj7PsvupZgkb~cN-jkvKT_dmk9X>=3Q4=4a#_r-+rKseJHIOr^ z>Wyw9`%m!b$gy+cG9PXB5UNkb@_^vCH)-okd>LFlTI~47sQy*?)!Gr7A6~v>W^yv| z)u=nKkMB(Ied8hg;+;$$GR>ATSJ1j*DzTFLx}jW|MjLG#@==0?!;Z^fS!6^_B2IiY zfB!Lk`*v<^&>Ux^46J!}O`^d+Yu~)a0x_0VxuqyYWC!Nl0h`$xODfs-T^Uk}vcI9q z%{qrYD6|L0T#;@0RbtrVyrHNY;~d7&6OUqGz2#y7WZN z4|<;Z2SPh;wnsYm_s3k7*n+9DLBbvP%@S#vVsEwC$AfV6Y)?YBp8Yx6iUhhM4!^2! zH~faHI~6N7C;~cXUOc}a>^uYQ>F=ggm5=#43&A5H%9Jwc_6+LF=OIYImN;lbp-j_h zvw`84n@mLoC7^vXy94#<$`Vi*S-bAPLvz5y1r_C0+%hChOYaXPsw!u0RMCPD8ep2J zONV!wJuc9fTXtZhA5tz^p2cDG%2kaC^`^lJY|Ixgp{Kddq;F{xzL9`GnGi^ZqpRNI z?OS#9vS41F_Ncv&Qjf^Vg!12w@PBG>%mkdb0GF)~-YA#*86WnSLT~I;Jb<`mPi5rL zsEWgOKi`&{s0r=v6DA6%ldHD``@y6*(rZp2Jm7C@Ogzo+;{F6!Zu!u+XGhuir7L@R zuxDlA@%w-{1X_^uG*f7*Nc(ViH71gWk})Qn7&PI#>ZSC&*S77}4?Qf_=zs+*4zw3R zuP!~obfPFyP4+Lkf0HV!xBu}o`5NoOgxOD>X_H>v(X%|;fPk>~0N6O-Oq^s*ce&jT z`Gp>MBjRwbQ}zC7RDr_vfPn5fZ8pU6A(_aps%^s;T#W~rWihe3rgI=$L4zdf+AZo0F3W(Q@e$B+W zS;bHGe!>;aQI8DfIZ#}!6rS4C`2J!DQncCWydu=V+mb_o5g5gcgol>j6Ly}~?9hz& zA?z=OXg=}h`oLu2p1DN;H^Q<5!@+s_L39j`bXKvNfYYrvRhB$Vx7585i0DKYU^|^` zY#zsjO@PJIl4r*+LSA=Pfl)J*gYGqhRK&>q1KTJyj&z|B zJljKgUVa-7x5&YDM_zT!7LrS_YL;z1!}165U6zx$4%Ehz=RWq^60B`A2T`q=X&kw{ z7YIp9&B!3}#`SL*d?;}|SAv0%)0Zn|6<9rg}wt`n2<09Bz64)&-q1M@w*HQMs=|d+}FW z3&Na5X@e3Vxxj8^W#qOl*rFzFbN`bj>YK3n+WjZM+F$&Ucsz@abW?ErHCkWmiq^ry zTsyJfld^*?UZS`i9;_na?0KU2TEE;WpCzz-iDAwGMfixQz?Buh13QSPOegsS3Q#ag$ELVD7 zh)54|IU}kxN_vNtz)Uyh-ia}WPF+Bt9~_Yep0@-eCu&NTw@|Cdk0#_>Kto&2_q7_K z;OH!kjN7UB2N8UwAc#HzC zVOoyODjRJ(5XAP7-bWlCBontZU6h~11cxqdHGr;P9aZtNrJ)e12b1q^9r79pfj8yxbZT|WXu#br14_P4HS{Ak`7thY!MpbcE zx`?gsi>&nYhd9^(DXhR#DvC+rs1rl`Chp7_oluTR{a$;W_!8QvDh;D!Ju89|t{k~9 z{(K~;YfUdU>2x@s0+6gMX+&jup91T-QA5HdP>+T0vsg(lnV^6!n(eWqsI0G_O%CSoWp1lB zBFm7b$C6^z%qf1(o<3I@^>0JD37sRr={7zEp^4pP8L53G{;^^ILG^yVWW#X_*=1fn7lpL1tt7qT+#VxPd?u z_?paS&g^9X-Ek>t_%2H3N82|hp6hPLFLsGK!jxnu`oLLu>|1mlr%dX=+YzwMH z0s||EmR$@82eXs9fgsm}S84U_vU#Bb#gQ@+{q$yHenzL{+tquA`r!i)13$Q(JqvT~ z6IxM2a4pdAZ4X-(k|G+TOz1$5^$mV#2!C7-3;m6ANM#8y3YWVBbui{mI$P|s8pJ89 zkyn?;XIli7yFk~rv24zdE17(b=*egdpK#B-RMejvm(L3cT2xzK2_N(O&Nk98WoSmN z2BgPQ3gKd}-Lb!HeidEF_naxx;9ld1_w40@9#4^{2>rc2ecFL!O57t2P}HE=F z)$UgA5EC3K$@AlmIX1jAH_J3lYq&+<0VItgJ+{^*|A_Sa1E1?c2gi&SvxjYiN(da< zux=H_uodOninE7)m8^rJ-~-%$Gbo?*bYHlabPo~v>i-0gMhX3@&aR*H(f?nBTcb0C zP)FoExPi^sZ5@zCdWY(IcK`AVO`9b49Z2>C(Ggeys zBWOep0l{+?h#0yUV%Ge}tL>uF&u*(bbUcigWT5n#4RU{_t?H6c^@kR0SFcP-Ymp+S z>koOKd<$`#4xnAU&H-vB@J;Zi(VgZ6dg_cTj~o_|s92m{|Xh3j?f`;mt63)Gd@N?)nJ?-kQ~)Vds}zah){W z7f5Ph13}@yjIb|V!$zs%x-(BXR>nCOI3eNK*6AQ7~dC_FmV-*`vHy1H22d$9qsp z4V55%$`SI9?Rv{IvT|K_&%?G3;yW)^S5Ei#OvTE2f&ww(2Zc^1>Ja8|Pr9@}Bt78!9! zM8+ic78&2};oII3Ed>j*U{lb(eVoYu9ofdau|XHM<|Hjp;VP#(zL1!uX*iXn&|i%~i`nFg9F7Z- zMYbn>v%|vl%p-q-U687;hC;%EOvXV4Uj_b|bn2Pu)j}8O9Tg*!6z{NEMwQT8Z{vQ1 zT+iG<$Q7O~7yTJ|3#_2_z@cT8f(D>AMIEiy*j;wpe2 z$LZnviyiCY z+q9|P>N=NY^Z(1WzUEK;HCuEJvgEVI)@>V@8OdQ%mAMC-) zRI~}Gbujw#;w*sPX~DPELhtiC%3Pj619uK`qn=qF0_m_d@$t5V_=lgjU9`ZdRg)hB z5K%!I5O+-1*AK$mF&BUIu27bL%7}*YSJbYu~YFV{t8mvR&8s89lU_SpxWW*>cOy`4;iX+ZQ|u2iLxD?s&Km^EGR zRp!iGY^LEf?o+~W=Tl2y*-Uz1zFD@3=TBV_+3^iLI7!?;BV%oDMU*z~jS*qAuFwmA zLj+wFc=PcPnt}`NdNn97Z2S7lwhb*VgzgQ+zn%%T2b&hA**ciY#X2e0m>bJVklt5F z$8 z_ZP`MRbDH5kxuGkLaoP&ewjBr6KAdBs~giFeIysfJ6JZp+!_{%eBm{9jXMPYP**rv zyN8hj6i;n0Ab03j=K5_PDwLm!x1Md5Lb3`+$!#9WM*l@?0Yjys>(KM=^k!)EV;NcZ z{Nj%_-kR^PF@zOg_`&+kL~b=dnSP6a@$x@u*uj}QAyys60)+n` z;IPw|D;z!>{-~br4%jfKy0a|grQ@wzO^@R!%F0W()3%L|ew8Jji$IOm?u7Yiw+>Wa z+g)qPQ{NIy-dDjB`;F<|#>ud@;}2(_4}Tc9#y0f>gva6)kXy-w?2EQL7^sOX0Ciab z3+F67a3D5YwmbOpWa2C)!^Ag+24KcIoDQlIl>L>s!fVb4>g@vEM@A+uHdn8Z)A}hq zj|%SQQzB~$AkP#sz9OslV24$1X7=vC%V5d)c2|P$oxc$yj8#{KQZTSFN)XlH*0kvJ z>vj7SALk+CpGmGM5AUnPT7_*g$=?*&6L24EKil$qLi)d>77_V_pHdDY1;LJ2cy%xx zyE!!>ea{ryw6yH?qe5I{t$lnx>8>dA!h6)dw=5-6?3WzHo|BDD74ne~JgrC1hy~sx zJf&|6S`3Pux2nBxYBP7~i7l2@2BSqv(UkIUCz>cm3|8X5*k*r&63gg!OHwlfo9MoB$wtCC z@e&C4^{l#T)Me_3SV-jBIE8UZB(&|A)NG79-8^_s$(LRIO{(QukAEVo~{b`$fW*?UIVff4X%$83sGLA;zeY_6u zi2Pb7XERBQFYJd5pw?rltLhnCKWN_d?zBZe7D24bC2i!s!7Ymb$m6Ti`1$%hlUdC7<>FB>9Q?EV#37NXe+YFQ}#pO!&!ZQUg=N|r{%^M1NipHyqj_Ska^JO52 z?>gN%;yLTi9~mIQ(5n~^6ZGuMiH5%~ENetyfslV^7DVJf{^#Lx)4K!*(q>Sb1rt}6 z#1T|631ayvRB}WNiCw&0=Vz*k6bT{DzArWM)fp)VfYPAyfR_{}yE8ywBc_^>%u zGH;y)TiHs*RKFVS43)CFPg1RxGFWBd45*(+WE`jKEs-4?7atZyPGmpr*x<9&yq!in zb@NocHmFFBd2c5KRFwdUZPuQ&3I?-OKsLZiU zFS1$~8lT5ZzYfwox!71H=6NL$RpsVF<_EVFZ$t@d)NwAaf6pg95gFp+87U6h-B9gh zZ4{J&il{Ix$mJ+y4UQh3)W$d}_5S3X?yV7Z@@_z-=0dy{&I-p>a7%~X$_*@x6;>FE z#850PEk%=Zd;W~XoqprL2@e+egK%+e&sT>scG-fXXa3uA^SD43H6Z(-NpY5Jn2@w| zT-4{W#+qSyO@N;>iRBrCZPJbz60Z17c&3q$Xq9lE1@co?#5PYn6b|`|xy`|~i15i` zEu>y6wg#E(!1K5Zk*g80Emrjajc)>LpWkRVRtpq-T8R&?SK6E8=uLgrj-AkYTZtRe zZvbW(@Q=%Tq?>jNEjgth=hgn+A9eOnA--agn|)WL>|9fC-Q6=V#CuS96{uU(a(0qH z)nT}Ah97d6`kh1JFNrm;~;DTuRaA}ik`|=ab=MbIL(LXcD4^|<8}3(kk$zT0s&kFA)*RNs?DU(Yg3H4 zhv=@6WeXWwYL;8+U?fV*PVm+AcJpAy!RZoT-JSnK@^t_7RqQ8Y;qUdFT6^Davsp`k zp7UdU>ic?nQeq4<8a369T0iRyowA#Lj=WN=`6b9}H(uOrL#s;oncvzs65!Uj6e~g~ zNdGVbS+J?70}D_-9dPkq;pd#iR*q^n-ZK|QvKrIi1}up(jg&r{v8&af_H-?*7jx)z z%u2(gY%t{S`_k`!R8-n5>7CB5ak$=AiZ7BB0*muJMpUJHgD<;tDXQ5uGRUq+I@{|@ zD_Y|#yq}M>2YjzT1q@`DQ}Vg#i$34DZRc$7M?UNPuj$hvc936rTaUvDtiZ3&b-(rQ zo^s+H0)EyYg`=t-Ge2O;+ryaoK8OL*i>0S`w?~`UDIEwsUsXH$J&-goKw>raR|`Yf z;DaNU98}l$$Cia<^+Kx!>L)rf6Ks(5v;#)Bj`OHr@|w-TnM#{=zG>Lss`R&`k*2LT zZ6oyKi=B-x>??3WugpaX+|!DAg&T`)aO~TCda76FNLX;?d)e z^rnU3*dqwqL~Rg5PhNZ5%RikXcUt-T{~Jg36#C!5QLPNceB1awxv2lLli+l99hR*f zYSD}p%^oA{R%hYY9bmjP6a1kz0S^_zMta{?_qlo7-0^2E6oCBW^>u~OHsV4aUVLoE zUd=qC!?L1j^=BH5@n9sq6B}Z>Z^SIWS87dqC=83gMDEvd1PyeEcIUE8>}I)(7p1@n zy*~|9a9mH((GE}E&DMmvGA(XlR{n-Xh@#-Wnw`gD_m}g1T3x<}D)aD>P{|H{5GMNt zbBryv>sjkN)}JP?9&SVUEEI4z)g{N!0Lj+wef^4WW5=;-<`;*zy^AEbywG}TF?*Ej7E>Vr*Rp@n5SY7w?IIYOwd2Xp+YjCr>@tdc;91v4Y+lk%Z(+N2Zt`Lc zP8axlKL{s8^r)bZN}tr0VXsSbng1ZGgi=p}ts7!pEE5p1lOXT-@yu8WW=)U-{gu+4 zb+3MV(w3rCWQctdaN2J0bp0I*fO0whC`_o}AcN?SLXW8jr6KPh!_HsaeCgZ-eTNU2 zL+6LRf^M*Z2;-WeFLCY}k3!{coC47hfg2kf+S-y72bfkY8P;hD=O(^ zCWPJkBqy0v)zRO6)RI$Z8&|H9Ssj~Et?X>rlhC>G=Q?hqxHHla{MPpK%Y?a{Y|h}# z7ogL$8Gsv=`L}n*T92{1>!yvq{Q7LY)&x5*93)(pvB||5q$2MRCJ$u3FNJ(7Sp%LM zX1L#0Pk^%w1h822K7)rL{s{mEoIsnvzVXlYzq4%qx16L;hYL(^!O;BQ-A(@GGyTF- zh0B&z+?)*fMR9NT`?$ePGJsL}+V+Iu365zrPw;`5&kv9L%F!3A!vD>$BcFaDW(zIUJERc228=vTDy7?j-I=aeR*CQLV`r>}*kLW(tm_`m z+gVC&9v=bL8slci(0t7J=5qE>Y<_U#HFn@e0}W2?NbMOkA|6_rpW4SBhQ7U~Z55sa z4(5**XJ+rezh*KyVmrPYEFX0AKlMUNhb4mcLfm0iXlmg@19SITSVr|HD`&_F6_+ z`xge>Hf9!JHnVnf&}wwuw^f>rwRH3W#v0Z`BmtJtLO9NLhvk^}U;by>4;6cQ)i4`uhfjIzYd|!L0^6LmRS{wU9o>^p!05zqw-12`hqiB^OY^idF`bBb zz4$SHx{OmfPTL(arc6r+UD5!tvw0h^KOqYCvSe0;-L3@qkTKbrmeS~8h#}Anh%9Q5 zANvNcXvSd(f^~fvd?*fTWu|GhGjcq@l`h6TbG_)l7H?aw2~#XjE&LtAYV9X0KE(qY z1nwJ#0Z%88Jph0-z$y2&)13hm_v=$%viSu9SQQ5j3`xRfB^^1rKJG8lM3h!H%ioiE zTG2s(AjlEo+M*RCzmX+!E^FiC9CpDg_%;wy6RQq=y>F25=wHF8n|7bk4hslJ>_SAs z7)2M>%>swE^to?J>hSJA8bxrFTNs2+av$`v)s`Xi5@(D#$>@KBQXekDBsnt_e-{6| zHS{Y05@mWT9;u^Z^L%Xw{oaM6O(iO-nTP*T>*DRK@N-n9XCUPG(2uv_Evph>9@8G% z2ip|OmQo@tVA{?)>2C3mj=%7L$r}!>{maZ!3H1n#WaveGoz&WdU`#BPqVk^>tfE6R z-#+IRUQcm?*qf47@ZiU)Zfv0|PiLLWJ?EJ)DspOV^nt2B+E-Yz7F1FE?uM^+>%rg7 zpFYK+0}E)9zJL)O~$Tod}g2XzB06_0OD>0{55{+HR` zLGz#rw*Vjh^%7y>ef~-|K#MCz0QjXcly&e>)Q8#w4Rcz&bdC_{r~?BgKIwXg2D-I2 z6qYoQdD+G>CJ9M~_8%%YtvnDd^v<|!Y+)JXz@a$CKx5u;#Tt_?%XI5T8lT=#G3kmz zopVdNlNdCb)S~y39`3PFF0(GXoao+e|76>L$3fv}`RW3S3f0>edH})S@X+miHCaha zWu+l-FoK2lko5(?E$aGP&qTWPYTdHoE~jbgw3k0?(ZxLa?nyLGUHq26G`)$l*VxBl z82I+DKN8Vc4@{xLfE`;68k3Lloe(9mYoR$3EcN}|XNBH33J4&wQ!TVwUbGSZ0M5Ac zD%itT`4I3J-Ov5JVFn75{tS{MHlo-5j)vYd!aHtuI<7|(O0A2u5<_F!Us%wD~xfc&>1Au$&Tdb}`IV z*~5SWq}!Avc!za2_Y<%-*A_wQyMu$fGQi{eC-2esi*3lP9bq|{N*gFy?mzfjvA*Xp z$L6Z88a?IJJKv(1*vq2mRHSd0Udx_Kl9GwjTAKWc#PW`AE;x&iYNho|Y&3xmT+!*& z(@l^0%lro`uH3n8WAgB|Mvb(BtY1YK?FiYsD_UI5vQpKWOGzB>~5pzc%LU?gMw@`G~VZ$KAi~Hb5+th~$*p z72Xk>i=Tg#V#@w(yv++o1r6jE3_i3|KOKelq!*){e`E7h6^rJYJ05+>zp4(07tef$ z8%02H9lphtm|+Fqew?7h3!FxM(sCm#e**$lN9li+jb$Tb@jt@GTAhL`|ECo=UAeuE(;+>ZI0?(r*SUZ*~e#=Unzeo0%4{AnBXTbEbV zEs{KHFMf~+Ojwu71-uj!j*3gS_C}rBR4c%3O8(7T>R<){o6-g|2$$n@z|;KFr`I~o z*=ai)I67;<610h_KP; zoa?HyW!<>mif)G^d=ho6X1$)h8XO+4!?p(?rFMnQhjpknbCK8_AFCj=JTCIG`_&sL z!`HCo7>RI(Mi~DC2-SKGQL4&ey%=n$Ps07^i^8ruy`BxFY|-4|wBBp7U>C;kwfSO& z>$7wWHMz&qR$6W`2saOKN2}Gy6M>z?C%!Lm`FOoRVxB9zIt;&)fl0PNWY+9q~ zoF1?~yU+G4BHL9M>(#UZII+3J2r62d>@{SpN9oLNehguLhD}j`E@vQdAIoY1aOM#D zSyO(nzWY%`m?Ov_u|HB1zMcHtak-wD1v3!eb?XMp=iCVC5Q#GhhDnlzgibc(dYedQ#L? zcqx|b!uz9d_oWLGtM!M)^k7FW?bVQb%jCfzm7+D&{{tP-Tx0O+?^wV;X^P-LQPIMB z7)&W2Tg%qXSgtgllDvDWo(!ck^^U z^xFmi1t>ihm(#XrZ~;a@W0gtXL-E<*iD)`Z>eR(-_9yJbEBr@3T$2t<$E|>Uyo}3! zn`fqVwu8CE@JIBE(y~~b1~;%u&`7r5zMX?FP!iibK@zg)siJzc2sSUm3R{Z>nG$1+bn+Y zj?HQ=JuH^~?KDnEsVlOFr>zWl;lw<#))Z z)}jNQ7wuyhIaTph5b<4OY&CZ6Lm~Urhfmz;tF{-R8qb$y;EkBM!ePhU8yL4ojMD7b zN49$%nPCrEzS=q0W1cXYUDa^28E(GR(5M5|Jk1=AaUiy^tCU(&JI}72lFbXG*^GJj zkC0xwG}M~-Iyk{$Q9SvcJ1G|v;q&v3Kr(*&ns!~(>ayI;^ne2KKXk^Pj2(NMaTdF* zw>%)(hFP07y^!MaAJKBn)m^hn?15PQb)kQWj$HqI2c*RqJG#ER)6mt8Z9!0NZRoSA z^0J!*dagp-+Mrkh+@~d@?IglX*6)JI`+_*c3JCWov$ZDYX?$i{(&tURQG7u|qOjX! zy=_5r+c*Y{$*bFyf`PoK`~6az-VK2evKSjk7$oU|mfK0nxSH8Ab7SQ4mDqXR zeMx!9d`-NqV^mrM<=HTbFI#!~A7%-~3{4_|?DT6-|Frv} zAQ??Nm0#ACi}QLC4&&)g9-BvOH?sNjF!D|Zi#H3iE02$SET9n&LX)x>$xro=giAyv zY@PkXesH}ST1QyhM@y=eAe!wkoU&BZ(P}7PDlVIh8M*iZsrP`=lF|u4=Sr>r17%TO zc!O~hGPsW&1XEAN73{yk6R%XuXz%wzsl4#xw55Q}=roGiYO(%d&|7e{QBmxp%8J9w z=+kq`^TjUXjMH?{d|jF)El{sW{on*+OAyOZzi%Yk9|5OS>X-V7OhT(!cpiBCzr*PG z2<^L`8U>RXZ~tuhgNLH{NIYCqQQ#X{Bhg@tf>?AHJxsucCe29T*W*43^??C`c_3F& z+*k6To@AuIEMN03cYC+&h6sQcyplrx6Dp2OlV^m{5PoWcZUOUjk3i9_egWq?n}%{$ za`hRQ8XKb`W&j$wv|Ce(M!Z615h>lp)-(#I@rM(Im|E$;O$z6p5eKPq^RVm6scZq% zbbe6(BDu0YK@Rf5=~dC*Pjwn>(YZ7(g&xWUqX7(r0}fd#xbn3#(j~_b|B9QSP!M*o z;;<6|j)c5df-sN!U5~-e;unih{T(`_FmjWNepLA>DQlH>Ml zU4GBApzXk)wV-{f&Zy$Pn_19^n@H^lpwg6k=;2~XdTcVfJr}9OFTg);*0$rSwL4>I zZE+uSE3Bea8?9qLF|0nc6;sSzwD(NL3tln_KluI-Kb<*+-&Sa%R<&H!X%JbCXK!oX zu&>NID;~GuQKP>ZTdi+XioLGZRvTr-5}Ipf593*Nu(9sNqFg|;IPI8t@0g~~e|1E~ z)u%fOfF721NRM|jK?B^8iIek2*xNY)M0z=a1|Bb^m6n4RezWW8)o`{aGbJw zjah~5>`q3YqPP3`5(-Kv02?6K*Ce&>$*%b^e8#~;G3U&0MSkVU)I?DJS)1H4cBES7 zJ8TbE$AeXw#!j0{-O92rpUN{=6;D2<|8mT;vY>6C8BsYQig#WT`2i11yM8A{h>Xfe znR3@gc$#*V3NYBdKSFjBwNQ|(8j-s3yY`t)=bA!$=!h25DBCC|UKP;J;D687QAuuJ z@Z3SL+Y+oN?H%>#}JV7lptMl=Bwq@B(MDyq=l;-hoPM${OMAX@c=eW@>6seCPgi&Gjmgi`|fKI zfIq;Vc$q3vl}SsfybQH^F3Ch22fYjki?62!tRgOWWQ$TMc)l|a3I=-=4dQSDn1s=6n5g{+a&nC@RdjVQAb+JZ$qK`HD&bbJP9Ee-`7!qX zsKCW_B3Z|pL(G2%pI&qHhm?6^KYhRK|;_f>nN_cwH+qy zPih@+-Q3>a-d)hKo+MR^9cdBRqbnoti?62McpT!=q~BmHu1p|5uYC|6AqwWexOn|e=7E8VTDb<78CpV!$O zKo3<)6BTz^xb}?XULDzvHU_5I?!t~H%*Vv4?U8xR1fNZy#$Ctp_P zOeyYO=fnu#*T^%xu$t@Znh0_6wSjm#Cyy@_{gf1ANHu$QmU&UtE8=L6!nl`J;?=Yt zj`?=Sd4kSN+MR%EVvDBfj*vU;SC*!c4B02wL+2hs!>R-}@uLITpTZk!>tSP)nUMyJ zAB`Il(28C61;>j$ah5k>n$n-t9gbuBzv`#irMYQ7FgW}QpY<;|A-^mo?H!SFF=19= zIUFac(F^x4SGH^lliM1##oy*2XsH?fZrJlUWPUaA3Lr|Lz6>1tbn7m>SG0Q$%eIv8 zTkNH4E$fvFr^Or8ezTlLg?R%d?x)I`?}x{EbB%~&;6nYg)=6tJuWRa>s_V47{B%%OLFiSkm*`tNsT8e=M{^$)8y=OXb zmqY`6O)*@@n?<9<9_)(Kb#sJ4i;HvyV4{5zJqgW12g$1ZIwam=a**Q+4*Ox^c?2t| z>O!pXOl)DIIUQ8h6h`g>k=Z5w7?h3aoBB{D60DyNWGnuYa$FDHs=JvVAiVpdZlzox zh{wH!UMY%I{~ei!vNElWy=mC|kL>>~+DKP!<3C0lm2aYQos@yhPVv3*#J#?ra+Aba zqMvtBxhx)~Y~%c4U95t$*vY6eCa*~f@~bMf_9_2pT$SE*Pq0-#l@5=ID%urk8lXT( zUI17=pgNuI9Aw)?Ty)(0XsgnO1fMNwp6~bg*%zpuRZRp1zpb!QhOU~LTSRQQRhBGT zC#f6j&G@h6$zg>6bJ!Jb@HJu{Y;;^QYA?wbMDgTg2U@yCBQfdZ zWW+vv37=)Hg4Ob~Lfpb<4*IfZe)OG zK0T9H=zDWV+Cinu{OlJ8n2$Zm*D|u;tcGFokg?nm2>m{9z`PDn)`9LaLbxEE+Aa}0 z6)N%lP!C!ANssy<1~^iTQM4%^r?by1{Aq}aq~~iK2$Df;X_RnVvzOZLK7Z@gI=tw= z^wRsm>3YwyR`?)ZwsHP9N0)V+X%!t-dF}4i@i1Z0Kjt%ih#?CU0SOKa?zO7SYkSAmKJC&| z?56s2qNMDg8_#$f7SX))dSw(vJiMobk@yn|IV--V$T`I?YE2a4QrugCw-(Y@aTmd9 zD@F~3p!fq85*Iu89C z$AXoSFmOOx{R;K$;^v2q?XqfAcHz((qn9fn6g3AAJQ=g!o?WK^4IV-^UgP1jj3|fx&WLwAtAsb3@=x+<-s(df=!zp7U(1qxqNKa0l~4 z*1qR`CkzgMU>FExT)}5Z10pz(LIT*208;?ql)!n|O?%27ecm_H0t1HPD_Glm<<$lO z601+11U@3bymfq@CcV(JE#Le(1X`Qz8wXuV2uxXk|My3}k>+`L+tKz8c!cI&!Tha< zO%q&(G2p;m@1X5!1FI;11BA5dztmJ&PI882QEe#htsJFh&Ab)(&?kL;8MP%Ktt&yP-hvlIJ5*oBo4Vw^Q zv`*9Cp*8o-&l`wr10BnQrZNo>WajCE{2$IZmTa;VUQNG%+a|ijG5>Ag|P{29F0VvhbfPa&C zhbE#YKIFz?g?`iFnUBRls6cA*?uOO_wy@0q9xj&RGbht^rK|aRvZnIoZR0-DW-c?% za%V2HUQ^gT5yNVuJvYmvZGx8aBU?ZF*~S6qi!Wb1mX{ z`J#8U#fIq%WsIuAifl;h3zfm?y*w>dBdXMZDVwi3;OX?*80PG9^N{dFIfjA~ddRik zbW3M}lR~3Cq#NopOKDvU58|pXlF8_W=N#xLlr>=^zZDEnmLcEWN1dlom3(Z~Sc;9I zIm4|%;E^ej#5!PUt#L9DtEs{Txd%;}PWnw!KOLWdAm^hVV`H91xQpY*A2;NFV_mk= zwCKrItQu<{%`2U~w3_TG9p4Jy#1)O$<(?z!NWYZ!Lk`N@Ajpw0dBmbP0L-UoQ|xu$ z9->BuRV1lGJQ{KL`*LB_7K0g9C0Xx`*p14h3#4vyvUx^!nH9l6mFZ568E4PHx>jK2 z4y$h?>LFRWD&e)MuoM#^MPpP@ZYOV8`G_(Q(HU$cx-j~YBpx=uoBBCrA$DVObbS~U zfVKbSNbo9Ji+D2F%2Q`}b$3VnF-~;W#jOJcv}YKfBtP#PL6%USeUx7q!lkvotl8ul zc4tXBm=Kvt-D)JE1z#OZbN6|f(QaH_d5|W;;;&CDZOoWn1T#IOew{aFo#>18NsbK_ z6`A&srS9MAPsF?iH$oQfT`wIH0;Y(GbmxW^gG=5de$+Xq^ra6{II5g@%-Uo0G_K1@ zD3YFmdCyEH`76DhKa6~9Cvge>HXTcua0}yci5N2BNHaj{4y11iPxGjoF7u^l5J*!r z$A+E5drL2o24$9tEuB)DyYMH4OWPh++UD-%lh&GqTJ%V4d1Yvh-c6TsNp5d#mENyirwU1H( zg;)*m%+hOY3sqn~wmX@$F(=S!2sgs3h2?Of5OJn9klYQJi@pgwm1fvMO)p%rb#9Ak ze5{+A@N3OPHIDfEju336duCVie{fa8;Iovy5>xM7f&W%N9lL$CE2%eR+-;bJH`k;2`2fbpagCiheJeEh_(PbM_k z^F62sU!6_7zcS)M;O5bwZ<2KL^JRSAl4PpYHJcCU!G{E|t0;|=GYp)I@Z8r=%A*>n zlO^X3Vfr+YYuFgFbbvC6lB?QnH9vs95fv{r0yV*@vf{$)lB?StKSx9R#ZpGeDlwZY zZb{TPSBg;6R91uHu!bgL9tpSyRwO)`G_nYd{&!&p=K^^ZQr)m*OdFmTqComOPFWs? zBq}m!0bNV+GS~{#X}Y?L5-(y^Z9oWNibCyRV!}HlcFj=rY>P&Kw%uRF7~0w#HU*up ze_^ZKa>qNNWfvD;S$glTV}beC=R|UcYNPCnOXbD)mn@1}J9=Ntz-0JoT)NdV*Fk#C z0>H8Sofwi)S=mU-wKww9@adf5C@=Zy2V|O$K_EYr)HxRcb8eOUP}QHR{xr~j@1#>w z2%MH8D6sJ_%Iz{fQ010<*>BAFL=eFA=|z{PQQYF$$Pcr{RcqfP8l#*X@&gAe8Z2W? zyiEzH$Ew1FMS3>~AZ}JXcq6C|DV{F%i${Z2Q5~MmUnsa37swjPHd6hLysPI?iRtQ3Ro$zTz-E z<9G&F`WIW5K9WEVv?tEa%#5DBuw0Aaw@mTHc2)@JsT@B-`o^mGq$8|md@@eZ=ye;S zY1>9A*&&hxN>1H5Bychmuxeol^0nN#XZYquKDJ~jmToEFh(Y2%!*Z7E^apCktCszt z6Q~eTmyb~XDd6i%+9+m+?ketl+f{doHD_ zjbL2!V}T|-J=v+M{{n8QD;A9V*i{-&vY~ zb4axj7X4wi8K@G`L1_hel-R^bT$JNst13knt5eW95fCcv?4m>G{2U;%+{WSxnC+0> zwdj``YXW62h0%m$PB30Ehl9y3C5hw?w*6<+8?aQ}kSH@`l5uVl-L0CG=1~qiMb8SoNd+47X+){*8Mh)h3ex- zzPAR7=T0;W5s<=t(d=2|5aR^v)=w;a!A;N$i|_0D&BR~;`I@2m4u!fTefzf2FwoYY zOT%30n;r-2Vk^8?g_+Fbd|>y%PU`4Tn2JQc7Y0h>VAslSW! z0cI|_zbGwlv7#^)~(R@ATCBaQi`shIHvFbatojIi()s~wj=}F5f zq&lfjl1N8|SuIA|+kpG3{1gCL#_M6OB(DO0V5vG$=^l_UqRG}^C>cLiOZE|_YP367 zz*W}I=3`q5l}-pOh|L*Gf?8II+4L3;*tej*GTc6*(W*33NfPzw1`I**1hI+)+Ep>M zm~Hp>*KmuV7LkWkyFeR!?w!whRJ=6iUn-UF#*-SRlJwI^MH5BYWsd*F5Cr(?&ycXP zr5N4oaY{dH6xg5c&HKq5IR@HT;D`oT2sMxLE3ey*(HJPSw^9 zi0p>*YF}uj3_Mf2w>>x2Iy7V1GC6ry%6Zq_&(;zwCMgLn2$HoZt%w%MOotU@Y?WFW zC3c-P#8TgiR~X(zL@{zUGP^~OKL763iW|(J2%T!+i^t~54g?|4-W3Hg6TBBS{SdQa}B@1-YiV?<6K0v1)2QuK!FBgspb6nEHW1!2yKgO~j&HbSE?`sl@~M zhQ!$p#Gz}TKF~8^cRby73XYQ*p6XGf;xdYoWYXE$zz4nWX_n=yH!0pRX z5Prxo@2es#f`Q?)4Zou%lmkO1bRHIUKRqb5gNiPU85$IzjldF{8+0hjpQx&&RaGy< z`QuJtK+A=7UajkO&bqAZ8r;U0xkPG8s00Ol4y2w#3zPy_6HP**72X!~q9uj)8weH?5L(s_|qc@YG z_7oKogcg!gAy(7hkbF!ljU;qO2FUK+hdVPxQkZe#Tj%D@ACVcEDjQcyeh~WTU8fQ z!14i(QdSrz>mI@JG7C;;aiVLQD^j}XMWRb#!4kxLTbNOJJL1&}88>Qg?PI`I%vvO!-FR2g#@Lb40>V#vghF1S$*os!f0ZEeC0^2z>zY?2%#|#pU1(rZCvg zU)g|p5d3sfhub-ynp>DF!(Uo|#}*`{s;}i36_d5BUK=|V5ILA?QV2 zo%<-$I`srfMKONgs^AGAN^F@JeKK2P3Xo9a&@TyorB65-VR`c1^-g}aI6&>krE+Gj z^v{p+EM{gD-=Zcz=z5J zk^=_j|3nz|KaXDi|A>hH?%rz$-QIe07ckVk`T>Cg7^BkMYfa2V6O#=Ex_XMDvDSl< z^{foN;;kvVKrO!eidhTHIv|5fBzU%LQ9Yrx*ybdOa5Ui;!5i^83T3=UqOxc2a&*s$xBb3e8dk zM!~{N!*pI!&h891&wKGfF8;i+I1Nm$p(=!ZPCuc2u~|NaOK#@g3GUoUy-_oko=cJu z>Yk>V`HPQa`G-d4XS?FN*$eq-F-u^R@ke@I>%(&q*n1bCn^9?1Ka4c z>+&^Fj4z*zjC7mX_DglcoWo?%qZcTqr;6m!KC$KuTAdI%x(>VTAsgu}6 z!NkniTLN8eZ~era**b6uRhXJ5Zw+!U$3i;kgZstVVzK&WF6)~PHnl&UNuFadHWqRtkc%Vv-*_~D}6~xz=i2rQ(5a{ zdGmw8Lh{=_e_kz4FNw&3KNrm(W1`4!%xpuZVNhWIV82153ESshF43@i`uUoOE9)x4 z(rxZ%@JFCK?%?c);NvPON=6zywy_VKENS+i)a5l%ViP`2=oyGQtxm{D#`~l|NRFHp;1qu7{2nF`laqPMLDk5y2&Al~6FJ zG}Wd_oWAOsXqKsW4-GT2!@Mcbs*2NdMG8jjX69+WK(;s@co5*Me|A~M6o)xXv6tyQ z&IUa`r3+zJGZQwmHr6^tSlWcuZV|aA(b(&}19lQT`76 zK8a;pCq|{QmfBA|r8~1xVsj9Nf>b8955IDEa_xqXp`_#!^S-|gHpLXhAAJI{kxL4A zY0NvxVIChimlq1&FStU6z3^FD+q}=NJ_-ZYWqId#B)H-#0ZqX{(h|C1Q-XZgSxaa> zJHEvst%Q57UMZ;Mv3F7Da?_evdTxu#akk6ISoaouNp>0C84R%S-52 z>qh1zY?`OLBh>c6Z5dPs743SEdpSw4OkS>V zb&>46iupj(hE)d7+cU3dm$UMbOnMTa+(P7&3#d(%Q0rl1uRAL_N7XA%72CRLREE%Y zfrB?NP;i^;sioPZFB(-bH#I$Ax@Yw!dg<}RXdk!r7ewMl!PySAD4gkXAA+K2->SAV zvr9bYC)x8)a4Ktj<`sfSyMRtsmtLpVpjol$b@97PN_r2cvvW_*8p7X%Rlyijcs}5k z_(q(4x{YGl%&R1W1{=}2ZkUw&EC^7wEhzW(7|bBY2L(t!Y} z{ju0==mW976i-@8EpHHCO*y@qk>81cj)7fCc>GxD5a?a6^!Tku`UER+;1L;P(ve?l zLri*#gFEY$G~VBVwV&1t48(M0MD<0}d_GzV+arrvw$t9;OrBNZzRa4dK~U<%%ErWG zp{C$3!B2w#3HbwemUD~Vd+CAEddI)l{fQwrqP$!m(od5uGR-xU2hL2}?ZQ)5O`$91%HRfTU86>%B{%t``Pvvl(uhT4GftS< zvbaL|r;VAG(=&_Z3069Ib8fD1?$58YiaTYC8!fuqjNmRMlELwLA7$%gH;|t8OCfwM`q{Wx4}MJa(7j6Jud6(Y{iRpRcJhEqtGf7P_=K-t@fcJ7jBC^b2| zHDDfiX93YRO~wXCWz>(KZo(d~VNinZRP%G#BP`Fu7|W;2R2X^&03%p*NlG7Tr(9+k zi4CSevR`z3R8&F7ox1pgTCCD1fi5xhB7-GSQ1ELm_R0A1j;AO~MQ`b#IR%wMoQ?|` zMk{CNHS0p7e2?4&Zh2N%5x73(F`dV;FQQPvC^mb{;pccH zlsqoQ)ORW~hK1iZQQA))t<>!@Wyct~xQ$#rFXG<2E^nkTa%T-Uv5R|!&qYfjvZPm^ zb(Fn*`eVZBkG>^!gvhkpe%$RIl^N4!UzVJfADQrf&gvZzdPnx!Sg>-vyK84v%=wg~ z+?33{EuC*uZm@<)xOec)c*F(q70FpCQ)R4tj0=)VuyCn=`kEBB_ji2gC2kVU(7q6F zGx@4Z{|EH7Gd%rM8_a|tbqN*Om@si7Tr}BA6lQK(E7$o7Q<0nI+0UWy>MFNDD)vYX~w49|Qj*MB4pdOV+E4sn~Kj&NtH)C0ec6d{chQF2wXjO26&7g=k zt@Y*dtx9t=2M>8W2~2}rdMH#t1=cXf67%O4YG+B98|<7kR(+9cE%_850b?wR7@=;}r8|H77_B|Fx+anE(J ziy2qxo)s~cissa&(m-8jI{tiZ1M+T=gRQbn9?quNm%v~~ksOR!!7-zpa=ltSl-ejj zp=&Yq!4`D5mKQeiTK3HtbN1^IHQg#q+Gp3e+$1)rq)Gi1a6MaQEH&AJU<69T--`I zX^4G3Q6R`yAOOR$!9RJqT}O24Mo(0>OUweB^Jo;T!0--O=oM;x1qBqH)7K1J$(XiVP=}@gh?hG)VFUv46>}a*z+#pqD}~+6J}3 zom>2g=E^#<=@G8~jqve^MuQ00SoYu0b-*W*{txEOxR2(Kv$ZFPJcMCY*C<1Il^C`Z ze-Uq;z2Bu90Uy=rSu0=}(VpW2mKD|v@95Xicwd(nuZ{VyX%P7K|K_$C*sm@g57ZX# zQfsn$fhKy&#E)*K&1QjOilH@_biF!P4Kmq%;pl^wud!30DFfvc*BlR;CYBx7)!lZT z!fvv$kFZm)`l^k6UWFVs zJ7S&ER>imbQ$C=vEZD@fcI}S!kqCZb#47k{|V+$NWH64~-#CeU~JV$eiI;utR>vaE?UlrYw?y=x|L0oWD_tw2sZ8AjROYcn+IJ|;BH(DQR`;WfV%fgS*;fazZcXK#8cutz z|3UQz==j%hE>o?*wAzj@G(x51TwSL>U)fL8k^Y_A?rT5E0|j9j2;AJm`RxAOKd;aj zE|3@9;cePHw!TN3cgiImmpAzhh|Oa zdtU7&K2tuQ5&Z6%x7zT@k{^&*$z{srHnKXIYPk?=rer3?lsKAb+xlAS?|wP4BXEV_ z_+H(Y=npK;qJWC$4~f2?J-_jCh8I99H>T3Z$q32iHQ1Qg&c!8?eN&n5F?_iA7!`Ie z2@RM@6h{bw6nd1H*AS@%5FTmb2+vQpE^ZS626xtL5y{{2t`s{|8rFsIwtjmbUl)Zr z&$*Q{y_vaysl2KwO&oIMi68MMY~^dpF09Sy-Zo^Bhq1C(Tg2K7ob z;<-TCAl>dS0#FlPpeO^uqsPyE!iPl^Lo_HWxgV8 z`}wDr>-M-=*`@XuqY-;p%!;Rg%Zjh7=WQfvrj%T|B}WA-^dxC7iOA`AaeACzqY{v# zZD_RzQ-*VkRkYV}MMjBo zU0Q2uM-5xsny;w}kF$~-ZS_qiX&l(g`%m>x2%L1~JLNA5A6nQ;Ehcp+nr+ccR>xh! z%7Ovfhzg$-g`m}FdJ1y8Q{fo6UmuDro>2GCZ@YsgF4`rN4@mp|n!MJN0ahM?c{ zdq9r(0tDpVjR5zP>VLKOR#9~|(Yhdi5<&t2LI@5axVw9TySqEV-8F&W5+Jy{LvVLz z;~RI_xVy_;oBXHy-0nUPJ?0zq*hqpv*_SzK~gP#p@-j zcJMb-9XyVH3f*)rISf7sI0(Cy+!h`0G%WiVU*Sa|hYO?%P_h4x3Hyl&P@njfGsV8` z3{fwIJ7Kz1`U+&QLfgB~KoIb9Yq#3mY3lU~djt3mzuNYxh4UafrMl$f;=HJ#1)qiW zgozg@TTI3UQ3xf3QvM}%Xv&Fo6*Y@U3z4FRD&~8|KJgc|TQa1dLe^uc&lG6l!fWPK zmoo}ho^JuOP4+y4H(ZV8u}tA}8)^5)E8f&65G9<$e>^cwQVzC+UsGB3fhY-sg94)( zA_FI|1c7ul;jf|GdckItYOcxQ01Q5@FnSN8rzho<>0EOQ;{}|1mnS1k9uQdl=@4xa78xG`aSdr1)%Bb0{RY<-2hkE8Q?GB(Er_kx}Cg`V)E{e~GJXVmKBeC$EHWijf%`W#?oYx4o# zgCpAj&aC%!<(_p@WhaEWU_wO}6Ltm;UI0$mFSqR_-U zVxMz!G zE+m*vq`PyRKIM13CjVHG1w<0@^vcA$1f11}U<~WDOpLi60=AlZPM4uqv=IdmEiSI` z3oHn)i?JV+Fl>+IZ#p%kmve3wK=O}m53o;U_(VjWnc}SYVs+}+m%e8R088sn4CHGI zhgDhcv2Pu6rviUKysYo#eRy3F0`G&O|{6}PMu>dbPfDDKi4yOIQ zt)tCA(4xGYpfK-Ki7KhI=HEP(H7)PgS}g=}0)An!Ev^k>(gAzYUTt3exSrh^XYwk& zKZg&gRNew?YvuSH9eUXhE+W0(_Y?HFIsbuqZw-SnzAu@IqWD}d%f=r)&JAB_j{s5g zouy(mk}3v>S&fj)A1Z1Xu#z5{f|l|G`T+bFu^&!Vz*8zdS@T#_sPacQbz5>k1PJRi zXizfg4?|;D+Zn|+CZ zawQkzV`Qko49QlgvA^Ow0!7}lB2NLe)^ju6B;(hi>r|!}2`rz$;NQgfW)tg-+@o@7 zK}}BCJ28eLgM4^NA51^A6Pv^w^Z9v3oe&oBqkHKCQv49$38~$Joqwrbi3me43Dtb0 zl&r?4X8#mcP_8HOKfsz7O)H5a>JDWIYOwB?t3H0N9xo_!IN{)Qri=oD zy9ToG^#XiM2$35V@0*7m%G}o#N(M-W#IU8K@0iajbpu2*7x^(WD7YiD{@JZh7HrE* zlXZ&fciTcU?SaWD6ot34>$+nA1E4Z-Z3vfUG`JH97T`u6$tQ%eagMZfd! z;4SOyYhFbeO*+Rdl&R87#7pb+dh<0(`{-JSN$&~WdFU33G5;QtxXo-w$W9kKXB5M&&t?6O6QxC%{lx{prX|0 zt{eINr~@a6Z+)03x0#8V;rsYLa(FpA`%g}i)e<*4j1#AS>G2yj%pH1IXu>Qr_^c{? z_!@s1*j0RHdQ*)|9rPwae@5kNgKSL(zy~`4D=hwxWQhJc614{`0o%?@Cm4>1LH4}@ zrfWVRrPUALfMkf*7-SS)9~z@YIYJhMgv7EYLPt{kUPRwlykFg+@^l)6o=68Ye`twP z=a0e-IzPMvfEc_|syFYmEiuxv?o)x-sQ~|5-?%EcNle@@hnIeYf%OBf`CJ)qn)xTR z6jgt}KWZokIF31Gf6kN%VqbUmrVL>B%c@z8(u_0T;5-*Us^j`+(Q)VPd~;SaFf1Mc zG617S@!JL4PKToW)jvF!33MY z?2oG`H-T$f6}*D^zm;~**lOUr@tPQO1d}Yq@NBrl(8!xZtDcXW*F^=&9^O$ky~wn+ zGCcmyWDAZUv(Cb`gy^&$`OyN}?pLcE{=odf?Y(13rG0T|MF!v8sItgy{PoOlmxfRL z3I0dHm=Hg_!V@}055msN4Rlsgf74jjK85J(Qr)k9a9q6$Jj_Ox!Eu*CiZ0SR0*d_8 zCH3`-v!GrOnGdTnYwxYg_2`K(q^$@$m7$V?N~bKu96`VhxAxAeYll=i6}s>q5@ z=sa)K0(4;_cVBB0&X21k$%QsKSPIGg z;p+etRt`8)&1Qsc=>pbAJhBNjpkY|Bcr$(&xm-GiMO5TG;`g+)&;RyyJdgtjJi#Y z9u|j0sMx`q3;4my_+4ear5e}(>c+$jY4BtVw^zT%@qU(9aZi3RylaX=8u&c_d7xxM zS9Jx*SZ4K3yfkrLDn;*UbqE>^;*8c798dmC?Uql(hLKlu8T_J|xxRq;u0WQhjqLp@ zECP6->-TpO3|ds~o6zimu6*?4gs%HS=|rLlnsKd?tI3e{*&ui9}z=Z z?xn#?V{%|qS}(d!%bEm zvQKa-&{H7b_leMBIMTS~*TZyOjK6$AJ2*L*^@5`)E*>d+pn%knpu!1d-Gow*Tj8A> z1-*Pt44_dmnnT+&2xUmjZ%TJ^=&eY(VIzDuWq<<6x`OBMlnjWR&Y>k}R(-HQsZSxa zWX}gM2f$_{fC7wiJ@0*VqC<1BH5h(8PaXKM036MKzyG)4oPEy6fDWqdKPp~;@Bi<3 za!q7lY#O!doh}x-5w_@b06#rwu>}Z|fRHTGiKvG?^b?!e`89*VtR=AN4{dwE$}LE1 zsWi;fCYvAK5080O0a14}#0A}we!5i4fC0|^++qv1vz`fXnCrqE?Jzl_{{FHJs`6_Q zK`iPm=FSe75U<`>vF7>s6%aIBCnAwhYzV#CLUnmc99=5=;=XMFf&}N^&>ChC@Hy&u zRI5%FH$sNPA$Y=d=@|9RRra4|>=M8}}9_!T|`$$o=07$bQ8@^Z94eaYG#Bd;r|8 zjBoKhaP+`N-zB}(M0|^iH~rmsRvg+zov@^^0x3y=WkLe<9Cl5`Rtf?JDj%8%*%Fn+ zWN8kMp?7Z0>;Y5md>XhIs9Lg$)V#Ry05$DCvHSU(Miez{tk#^K({e&6naeECldg6b z+&=kaZc$fxiU;`%~N&mDU%{u`pbEpA>QPIQk1t%&Ukf zNtw`?Kn}9lckS8U$;mtCKF50w(!==CqSrqY?SJ%044x-{i6E6sLib|7CrtRml7ps> zX6>T~O4o!vx=%IHy41ew7^-2HSSn*~P>VAuX?B`D+^TFHsf2+63%30;SlE~)fU?R2 zBE9ePG(vC|3BZ$M054M&6>D!|d}S{1XBd}Z=dIZF%*^W| zct)`&?FW2;elk{$3-<~1W(?=-{8SsGpGb~)+_-WIzA_(p>O3lzC}}Hhc@wf}7@3k~ zF=n+?SX2lkppi`OxLX}Wc~2)W1e-QPZ|8%hi-e?m`Hl}W?2X2Xv&hWWXN_M6eN)C& zDqBH6{HiQK-uP^t$QWE&dx52^1ag+wcD2uXNC+!+`1 zm(4Av>*j2-b6DHX{_JZ<7vymb0?uzjV6!bAjl z$vR9@-9Q$}=A#-adI+-MMRY z)eE0owKg{^m)a z99&dA#Xtde$l>IXk}6$I`Enl2AlS|HRaZza2-b7A=MoZH4 zk(2_Pyjr#88hW!3)?D@{m2;B*r&1?ssr>DU+Hp$us2~pLJO|&{L+>?v?b0WRYE$*Z zuzDrZ@}L7Ju7AsU%Pleg_~5bM`uexEXa(MEVtlzY7Cv#dlp|=SpBXL3$mB6kl5H1c zL@cLbDLoSDnYr<;f7g_iP~n3m5>i3YS(NBfvt^9QC88ziv&o3ZqGb1(WuhZCVfT+r#YVL%@v zzrZFB4nFb)GU?QT_=9BeeC96J+#cv>mKyIiSKJMLoD&Xe`0I>nrb>8f$AIbPM+7eG zLRo#!U}ed%^K!4fsr#O+FdFwCtmDT@&F^Q6q7n@o}I@uAI6dTdmERc^OnyK(3B`E zn#(_H4Hb_S$czwVp?XHixpwhoE4R{h{!V^~F~ZL$k?PRU%9LWEgf5>hDZjr6{u`L+ zT^w9#+pb`IK=BybSTIxSmtVL0_a4%PsWP9p42<_#yy3@q9ytVz zFdo-y&1m1-Uo?GAhUU*^GaZ0bjAg#{HEjp>m5cCIS%}wso=@1peJ)=*{*dMSYOv5K zB(vuepUeGukc+mR&TL18ki@gWYI|10zzbH_w|y7Up?j&|CZ9zd%}n4Gam@=JsxHo9R36E$pM&xCACwePM}?1_4DlCzk48A5rRiX zbQJ7v!mf9?Zr^?oK@RBgPYdUZ9`g_700fohoz5R-eYv#`)x$Rc@c;}=G|>u-Hg|%K zpok#j{qsVk;^-he$Br=b!2gbkcC3mE_sVH)rtwPbg5uQ8m4vc1W+ri6z5~Ce51 zjc3wi$!Xp(;y6jg51CU@-n3>`xk@g%J`ee>%av=W&YGmEZ>-)qR=GGY7*?IqDdhMS zCo;f1oKE_v;97GXZJkto%7HCbxppaR=~~Y=*C1G6H*B4QO!a`%tIp`4y`yc?D^8#E z03@5vTH5vv>^PK?c=(2WyF6(MR$?kAuqH|17J1KZ9iWdW5E^g!aA3o0f@y<&OTvil zL3Yw4V-89_l7tl8q&P^)X~jKptb8bOnsMGLOGMdR-Ve(|DW6igIE`nF>i_J-WcT(Yix#pbh#wC8ee-)TL z-}#iL*@_1Heb0w9ZO@xkgWWO`jjN*cE>57$%5sP_fFi!CxbTJIqlP6dt&Vx*IL`jR+5enaru%9=n$^0hk2o+mtQ->;ZEB&N`NgnBL z`>-(_yeS?s3_r-Qe@Vc1Z_OE`vG+LM=ij^v%{KV3=qX@HD{;IU8Fiz#F9hnpOU`cj z9*U+&Zr`M2c#(!DiviZ-feRqvve}X0Q?(#&2u~Q=#fo;#h`QX7sj=Q!OiEMkHVWos z3LCFQR+j=Vs&1$!CMhT=F6HoeGO(Cv=hz3o8h98&?A?43@C|Y4OA9)G0#+C>AsB7u z7f8C&vWs#c*&iK4EIE!mMjXvbESZ0}4R*$1 zn@u$Kb4{Y6&(m7=k`{%(+k-W7=@W*-&DCLhYr4+93t<&*5>C5V@GzX2UIcZiu7(du za(mOXtm|f&1O`_pZW?E$h8Eki#!fBT9Maj4!fzRNGb&FpFjA_jZ7pm&VHs$j{}M`7 zEqUj5F2OK*D_2jl8MssjvM)qrOEwoWy;*JKJ&;LZfCBk^oWP`!>i2FZ7=O26Dvy%h zm70BN$3F3me@WtoBLUK&S=6eO<;SV~Y>%E`tN4~^`Wx5swv#cZqBMcwT6)8SnNyH^ z{U-NElRVBsaeP+IOMF3y6}3{kRIWiMyt6=&jn#Pybylr=`W}M0mj7{ThND>!Wx1*O zIST=AncYnc$9Xc37Y9x2m$dv$5gGWZ#5W4wwJs!5@0^mPTzlqaK~l_0fhhsRWd^2|tH%@Rjf%JKUuzQt&QN2TxXAbJaW63R-_5-+Gt{EI48; zj!EFgQW1q#^wG4b;$C(598 zMlcEr1=bB#L}4x$zG{YPzaBG{lp`1+GcDRr0JSvl7sX*}nX7anqBK_tl?A;Y0hRYq z@LKN-4@!mFzB_L;5^3=qP1q>tm{C>zfQhSmd(gr3(tKey*!+Y8_9RwTSYv)aSc*+r zMoN(YclkU}&^o49D^5}gNFrS1Qob@xQhuMI>mQQ~KxVAVimldgHy1QDB)kkxJ&dWKf1{zaT?B_>3qIziG>Uio%E zzoWnxTtWnW;9iQrd#jlq79^{slg4YGloz}zlD~4i!Is~Sd66!+xPmkMaAZ+p8a^^x zQw>p2u}E$aPMx2fksAIkJlz&mvzvHL5s?3C49xV0nU{f=K}pdQ9G!$2M_%d}G8OwB ztueQlA~1Z62$Mx8XcUAMIx0fPO<~4{p2|aK7ZS$aIXSvJ<3V52+(Xi28i_@`p>#Wp zLzxxEKZ3l8iCSbd?64Ps3wIEmhl)j5*fSgE{5inBOMt8i|5b>*;Wkov-v z;6Ap=u&{H=JN9CQad>l{;o)r@I~*1FR%hMFo_GyEF=1e`^m~ADtrxmTfHbiMRu9Ub z5GmPgd)(G8Nw*9jO>`y0BGt>%{OSjBWv+oUA(UL(=v46bYwDCyb|GZAnRe3`J0iwq z+AgmbvkepX+^fq8I?DBae%nUV&jMTka0SPRUb5KeT_x>oVr2)7zMrefEV6rq8yK#n zw}p0VrRB1-&`tk&d-iM26U=LaQ=}NYb(7l->Z$B6$Sl;(awiFw9gg>zqbA{^i@9qr zvHn@#GgoXEQZK(!ZSS?JGt(?D^zW|Y2t>h(uXM)_0K-jdf#1kqGMF7I~61uA#SKyaP*s`C!|S5929YhIjuwc1eoY-Px~ryi|F3*GRut4WDmna zr%62dWGYR^66Y`r^6fu(Y4+ry4^2*%4f!0J8%!+w#OJ%nq__a8*G3KaWSg%ZSj$h7 zTp!vQ?5AGWN}>-0RF$1T?yZ{4ju+~Q(hJ}=ULTw&<}o^8v3*(-k&DpEnTUxi8=M%9oD0M9~>6^SkF6? zk(DkxA>9*eN@9Vo2$<%oL}?uXZjzO(=|4J8g7cYTsqKy3rRi(}gIUjtwv_5LNdz*J zeT82bX+CuVyaf>PMP*W*pIuVnr^YJMsZ8dO*+gMyTjSZy#7sy@j6#f@!xfDbXvAT& zOBI2wiF=a7Y(O=^4DWZZ-Sm#kQ#y!Cy%(HpOR}-Ip|#zx>C~ctHLY3L1245T2ern0 zqJ78PFaOb%_w?6Xp-kzo40}`!lKrEc@lZpq0-7aFyo<_2!;ijmX~= zY#57({~Z_7d(jqh>2N=L>^pWB@rl>vUY6I3m6w-}n3&mcspA+nWQ#6Py-t)D9XdFl zAYiz3dgdTbphM|Kj#YOyzup*+{O*aUgTXdgvm@l{6!`yjnBgYtbpvVu54-BAT zLkP0MB1Mp6g#~{GNS~OKS4MOr5N-ziT*kq1JwFc$<#+|{+J^D+?mNbpT%0R>p3v~X zWX8qgUm61k8$bvF)zvV^LI8+U^SvCU#L1xiJpt1Ki{K&2RSeBDwB`c~*sY>Ze4cY$ zjDK9bx(30BT7e5HHOJRGs0<2Pd5qu+RPiLzdEJqM;g!9_;PHx(P%W;A{eJ0q8wmpg z(D!?!qv2aFP~~Q= z7P*zXc^5i=71j>VFEKYG2lYJ@v(p3ojQ|4-PEVS|t=;?vFKZSs1NV`lc| z_IKjF#9<%*w9J9wy79I=S~Gt7Gbi>8>1Oe1sNY^<-dT3>-}Kpa2xx1M%JqXmI~uAw-#H3r}Kv zWs5WMgvYGd^7gt=RQpNkYX_M%OWIuY!uiP(r>hm?!S+Im0*ykvb@MD7bQqFh&{!V% z6QAY>6tudg{~J`)D9K!Ne|w4UIeBzCQeytQ$m3@~&Kguq{?-WiBZeV{U?mm*AmV`d zLb_GJd>kA?IAZSD8ketXieX9k&V~zC@eIZliSGlF9hRCXm0M%tZj~$pYTEBqhSo`L zzNkG=MmAp+HhM4GJMzXRW;&$j^Mdd7UAtBO;PlC*M3B?@x3m2zpGc+LvTG z=`G2u5{L`lFeQEtPUBetpZAg*09O*ey$HYLBfXdDEp6qKJ>BASIuF`cwI3DV9iBJ$ zwV;K<7g(c|)Kx*;=F20c7aH|$t=MFQf&!+OOX2%kc4EDgMMcZ%IUVTuX1=;?dU6oi zQp}jo(PrakAw3nh^G0*UxM{69_?*@39L;Nnaw483eY{^ApzpS>f37>iYZ$%-AY4CL zDG(HSOv4*$y|K(0VANMo>K5Mre&ywU#|A)khw>fX)v$*E z*6=PiOdBP9jrBwG;Fp+y@J7LH)Pyua3uWAn?@@_l#lS@P_otz6Eulqt7~#O1FA7q& z57#b`mZ4%FCyBZ`RH@BVf+fYbNm7Lh1(OgIz5KrdPhjH@mSuDp;hUz+In_+QKJu`r zw?vld!3`WmJtlSHD&QR|{b_fs6f*dU-)Y@N15|}ziD>J%Nt2-5%wRF(^ksDt7LM7o z8SALAaA~XrV)G*9oEF>k;zyr9$~F$4X6;4Gs0uv!H0V3Yfid`C0anR)1km6SX}oV! z*p{tbFUy>!9v_|GJ`v&PetHTM=bTu`#GB)E#E5bHF4Yg9H2SxRywWbM+TP>>Ve>{k zYyRW=epl5Z90AgSyG4YJf*#4*1m_fd$k~B@S4rX7%5f_B4w=N=txAyLvduI0j5zm& zCP$^~LCU#bL}q)1Ly9E2uY$d-3QGm4h~@Zy?iV`uzL*-=Tl=Vv;NqiH7X!u#+$i~bWWzffqj4>11KCVNTbPoxxV_23=WYW z0FNR|0KPzAuFrs42Ekb-5n=DAhtUd(*&h}r!T`7lv^G!}(g*sYSejsL_xlw9npt!0 z2S9ufI?Xez4ucBS{v86HYlK-xeAuXKH?IprK|qK9uwA;)b<}VWG+e*`>1%Lhl4=_Y zYF=*{qN)pBe#Za2T+amj~?d!V# zdB)FAIw!x#v@zye-iy3WJ7EPK=NDN(ha(VygS*d&0PA`Yb}sU4U6GLyqs0Q4d3kB= z!+K@XxV=tY_WK3ubBG2Ark{uKh5kIw2ZMr}c>)FP%xrxe(8GWk#Kp$(;(U}8#$nui zgP2fD@6kRUC=`TXP)|e+y_+Q9rMR3JdZFN<^dCQ`KD$_X&;WECKznvPjCDq4gFBUI zq3_sAZ^wHy-QjI3YRf<<=yAVYNKuHh(9rssjlo7?_Q5KgZrENOy*?bD*9qJ(6y->f zd9eBGv9lrB_osCIbe_(;S6o%1b1M2Zq%v6z7gpyz$RSL4Uu!Qo+yK1liFPFRwx#`Q zeB`TSPIX~Zxg|DD-CnqIkfjmFnnm{(A>9gPf06Se8iYc1gp#Ckys0C)dc{l`qwvs1 zPOD8U+t3TsU2eX7+ODt%_P6C!4;m)fP9epC6dfoC1NFqU+qWrqX=}V?+lYvR)|$Kt-FKM#Ihn zr^uYu(iNh3`uxPWZH5fmF#^wh! zCUqE5`zwjxw77}XD;>NrGeA)Dpe4h%^SBb{u1Y^ z6z#+b$9Lvh-&i&DZWB~uLT-CoujE)f6X#Z5MN9iFd(8}gAyEjbE`2b90DFbXtd4Ovu18Pbu>4uGkxR)T z?=0Gj$C8Z4Gj0|oFim@p0JMs^kG0(585UbjUV$-R5fmEk{O2^=&A(7qDpT);P=I!)KcEtf zY^l1BH~b##xMv@!_LOk1r!o3mqBIq!L5jUxOcBaNr*hG(d0}S~3<(GcH^+)D&nj=9 zO%iY}LiPrLutMm52z?_o-ur3<*Yo+sZ1)5A7!zZ6g38V>&!faW3N7yzQ!A~djT>+u zg`u}&$sh`23f^=Te7I%MR?uDl4F7W9@-JG#(k6CN6qk! z%DQ@r3zH+z8#W;Rp9J>xeiP4$0xh`d6PZRfeCi3>z`g74u7@6jv;zr7qd)Qv+A)gk zXDZ4{%%qz^g_ki#jNRKU#VKtg7oxhEBab=_(YV5hZ*&pfb8yX!%{(<1PC9hAY8DmLq%CP7$PC~fo2=Ll z5kh;wx4QgPlGDz$*=O<|5xNo~(15>VlJ;dtjQYFsg2H>7$a+m9xWdCPO%OJ*fS!3B zM=jLMR*j}8($|e)=*Iy#;TKPk!c7_^vJ&xr(~ONG77Q)S z+crlV4pXSst=mid^P@8%ZC2jvyx1T*UefyN%QQypckb9MLr%q^K=P8v_O4vbv6%|i z+?sovB#*b^hE0xj-#%LjI2^IP6w^qN@bktbP>AU4H6%)!#PZ6HU6@yqfA6q~bT-y( z6U}-hL^m$XZSyDyW}=0G|BZxFDesj(^Zk*@wtpw|Bi-SzgiJB%Y`I zUQaK@q9Y?B_l5us*FVgZBFvKQA#V5JPLc3i?|7kHu@WU|$tizXk6@$M*b#3pD&R9(>rSDV_R-Xy^E#H-D%)5= zD62TqF2s{^GQnIR_&kz*&3+`5vL!mErGko2p^t3ugS3e`Xeh_@YcBLOUKlSszyKw$ zls1x1wGeg<3=}_(pLT{P`!>2&AJ%rVMZJmVZ0B^% z6SBnzv@k2%kpoE+7pS?^23WTNbXq_Hw_~zY#)U%M9Q)$2u;{^`i3;4f_z?|vN)D~? zN2g(eEs>3rn7xl!t*Tr+>a=|1Zw>C*_c03)kr5}1Y*W5OaCl-sUo^5ZdhELCP{LT~ z5e)h=mH=4;?ru*hQkHdV`5(b+6Wqlx6F6m;tudSBMk@WLM)$)`{ozEE^Y^IL>4@bh z8dvy(j?;=k+#~e3N~3y9<`O;0i`K2C*=V8GI}mFwjuTwDE>CR@w$WrqdMtL%kH5r9w*4NQy@? z_7Jw8fM4|y+k$!le@lKVmxl|@JHK(zB{gz?kS?TpLhfCa8x`I8UFthcY8vaNA~Zqv zT(b-=D}@~124V)fgSrH|d=7DLXZreLC9!;Ylt}yI&ZR7&ULhkdW3?7uJF*#itdyc2 zv_{gIU_jpQNO@EBc$XUc`#QDGhk2n&byl9AS?ou6qulL(~FV zRV2GLT+|k;kO8T8F!2WK za4~bCmsN0(+!Wrhgt810r<3ELLVK%W&m<{ zxLh_oOj|PH(@xy~^iREvv&{hiMLcZi4~Ks}or92J-T_qHjVlk>@v+?~2%mUdr`Xsf zq2%#&JU1iF_aZuvU^+g4AlygF?_C7%dzjp<$C*wMnKWvxkL5OAE-tF7qJhBB=@1jl zfmma`J*(F@%iv6)6R3S@(+1=w!S(cfwo@bmpU;yJ$PR!xwtI_^g7FEvM%Rm-NCBu9 z`9Gk|NXh?mqZ+s(Pzmz?i`Vly{5OJs|JMcnc}D*U+3t9ys{x?r&*3i&!QT{;=RRBp zfzR>ke{Veezg&>bK#<9>W*w0>qjMs5lgzGsXT1b?xf-(2AIl|7WMW(aS5jNc5>wQ%0?O-iC~KkgHUchqFfDO7B6V&8F}g(vj(P6--UI&@Ezw(_`TG6BbXifFm*M6#b{75X(lK}@nKUtMnF*PAgr)D105gvg!tFn(f80uR|!MWzgc$dB#3{1p!`Xs8st57bh` zBi$r<^K2fZa8>EpB`_+o1onH$@wE2rV%%vC{wDag8{47CKyuSEMXXJ?aKKZ#DA5ND z-X|?q`|-Uc_Y}k@5ln>D=)F`H%x);-wbi(kdVd;$Q+nuDPwB}|eB4wU}+!hw_gYYFB5P9U1 zdY=WzvmMZT)5PUjs(WwVN$3w1DI8+m^~@LSs(3b|H=6AfhzqDJUxVZH!HsTy9&-+M&i<{stG8O={#=|E=%-6_t)9_k;ms$VqzOihfb$` zBV-~EohOqI#YN|DjJ?YO#XstQ(OFZ&-+23(v!xXcZPxpiDJ`(;Y$#t7r*&sh_p8rr z#QQae%G)yuo~)hmtBcY}jd}V^GbLpQ8BTOz=Nmd6!U1+a68W(rY0?=LZLGBL&>N*g zodBUhcx%wK$l8)(3C)OVS5DyYvnmX4+|5K&*uL%$8D zvs0q84DE0!bLpG+1?u3Q+ zH<%9QAc_Gf8iAncZgXC_pPW>CR^}dDjQk@yhJ&^hd=HCNYK0T=Kmh|iON>Hl%S=_r z+3N`piqi{zEDXtdlivEo)Ve0-@+Jd0#a+@px_i5rDDCa(W!~F!qcMiH29HYBQamAJ z^p?hjn_1Db{@CsQB^G_6IBIFPd9I2>JMWOYC{@T1du;1tO`S`H5g5UH5320FD2aN2 zypk~tX{R3IZ=mz}@TOVshs%e6x28J}CbT^kOD&pSniyqhJxIy*_*KK5h6}xdP8X9} z#yX3U*)oZNNCP{#n2zBv^MgyO`G``=S(m>oo|lPGAR04)E6`pEF>d;9*pdwAiIei- zApfYWE7ZgFHo5RR(9<9w(F**a9?PBI zd&VKb=W%Lhmr0y=A8b69@e8W=sR#}n$3EEXp{FqP=VOml-1@Up6{>$I;nTSYC2iy> z`4o~*p<+PTNDIqxIE=@NJ0tx!1E38V?A-1KiSiK>=J_YUc3yD#RXf{bJi1a@IV4~S zlcBHKz!KPggpL~q;17HDJz#Y0|4R?%Ib-y>_y6aP_=7hvj;E)s`7vAI%WG)#^AhHh WtY5U|$!#Zeh+;z0f~EYrKmG??uT4Mz literal 0 HcmV?d00001 diff --git a/docs/screenshots/report.png b/docs/screenshots/report.png new file mode 100644 index 0000000000000000000000000000000000000000..1797f4c528fde424befb3169b1083066f44ad49a GIT binary patch literal 129116 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 UH*s!+!9t;qDrp=cDwtmWKhIXhPXGV_ literal 0 HcmV?d00001 diff --git a/docs/screenshots/retest.png b/docs/screenshots/retest.png new file mode 100644 index 0000000000000000000000000000000000000000..0447061a3c8ec2732f825569ec326214d7f09328 GIT binary patch literal 111033 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 i@C5GyUp_?s3Kd0R`gz>IL`W9$$5P_*VkIJ Date: Wed, 7 Oct 2026 18:25:17 +0800 Subject: [PATCH 09/15] ci: lock Python 3.10 pytest compatibility dependencies --- docs/DEPENDENCIES.md | 5 +++ docs/dependency-audit.json | 88 ++++++++++++++++++++++++++++++++++++++ requirements-dev.txt | 3 ++ requirements.lock | 49 +++++++++++++++++++++ 4 files changed, 145 insertions(+) diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index dfaefcb..14f5879 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -13,9 +13,14 @@ No runtime framework was added. Runtime uses Python's standard library plus the | pluggy | 1.6.0 | MIT | Pytest dependency | | Pygments | 2.19.2 | BSD-2-Clause | Pytest dependency | | colorama | 0.4.6 | BSD-3-Clause | Windows test output | +| exceptiongroup | 1.3.1 | MIT / PSF-2.0 | Pytest on Python 3.10 | +| tomli | 2.3.0 | MIT | Pytest on Python 3.10 | +| typing_extensions | 4.15.0 | PSF-2.0 | exceptiongroup on Python 3.10 | `requirements-runtime.lock` contains the two runtime releases. `requirements.lock` contains the complete test environment. Both use release artifact hashes; run `python -m pip install --require-hashes -r `. The corresponding `.txt` files are readable version manifests. Source tag commits were checked against official repositories; installed artifacts are PyPI distributions, not source builds. The audit records their separate identities without claiming bit-for-bit reproducibility between wheel and source commit. Hash verification verifies the selected distribution, not absence of vulnerabilities. +The three Python 3.10 compatibility packages are conditional test dependencies (`python_version < "3.11"`). Their audited source commits and release hashes are recorded separately from the local Python 3.13 installation; their actual installation is recorded in the Python 3.10 CI logs. These packages are excluded from runtime bundles. + CI Actions are fixed to reviewed commits: `actions/checkout` v4.2.2 at `11bd71901bbe5b1630ceea73d27597364c9af683`; `actions/setup-python` v5.6.0 at `a26af69be951a213d495a4c3e4e4022e16d87065` (both MIT). Runner images and Python patch versions remain platform-managed and are not fully reproducible artifacts. No new third-party Skill was installed or cloned. The optional browser regression uses an existing local Playwright/Chrome installation. It is not part of the application runtime or hash-locked Python environment. See [validation](VALIDATION.md) for the versions actually exercised. diff --git a/docs/dependency-audit.json b/docs/dependency-audit.json index 4c343ca..8afc9a7 100644 --- a/docs/dependency-audit.json +++ b/docs/dependency-audit.json @@ -222,6 +222,94 @@ "08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" ] + }, + { + "name": "exceptiongroup", + "version": "1.3.1", + "repo": "agronholm/exceptiongroup", + "commit": "ddddb6fdf8582c4ae5187dc1bd258115974229fe", + "license": "MIT AND PSF-2.0", + "environment_marker": "python_version < \"3.11\"", + "audited_at_utc": "2026-10-07T10:24:45+00:00", + "installed_at_utc": null, + "installation_record": "Installed only by the Python 3.10 CI jobs; see the matching GitHub Actions run logs.", + "allowed_artifact_sha256": [ + "8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", + "a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" + ], + "artifact_metadata_url": "https://pypi.org/pypi/exceptiongroup/1.3.1/json" + }, + { + "name": "tomli", + "version": "2.3.0", + "repo": "hukkin/tomli", + "commit": "3fccd16450d0f1d87c042473d95a07f60955206e", + "license": "MIT", + "environment_marker": "python_version < \"3.11\"", + "audited_at_utc": "2026-10-07T10:24:45+00:00", + "installed_at_utc": null, + "installation_record": "Installed only by the Python 3.10 CI jobs; see the matching GitHub Actions run logs.", + "allowed_artifact_sha256": [ + "00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", + "0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", + "0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", + "0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", + "1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", + "4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", + "4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", + "4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", + "4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", + "5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", + "5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", + "64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", + "70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", + "73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", + "74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", + "792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", + "7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", + "883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", + "88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", + "8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", + "940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", + "97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", + "a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", + "a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", + "a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", + "a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", + "ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", + "b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", + "b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", + "b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", + "be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", + "c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", + "c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", + "c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", + "cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", + "d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", + "d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", + "e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", + "e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", + "f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", + "feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", + "ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876" + ], + "artifact_metadata_url": "https://pypi.org/pypi/tomli/2.3.0/json" + }, + { + "name": "typing_extensions", + "version": "4.15.0", + "repo": "python/typing_extensions", + "commit": "9d1637e264b5c1a6b7acee3e907015f89b20c2c9", + "license": "PSF-2.0", + "environment_marker": "python_version < \"3.11\"", + "audited_at_utc": "2026-10-07T10:24:46+00:00", + "installed_at_utc": null, + "installation_record": "Installed only by the Python 3.10 CI jobs; see the matching GitHub Actions run logs.", + "allowed_artifact_sha256": [ + "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "artifact_metadata_url": "https://pypi.org/pypi/typing_extensions/4.15.0/json" } ] } diff --git a/requirements-dev.txt b/requirements-dev.txt index 43d5519..efc45ac 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,3 +6,6 @@ packaging==25.0 pluggy==1.6.0 pygments==2.19.2 colorama==0.4.6 +exceptiongroup==1.3.1; python_version < "3.11" +tomli==2.3.0; python_version < "3.11" +typing_extensions==4.15.0; python_version < "3.11" diff --git a/requirements.lock b/requirements.lock index ceb62ab..a1f73c6 100644 --- a/requirements.lock +++ b/requirements.lock @@ -131,3 +131,52 @@ Pygments==2.19.2 \ colorama==0.4.6 \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +exceptiongroup==1.3.1; python_version < "3.11" \ + --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 +tomli==2.3.0; python_version < "3.11" \ + --hash=sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456 \ + --hash=sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845 \ + --hash=sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999 \ + --hash=sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0 \ + --hash=sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878 \ + --hash=sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf \ + --hash=sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3 \ + --hash=sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be \ + --hash=sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52 \ + --hash=sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b \ + --hash=sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67 \ + --hash=sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549 \ + --hash=sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba \ + --hash=sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22 \ + --hash=sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c \ + --hash=sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f \ + --hash=sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6 \ + --hash=sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba \ + --hash=sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45 \ + --hash=sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f \ + --hash=sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77 \ + --hash=sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606 \ + --hash=sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441 \ + --hash=sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0 \ + --hash=sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f \ + --hash=sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530 \ + --hash=sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05 \ + --hash=sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8 \ + --hash=sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005 \ + --hash=sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879 \ + --hash=sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae \ + --hash=sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc \ + --hash=sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b \ + --hash=sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b \ + --hash=sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e \ + --hash=sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf \ + --hash=sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac \ + --hash=sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8 \ + --hash=sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b \ + --hash=sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf \ + --hash=sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463 \ + --hash=sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876 +typing_extensions==4.15.0; python_version < "3.11" \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 From a9bbf872bb53a7dbbd87d86c85f3f009dfc72a9b Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:28:04 +0800 Subject: [PATCH 10/15] fix: cap TCP receive timeout across Windows clock precision --- core/transport.py | 3 ++- tests/test_protocol_regressions.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/core/transport.py b/core/transport.py index f7109a3..57f39eb 100644 --- a/core/transport.py +++ b/core/transport.py @@ -17,7 +17,8 @@ def receive_response(sock, timeout, max_bytes=4096, complete=None): remaining = deadline - time.monotonic() if remaining <= 0: break - sock.settimeout(remaining) + # Coarse clocks can round deadline subtraction above the original budget. + sock.settimeout(min(timeout, remaining)) try: fragment = sock.recv(max_bytes - len(data)) except socket.timeout: diff --git a/tests/test_protocol_regressions.py b/tests/test_protocol_regressions.py index 93c6a12..376905a 100644 --- a/tests/test_protocol_regressions.py +++ b/tests/test_protocol_regressions.py @@ -69,6 +69,24 @@ def test_timeout_preserves_partial_response(): assert receive_response(FragmentSocket([b'partial', socket.timeout()]), .1) == b'partial' +@pytest.mark.parametrize('epoch', [1_000_000.0, 1_000_000_000.0]) +def test_tcp_receive_timeout_stays_within_budget_on_coarse_clock(monkeypatch, epoch): + from core import transport + ticks = iter([epoch, epoch, epoch + .025, epoch + .15]) + monkeypatch.setattr(transport.time, 'monotonic', lambda: next(ticks)) + class BudgetSocket(FragmentSocket): + def __init__(self): + super().__init__([b'PO', b'NG', AssertionError('deadline must stop reception')]) + self.timeouts = [] + def settimeout(self, value): + self.timeouts.append(value) + sock = BudgetSocket() + assert transport.receive_response(sock, .1) == b'PONG' + assert len(sock.timeouts) == 2 + assert all(0 < value <= .1 for value in sock.timeouts) + assert sock.timeouts[1] < sock.timeouts[0] + + def test_complete_mbap_frame_does_not_wait_for_eof(): from core.transport import receive_response, modbus_tcp_complete raw = bytes.fromhex('00 01 00 00 00 05 01 03 02 00 2A') From 685946e7f9f4f6999b915e5a46114b8d6f32737c Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:33:22 +0800 Subject: [PATCH 11/15] fix: authorize reports and bound paged acceptance history --- inspection/service.py | 12 +-- inspection/store.py | 86 ++++++++++++++++-- tests/browser_workflow.cjs | 86 +++++++++++++++++- tests/history_selection.cjs | 27 ++++++ tests/test_history_projection.py | 147 +++++++++++++++++++++++++++++++ tests/test_legacy_reports.py | 63 +++++++++++++ web/assets/app.js | 6 +- web/legacy.html | 32 ++++++- 8 files changed, 435 insertions(+), 24 deletions(-) create mode 100644 tests/history_selection.cjs create mode 100644 tests/test_history_projection.py create mode 100644 tests/test_legacy_reports.py diff --git a/inspection/service.py b/inspection/service.py index 81116a5..46bc75d 100644 --- a/inspection/service.py +++ b/inspection/service.py @@ -31,7 +31,7 @@ def __init__(self, db_path, demo=False, config_path=None, adapter=None): self.active_started = None self.cancel_event = threading.Event() try: - for job in self.store.all(): + for job in self.store.unfinished(): if job['status'] not in TERMINAL: job['status'] = 'interrupted' job['finished_at'] = utc_now() @@ -194,15 +194,7 @@ def retest(self, job_id, payload): return job def insights(self): - jobs = self.store.all() - output = {} - for mode in ('demo', 'live'): - records = [job for job in jobs if job['mode'] == mode] - finished = [job for job in records if job['status'] == 'completed'] - output[mode] = {'total': len(records), 'completed': len(finished), 'passed': sum(job['summary']['verdict'] == 'PASS' for job in finished), - 'failed': sum(job['summary']['verdict'] == 'FAIL' for job in finished), 'devices_passed': sum(job['summary']['passed'] for job in finished), - 'devices_expected': sum(job['summary']['expected'] for job in finished)} - return output + return self.store.insights() def close(self): with self.lock: diff --git a/inspection/store.py b/inspection/store.py index 47be18a..3bc964f 100644 --- a/inspection/store.py +++ b/inspection/store.py @@ -3,7 +3,21 @@ import threading from pathlib import Path -from .domain import STATUSES +from .domain import STATUSES, TERMINAL + + +HISTORY_FIELDS = ('id', 'created_at', 'status', 'mode', 'scenario', 'metadata', 'summary', + 'progress', 'config_hash', 'parent_job_id', 'started_at', 'finished_at', 'duration_ms') +INDEX_FIELDS = ('projection', 'metadata_search', 'id_search', 'mode', 'summary_verdict', + 'summary_passed', 'summary_expected') + + +def indexed_values(job): + """Keep evidence solely in the original record; indexes contain summary data.""" + summary = job['summary'] + return (json.dumps({key: job.get(key) for key in HISTORY_FIELDS}, ensure_ascii=False), + json.dumps(job['metadata'], ensure_ascii=False).casefold(), job['id'].casefold(), + job['mode'], summary['verdict'], summary['passed'], summary['expected']) class SQLiteStore: @@ -14,11 +28,32 @@ def __init__(self, path): self.connection = sqlite3.connect(str(path), check_same_thread=False, timeout=5) self.connection.execute('PRAGMA journal_mode=WAL') self.connection.execute('CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, record TEXT NOT NULL)') - self.connection.commit() + try: + with self.connection: + # sqlite3 does not start a transaction for DDL automatically. + self.connection.execute('BEGIN') + columns = {row[1] for row in self.connection.execute('PRAGMA table_info(jobs)')} + for name in INDEX_FIELDS: + if name not in columns: + kind = 'INTEGER' if name in ('summary_passed', 'summary_expected') else 'TEXT' + self.connection.execute('ALTER TABLE jobs ADD COLUMN ' + name + ' ' + kind) + # A cursor streams one old record at a time. Updating only derived columns + # preserves record bytes and rowid ordering, and a transaction makes retry safe. + cursor = self.connection.execute('SELECT rowid, record FROM jobs WHERE projection IS NULL') + for rowid, raw in cursor: + self.connection.execute('UPDATE jobs SET ' + ', '.join(name + '=?' for name in INDEX_FIELDS) + ' WHERE rowid=?', + (*indexed_values(json.loads(raw)), rowid)) + self.connection.execute('CREATE INDEX IF NOT EXISTS jobs_created ON jobs(created_at)') + self.connection.execute('CREATE INDEX IF NOT EXISTS jobs_status_created ON jobs(status, created_at)') + self.connection.execute('CREATE INDEX IF NOT EXISTS jobs_mode_status ON jobs(mode, status)') + except Exception: + self.connection.close() + raise def insert(self, job): with self.lock, self.connection: - self.connection.execute('INSERT INTO jobs VALUES (?, ?, ?, ?)', (job['id'], job['created_at'], job['status'], json.dumps(job, ensure_ascii=False))) + self.connection.execute('INSERT INTO jobs (id, created_at, status, record, ' + ', '.join(INDEX_FIELDS) + ') VALUES (' + ', '.join('?' for _ in range(4 + len(INDEX_FIELDS))) + ')', + (job['id'], job['created_at'], job['status'], json.dumps(job, ensure_ascii=False), *indexed_values(job))) def get(self, job_id): with self.lock: @@ -29,7 +64,8 @@ def get(self, job_id): def update(self, job): with self.lock, self.connection: - cursor = self.connection.execute('UPDATE jobs SET status=?, record=? WHERE id=?', (job['status'], json.dumps(job, ensure_ascii=False), job['id'])) + cursor = self.connection.execute('UPDATE jobs SET status=?, record=?, ' + ', '.join(name + '=?' for name in INDEX_FIELDS) + ' WHERE id=?', + (job['status'], json.dumps(job, ensure_ascii=False), *indexed_values(job), job['id'])) if not cursor.rowcount: raise KeyError('Job not found') @@ -43,11 +79,45 @@ def list(self, limit=20, offset=0, status='', query='', sort='newest'): if sort not in ('newest', 'oldest'): raise ValueError('Invalid sort') direction = 'DESC' if sort == 'newest' else 'ASC' + clauses, params = [], [] + if status: + clauses.append('status=?') + params.append(status) + if query: + # instr is literal substring search, so % and _ keep their original meaning. + clauses.append('(instr(metadata_search, ?) > 0 OR instr(id_search, ?) > 0)') + params.extend((query.casefold(), query.casefold())) + where = ' WHERE ' + ' AND '.join(clauses) if clauses else '' + with self.lock, self.connection: + total = self.connection.execute('SELECT count(*) FROM jobs' + where, params).fetchone()[0] + rows = self.connection.execute('SELECT projection, created_at, status FROM jobs' + where + + ' ORDER BY created_at ' + direction + ', rowid ' + direction + ' LIMIT ? OFFSET ?', + (*params, limit, offset)).fetchall() + jobs = [dict(json.loads(raw), created_at=created, status=saved_status) for raw, created, saved_status in rows] + return {'jobs': jobs, 'total': total, 'limit': limit, 'offset': offset, 'sort': sort} + + def unfinished(self): + """Stream full records only when startup recovery must modify them.""" + with self.lock: + cursor = self.connection.execute('SELECT record FROM jobs WHERE status NOT IN (' + + ', '.join('?' for _ in TERMINAL) + ')', tuple(TERMINAL)) + for row in cursor: + yield json.loads(row[0]) + + def insights(self): + output = {mode: dict(total=0, completed=0, passed=0, failed=0, devices_passed=0, devices_expected=0) + for mode in ('demo', 'live')} with self.lock: - rows = self.connection.execute('SELECT record FROM jobs ORDER BY created_at ' + direction + ', rowid ' + direction).fetchall() - jobs = [json.loads(row[0]) for row in rows] - jobs = [job for job in jobs if (not status or job['status'] == status) and (not query or query.casefold() in json.dumps(job['metadata'], ensure_ascii=False).casefold() or query.casefold() in job['id'].casefold())] - return {'jobs': jobs[offset:offset + limit], 'total': len(jobs), 'limit': limit, 'offset': offset, 'sort': sort} + rows = self.connection.execute("""SELECT mode, count(*), + sum(status='completed'), sum(status='completed' AND summary_verdict='PASS'), + sum(status='completed' AND summary_verdict='FAIL'), + sum(CASE WHEN status='completed' THEN summary_passed ELSE 0 END), + sum(CASE WHEN status='completed' THEN summary_expected ELSE 0 END) + FROM jobs GROUP BY mode""").fetchall() + for mode, *values in rows: + if mode in output: + output[mode] = dict(zip(output[mode], values)) + return output def all(self): with self.lock: diff --git a/tests/browser_workflow.cjs b/tests/browser_workflow.cjs index be6224d..d35191e 100644 --- a/tests/browser_workflow.cjs +++ b/tests/browser_workflow.cjs @@ -86,6 +86,59 @@ const { chromium } = require('playwright'); return (await fetch('/api/jobs?sort=oldest&limit=20', { headers: token ? { Authorization: `Bearer ${token}` } : {} })).json(); }); assert.equal(await page.locator('[data-action="open"]').first().getAttribute('data-id'), ordered.jobs[0].id); + // Actual retained tasks and comparison route, with oldest/newest on different pages. + const crossPage = await page.evaluate(async () => { + const token = sessionStorage.getItem('qldc.token'); + const headers = {'Content-Type': 'application/json', ...(token ? {Authorization: `Bearer ${token}`} : {})}; + const ids = [], marker = `CROSS-PAGE-${Date.now()}`; + for (let i = 0; i < 21; i++) { + const response = await fetch('/api/jobs', {method: 'POST', headers, + body: JSON.stringify({station_id: 'CROSS-PAGE ', batch: marker, scenario: i === 0 ? 'faults' : 'healthy'})}); + if (!response.ok) throw Error('fixture creation failed'); + const job = (await response.json()).job; + ids.push(job.id); + let finished = false; + for (let attempt = 0; attempt < 200; attempt++) { + const saved = (await (await fetch(`/api/jobs/${job.id}`, {headers})).json()).job; + if (['completed', 'failed', 'cancelled', 'interrupted'].includes(saved.status)) {finished = true;break;} + await new Promise(resolve => setTimeout(resolve, 30)); + } + if (!finished) throw Error('fixture task did not finish'); + } + return {baseline: ids[0], current: ids[20], marker}; + }); + await page.getByLabel('搜索记录').fill(crossPage.marker); + await page.getByRole('button', {name: '筛选', exact: true}).click(); + await page.locator('[data-action="open"]').first().waitFor(); + await page.locator(`select[name="baseline"] option[value="${crossPage.baseline}"]`).waitFor({state: 'attached'}); + await page.getByLabel('基线任务').selectOption(crossPage.baseline); + await page.getByRole('button', {name: '下一页', exact: true}).click(); + await page.locator(`select[name="current"] option[value="${crossPage.current}"]`).waitFor({state: 'attached'}); + assert.equal(await page.getByLabel('基线任务').inputValue(), crossPage.baseline); + await page.getByLabel('当前任务').selectOption(crossPage.current); + await page.getByRole('button', {name: '上一页', exact: true}).click(); + await page.locator(`select[name="baseline"] option[value="${crossPage.baseline}"]`).waitFor({state: 'attached'}); + assert.equal(await page.getByLabel('当前任务').inputValue(), crossPage.current); + await page.getByLabel('搜索记录').fill('DEMO-FAT-01'); + await page.getByRole('button', {name: '筛选', exact: true}).click(); + await page.locator('[data-action="open"]').first().waitFor(); + assert.equal(await page.getByLabel('基线任务').inputValue(), crossPage.baseline); + assert.equal(await page.getByLabel('当前任务').inputValue(), crossPage.current); + assert.equal(await page.locator('#compare-form script').count(), 0); + const comparisonResponse = page.waitForResponse(response => response.url().includes('/api/compare?')); + await page.getByRole('button', {name: '比较任务', exact: true}).click(); + const compared = await comparisonResponse; + const comparedUrl = new URL(compared.url()); + assert.equal(comparedUrl.searchParams.get('baseline'), crossPage.baseline); + assert.equal(comparedUrl.searchParams.get('current'), crossPage.current); + const actualComparison = (await compared.json()).comparison; + assert.equal(actualComparison.baseline_id, crossPage.baseline); + assert.equal(actualComparison.current_id, crossPage.current); + assert.equal(actualComparison.comparable, true); + assert.equal(actualComparison.whole_unit_recovered, true); + await page.getByTestId('comparison').waitFor(); + assert.ok((await page.getByTestId('comparison').innerText()).includes('恢复')); + await page.screenshot({path: 'output/playwright/cross-page-comparison.png', fullPage: true}); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: 'output/playwright/mobile.png', fullPage: true }); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); @@ -105,7 +158,38 @@ const { chromium } = require('playwright'); assert.ok((await page.getByRole('dialog', { name: '设备测试证据' }).innerText()).includes('UI-CARRIER-RESPONSE')); assert.equal(await page.locator('#evidence-body script').count(), 0); assert.equal(await page.evaluate(() => window.badInjected), undefined); + // Browser rendering/click/download behavior, with synthetic protected report + // responses. test_legacy_reports.py separately covers the real loopback server. + const legacy = await browser.newPage({viewport: {width: 1200, height: 900}}); + legacy.on('pageerror', e => errors.push(e.message)); + await legacy.goto(url + '/legacy'); + await legacy.evaluate(() => { + sessionStorage.setItem('qldc.token', 'browser-fixture-token'); + renderReport({summary: {passed: 1, failed: 0}, report: {url: '/report/fixture/report.html', + html_url: '/report/fixture/report.html', txt_url: '/report/fixture/report.txt', json_url: '/report/fixture/report.json'}}); + }); + await legacy.route('**/report/fixture/report.*', async route => { + const authorized = route.request().headers().authorization === 'Bearer browser-fixture-token'; + const format = new URL(route.request().url()).pathname.split('.').pop(); + await route.fulfill({status: authorized ? 200 : 401, + contentType: {html: 'text/html', txt: 'text/plain', json: 'application/json'}[format], + body: authorized ? `synthetic browser report ${format}` : '{"ok":false,"error":"Access token required"}'}); + }); + for (const [label, format] of [['网页', 'html'], ['TXT', 'txt'], ['JSON', 'json']]) { + const downloaded = legacy.waitForEvent('download'); + await legacy.getByRole('button', {name: label, exact: true}).click(); + const file = await downloaded; + assert.equal(file.suggestedFilename(), `report.${format}`); + assert.equal(await fs.readFile(await file.path(), 'utf8'), `synthetic browser report ${format}`); + assert.ok(!file.url().includes('token')); + } + await legacy.evaluate(() => sessionStorage.setItem('qldc.token', 'wrong')); + await legacy.getByRole('button', {name: '网页', exact: true}).click(); + await legacy.locator('#statusText').filter({hasText: '401'}).waitFor(); + assert.equal(await legacy.locator('a[href*="token"]').count(), 0); + await legacy.screenshot({path: 'output/playwright/legacy-protected-report.png', fullPage: true}); + await legacy.close(); assert.deepEqual(errors, []); - console.log(JSON.stringify({ result: 'PASS', baselineId, consoleErrors: errors, screenshots: ['desktop.png', 'mobile.png'] })); + console.log(JSON.stringify({ result: 'PASS', baselineId, crossPage, consoleErrors: errors, screenshots: ['desktop.png', 'mobile.png', 'cross-page-comparison.png', 'legacy-protected-report.png'] })); } finally { await browser.close(); } })().catch(err => { console.error(err); process.exitCode = 1; }); diff --git a/tests/history_selection.cjs b/tests/history_selection.cjs new file mode 100644 index 0000000..709368f --- /dev/null +++ b/tests/history_selection.cjs @@ -0,0 +1,27 @@ +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const listeners = {}; +const main = { innerHTML: '' }; +const context = { document: {querySelector: () => main, addEventListener: (name, handler) => listeners[name] = handler}, + sessionStorage: {getItem: () => ''}, clearTimeout, setTimeout, URL, console, + fetch: async url => ({ok: true, json: async () => ({total: 21, jobs: url.includes('offset=20') ? [job(0)] : Array.from({length: 20}, (_, i) => job(20 - i))})}) }; +function job(n) { return {id: `job-${n}`, metadata: {station_id: ''}, + created_at: '2026-10-07', status: 'completed', summary: {verdict: 'PASS', passed: 1, expected: 1}}; } +vm.createContext(context); +vm.runInContext(fs.readFileSync('web/assets/app.js', 'utf8').replace(/\nboot\(\);\s*$/, ''), context); +async function choose(name, value, label) { + await listeners.change({target: {name, value, selectedOptions: [{textContent: label}], closest: () => ({id: 'compare-form'})}}); +} +(async () => { + await vm.runInContext('history()', context); + await choose('current', 'job-20', ' · newest'); + await vm.runInContext('S.offset=20; history()', context); + assert.match(main.innerHTML, /value="job-20" selected/); + await choose('baseline', 'job-0', ' · oldest'); + await vm.runInContext("S.offset=0; S.query='filtered'; history()", context); + assert.match(main.innerHTML, /value="job-0" selected/); + assert.match(main.innerHTML, /value="job-20" selected/); + assert.ok(!main.innerHTML.includes('')); + console.log('PASS: comparison selections and escaped labels survive paging/filtering'); +})().catch(e => {console.error(e); process.exitCode = 1;}); diff --git a/tests/test_history_projection.py b/tests/test_history_projection.py new file mode 100644 index 0000000..66056db --- /dev/null +++ b/tests/test_history_projection.py @@ -0,0 +1,147 @@ +"""History reads must scale with page size, while full evidence survives migration.""" +import json +import sqlite3 +import tracemalloc + +import pytest + +from inspection.domain import refresh +from inspection.service import InspectionService +from inspection.store import SQLiteStore + + +def record(number, mode='demo', status='completed', verdict='PASS'): + return refresh({'id': f'job-{number:04}', 'created_at': '2026-10-07T00:00:00.000Z', + 'status': status, 'mode': mode, 'scenario': 'healthy', + 'metadata': {'station_id': 'Straße 工位', 'batch': 'literal%_batch', + 'operator': 'Alice', 'notes': ''}, + 'config_hash': 'hash', 'config_snapshot': {'evidence': 'X' * 80000}, + 'device_ids': ['device'], 'parent_job_id': None, 'started_at': None, + 'finished_at': None, 'duration_ms': 0, 'events': [], + 'results': [{'device_id': 'device', 'verdict': verdict, 'attempts': [{'response': 'original'}], + 'supporting_checks': [{'response': 'carrier'}]}]}) + + +def test_history_and_insights_do_not_decode_retained_evidence(tmp_path, monkeypatch): + service = InspectionService(tmp_path / 'jobs.db', demo=True) + try: + for i in range(300): + service.store.insert(record(i, mode='live' if i % 2 else 'demo', verdict='FAIL' if i % 3 == 0 else 'PASS')) + original_loads = json.loads + decoded = [] + def bounded_loads(value, *args, **kwargs): + decoded.append(len(value)) + assert 'config_snapshot' not in value, 'history decoded full evidence' + return original_loads(value, *args, **kwargs) + monkeypatch.setattr(json, 'loads', bounded_loads) + tracemalloc.start() + history = service.list_jobs(limit=20, offset=20) + insights = service.insights() + peak = tracemalloc.get_traced_memory()[1] + tracemalloc.stop() + assert len(history['jobs']) == 20 and history['total'] == 300 + assert len(decoded) <= 20 and peak < 4 * 1024 * 1024 + assert insights == {mode: {'total': 150, 'completed': 150, 'passed': 100, 'failed': 50, + 'devices_passed': 100, 'devices_expected': 150} for mode in ('demo', 'live')} + finally: + if tracemalloc.is_tracing(): + tracemalloc.stop() + service.close() + + +def test_old_four_column_database_streams_migration_and_preserves_exact_json(tmp_path): + path = tmp_path / 'old.db' + connection = sqlite3.connect(path) + connection.execute('CREATE TABLE jobs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, record TEXT NOT NULL)') + original = {} + for i in range(120): + job = record(i) + raw = json.dumps(job, ensure_ascii=False, indent=1) + original[job['id']] = raw + connection.execute('INSERT INTO jobs VALUES (?, ?, ?, ?)', (job['id'], job['created_at'], job['status'], raw)) + connection.commit() + connection.close() + tracemalloc.start() + store = SQLiteStore(path) + peak = tracemalloc.get_traced_memory()[1] + tracemalloc.stop() + try: + assert peak < 4 * 1024 * 1024 + assert dict(store.connection.execute('SELECT id, record FROM jobs')) == original + page = store.list(limit=2, offset=1, query='STRASSE', sort='oldest') + assert page['total'] == 120 + assert [job['id'] for job in page['jobs']] == ['job-0001', 'job-0002'] + assert 'config_snapshot' not in page['jobs'][0] + assert store.get('job-0001') == json.loads(original['job-0001']) + finally: + store.close() + store = SQLiteStore(path) + try: + assert store.list(limit=1)['total'] == 120 + assert store.connection.execute('SELECT record FROM jobs WHERE id=?', ('job-0001',)).fetchone()[0] == original['job-0001'] + finally: + store.close() + + +def test_casefold_literal_filters_tied_order_and_updates(tmp_path): + store = SQLiteStore(tmp_path / 'jobs.db') + try: + for i in range(4): + job = record(i) + if i == 2: + job['metadata']['batch'] = 'literalXXbatch' + store.insert(job) + assert store.list(query='%_')['total'] == 3 + assert store.list(query='STRASSE')['total'] == 4 + assert store.list(query='JOB-0002')['total'] == 1 + assert [j['id'] for j in store.list(query='%_', limit=2, offset=1, sort='oldest')['jobs']] == ['job-0001', 'job-0003'] + assert [j['id'] for j in store.list(limit=2, sort='newest')['jobs']] == ['job-0003', 'job-0002'] + changed = store.get('job-0001') + changed['metadata']['station_id'] = '更新站' + changed['status'] = 'failed' + store.update(changed) + assert store.list(status='failed', query='更新站')['jobs'][0]['id'] == 'job-0001' + assert store.list(query='STRASSE')['total'] == 3 + assert store.list(offset=10)['jobs'] == [] + finally: + store.close() + + +def test_restart_decodes_only_unfinished_mixed_history(tmp_path, monkeypatch): + path = tmp_path / 'jobs.db' + store = SQLiteStore(path) + for i in range(100): + store.insert(record(i, mode='live' if i % 2 else 'demo')) + for i, status in enumerate(('queued', 'running', 'cancelling'), 100): + store.insert(record(i, mode='live', status=status, verdict='NOT_RUN')) + store.close() + original_loads = json.loads + full_decodes = [] + def tracked_loads(value, *args, **kwargs): + if 'config_snapshot' in value: + full_decodes.append(value) + return original_loads(value, *args, **kwargs) + monkeypatch.setattr(json, 'loads', tracked_loads) + service = InspectionService(path, demo=True) + try: + assert len(full_decodes) == 3 + assert service.list_jobs(status='interrupted')['total'] == 3 + recovered = service.get('job-0101') + assert recovered['events'][-1]['type'] == 'interrupted' + assert recovered['results'][0]['attempts'] == [{'response': 'original'}] + assert service.insights()['live']['completed'] == 50 + assert service.get('job-0001')['status'] == 'completed' + finally: + service.close() + + +def test_migration_error_rolls_back_schema_and_preserves_records(tmp_path): + path = tmp_path / 'corrupt.db' + with sqlite3.connect(path) as connection: + connection.execute('CREATE TABLE jobs (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, record TEXT NOT NULL)') + connection.execute('INSERT INTO jobs VALUES (?, ?, ?, ?)', ('broken', 'date', 'completed', '{broken')) + with pytest.raises(json.JSONDecodeError): + SQLiteStore(path) + with sqlite3.connect(path) as connection: + assert [row[1] for row in connection.execute('PRAGMA table_info(jobs)')] == ['id', 'created_at', 'status', 'record'] + assert connection.execute('SELECT record FROM jobs').fetchone()[0] == '{broken' diff --git a/tests/test_legacy_reports.py b/tests/test_legacy_reports.py new file mode 100644 index 0000000..113f330 --- /dev/null +++ b/tests/test_legacy_reports.py @@ -0,0 +1,63 @@ +"""Execute the shipped report helper against actual protected loopback routes.""" +import json +import re +import shutil +import subprocess + +import pytest + +from test_http import api + + +def test_legacy_report_helper_authorizes_blobs_and_rejects_bad_credentials(api, tmp_path, monkeypatch): + import inspection.http + svc, server, request = api + # Only serve static synthetic files; this never invokes a hardware operation. + script = re.search(r'', request('GET', '/legacy')[1].decode()).group(1) + monkeypatch.setattr(inspection.http, 'BASE_DIR', tmp_path) + svc.demo = False + root = tmp_path / 'report' / 'fixture' + root.mkdir(parents=True) + for suffix in ('html', 'txt', 'json'): + (root / ('report.' + suffix)).write_text('synthetic report ' + suffix, encoding='utf8') + server.access_token = 'session-secret' + node = shutil.which('node') + if not node: + pytest.skip('Node required to execute actual legacy helper') + code = r""" +const vm = require('node:vm'), assert = require('node:assert/strict'); +const script = JSON.parse(process.argv[1]), base = process.argv[2]; +const values = new Map([['qldc.token','session-secret']]); +const blobs = [], clicked = [], revoked = []; +const element = {addEventListener(){}, querySelectorAll(){return []}}; +const document = {getElementById:()=>element, addEventListener(){}, body:{append(){}}, + createElement:()=>({click(){clicked.push({href:this.href,download:this.download})},remove(){}})}; +const context = {document, Blob, URL:{createObjectURL:b=>{blobs.push(b);return 'blob:local-'+blobs.length},revokeObjectURL:u=>revoked.push(u)}, + setTimeout:f=>f(), sessionStorage:{getItem:k=>values.get(k)}, + fetch:(url,opts)=>{assert.ok(!url.includes('secret'));return fetch(base+url,opts)}}; +vm.createContext(context);vm.runInContext(script,context); +(async()=>{ + for(const format of ['html','txt','json']){ + await vm.runInContext(`openReport('/report/fixture/report.${format}')`,context); + assert.equal(await blobs.at(-1).text(),'synthetic report '+format); + assert.equal(blobs.at(-1).type.replace(/\s/g,''),{html:'text/html;charset=utf-8',txt:'text/plain;charset=utf-8',json:'application/json;charset=utf-8'}[format]); + } + assert.equal(clicked.length,3);assert.equal(revoked.length,3); + for(const c of clicked){assert.ok(c.href.startsWith('blob:'));assert.ok(!JSON.stringify(c).includes('secret'));} + for(const token of ['', 'wrong']){ + values.set('qldc.token',token); + await assert.rejects(vm.runInContext("openReport('/report/fixture/report.html')",context)); + } + assert.equal(clicked.length,3); + for(const url of ['https://evil.example/report/x.html','//evil/report/x.html','/api/scan','/report/x.html?token=secret']) + await assert.rejects(vm.runInContext(`openReport(${JSON.stringify(url)})`,context)); +})().catch(e=>{console.error(e);process.exitCode=1}); +""" + result = subprocess.run([node, '-e', code, json.dumps(script), f'http://127.0.0.1:{server.server_port}'], + capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr + assert request('GET', '/report/fixture/report.html')[0] == 401 + server.access_token = '' + code_local = code[:code.index('(async()=>{')] + "(async()=>{values.set('qldc.token','');await vm.runInContext(\"openReport('/report/fixture/report.txt')\",context);assert.equal(await blobs[0].text(),'synthetic report txt');})().catch(e=>{console.error(e);process.exitCode=1});" + result = subprocess.run([node, '-e', code_local, json.dumps(script), f'http://127.0.0.1:{server.server_port}'], capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr diff --git a/web/assets/app.js b/web/assets/app.js index 1c2b706..570ebbb 100644 --- a/web/assets/app.js +++ b/web/assets/app.js @@ -6,7 +6,7 @@ const verdicts={PASS:'通过',FAIL:'失败',REVIEW:'待确认',NOT_RUN:'未执 const scenarios={healthy:'全部正常',faults:'混合故障',timeout:'响应超时',crc:'CRC 错误',missing:'设备缺失'}; const scopes={communication_path:'通信路径验证',pci_passive:'PCI 被动检查'}; const faults={TIMEOUT:'响应超时',CRC:'CRC 校验失败',MISSING:'应检设备未响应',CARRIER:'载体通信失败',RESPONSE_MISMATCH:'响应不匹配',UNAVAILABLE:'接口或设备不可用',NOT_RUN:'尚未执行'}; -const S={catalog:null,job:null,view:'overview',offset:0,filter:'',sort:'newest',query:'',timer:null,token:sessionStorage.getItem('qldc.token')||''}; +const S={catalog:null,job:null,view:'overview',offset:0,filter:'',sort:'newest',query:'',comparison:{baseline:null,current:null},timer:null,token:sessionStorage.getItem('qldc.token')||''}; const done=s=>['completed','cancelled','failed','interrupted'].includes(s); const date=v=>v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'; const id=v=>String(v||'').slice(0,8).toUpperCase(); @@ -28,7 +28,9 @@ $('#main').innerHTML=heading('让每一次验收,有据可查。','从应检 `

`;} function newJob(){$('#main').innerHTML=heading('新建现场验收','记录工位与批次,按当前配置检查全部应检设备。')+demoNote()+`

验收信息

${S.catalog.devices.length} DEVICES
${S.catalog.mode==='demo'?``:''}
同一工位同一时间执行一个任务。

本次应检

${S.catalog.devices.map(d=>`
${esc(d.name)}${esc(scopes[d.scope])}
${esc(d.connection_type.toUpperCase())}
`).join('')}
未响应设备保留为失败项;取消后未执行的设备单独标记。
`;} function catalog(){$('#main').innerHTML=heading('应检设备清单','这份清单决定验收覆盖范围。设备无需先被扫描发现,才进入验收结果。','')+demoNote()+`

${S.catalog.devices.length} 个应检设备

当前配置
${S.catalog.devices.map(d=>``).join('')}
设备 / 标识接口类型接口 / 槽位协议验收范围
${device(d)}${esc(d.connection_type.toUpperCase())}${esc(d.interface||'按配置匹配')}${esc(d.protocol||'被动检查')}${esc(scopes[d.scope])}
配置摘要 ${esc(S.catalog.config_hash)}
${S.catalog.mode==='live'?'
':''}`;} -async function history(){const d=await api(`/api/jobs?limit=20&offset=${S.offset}&sort=${S.sort}&status=${encodeURIComponent(S.filter)}&query=${encodeURIComponent(S.query)}`);$('#main').innerHTML=heading('任务历史','按工位、批次或操作者查找验收记录,选择两次任务查看恢复与退化。','')+`
${d.jobs.length?jobTable(d.jobs):empty('未找到任务','调整筛选条件或创建新任务。')}
共 ${d.total} 个任务

两次任务比较

${[['baseline','基线任务'],['current','当前任务']].map(([key,label])=>``).join('')}
`;} +async function history(){const d=await api(`/api/jobs?limit=20&offset=${S.offset}&sort=${S.sort}&status=${encodeURIComponent(S.filter)}&query=${encodeURIComponent(S.query)}`);$('#main').innerHTML=heading('任务历史','按工位、批次或操作者查找验收记录,选择两次任务查看恢复与退化。','')+`
${d.jobs.length?jobTable(d.jobs):empty('未找到任务','调整筛选条件或创建新任务。')}
共 ${d.total} 个任务

两次任务比较

${[['baseline','基线任务'],['current','当前任务']].map(([key,label])=>``).join('')}
`;} +function comparisonOptions(key,jobs){const selected=S.comparison[key],choices=jobs.map(j=>({id:j.id,label:`${j.metadata.station_id||'未命名'} · ${id(j.id)} · ${verdicts[j.summary.verdict]}`}));if(selected&&!choices.some(j=>j.id===selected.id))choices.unshift(selected);return choices.map(j=>``).join('');} +document.addEventListener('change',event=>{const select=event.target;if(select.closest('#compare-form')&&['baseline','current'].includes(select.name))S.comparison[select.name]=select.value?{id:select.value,label:select.selectedOptions[0].textContent}:null;}); function config(){$('#main').innerHTML=heading('校验设备配置','检查设备标识、接口参数和只读验收规则。校验不会修改工位当前配置。')+`

配置内容

VALIDATE ONLY
`;} async function openJob(jobId){setView('task');S.job=null;$('#main').innerHTML=empty('正在读取任务','');S.job=(await api(`/api/jobs/${encodeURIComponent(jobId)}`)).job;task();poll(jobId);} function poll(jobId){clearTimeout(S.timer);if(!S.job||done(S.job.status)||S.view!=='task')return;S.timer=setTimeout(async()=>{try{const d=await api(`/api/jobs/${jobId}`);if(S.view!=='task'||S.job?.id!==jobId)return;S.job=d.job;task();poll(jobId);}catch(e){notice(e.message,true);}},650);} diff --git a/web/legacy.html b/web/legacy.html index 756fc83..343756d 100644 --- a/web/legacy.html +++ b/web/legacy.html @@ -396,6 +396,24 @@ return ["正常", "在线", "已发现"].includes(device.status); } + async function openReport(path) { + if (typeof path !== 'string' || !/^\/report\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.(html|txt|json)$/.test(path)) { + throw new Error('报告地址无效'); + } + const headers = {}, token = sessionStorage.getItem('qldc.token') || ''; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(path, {headers, cache: 'no-store', redirect: 'error'}); + if (!response.ok) throw new Error(`报告读取失败 (${response.status}),请检查访问凭证`); + const url = URL.createObjectURL(await response.blob()); + const link = document.createElement('a'); + link.href = url; + link.download = path.split('/').pop(); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 3000); + } + function stateView(device, mode) { if (mode === "scan") return { text: "已连接", cls: "good" }; if (isPassed(device)) return { text: "正常", cls: "good" }; @@ -499,9 +517,9 @@ root.innerHTML = `
通过 ${summary.passed || 0},失败 ${summary.failed || 0}
`; } @@ -628,6 +646,14 @@ runOperation("/api/run", "正在扫描并测试全部已发现设备", "全部测试完成"); }); document.addEventListener("click", event => { + const reportButton = event.target.closest('[data-report-path]'); + if (reportButton) { + reportButton.disabled = true; + openReport(reportButton.dataset.reportPath) + .catch(error => setStatus(error.message || '报告读取失败', 'error')) + .finally(() => { reportButton.disabled = false; }); + return; + } const scanButton = event.target.closest("[data-scan-interface]"); if (scanButton) { scanInterface(scanButton.dataset.interfaceKind, scanButton.dataset.interfaceName, scanButton); From 526fde1d7f3a82ea3194773ba89da7e3d19e970c Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:36:33 +0800 Subject: [PATCH 12/15] docs: record final history and report regression evidence --- docs/API.md | 2 ++ docs/ARCHITECTURE.md | 4 ++++ docs/VALIDATION.md | 16 +++++++++------- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/API.md b/docs/API.md index 6c48551..abf7b0b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,6 +2,8 @@ Base URL defaults to `http://127.0.0.1:8080`. JSON is UTF-8. When `QLDC_ACCESS_TOKEN` is set, send `Authorization: Bearer ` for `/api/*` and `/report/*`; never place credentials in query strings. Browser requests must use the station's own origin. POST bodies are JSON objects with a 64 KiB maximum. +`GET /api/jobs` returns lightweight history summaries: identity, timestamps, status, metadata, summary, progress, configuration hash and parent task ID. Full configuration snapshots, device results, attempts and events are available through task detail and export routes. Filtering/counting/pagination happen in SQLite without loading all saved evidence. + | Method | Route | Behavior | | --- | --- | --- | | GET | `/api/health` | Service mode, version, active job and persistence status | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 652719c..1643d7a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,8 @@ Each selected configured device starts `NOT_RUN`. The adapter preserves missing Task creation deep-copies configuration, hashes canonical JSON and commits it with metadata before execution. Results and events persist after each update. Retests use that original full snapshot and allow only original non-passing device IDs. Parent IDs retain the evidence chain. Structured attempts preserve every attributed protocol probe, including earlier failed attempts; child-only results retain parent-carrier supporting checks without adding the carrier to the selected result scope. The SHA-256 identifies configuration content; it is not a digital signature or tamper-proof audit log. +History returns summary projections using SQLite filtering, ordering and pagination; overview statistics use SQL aggregates. Full configuration and protocol evidence are read only for task detail/export or unfinished-task recovery. Existing four-column databases receive derived columns in one transaction, with one-record-at-a-time backfill that preserves original JSON and rowids. No historical evidence is deleted. Comparison selections survive history page and filter changes. + Comparisons check unit/station identity, batch, mode, configuration hash and selected/evidence scope. Related failed-only retests can compare shared devices, while declaring partial coverage. Communication-path and passive PCI checks are separate from electrical or business acceptance. ## Ownership and compatibility @@ -46,6 +48,8 @@ One process owns one database, enforced by an OS-held ownership lease acquired b The original `web_app.py` implementation is preserved as `legacy_web.py`; the old UI is served at `/legacy`. Legacy reports under `report/` remain independent of SQLite history. The configured active TCP path now assembles fragmented responses with bounded reads; Modbus validation checks frame length, byte counts, identity and expected quantity. +Legacy report buttons fetch HTML/TXT/JSON using the same session bearer token and download local Blob content. Tokens are never inserted into report URLs; authentication failures remain visible on the page. + ## Local deployment choices The design uses Python's standard-library HTTP server, threads and SQLite to keep a single-station deployment small. This is intended for a trusted local workstation, not a multi-tenant Internet service. A network-bound station requires a bearer token; managed TLS, user roles, retention, signed reports, distributed scheduling and production service supervision would be separate work. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 58477df..6ca7e01 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -23,21 +23,23 @@ See [DEMO.md](DEMO.md) for the optional browser workflow. CI runs the Python off | Area | Evidence | | --- | --- | | Coverage and verdicts | Missing expected devices, unfinished tasks, adapter failure and non-passing retests | -| State and persistence | Concurrent creation, lock conflicts, cancellation boundaries, restart recovery, bounded shutdown, no late passes | +| State and persistence | Concurrent creation, lock conflicts, cancellation boundaries, restart recovery, bounded shutdown, no late passes, evidence-preserving atomic old-schema migration | | Configuration | Snapshot survives file changes; original snapshot reused in retest; explicit write rejection | -| HTTP | Real ephemeral loopback requests, task lifecycle, history/search, comparison/export, malformed payloads, caps, traversal, Host/Origin/token checks | +| HTTP | Real ephemeral loopback requests, task lifecycle, bounded history/search/statistics, comparison/export, protected legacy report helper, malformed payloads, caps, traversal, Host/Origin/token checks | | Demo isolation | Subprocess assertion that the normal demo workflow never imports the hardware detector or legacy server | | Protocol | Fragmented configured active TCP, EOF, timeout/reset with partial evidence, Modbus CRC/truncation/identity/byte counts/quantity and write refusal | -| Browser | Fault task, evidence dialog, report download, repair retest, partial comparison, persisted history, configuration validation, desktop/390 px layout and page script errors | +| Browser | Fault task, evidence dialog, report download, repair retest, partial comparison, 21-task cross-page comparison with retained filter selections, protected legacy report downloads/error display, configuration validation, desktop/390 px layout and page script errors | ## Recorded results — 2026-10-07 - Clean hash-locked environment: CPython 3.13.0, Windows 11 build 26200, SQLite 3.45.3. `pip install --require-hashes -r requirements.lock` and dependency consistency check passed. -- Pytest: **85 passed**, independent clean-environment run completed in 19.90 seconds. Includes cross-process ownership, configuration-injected legacy requests, stable demo outcomes, identity-aware comparisons, every-probe/carrier evidence, incoming/outgoing TCP framing and response/lock release timing. -- Line coverage: **91%** for the new `inspection/` modules (693 statements, 62 uncovered); **85%** for the combined inspection/protocol-engine/transport selection (941 statements, 140 uncovered). These percentages do not cover the complete legacy detector. Windows run leaves POSIX locking branches uncovered locally; the CI matrix exercises POSIX separately. +- Pytest: **93 passed**, independent clean-environment run of source commit `685946e7f9f4f6999b915e5a46114b8d6f32737c` completed in 20.22 seconds. Includes cross-process ownership, configuration-injected legacy requests, stable demo outcomes, identity-aware comparisons, every-probe/carrier evidence, incoming/outgoing TCP framing, coarse-clock timeout bounds, response/lock release timing, bounded history reads and migration rollback. +- Line coverage: **92%** for the new `inspection/` modules (729 statements, 58 uncovered); **86%** for the combined inspection/protocol-engine/transport selection (977 statements, 135 uncovered). These percentages do not cover the complete legacy detector. Windows run leaves POSIX locking branches uncovered locally; the CI matrix exercises POSIX separately. - Original self-test: **23 offline checks passed**. Configuration validation: 5 root devices, 1 child, writes disabled. Python compile checks, JavaScript syntax checks and shell/PowerShell parsing passed. -- Browser: Node 24.19.0, Playwright 1.62.1, installed Chrome 155.0.8059.40. Desktop and 390 px workflow passed with **zero page script errors**. Structured evidence rendering used an explicitly labelled browser-only fixture to verify escaped content; screenshots show normal synthetic demo records. -- Release candidate `8872651`: Windows package extraction, fresh offline runtime installation with `PIP_NO_INDEX=1`, launcher argument forwarding, actual localhost server, fault task, export, healthy failed-only retest, scope comparison and restart/history preservation passed. Final bundles are rebuilt from the final tagged commit and checked again before upload. +- Browser: Node 24.19.0, Playwright 1.62.1, installed Chrome 155.0.8059.40. Desktop and 390 px workflow passed with **zero page script errors**. Cross-page comparison selected the first and 21st actual stored tasks and preserved both selections after changing the filter. Structured evidence and protected report browser tests use explicitly labelled synthetic fixtures; the Python/Node helper separately tests actual protected loopback report routes. README screenshots show normal demo records. +- Bounded-history regression: 300 records with 80 KiB snapshots; 20-row history decoded at most 20 projections and no full evidence, statistics decoded none. Peak Python allocations stayed below 4 MiB. A 120-record old-schema migration preserved original JSON/rowids and stayed below 4 MiB. Mixed recovery decoded only the three unfinished tasks among 103 records. These are regression fixtures, not a sustained-load benchmark. +- CI source `a9bbf872bb53a7dbbd87d86c85f3f009dfc72a9b`: Linux/Windows Python 3.10/3.13 all passed in [run 37607639342](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/actions/runs/37607639342). Final changed-source outcomes are available from [PR checks](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/pull/1/checks) and the Release notes. +- Release candidate `e0dc7fc`: Windows package extraction, fresh offline runtime installation with `PIP_NO_INDEX=1`, launcher argument forwarding, actual localhost server, fault task, export, healthy failed-only retest, scope comparison and restart/history preservation passed. Final bundles are rebuilt from the final tagged commit and checked again before upload; the final source/hash and smoke outcome are recorded in the Release notes. ## Scope and limits From a88a5f44a09fc17fdbf1ce58166e041a3d144e23 Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:05:51 +0800 Subject: [PATCH 13/15] docs: define bilingual interface and main integration plan --- .../plans/2026-10-08-english-interface.md | 44 +++++++++++++++++++ .../2026-10-08-english-interface-design.md | 11 +++++ 2 files changed, 55 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-08-english-interface.md create mode 100644 docs/superpowers/specs/2026-10-08-english-interface-design.md diff --git a/docs/superpowers/plans/2026-10-08-english-interface.md b/docs/superpowers/plans/2026-10-08-english-interface.md new file mode 100644 index 0000000..5530a6e --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-english-interface.md @@ -0,0 +1,44 @@ +# English Interface Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Execute continuously under the user's English-interface and main-integration authorization. + +**Goal:** Add a usable bilingual interface and report export, then merge verified upgrade into main and deliver matching packages. + +**Architecture:** One dependency-free local browser translation module, explicit translated UI strings, language-aware printable reports. Backend evidence stays authoritative and unchanged. + +**Tech Stack:** Existing standard-library Python HTTP/SQLite and plain JavaScript, existing pytest/Node/Playwright. + +## Global Constraints + +- English/en and Chinese/zh only; HTML export default zh preserves compatibility. +- No new dependency, hardware operation, credential-in-URL or translation of user metadata/evidence. +- Reuse isolated checkout E:/学习/实习/工控验收升级, current feature branch; preserve12existing commits and v3.0.0. +- Runtime/source provenance remains pinned. Publish only exact reviewed/verified source. + +### Task1: Bilingual current and legacy interface + +Files: new web/assets/i18n.js; web/index.html, web/legacy.html, web/assets/app.js/style.css; new tests/browser_i18n.cjs plus affected existing JavaScript-helper tests. Controller serves /assets/i18n.js via existing allowlist. + +Interface: global QLI18N supplies selected language and explicit text lookup; storage key qldc.lang, values en/zh. Export URL adds &lang=selected language. Preserve all existing data actions and test selectors. + +- [ ] Add real-browser failing regression: `await page.getByLabel('Interface language').selectOption('en'); await page.getByRole('button',{name:'New inspection',exact:true}).click();` assert English form and unchanged typed station after switching. Cover all pages, task/retest/compare, report, legacy, reload, blocked storage and mobile. +- [ ] Run against current code and capture missing-language-control failure. +- [ ] Implement explicit translations and selector; never replace innerHTML text globally or mutate task/config data. Preserve forms/active polling/comparison selections. +- [ ] Run new browser test and existing Chinese workflow/Node helper tests on owned demo port. No browser/runtime downloads. Commit only owned frontend/tests files; report red/green evidence in this plan's ignored workspace. + +### Task2: Language-aware report API and delivery docs + +Files: inspection/reports.py/http.py, tests/test_report_languages.py, README.md/README_zh.md, docs/API.md/RELEASE.md/VALIDATION.md, CHANGELOG.md, version references and release smoke/build default. + +Interfaces: `export_job(job, format, lang='zh')`; `html_report(job, marker, lang='zh')`; `GET /api/jobs/{id}/export?format=html&lang=en` returns English portable report, invalid lang returns400. Serve exact new i18n asset; no asset-directory widening. + +- [ ] Add failing tests: `export_job(job,'html',lang='en')`, English title/scope/verdict and complete escaped raw evidence; default Chinese and JSON/CSV content equality; invalid language HTTP400; exact new asset200/traversal404. +- [ ] Implement explicit report text mappings and validated query forwarding; preserve evidence. Run focused tests then full targeted coverage/self-test/syntax checks. +- [ ] Synchronize bilingual usage docs and minor version3.1.0; keep93existing regressions. Record actual new outcomes. Commit owned files only. + +### Task3: Review, verify and integrate main + +- [ ] Independent task/final review at exact commit, disposition of actionable findings, affected regressions. +- [ ] Push preserved-SHA branch (GitHub REST fallback if Git transport fails), update attached PR1 with final scope and checks. All4CI matrix jobs must pass. +- [ ] Verify main/base and expected PR head, merge by GitHub connector with SHA guard, verify PR merged and remote main contains final feature commit. Update local tracking without resetting work. +- [ ] Buildv3.1.0 from verified main/source, fresh Windows offline package smoke, manifests/hashes for both platforms. Publish immutable tag/Release under standing authorization and verify remote assets. Keep prior Release intact. diff --git a/docs/superpowers/specs/2026-10-08-english-interface-design.md b/docs/superpowers/specs/2026-10-08-english-interface-design.md new file mode 100644 index 0000000..cc9b94a --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-english-interface-design.md @@ -0,0 +1,11 @@ +# English interface and main integration + +User requested an English interface if missing and integration into main. Current workbench and retained legacy UI are Chinese; README already has English. The authorized addition preserves the established product architecture and workflows. + +Use one interface with an English/中文 language selector and one local translation resource. Compared with separate English pages or replacing Chinese, this preserves existing users and avoids duplicate workflow logic. Choose browser language on first visit (Chinese for zh, English otherwise), remember a valid selection in localStorage, and fall back to in-memory selection when storage is blocked. Update HTML lang, page title, accessible labels, navigation, static/dynamic text, statuses, scenarios, dialogs, validation and error messages in both current and legacy pages. Switching must preserve active tasks, history filters/selections and entered forms. User metadata, configured names, JSON and raw protocol evidence remain original and escaped. + +HTML exports accept lang=zh/en, default zh for compatibility, and translate report headings, scope/verdict labels, metadata labels, explanatory text and table headings. Preserve raw evidence and configuration exactly. JSON/CSV structure stays unchanged. UI exports select current language; unsupported language returns400. No new runtime dependency/CDN, writes to equipment or protocol behavior changes. + +Browser regressions use real localhost demo service, cover both languages across all workbench pages, creation/evidence/retest/comparison, English report download, language persistence, form/filter retention, legacy labels, blocked-storage behavior and390px layout. Existing Chinese workflow and93Python tests remain valid. English labels must be natural operational language rather than literal word substitution. + +After review, tests and final CI, update existing PR1 and merge it into main at a verified expected head. Keep v3.0.0 immutable. The standing authorization for usable Releases allows a v3.1.0 follow-up containing this language addition; build/verify/install-check matching source packages and publish only after main integration. No third-party Skill installation or external source submission. From 2a5cd44d73b71b8fa50c40df66318b9be2f5ebe8 Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:14:32 +0800 Subject: [PATCH 14/15] feat: add English acceptance reports and bilingual delivery support --- CHANGELOG.md | 7 ++++ README.md | 2 ++ README_zh.md | 2 ++ docs/API.md | 2 ++ docs/RELEASE.md | 12 ++++--- inspection/http.py | 10 +++--- inspection/reports.py | 60 ++++++++++++++++++++++++-------- tests/release_smoke.py | 5 +++ tests/test_report_languages.py | 62 ++++++++++++++++++++++++++++++++++ tools/build_release.py | 2 +- 10 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 tests/test_report_languages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f642f8b..84c0c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 3.1 — 2026-10-08 + +- Add English/Chinese interfaces for the acceptance workbench and compatibility scan page, with browser-language defaults and a remembered preference. +- Translate navigation, forms, status labels, dialogs and operational messages while retaining user-entered data and original evidence. +- Export printable HTML reports in the chosen language; preserve default Chinese exports and unchanged JSON/CSV data. +- Add bilingual browser/API/report regression checks and matching offline release packages. + ## 3.0 — 2026-10-07 - Add a configuration-driven acceptance workbench with station/batch/operator metadata, async progress and cooperative cancellation. diff --git a/README.md b/README.md index e32698c..5fb27ea 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ The intended position is a lightweight Linux field acceptance workflow with loca For a ready-to-install bundle, download the Windows x64 or Linux x86_64 package from [Releases](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/releases). Bundles include offline runtime dependencies. Install CPython 3.10+ first; then double-click `Start-Demo.cmd` on Windows or run `bash run_demo.sh` on Linux. [Release guide](docs/RELEASE.md). +The workbench and compatibility scan page support **English / 简体中文**. Use the language selector to switch; your preference is remembered on this browser. On first visit, Chinese browser locales select Chinese and other locales select English. HTML reports use the selected interface language. Configured device names, entered metadata and original protocol evidence retain their source text. + Python **3.10+**. The demo and offline checks also run on Windows. ```bash diff --git a/README_zh.md b/README_zh.md index 57aaa80..529cc18 100644 --- a/README_zh.md +++ b/README_zh.md @@ -21,6 +21,8 @@ 直接安装可从 [Releases](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/releases) 下载 Windows x64 或 Linux x86_64 包,内含离线运行依赖。先安装 CPython 3.10+,Windows 双击 `Start-Demo.cmd`,Linux 运行 `bash run_demo.sh`。[Release 使用说明](docs/RELEASE.md)。 +工作台和兼容扫描页面支持 **English/简体中文**。使用页面语言选择器切换,浏览器会保存选择;首次访问时按浏览器语言选择中文或英文。HTML 报告按当前界面语言导出,配置设备名称、输入信息和原始协议证据保留原文。 + 需要 Python **3.10+**;Windows 也可运行演示及离线测试。 ```bash diff --git a/docs/API.md b/docs/API.md index abf7b0b..a4f6b9f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -4,6 +4,8 @@ Base URL defaults to `http://127.0.0.1:8080`. JSON is UTF-8. When `QLDC_ACCESS_T `GET /api/jobs` returns lightweight history summaries: identity, timestamps, status, metadata, summary, progress, configuration hash and parent task ID. Full configuration snapshots, device results, attempts and events are available through task detail and export routes. Filtering/counting/pagination happen in SQLite without loading all saved evidence. +Exports accept `lang=zh` (default) or `lang=en`. HTML headings, labels and explanations use that language; metadata, configuration and raw evidence stay original. JSON/CSV schemas and content remain unchanged by language. Unsupported language values return400. Example: `/api/jobs/{id}/export?format=html&lang=en`. + | Method | Route | Behavior | | --- | --- | --- | | GET | `/api/health` | Service mode, version, active job and persistence status | diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 4c630a4..d53328a 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -4,8 +4,8 @@ | 包 | 用途 | | --- | --- | -| `QLDeviceCheck-v3.0.0-windows-x64.zip` | Windows x64,含离线 psutil/pyserial 运行依赖 | -| `QLDeviceCheck-v3.0.0-linux-x86_64.zip` | Linux x86_64/glibc,含离线运行依赖 | +| `QLDeviceCheck-v3.1.0-windows-x64.zip` | Windows x64,含中英界面及离线 psutil/pyserial 运行依赖 | +| `QLDeviceCheck-v3.1.0-linux-x86_64.zip` | Linux x86_64/glibc,含中英界面及离线运行依赖 | | `SHA256SUMS.txt` | 下载文件校验值 | 需要预先安装 **CPython 3.10+**,包括 venv/pip。包不包含 Python 解释器;ARM、其他架构及 Python 自由线程构建需要匹配的依赖制品。Linux 的 psutil wheel 支持 glibc 2.12+ 的常规 CPython 构建。项目方已确认项目测试可用;本次 Release 的检查结果见 [VALIDATION.md](VALIDATION.md)。 @@ -26,8 +26,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\run_web.ps1 --config confi ## Linux ```bash -unzip QLDeviceCheck-v3.0.0-linux-x86_64.zip -cd QLDeviceCheck-v3.0.0 +unzip QLDeviceCheck-v3.1.0-linux-x86_64.zip +cd QLDeviceCheck-v3.1.0 bash setup_linux.sh bash run_demo.sh # 真实验收使用审核后的设备配置: @@ -40,6 +40,8 @@ bash run_web.sh --config config/device_list.json --data-dir data/live 默认监听本机;网络使用和访问令牌见 [部署说明](DEPLOYMENT.md)。任务在选定的 `data` 目录保留。升级时先停止服务,备份数据和配置,将新版本解压到新目录,再用 `--data-dir` 指向原数据目录。不要复制旧虚拟环境到新平台,重新运行安装器。旧扫描报告位于 `report/`,需要单独保留。 -同一设备工位避免并发启动多个实例;同一数据库由独占锁保护。关闭服务后可打包备份,任务配置快照仍在记录中。Release 来源通过标签固定,PR 保留便于审阅,不自动合并主分支。 +同一设备工位避免并发启动多个实例;同一数据库由独占锁保护。关闭服务后可打包备份,任务配置快照仍在记录中。Release 来源通过标签固定;3.1 的中英界面与此前验收升级按项目方要求合并到 `main`。旧版 v3.0.0 保留。 + +页面语言选择器提供 English/简体中文,并保存在当前浏览器。HTML 报告采用导出时的界面语言,原始设备配置及检测证据不做翻译。首次访问时按浏览器语言选择默认语言,切换可在当前任务中进行。 离线 wheel 选择与哈希校验遵循 [pip 官方下载说明](https://pip.pypa.io/en/stable/cli/pip_download/)和[安全安装说明](https://pip.pypa.io/en/stable/topics/secure-installs/)。 diff --git a/inspection/http.py b/inspection/http.py index 64e9f9e..8bc9a5a 100644 --- a/inspection/http.py +++ b/inspection/http.py @@ -41,7 +41,7 @@ class Server(ThreadingHTTPServer): class Handler(BaseHTTPRequestHandler): - server_version = 'QLDeviceCheckWorkbench/3.0' + server_version = 'QLDeviceCheckWorkbench/3.1' def setup(self): super().setup() @@ -61,8 +61,8 @@ def _dispatch(self, method): path = unquote(parsed.path) self._access(path) query = parse_qs(parsed.query) - if method == 'GET' and path in ('/', '/index.html', '/legacy', '/assets/app.js', '/assets/style.css'): - relative = {'/': 'web/index.html', '/index.html': 'web/index.html', '/legacy': 'web/legacy.html', '/assets/app.js': 'web/assets/app.js', '/assets/style.css': 'web/assets/style.css'}[path] + if method == 'GET' and path in ('/', '/index.html', '/legacy', '/assets/app.js', '/assets/i18n.js', '/assets/style.css'): + relative = {'/': 'web/index.html', '/index.html': 'web/index.html', '/legacy': 'web/legacy.html', '/assets/app.js': 'web/assets/app.js', '/assets/i18n.js': 'web/assets/i18n.js', '/assets/style.css': 'web/assets/style.css'}[path] target = BASE_DIR / relative if not target.is_file(): raise HttpError(404, 'Asset not found') @@ -72,7 +72,7 @@ def _dispatch(self, method): return self._send(b'', 'image/x-icon', 204) svc = self.server.service if method == 'GET' and path == '/api/health': - return self._json({'ok': True, 'version': '3.0', 'mode': svc.mode, 'active_job_id': svc.active_id, 'persistence_ready': not svc.closed}) + return self._json({'ok': True, 'version': '3.1', 'mode': svc.mode, 'active_job_id': svc.active_id, 'persistence_ready': not svc.closed}) if method == 'GET' and path == '/api/catalog': return self._json(dict(ok=True, **svc.catalog())) if method == 'POST' and path == '/api/config/validate': @@ -101,7 +101,7 @@ def _dispatch(self, method): return self._json({'ok': True, 'job': svc.get(job_id)}) if method == 'GET' and action == 'export': format = query.get('format', ['html'])[0] - content, mime = export_job(svc.get(job_id), format) + content, mime = export_job(svc.get(job_id), format, lang=query.get('lang', ['zh'])[0]) return self._send(content, mime, headers={'Content-Disposition': 'attachment; filename="acceptance-' + job_id + '.' + format + '"'}) if method == 'POST' and action in ('cancel', 'retest'): body = self._body() diff --git a/inspection/reports.py b/inspection/reports.py index 6097b42..704d99f 100644 --- a/inspection/reports.py +++ b/inspection/reports.py @@ -6,15 +6,41 @@ from .domain import catalog_devices -def html_report(job, marker): +REPORT_EN = { + '仅选定设备': 'Selected devices only', '完整配置设备': 'All configured devices', + 'SIMULATED · 合成演示,不能作为真实设备验收凭据。': 'SIMULATED · Synthetic demonstration; cannot be used as live acceptance evidence.', + 'LIVE · 结论仅覆盖本次通信路径及 PCI 被动状态;电气性能、业务功能需另行验证。': 'LIVE · Findings cover the selected communication paths and passive PCI state. Electrical performance and business functionality require separate validation.', + '通过': 'Passed', '未通过': 'Failed', '需复核': 'Review required', '未执行': 'Not run', + '工位 / 设备编号': 'Station / unit ID', '批次': 'Batch', '操作者': 'Operator', '备注': 'Notes', + '任务 ID': 'Task ID', '复测源任务': 'Parent task', '任务状态': 'Execution status', + '创建时间 UTC': 'Created (UTC)', '结束时间 UTC': 'Finished (UTC)', '耗时 ms': 'Duration (ms)', + '配置 SHA-256': 'Configuration SHA-256', 'PCI 被动状态': 'Passive PCI state', '通信路径': 'Communication path', + '排查建议:': 'Suggested next step: ', '请求 / Request': 'Request', '响应 / Response': 'Response', + '逐项探测 / Attempts': 'Probe attempts', '载体支持证据 / Supporting checks': 'Supporting carrier checks', + '通过 {passed} · 未通过 {failed} · 需复核 {review} · 未执行 {not_run}': 'Passed {passed} · Failed {failed} · Review {review} · Not run {not_run}', + '工控设备验收报告': 'Industrial device acceptance report', '台': 'devices', + '部分设备复测通过不表示整机恢复。任务中止、取消或失败时,已收集的证据仍保留,结论需复核。': 'A partial retest does not establish whole-unit recovery. Evidence remains available after interruption, cancellation or failure; review the conclusion.', + '验收记录': 'Acceptance record', '设备结论': 'Device results', '设备 ID': 'Device ID', + '设备名称': 'Device name', '接口': 'Interface', '验证范围': 'Inspection scope', '结论': 'Verdict', + '故障类别': 'Fault category', '测试证据': 'Original test evidence', + '配置快照与任务事件': 'Configuration snapshot and task events', + 'SHA-256 用于识别配置版本;本报告未使用数字签名,不提供防篡改证明。': 'SHA-256 identifies the configuration version. This report is not digitally signed and does not prove tamper resistance.', +} + + +def html_report(job, marker, lang='zh'): """A portable, printable report; evidence and snapshot travel with the file.""" def esc(value): return html.escape(str(value if value is not None else '—'), quote=True) def code(value): return '
' + esc(value) + '
' + if lang not in ('zh', 'en'): + raise ValueError('Unsupported report language') + def tr(text): + return REPORT_EN.get(text, text) if lang == 'en' else text expected = len(catalog_devices(job['config_snapshot'])) selected = len(job['device_ids']) - scope = '仅选定设备' if selected != expected else '完整配置设备' + scope = tr('仅选定设备' if selected != expected else '完整配置设备') notice = ('SIMULATED · 合成演示,不能作为真实设备验收凭据。' if job['mode'] == 'demo' else 'LIVE · 结论仅覆盖本次通信路径及 PCI 被动状态;电气性能、业务功能需另行验证。') labels = {'PASS': '通过', 'FAIL': '未通过', 'REVIEW': '需复核', 'NOT_RUN': '未执行'} @@ -23,32 +49,38 @@ def code(value): metadata += [('任务 ID', job['id']), ('复测源任务', job.get('parent_job_id')), ('任务状态', job['status']), ('创建时间 UTC', job['created_at']), ('结束时间 UTC', job['finished_at']), ('耗时 ms', job['duration_ms']), ('配置 SHA-256', job['config_hash'])] - info = ''.join('
' + esc(k) + '
' + esc(v) + '
' for k, v in metadata) + notice = tr(notice) + labels = {key: tr(value) for key, value in labels.items()} + info = ''.join('
' + esc(tr(k)) + '
' + esc(v) + '
' for k, v in metadata) rows, evidence = [], [] for result in job['results']: verdict = result['verdict'] - scope_name = 'PCI 被动状态' if result['scope'] == 'pci_passive' else '通信路径' + scope_name = tr('PCI 被动状态' if result['scope'] == 'pci_passive' else '通信路径') rows.append('' + ''.join('' + esc(v) + '' for v in [result['device_id'], result['name'], result['interface'], scope_name, labels[verdict] + ' / ' + verdict, result['fault_code'], result['duration_ms']]) + '') evidence.append('

' + esc(result['name']) + ' · ' + esc(result['device_id']) + '

' + - esc(result['summary']) + '

排查建议:' + esc(result['suggestion']) + - '

请求 / Request

' + code(result['request']) + '

响应 / Response

' + code(result['response']) + - '

逐项探测 / Attempts

' + code(json.dumps(result.get('attempts', []), ensure_ascii=False, indent=2)) + - '

载体支持证据 / Supporting checks

' + code(json.dumps(result.get('supporting_checks', []), ensure_ascii=False, indent=2)) + '
') + esc(result['summary']) + '

' + esc(tr('排查建议:')) + esc(result['suggestion']) + + '

' + tr('请求 / Request') + '

' + code(result['request']) + '

' + tr('响应 / Response') + '

' + code(result['response']) + + '

' + tr('逐项探测 / Attempts') + '

' + code(json.dumps(result.get('attempts', []), ensure_ascii=False, indent=2)) + + '

' + tr('载体支持证据 / Supporting checks') + '

' + code(json.dumps(result.get('supporting_checks', []), ensure_ascii=False, indent=2)) + '') summary = job['summary'] - totals = '通过 {passed} · 未通过 {failed} · 需复核 {review} · 未执行 {not_run}'.format(**summary) - return ''' -工控设备验收报告
QLDEVICECHECK / FIELD ACCEPTANCE

工控设备验收报告

''' + '

' + esc(notice) + '

' + esc(labels[summary['verdict']]) + ' / ' + esc(summary['verdict']) + '
' + esc(scope) + ':' + str(selected) + ' / ' + str(expected) + ' 台
' + esc(totals) + '

部分设备复测通过不表示整机恢复。任务中止、取消或失败时,已收集的证据仍保留,结论需复核。

验收记录

' + info + '

设备结论

' + ''.join(rows) + '
设备 ID设备名称接口验证范围结论故障类别耗时 ms

测试证据

' + ''.join(evidence) + '

配置快照与任务事件

SHA-256 用于识别配置版本;本报告未使用数字签名,不提供防篡改证明。

' + code(json.dumps({'config_snapshot': job['config_snapshot'], 'events': job['events']}, ensure_ascii=False, indent=2)) + '
' +
QLDEVICECHECK / FIELD ACCEPTANCE
''' + '

' + title + '

' + esc(notice) + '

' + esc(labels[summary['verdict']]) + ' / ' + esc(summary['verdict']) + '
' + esc(scope) + ': ' + str(selected) + ' / ' + str(expected) + ' ' + tr('台') + '
' + esc(totals) + '

' + tr('部分设备复测通过不表示整机恢复。任务中止、取消或失败时,已收集的证据仍保留,结论需复核。') + '

' + tr('验收记录') + '

' + info + '

' + tr('设备结论') + '

' + headers + '' + ''.join(rows) + '

' + tr('测试证据') + '

' + ''.join(evidence) + '

' + tr('配置快照与任务事件') + '

' + tr('SHA-256 用于识别配置版本;本报告未使用数字签名,不提供防篡改证明。') + '

' + code(json.dumps({'config_snapshot': job['config_snapshot'], 'events': job['events']}, ensure_ascii=False, indent=2)) + '
' -def export_job(job, format): +def export_job(job, format, lang='zh'): + if lang not in ('zh', 'en'): + raise ValueError('Unsupported report language') marker = 'SIMULATED' if job['mode'] == 'demo' else 'LIVE' if format == 'json': return json.dumps(dict(job, report_marker=marker), ensure_ascii=False, indent=2).encode(), 'application/json; charset=utf-8' if format == 'html': - content = html_report(job, marker) + content = html_report(job, marker, lang=lang) return content.encode(), 'text/html; charset=utf-8' if format == 'csv': stream = io.StringIO(newline='') diff --git a/tests/release_smoke.py b/tests/release_smoke.py index d663e78..efe0d41 100644 --- a/tests/release_smoke.py +++ b/tests/release_smoke.py @@ -65,6 +65,8 @@ def smoke(package): wait_for(lambda: request(base, '/api/health'), lambda d: d['mode'] == 'demo') with urllib.request.urlopen(base, timeout=3) as page: assert 'FIELD ACCEPTANCE'.encode() in page.read() + with urllib.request.urlopen(base + '/assets/i18n.js', timeout=3) as resource: + assert 'javascript' in resource.headers.get('Content-Type', '') and resource.read() if run == 0: original = request(base, '/api/jobs', {'station_id': 'RELEASE-SMOKE-01', 'batch': 'RELEASE', 'scenario': 'faults'})['job'] original = wait_for(lambda: request(base, '/api/jobs/' + original['id'])['job'], lambda j: j['status'] == 'completed') @@ -76,6 +78,9 @@ def smoke(package): assert comparison['comparable'] and comparison['partial_scope'] and not comparison['whole_unit_recovered'] report = request(base, '/api/jobs/' + child['id'] + '/export?format=json') assert report['report_marker'] == 'SIMULATED' and report['config_snapshot'] == original['config_snapshot'] + with urllib.request.urlopen(base + '/api/jobs/' + child['id'] + '/export?format=html&lang=en', timeout=3) as html: + exported = html.read() + assert b'' in exported and b'Industrial device acceptance report' in exported else: assert request(base, '/api/jobs')['total'] == 2 assert request(base, '/api/jobs/' + original['id'])['job']['summary']['failed'] > 0 diff --git a/tests/test_report_languages.py b/tests/test_report_languages.py new file mode 100644 index 0000000..a515086 --- /dev/null +++ b/tests/test_report_languages.py @@ -0,0 +1,62 @@ +import json +import re + +import pytest + +from inspection.reports import export_job +from test_http import api +from test_workbench import service, wait_finished + + +def test_english_html_report_translates_labels_and_preserves_evidence(service): + job = wait_finished(service, service.create({'station_id': '英文工位 ', + 'notes': '工控设备验收报告:原始备注', 'device_ids': ['DEMO_RELAY']})['id']) + content, mime = export_job(job, 'html', lang='en') + report = content.decode('utf8') + assert '' in report + assert 'Industrial device acceptance report' in report + assert 'Selected devices only' in report and '1 / 3' in report + assert 'SIMULATED' in report and 'cannot be used as live acceptance evidence' in report + assert 'A partial retest does not establish whole-unit recovery.' in report + assert all(not re.search('[\u3400-\u9fff]', heading) for heading in re.findall(r'<(?:h[124]|th|dt)>(.*?)', report)) + assert '英文工位 <script>x</script>' in report + assert '' not in report + assert '工控设备验收报告:原始备注' in report + assert job['results'][0]['name'] in report + assert job['config_hash'] in report and job['results'][0]['response'] in report + assert '@media print' in report and mime.startswith('text/html') + + +def test_default_chinese_and_machine_exports_remain_compatible(service): + job = wait_finished(service, service.create({'notes': '原始测试证据'})['id']) + assert b'' in export_job(job, 'html')[0] + assert '工控设备验收报告' in export_job(job, 'html', lang='zh')[0].decode() + for format in ('json', 'csv'): + assert export_job(job, format, lang='en') == export_job(job, format) + raw = json.loads(export_job(job, 'json', lang='en')[0]) + assert raw['config_snapshot'] == job['config_snapshot'] + assert raw['results'] == job['results'] and raw['metadata'] == job['metadata'] + + +def test_report_language_route(api): + svc, server, request = api + job = wait_finished(svc, svc.create({})['id']) + path = '/api/jobs/' + job['id'] + '/export?format=html' + status, content = request('GET', path + '&lang=en') + assert status == 200 and b'' in content + assert '工控设备验收报告' in request('GET', path + '&lang=zh')[1].decode() + assert request('GET', path + '&lang=fr')[0] == 400 + + +def test_language_resource_is_a_fixed_public_asset(api): + svc, server, request = api + server.access_token = 'secret' + assert request('GET', '/assets/i18n.js')[0] == 200 + assert request('GET', '/assets/%2e%2e/config/device_list.json')[0] == 404 + + +@pytest.mark.parametrize('lang', ['fr', '', None]) +def test_unsupported_report_language_is_rejected(service, lang): + job = wait_finished(service, service.create({})['id']) + with pytest.raises(ValueError, match='Unsupported report language'): + export_job(job, 'html', lang=lang) diff --git a/tools/build_release.py b/tools/build_release.py index 758a0f0..4bb0080 100644 --- a/tools/build_release.py +++ b/tools/build_release.py @@ -60,7 +60,7 @@ def build(ref, version, wheels_root, output): if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--ref', default='HEAD') - parser.add_argument('--version', default='v3.0.0') + parser.add_argument('--version', default='v3.1.0') parser.add_argument('--wheels', type=Path, default=Path('output/release-wheels')) parser.add_argument('--output', type=Path, default=Path('output/release')) args = parser.parse_args() From bcb6697464c1bf161f886aed4d931ebe0a247a80 Mon Sep 17 00:00:00 2001 From: Kaiser <104786001+KaiserIIII@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:50:40 +0800 Subject: [PATCH 15/15] feat: ship bilingual acceptance workbench interface --- docs/VALIDATION.md | 7 ++ inspection/http.py | 3 +- tests/browser_i18n.cjs | 111 ++++++++++++++++++++ tests/browser_workflow.cjs | 2 + tests/history_selection.cjs | 6 +- tests/test_backend_fixes.py | 13 ++- tests/test_legacy_reports.py | 14 +-- tests/test_report_languages.py | 1 + web/assets/app.js | 109 +++++++++++++------- web/assets/i18n.js | 178 +++++++++++++++++++++++++++++++++ web/assets/style.css | 2 + web/index.html | 10 +- web/legacy.html | 135 +++++++++++++------------ 13 files changed, 473 insertions(+), 118 deletions(-) create mode 100644 tests/browser_i18n.cjs create mode 100644 web/assets/i18n.js diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 6ca7e01..14a4230 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -41,6 +41,13 @@ See [DEMO.md](DEMO.md) for the optional browser workflow. CI runs the Python off - CI source `a9bbf872bb53a7dbbd87d86c85f3f009dfc72a9b`: Linux/Windows Python 3.10/3.13 all passed in [run 37607639342](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/actions/runs/37607639342). Final changed-source outcomes are available from [PR checks](https://github.com/KaiserIIII/QLDeviceCheck_Generic_WebUI_Linux/pull/1/checks) and the Release notes. - Release candidate `e0dc7fc`: Windows package extraction, fresh offline runtime installation with `PIP_NO_INDEX=1`, launcher argument forwarding, actual localhost server, fault task, export, healthy failed-only retest, scope comparison and restart/history preservation passed. Final bundles are rebuilt from the final tagged commit and checked again before upload; the final source/hash and smoke outcome are recorded in the Release notes. +## Recorded results — 2026-10-08 bilingual upgrade + +- Python regression suite: **100 passed in 20.90 seconds** on the Windows development environment. The new report route checks English and Chinese exports, byte-identical JSON/CSV output, fixed public asset delivery, unsupported and explicitly blank language rejection, and HTML escaping of original evidence. +- JavaScript syntax and Python compilation checks passed for the bilingual assets and application modules. Configuration validation passed with 5 root devices, 1 child device and writes disabled; the original offline self-test passed all **23 checks**. +- Browser workflow: the new bilingual workflow passed on Chromium with English and Chinese switching, task creation, evidence display, English HTML download, failed-only retest, history filters, cross-page comparison selection retention, reload preference, blocked `localStorage` fallback, the compatibility scan page and 390 px layout. The existing Chinese workflow also passed with zero page errors. +- API boundary: omitted `lang` keeps the Chinese default; `lang=zh` and `lang=en` return the requested report language; unsupported or explicitly blank language values return HTTP 400. No live device write or unknown hardware operation was used by these checks. + ## Scope and limits Coverage is collected for `inspection/`, `core.protocol_engine` and `core.transport`, not the entire legacy detector or repository. The original offline self-test adds loopback checks but does not establish field hardware compatibility. Browser tests are separate from the Python CI job and use an existing local browser runtime. diff --git a/inspection/http.py b/inspection/http.py index 8bc9a5a..c9078b8 100644 --- a/inspection/http.py +++ b/inspection/http.py @@ -101,7 +101,8 @@ def _dispatch(self, method): return self._json({'ok': True, 'job': svc.get(job_id)}) if method == 'GET' and action == 'export': format = query.get('format', ['html'])[0] - content, mime = export_job(svc.get(job_id), format, lang=query.get('lang', ['zh'])[0]) + export_query = parse_qs(parsed.query, keep_blank_values=True) + content, mime = export_job(svc.get(job_id), format, lang=export_query.get('lang', ['zh'])[0]) return self._send(content, mime, headers={'Content-Disposition': 'attachment; filename="acceptance-' + job_id + '.' + format + '"'}) if method == 'POST' and action in ('cancel', 'retest'): body = self._body() diff --git a/tests/browser_i18n.cjs b/tests/browser_i18n.cjs new file mode 100644 index 0000000..5142a4c --- /dev/null +++ b/tests/browser_i18n.cjs @@ -0,0 +1,111 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const { chromium } = require('playwright'); +(async()=>{ + const base=process.argv[2]||'http://127.0.0.1:18336'; + const browser=await chromium.launch({headless:true,...(process.env.PLAYWRIGHT_CHANNEL?{channel:process.env.PLAYWRIGHT_CHANNEL}:{})}); + const page=await browser.newPage({locale:'en-US',viewport:{width:1440,height:980}}), errors=[]; + page.on('pageerror',e=>errors.push(e.message)); + try { + await page.goto(base); + await page.getByLabel('Interface language').selectOption('en',{timeout:2000}); + await page.getByRole('button',{name:'New inspection',exact:true}).click(); + const station='工位 & unchanged', operator='工程师原文'; + await page.getByLabel('Station / equipment ID').fill(station); + await page.getByLabel('Operator',{exact:true}).fill(operator); + await page.getByLabel('Inspection notes').fill('原始备注 raw'); + await page.locator('#language-switch').selectOption('zh'); + await page.getByLabel('工位 / 设备编号').waitFor(); + assert.equal(await page.getByLabel('工位 / 设备编号').inputValue(),station); + await page.locator('#language-switch').selectOption('en'); + await page.getByLabel('Demo scenario').selectOption('faults'); + await page.getByRole('button',{name:'Create inspection task',exact:true}).click(); + await page.getByTestId('task-state').filter({hasText:'Completed'}).waitFor(); + const baseline=await page.getByTestId('task-state').getAttribute('data-job-id'); + assert.ok((await page.locator('main').innerText()).includes(station)); + assert.equal(await page.evaluate(()=>window.injected),undefined); + await page.getByRole('button',{name:'View evidence',exact:true}).first().click(); + await page.getByRole('dialog',{name:'Device test evidence',exact:true}).waitFor(); + assert.ok((await page.locator('#evidence-body').innerText()).includes('SIMULATED')); + await page.getByRole('button',{name:'Close evidence',exact:true}).click(); + const downloaded=page.waitForEvent('download'); + await page.getByRole('button',{name:'Export HTML',exact:true}).click(); + const report=await fs.readFile(await (await downloaded).path(),'utf8'); + assert.ok(report.includes('lang="en"')); + assert.ok(report.includes('Industrial device acceptance report')); + assert.ok(report.includes('工程师原文')); + assert.ok(report.includes('<script>')); + await page.getByLabel('Retest scenario').selectOption('healthy'); + await page.getByRole('button',{name:'Retest devices that did not pass',exact:true}).click(); + await page.getByTestId('task-state').filter({hasText:'Completed'}).waitFor(); + const current=await page.getByTestId('task-state').getAttribute('data-job-id'); + await page.getByRole('button',{name:'Compare with original task',exact:true}).click(); + await page.getByTestId('comparison').filter({hasText:'Recovered'}).waitFor(); + await page.locator('#language-switch').selectOption('zh'); + await page.getByTestId('comparison').filter({hasText:'恢复'}).waitFor(); + await page.locator('#language-switch').selectOption('en'); + for(const [nav,title] of [['Station overview','Make every inspection traceable.'],['Device catalog','Expected device catalog'],['Configuration validation','Validate device configuration'],['Task history','Task history']]){ + await page.getByRole('button',{name:nav,exact:true}).click(); + await page.getByRole('heading',{name:title,exact:true}).waitFor(); + if(nav==='Configuration validation'){ + await page.getByRole('button',{name:'Load current configuration',exact:true}).click(); + const config=await page.getByLabel('Device configuration JSON').inputValue(); + await page.locator('#language-switch').selectOption('zh'); + await page.getByLabel('设备配置 JSON').waitFor(); + assert.equal(await page.getByLabel('设备配置 JSON').inputValue(),config); + await page.locator('#language-switch').selectOption('en'); + await page.getByRole('button',{name:'Validate configuration',exact:true}).click(); + await page.locator('#validation').filter({hasText:'"valid": true'}).waitFor(); + } + } + await page.getByLabel('Search records').fill('工位'); + await page.getByLabel('Execution status').selectOption('completed'); + await page.getByLabel('Time order').selectOption('oldest'); + await page.getByLabel('Baseline task').selectOption(baseline); + await page.getByLabel('Current task').selectOption(current); + await page.locator('#language-switch').selectOption('zh'); + await page.getByLabel('搜索记录').waitFor(); + assert.equal(await page.getByLabel('搜索记录').inputValue(),'工位'); + assert.equal(await page.getByLabel('执行状态').inputValue(),'completed'); + assert.equal(await page.getByLabel('时间排序').inputValue(),'oldest'); + assert.equal(await page.getByLabel('基线任务').inputValue(),baseline); + assert.equal(await page.getByLabel('当前任务').inputValue(),current); + await page.getByRole('button',{name:'比较任务',exact:true}).click(); + await page.getByTestId('comparison').waitFor(); + await page.locator('#language-switch').selectOption('en'); + await page.reload(); + await page.getByRole('button',{name:'New inspection',exact:true}).waitFor(); + assert.equal(await page.locator('html').getAttribute('lang'),'en'); + await page.setViewportSize({width:390,height:844}); + await page.getByRole('button',{name:'Task history',exact:true}).click(); + assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + await fs.mkdir('output/playwright',{recursive:true}); + await page.screenshot({path:'output/playwright/i18n-mobile-en.png',fullPage:true}); + await page.goto(base+'/legacy'); + await page.getByRole('heading',{name:'Industrial device diagnostics',exact:true}).waitFor(); + await page.getByRole('button',{name:'Scan devices',exact:true}).waitFor(); + await page.getByRole('button',{name:'Test all',exact:true}).waitFor(); + await page.locator('#language-switch').selectOption('zh'); + await page.getByRole('heading',{name:'工控机设备检测',exact:true}).waitFor(); + await page.locator('#language-switch').selectOption('en'); + await page.getByRole('button',{name:'Access token',exact:true}).click(); + await page.locator('#accessToken').fill('unchanged-secret'); + await page.evaluate(()=>QLI18N.setLanguage('zh')); + assert.equal(await page.locator('#accessToken').inputValue(),'unchanged-secret'); + await page.locator('#credentialsCancel').click(); + await page.locator('#language-switch').selectOption('en'); + assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + await page.screenshot({path:'output/playwright/i18n-legacy-mobile-en.png',fullPage:true}); + const blocked=await browser.newPage(); + blocked.on('pageerror',e=>errors.push(e.message)); + await blocked.addInitScript(()=>Object.defineProperty(window,'localStorage',{get(){throw Error('blocked');}})); + await blocked.goto(base); + await blocked.locator('#language-switch').selectOption('en'); + await blocked.getByRole('button',{name:'New inspection',exact:true}).click(); + await blocked.locator('#language-switch').selectOption('zh'); + await blocked.getByLabel('工位 / 设备编号').waitFor(); + await blocked.close(); + assert.deepEqual(errors,[]); + console.log('PASS: bilingual workflow, exports, form/filter/selection retention, legacy, reload, blocked storage, 390px layout'); + }finally{await browser.close();} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/tests/browser_workflow.cjs b/tests/browser_workflow.cjs index d35191e..0f32177 100644 --- a/tests/browser_workflow.cjs +++ b/tests/browser_workflow.cjs @@ -15,6 +15,7 @@ const { chromium } = require('playwright'); const errors = []; page.on('pageerror', e => errors.push(e.message)); try { + await page.addInitScript(()=>localStorage.setItem('qldc.lang','zh')); await page.goto(url); if (process.env.QLDC_BROWSER_TOKEN) { await page.getByRole('button', { name: '访问凭证', exact: true }).click(); @@ -162,6 +163,7 @@ const { chromium } = require('playwright'); // responses. test_legacy_reports.py separately covers the real loopback server. const legacy = await browser.newPage({viewport: {width: 1200, height: 900}}); legacy.on('pageerror', e => errors.push(e.message)); + await legacy.addInitScript(()=>localStorage.setItem('qldc.lang','zh')); await legacy.goto(url + '/legacy'); await legacy.evaluate(() => { sessionStorage.setItem('qldc.token', 'browser-fixture-token'); diff --git a/tests/history_selection.cjs b/tests/history_selection.cjs index 709368f..54400a9 100644 --- a/tests/history_selection.cjs +++ b/tests/history_selection.cjs @@ -2,13 +2,15 @@ const assert = require('node:assert/strict'); const vm = require('node:vm'); const fs = require('node:fs'); const listeners = {}; -const main = { innerHTML: '' }; -const context = { document: {querySelector: () => main, addEventListener: (name, handler) => listeners[name] = handler}, +const main = { innerHTML: '',addEventListener(){},querySelectorAll(){return []} }; +const context = { document: {documentElement:{dataset:{}},querySelectorAll:()=>[],querySelector: () => main, addEventListener: (name, handler) => listeners[name] = handler}, sessionStorage: {getItem: () => ''}, clearTimeout, setTimeout, URL, console, fetch: async url => ({ok: true, json: async () => ({total: 21, jobs: url.includes('offset=20') ? [job(0)] : Array.from({length: 20}, (_, i) => job(20 - i))})}) }; function job(n) { return {id: `job-${n}`, metadata: {station_id: ''}, created_at: '2026-10-07', status: 'completed', summary: {verdict: 'PASS', passed: 1, expected: 1}}; } vm.createContext(context); +vm.runInContext(fs.readFileSync('web/assets/i18n.js','utf8'),context); +vm.runInContext("QLI18N.setLanguage('zh')",context); vm.runInContext(fs.readFileSync('web/assets/app.js', 'utf8').replace(/\nboot\(\);\s*$/, ''), context); async function choose(name, value, label) { await listeners.change({target: {name, value, selectedOptions: [{textContent: label}], closest: () => ({id: 'compare-form'})}}); diff --git a/tests/test_backend_fixes.py b/tests/test_backend_fixes.py index 072f176..4712679 100644 --- a/tests/test_backend_fixes.py +++ b/tests/test_backend_fixes.py @@ -262,7 +262,7 @@ def test_nested_malformed_configuration_returns_validation_error(api, key, value assert request('POST', '/api/config/validate', {'config': config})[0] == 400 -def test_legacy_page_helper_uses_session_bearer_for_actual_http(api, monkeypatch): +def test_legacy_page_helper_uses_session_bearer_for_actual_http(api, monkeypatch, tmp_path): import legacy_web svc, server, request = api svc.demo = False @@ -271,17 +271,20 @@ def test_legacy_page_helper_uses_session_bearer_for_actual_http(api, monkeypatch monkeypatch.setattr(legacy_web, 'scan_catalog', lambda *args, **kwargs: {'ok': True}) monkeypatch.setattr(legacy_web, 'test_single_device', lambda *args, **kwargs: {'ok': True}) server.access_token = 'session-secret' - script = re.search(r'', request('GET', '/legacy')[1].decode()).group(1) + script = request('GET', '/assets/i18n.js')[1].decode() + '\n' + re.search(r'', request('GET', '/legacy')[1].decode()).group(1) node = shutil.which('node') if not node: pytest.skip('Node required to execute legacy page JavaScript') + script_path = tmp_path / 'legacy-page.js' + script_path.write_text(script, encoding='utf-8') code = """ const vm = require('node:vm'); -const script = JSON.parse(process.argv[1]); +const fs = require('node:fs'); +const script = fs.readFileSync(process.argv[1], 'utf8'); const base = process.argv[2]; const values = new Map([['qldc.token', 'session-secret']]); const element = {addEventListener(){}, querySelectorAll(){return []}}; -const document = {getElementById(){return element}, addEventListener(){}}; +const document = {documentElement:{dataset:{}},querySelector:()=>element,querySelectorAll:()=>[],getElementById(){return element}, addEventListener(){}}; const context = {document, sessionStorage:{getItem:k=>values.get(k),setItem:(k,v)=>values.set(k,v)}, fetch:(url,opts)=>{if(url.includes('secret'))throw Error('credential in URL');return fetch(base+url,opts)}}; vm.createContext(context); vm.runInContext(script, context); @@ -293,7 +296,7 @@ def test_legacy_page_helper_uses_session_bearer_for_actual_http(api, monkeypatch if(!rejected)throw Error('wrong token accepted'); })().catch(e=>{console.error(e.message);process.exitCode=1}); """ - result = subprocess.run([node, '-e', code, json.dumps(script), 'http://127.0.0.1:%d' % server.server_port], capture_output=True, text=True, timeout=8) + result = subprocess.run([node, '-e', code, str(script_path), 'http://127.0.0.1:%d' % server.server_port], capture_output=True, text=True, timeout=8) assert result.returncode == 0, result.stderr diff --git a/tests/test_legacy_reports.py b/tests/test_legacy_reports.py index 113f330..978b766 100644 --- a/tests/test_legacy_reports.py +++ b/tests/test_legacy_reports.py @@ -13,7 +13,7 @@ def test_legacy_report_helper_authorizes_blobs_and_rejects_bad_credentials(api, import inspection.http svc, server, request = api # Only serve static synthetic files; this never invokes a hardware operation. - script = re.search(r'', request('GET', '/legacy')[1].decode()).group(1) + script = request('GET', '/assets/i18n.js')[1].decode() + '\n' + re.search(r'', request('GET', '/legacy')[1].decode()).group(1) monkeypatch.setattr(inspection.http, 'BASE_DIR', tmp_path) svc.demo = False root = tmp_path / 'report' / 'fixture' @@ -24,13 +24,15 @@ def test_legacy_report_helper_authorizes_blobs_and_rejects_bad_credentials(api, node = shutil.which('node') if not node: pytest.skip('Node required to execute actual legacy helper') + script_path = tmp_path / 'legacy-report.js' + script_path.write_text(script, encoding='utf-8') code = r""" -const vm = require('node:vm'), assert = require('node:assert/strict'); -const script = JSON.parse(process.argv[1]), base = process.argv[2]; +const vm = require('node:vm'), assert = require('node:assert/strict'), fs = require('node:fs'); +const script = fs.readFileSync(process.argv[1], 'utf8'), base = process.argv[2]; const values = new Map([['qldc.token','session-secret']]); const blobs = [], clicked = [], revoked = []; const element = {addEventListener(){}, querySelectorAll(){return []}}; -const document = {getElementById:()=>element, addEventListener(){}, body:{append(){}}, +const document = {documentElement:{dataset:{}},querySelector:()=>element,querySelectorAll:()=>[],getElementById:()=>element, addEventListener(){}, body:{append(){}}, createElement:()=>({click(){clicked.push({href:this.href,download:this.download})},remove(){}})}; const context = {document, Blob, URL:{createObjectURL:b=>{blobs.push(b);return 'blob:local-'+blobs.length},revokeObjectURL:u=>revoked.push(u)}, setTimeout:f=>f(), sessionStorage:{getItem:k=>values.get(k)}, @@ -53,11 +55,11 @@ def test_legacy_report_helper_authorizes_blobs_and_rejects_bad_credentials(api, await assert.rejects(vm.runInContext(`openReport(${JSON.stringify(url)})`,context)); })().catch(e=>{console.error(e);process.exitCode=1}); """ - result = subprocess.run([node, '-e', code, json.dumps(script), f'http://127.0.0.1:{server.server_port}'], + result = subprocess.run([node, '-e', code, str(script_path), f'http://127.0.0.1:{server.server_port}'], capture_output=True, text=True, timeout=10) assert result.returncode == 0, result.stderr assert request('GET', '/report/fixture/report.html')[0] == 401 server.access_token = '' code_local = code[:code.index('(async()=>{')] + "(async()=>{values.set('qldc.token','');await vm.runInContext(\"openReport('/report/fixture/report.txt')\",context);assert.equal(await blobs[0].text(),'synthetic report txt');})().catch(e=>{console.error(e);process.exitCode=1});" - result = subprocess.run([node, '-e', code_local, json.dumps(script), f'http://127.0.0.1:{server.server_port}'], capture_output=True, text=True, timeout=10) + result = subprocess.run([node, '-e', code_local, str(script_path), f'http://127.0.0.1:{server.server_port}'], capture_output=True, text=True, timeout=10) assert result.returncode == 0, result.stderr diff --git a/tests/test_report_languages.py b/tests/test_report_languages.py index a515086..1e68ceb 100644 --- a/tests/test_report_languages.py +++ b/tests/test_report_languages.py @@ -46,6 +46,7 @@ def test_report_language_route(api): assert status == 200 and b'' in content assert '工控设备验收报告' in request('GET', path + '&lang=zh')[1].decode() assert request('GET', path + '&lang=fr')[0] == 400 + assert request('GET', path + '&lang=')[0] == 400 def test_language_resource_is_a_fixed_public_asset(api): diff --git a/web/assets/app.js b/web/assets/app.js index 570ebbb..527f869 100644 --- a/web/assets/app.js +++ b/web/assets/app.js @@ -1,54 +1,89 @@ 'use strict'; +const T=phrase=>QLI18N.t(phrase); const $=s=>document.querySelector(s); const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -const statuses={queued:'等待执行',running:'执行中',cancelling:'正在取消',completed:'已完成',cancelled:'已取消',failed:'执行故障',interrupted:'已中断'}; -const verdicts={PASS:'通过',FAIL:'失败',REVIEW:'待确认',NOT_RUN:'未执行'}; -const scenarios={healthy:'全部正常',faults:'混合故障',timeout:'响应超时',crc:'CRC 错误',missing:'设备缺失'}; -const scopes={communication_path:'通信路径验证',pci_passive:'PCI 被动检查'}; -const faults={TIMEOUT:'响应超时',CRC:'CRC 校验失败',MISSING:'应检设备未响应',CARRIER:'载体通信失败',RESPONSE_MISMATCH:'响应不匹配',UNAVAILABLE:'接口或设备不可用',NOT_RUN:'尚未执行'}; -const S={catalog:null,job:null,view:'overview',offset:0,filter:'',sort:'newest',query:'',comparison:{baseline:null,current:null},timer:null,token:sessionStorage.getItem('qldc.token')||''}; +const statuses=QLI18N.labels({queued:"等待执行",running:"执行中",cancelling:"正在取消",completed:"已完成",cancelled:"已取消",failed:"执行故障",interrupted:"已中断"}); +const verdicts=QLI18N.labels({PASS:"通过",FAIL:"失败",REVIEW:"待确认",NOT_RUN:"未执行"}); +const scenarios=QLI18N.labels({healthy:"全部正常",faults:"混合故障",timeout:"响应超时",crc:"CRC 错误",missing:"设备缺失"}); +const scopes=QLI18N.labels({communication_path:"通信路径验证",pci_passive:"PCI 被动检查"}); +const faults=QLI18N.labels({TIMEOUT:"响应超时",CRC:"CRC 校验失败",MISSING:"应检设备未响应",CARRIER:"载体通信失败",RESPONSE_MISMATCH:"响应不匹配",UNAVAILABLE:"接口或设备不可用",NOT_RUN:"尚未执行"}); +const S={catalog:null,job:null,view:'overview',offset:0,filter:'',sort:'newest',query:'',comparison:{baseline:null,current:null},lastComparison:null,overviewData:null,historyData:null,evidenceIndex:null,timer:null,token:sessionStorage.getItem('qldc.token')||''}; const done=s=>['completed','cancelled','failed','interrupted'].includes(s); -const date=v=>v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'; +const date=v=>v?new Date(v).toLocaleString(QLI18N.language==='zh'?'zh-CN':'en-GB',{hour12:false}):'—'; const id=v=>String(v||'').slice(0,8).toUpperCase(); const pill=(v,label)=>`${esc(label||verdicts[v]||statuses[v]||v)}`; function notice(text,error=false){$('#notice').hidden=!text;$('#notice').className=error?'error':'';$('#notice').textContent=text;} -async function api(url,method='GET',body){const headers={};if(S.token)headers.Authorization=`Bearer ${S.token}`;if(body!==undefined)headers['Content-Type']='application/json';const r=await fetch(url,{method,headers,body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});const d=await r.json();if(!r.ok||d.ok===false)throw new Error(d.error||`请求失败 (${r.status})`);return d;} -const heading=(title,description,actions='')=>`
FIELD ACCEPTANCE / 工位验收

${esc(title)}

${esc(description)}

${actions}
`; +async function api(url,method='GET',body){const headers={};if(S.token)headers.Authorization=`Bearer ${S.token}`;if(body!==undefined)headers['Content-Type']='application/json';const r=await fetch(url,{method,headers,body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});const d=await r.json();if(!r.ok||d.ok===false)throw new Error(T(d.error)||`${T("请求失败 (")}${r.status})`);return d;} +const heading=(title,description,actions='')=>`
${T("FIELD ACCEPTANCE / 工位验收")}

${esc(title)}

${esc(description)}

${actions}
`; const metric=(name,value,description,kind='')=>`
${esc(name)}
${esc(value)}${esc(description)}
`; const empty=(title,text)=>`
${esc(title)}${esc(text)}
`; -const demoNote=()=>S.catalog.mode==='demo'?'
SIMULATED · 当前为离线演示,使用合成设备与报文,不连接现场硬件。
':''; +const demoNote=()=>S.catalog.mode==='demo'?`
${T("SIMULATED · 当前为离线演示,使用合成设备与报文,不连接现场硬件。")}
`:''; const device=d=>`
${d.connection_type==='serial'?'↔':d.connection_type==='pci'?'◫':'⌁'}
${esc(d.name)}${esc(d.device_id)}
`; -function jobTable(jobs){return `
${jobs.map(j=>``).join('')}
工位 / 批次任务 / 创建时间执行状态验收结果操作
${esc(j.metadata.station_id||'未命名工位')}${esc(j.metadata.batch||'无批次')} · ${esc(j.metadata.operator||'未记录操作者')}${id(j.id)}${date(j.created_at)}${pill(j.status)}${pill(j.summary.verdict)}${j.summary.passed} / ${j.summary.expected} 通过
`;} -function setView(view){clearTimeout(S.timer);S.view=view;notice('');$('#page-name').textContent=({overview:'工位总览',new:'新建验收',catalog:'设备清单',history:'任务历史',task:'任务详情',config:'配置校验'})[view];document.querySelectorAll('nav [data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===view));} -async function navigate(view){setView(view);$('#main').innerHTML=empty('正在读取工位数据','');try{if(view==='overview')await overview();if(view==='new')newJob();if(view==='catalog')catalog();if(view==='history')await history();if(view==='config')config();}catch(e){notice(e.message,true);$('#main').innerHTML=empty('暂时无法读取工位','检查服务和访问凭证后重试。');}} -async function overview(){const [stats,list]=await Promise.all([api('/api/insights'),api('/api/jobs?limit=5')]);const counts={serial:0,network:0,pci:0};S.catalog.devices.forEach(d=>counts[d.connection_type]++);const m=stats[S.catalog.mode]; -$('#main').innerHTML=heading('让每一次验收,有据可查。','从应检清单到协议证据,再到交付后的复测对比。所有统计来自当前工位保存的任务记录。')+ -`
${S.catalog.mode==='demo'?'SIMULATED / OFFLINE DEMO':'LIVE / LOCAL STATION'}

${S.catalog.mode==='demo'?'先演示一轮故障,再验证恢复。':'新一轮验收,从完整设备清单开始。'}

配置中应存在的设备都会进入验收结果。未响应设备保留失败记录,复测后可检查恢复、退化与证据变化。

`+ -`
${metric('应检设备',S.catalog.devices.length,'当前配置中的完整清单')}${metric('已完成任务',m.completed,'当前模式的已完成验收')}${metric('通过任务',m.passed,'所有选定设备均通过','good')}${metric('失败任务',m.failed,'至少一项设备验收失败','bad')}
`+ -`

最近验收

${list.jobs.length?jobTable(list.jobs):empty('等待第一份验收记录','创建任务后,这里会展示工位、批次和结果。')}

验收流程

EVIDENCE FIRST
${[['完整清单','未发现、超时或无响应设备也保留结果。'],['可追溯证据','接口、请求和响应与本次配置快照一起保存。'],['复测与比较','检查配置和范围,区分部分复测与整套验收。']].map(([t,p],i)=>`
0${i+1}
${t}

${p}

`).join('')}

设备组成

按配置统计
${[['串口 / RS-485',counts.serial],['网络 / TCP · UDP',counts.network],['PCI / 被动状态',counts.pci]].map(([n,v])=>`
${n}${v}
`).join('')}
通信响应证明当前通信路径可用;PCI 被动状态不代表完整业务功能或电气动作正常。

当前配置

SHA-256

${esc(S.catalog.config_hash)}

任务创建时固定配置快照,历史结果不会随配置修改而改变。

`;} -function newJob(){$('#main').innerHTML=heading('新建现场验收','记录工位与批次,按当前配置检查全部应检设备。')+demoNote()+`

验收信息

${S.catalog.devices.length} DEVICES
${S.catalog.mode==='demo'?``:''}
同一工位同一时间执行一个任务。

本次应检

${S.catalog.devices.map(d=>`
${esc(d.name)}${esc(scopes[d.scope])}
${esc(d.connection_type.toUpperCase())}
`).join('')}
未响应设备保留为失败项;取消后未执行的设备单独标记。
`;} -function catalog(){$('#main').innerHTML=heading('应检设备清单','这份清单决定验收覆盖范围。设备无需先被扫描发现,才进入验收结果。','')+demoNote()+`

${S.catalog.devices.length} 个应检设备

当前配置
${S.catalog.devices.map(d=>``).join('')}
设备 / 标识接口类型接口 / 槽位协议验收范围
${device(d)}${esc(d.connection_type.toUpperCase())}${esc(d.interface||'按配置匹配')}${esc(d.protocol||'被动检查')}${esc(scopes[d.scope])}
配置摘要 ${esc(S.catalog.config_hash)}
${S.catalog.mode==='live'?'':''}`;} -async function history(){const d=await api(`/api/jobs?limit=20&offset=${S.offset}&sort=${S.sort}&status=${encodeURIComponent(S.filter)}&query=${encodeURIComponent(S.query)}`);$('#main').innerHTML=heading('任务历史','按工位、批次或操作者查找验收记录,选择两次任务查看恢复与退化。','')+`
${d.jobs.length?jobTable(d.jobs):empty('未找到任务','调整筛选条件或创建新任务。')}
共 ${d.total} 个任务

两次任务比较

${[['baseline','基线任务'],['current','当前任务']].map(([key,label])=>``).join('')}
`;} -function comparisonOptions(key,jobs){const selected=S.comparison[key],choices=jobs.map(j=>({id:j.id,label:`${j.metadata.station_id||'未命名'} · ${id(j.id)} · ${verdicts[j.summary.verdict]}`}));if(selected&&!choices.some(j=>j.id===selected.id))choices.unshift(selected);return choices.map(j=>``).join('');} -document.addEventListener('change',event=>{const select=event.target;if(select.closest('#compare-form')&&['baseline','current'].includes(select.name))S.comparison[select.name]=select.value?{id:select.value,label:select.selectedOptions[0].textContent}:null;}); -function config(){$('#main').innerHTML=heading('校验设备配置','检查设备标识、接口参数和只读验收规则。校验不会修改工位当前配置。')+`

配置内容

VALIDATE ONLY
`;} -async function openJob(jobId){setView('task');S.job=null;$('#main').innerHTML=empty('正在读取任务','');S.job=(await api(`/api/jobs/${encodeURIComponent(jobId)}`)).job;task();poll(jobId);} +function jobTable(jobs){return `
${jobs.map(j=>``).join('')}
${T("工位 / 批次")}${T("任务 / 创建时间")}${T("执行状态")}${T("验收结果")}${T("操作")}
${esc(j.metadata.station_id||T("未命名工位"))}${esc(j.metadata.batch||T("无批次"))} · ${esc(j.metadata.operator||T("未记录操作者"))}${id(j.id)}${date(j.created_at)}${pill(j.status)}${pill(j.summary.verdict)}${j.summary.passed} / ${j.summary.expected}${T(" 通过")}
`;} +function setView(view){clearTimeout(S.timer);S.view=view;S.lastComparison=null;notice('');$('#page-name').textContent=({overview:T("工位总览"),new:T("新建验收"),catalog:T("设备清单"),history:T("任务历史"),task:T("任务详情"),config:T("配置校验")})[view];document.querySelectorAll('nav [data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===view));} +async function navigate(view){setView(view);$('#main').innerHTML=empty(T("正在读取工位数据"),'');try{if(view==='overview')await overview();if(view==='new')newJob();if(view==='catalog')catalog();if(view==='history')await history();if(view==='config')config();}catch(e){notice(e.message,true);$('#main').innerHTML=empty(T("暂时无法读取工位"),T("检查服务和访问凭证后重试。"));}} +async function overview(refresh=true){const [stats,list]=!refresh&&S.overviewData?S.overviewData:await Promise.all([api('/api/insights'),api('/api/jobs?limit=5')]);S.overviewData=[stats,list];const counts={serial:0,network:0,pci:0};S.catalog.devices.forEach(d=>counts[d.connection_type]++);const m=stats[S.catalog.mode]; +$('#main').innerHTML=heading(T("让每一次验收,有据可查。"),T("从应检清单到协议证据,再到交付后的复测对比。所有统计来自当前工位保存的任务记录。"))+ +`
${S.catalog.mode==='demo'?'SIMULATED / OFFLINE DEMO':'LIVE / LOCAL STATION'}

${S.catalog.mode==='demo'?T("先演示一轮故障,再验证恢复。"):T("新一轮验收,从完整设备清单开始。")}

${T("配置中应存在的设备都会进入验收结果。未响应设备保留失败记录,复测后可检查恢复、退化与证据变化。")}

`+ +`
${metric(T("应检设备"),S.catalog.devices.length,T("当前配置中的完整清单"))}${metric(T("已完成任务"),m.completed,T("当前模式的已完成验收"))}${metric(T("通过任务"),m.passed,T("所有选定设备均通过"),'good')}${metric(T("失败任务"),m.failed,T("至少一项设备验收失败"),'bad')}
`+ +`

${T("最近验收")}

${list.jobs.length?jobTable(list.jobs):empty(T("等待第一份验收记录"),T("创建任务后,这里会展示工位、批次和结果。"))}

${T("验收流程")}

EVIDENCE FIRST
${[[T("完整清单"),T("未发现、超时或无响应设备也保留结果。")],[T("可追溯证据"),T("接口、请求和响应与本次配置快照一起保存。")],[T("复测与比较"),T("检查配置和范围,区分部分复测与整套验收。")]].map(([t,p],i)=>`
0${i+1}
${t}

${p}

`).join('')}

${T("设备组成")}

${T("按配置统计")}
${[[T("串口 / RS-485"),counts.serial],[T("网络 / TCP · UDP"),counts.network],[T("PCI / 被动状态"),counts.pci]].map(([n,v])=>`
${n}${v}
`).join('')}
${T("通信响应证明当前通信路径可用;PCI 被动状态不代表完整业务功能或电气动作正常。")}

${T("当前配置")}

SHA-256

${esc(S.catalog.config_hash)}

${T("任务创建时固定配置快照,历史结果不会随配置修改而改变。")}

`;} +function newJob(){$('#main').innerHTML=heading(T("新建现场验收"),T("记录工位与批次,按当前配置检查全部应检设备。"))+demoNote()+`

${T("验收信息")}

${S.catalog.devices.length} DEVICES
${S.catalog.mode==='demo'?``:''}
${T("同一工位同一时间执行一个任务。")}

${T("本次应检")}

${S.catalog.devices.map(d=>`
${esc(d.name)}${esc(scopes[d.scope])}
${esc(d.connection_type.toUpperCase())}
`).join('')}
${T("未响应设备保留为失败项;取消后未执行的设备单独标记。")}
`;} +function catalog(){$('#main').innerHTML=heading(T("应检设备清单"),T("这份清单决定验收覆盖范围。设备无需先被扫描发现,才进入验收结果。"),``)+demoNote()+`

${S.catalog.devices.length}${T(" 个应检设备")}

${T("当前配置")}
${S.catalog.devices.map(d=>``).join('')}
${T("设备 / 标识")}${T("接口类型")}${T("接口 / 槽位")}${T("协议")}${T("验收范围")}
${device(d)}${esc(d.connection_type.toUpperCase())}${esc(d.interface||T("按配置匹配"))}${esc(d.protocol||T("被动检查"))}${esc(scopes[d.scope])}
${T("配置摘要 ")}${esc(S.catalog.config_hash)}
${S.catalog.mode==='live'?``:''}`;} +async function history(refresh=true){const d=!refresh&&S.historyData?S.historyData:await api(`/api/jobs?limit=20&offset=${S.offset}&sort=${S.sort}&status=${encodeURIComponent(S.filter)}&query=${encodeURIComponent(S.query)}`);S.historyData=d;$('#main').innerHTML=heading(T("任务历史"),T("按工位、批次或操作者查找验收记录,选择两次任务查看恢复与退化。"),``)+`
${d.jobs.length?jobTable(d.jobs):empty(T("未找到任务"),T("调整筛选条件或创建新任务。"))}
${T("共 ")}${d.total}${T(" 个任务")}

${T("两次任务比较")}

${[['baseline',T("基线任务")],['current',T("当前任务")]].map(([key,label])=>``).join('')}
`;} +function comparisonOptions(key,jobs){const selected=S.comparison[key],choices=jobs.map(j=>({id:j.id,label:`${j.metadata.station_id||T("未命名")} · ${id(j.id)} · ${verdicts[j.summary.verdict]}`}));if(selected&&!choices.some(j=>j.id===selected.id))choices.unshift(selected.job?{id:selected.id,label:`${selected.job.metadata.station_id||T("未命名")} · ${id(selected.id)} · ${verdicts[selected.job.summary.verdict]}`}:selected);return choices.map(j=>``).join('');} +document.addEventListener('change',event=>{const select=event.target;if(select.closest('#compare-form')&&['baseline','current'].includes(select.name))S.comparison[select.name]=select.value?{id:select.value,label:select.selectedOptions[0].textContent,job:S.historyData?.jobs.find(j=>j.id===select.value)||S.comparison[select.name]?.job}:null;}); +function config(){$('#main').innerHTML=heading(T("校验设备配置"),T("检查设备标识、接口参数和只读验收规则。校验不会修改工位当前配置。"))+`

${T("配置内容")}

VALIDATE ONLY
`;} +async function openJob(jobId){setView('task');S.job=null;$('#main').innerHTML=empty(T("正在读取任务"),'');S.job=(await api(`/api/jobs/${encodeURIComponent(jobId)}`)).job;task();poll(jobId);} function poll(jobId){clearTimeout(S.timer);if(!S.job||done(S.job.status)||S.view!=='task')return;S.timer=setTimeout(async()=>{try{const d=await api(`/api/jobs/${jobId}`);if(S.view!=='task'||S.job?.id!==jobId)return;S.job=d.job;task();poll(jobId);}catch(e){notice(e.message,true);}},650);} function task(){const j=S.job,s=j.summary,finished=done(j.status),pending=j.results.some(r=>r.verdict!=='PASS'),pct=j.progress.total?Math.round(j.progress.completed/j.progress.total*100):0; -$('#main').innerHTML=heading(j.metadata.station_id||'验收任务',`任务 ${id(j.id)} · ${j.parent_job_id?'指定设备复测':'设备验收'}`,`${finished?'':''}`)+ -`
${j.mode==='demo'?'SIMULATED / 合成验收记录':'LIVE / 现场验收记录'}

${j.parent_job_id?'复测验收':'工位验收'} · ${esc(j.metadata.batch||'无批次')}

操作者 ${esc(j.metadata.operator||'未记录')}创建 ${date(j.created_at)}执行 ${(j.duration_ms/1000).toFixed(2)} s
${pill(j.status)}
设备进度 ${j.progress.completed} / ${j.progress.total}${pct}%
${j.metadata.notes?`

${esc(j.metadata.notes)}

`:''}
本次配置摘要 ${esc(j.config_hash)}
`+ -(j.parent_job_id?'
本任务只复测原任务未通过设备。部分复测通过不代表整套设备验收通过。
':'')+ -`
${metric('应检设备',s.expected,'本次选定的验收范围')}${metric('通过',s.passed,'保留对应通信 / 被动证据','good')}${metric('失败 / 待确认',s.failed+s.review,'需要排查或现场确认','bad')}${metric('未执行',s.not_run,'等待执行或提前结束','warn')}

设备验收结果

${pill(s.verdict,'本次范围 '+verdicts[s.verdict])}
${j.results.map((r,i)=>``).join('')}
设备接口 / 协议验收结果判定依据耗时操作
${device(r)}${esc(r.interface||r.connection_type)}${esc(r.protocol||'被动检查')}${pill(r.verdict)}${esc(r.verdict==='PASS'?scopes[r.scope]:faults[r.fault_code]||r.fault_code)}${esc(r.summary)}${Number(r.duration_ms).toFixed(1)} ms
`+ -`

复测与交付

${finished&&pending?`
${j.mode==='demo'?``:''}
`:finished?'

已完成当前选定范围检查,证据与配置快照已保留。

':'

执行结束后可复测或导出。取消在当前设备组结束后生效。

'}${j.parent_job_id&&finished?'
':''}
${['HTML','JSON','CSV'].map(f=>``).join('')}
报告保留验收范围、报文证据与判定。模拟报告始终标记 SIMULATED。

任务记录

${j.events.map(e=>`
${esc(e.message)}
`).join('')}
`;} +$('#main').innerHTML=heading(j.metadata.station_id||T("验收任务"),`${T("任务 ")}${id(j.id)} · ${j.parent_job_id?T("指定设备复测"):T("设备验收")}`,`${finished?'':``}`)+ +`
${j.mode==='demo'?T("SIMULATED / 合成验收记录"):T("LIVE / 现场验收记录")}

${j.parent_job_id?T("复测验收"):T("工位验收")} · ${esc(j.metadata.batch||T("无批次"))}

${T("操作者 ")}${esc(j.metadata.operator||T("未记录"))}${T("创建 ")}${date(j.created_at)}${T("执行 ")}${(j.duration_ms/1000).toFixed(2)} s
${pill(j.status)}
${T("设备进度 ")}${j.progress.completed} / ${j.progress.total}${pct}%
${j.metadata.notes?`

${esc(j.metadata.notes)}

`:''}
${T("本次配置摘要 ")}${esc(j.config_hash)}
`+ +(j.parent_job_id?`
${T("本任务只复测原任务未通过设备。部分复测通过不代表整套设备验收通过。")}
`:'')+ +`
${metric(T("应检设备"),s.expected,T("本次选定的验收范围"))}${metric(T("通过"),s.passed,T("保留对应通信 / 被动证据"),'good')}${metric(T("失败 / 待确认"),s.failed+s.review,T("需要排查或现场确认"),'bad')}${metric(T("未执行"),s.not_run,T("等待执行或提前结束"),'warn')}

${T("设备验收结果")}

${pill(s.verdict,T("本次范围 ")+verdicts[s.verdict])}
${j.results.map((r,i)=>``).join('')}
${T("设备")}${T("接口 / 协议")}${T("验收结果")}${T("判定依据")}${T("耗时")}${T("操作")}
${device(r)}${esc(r.interface||r.connection_type)}${esc(r.protocol||T("被动检查"))}${pill(r.verdict)}${esc(r.verdict==='PASS'?scopes[r.scope]:faults[r.fault_code]||r.fault_code)}${esc(r.summary)}${Number(r.duration_ms).toFixed(1)} ms
`+ +`

${T("复测与交付")}

${finished&&pending?`
${j.mode==='demo'?``:''}
`:finished?`

${T("已完成当前选定范围检查,证据与配置快照已保留。")}

`:`

${T("执行结束后可复测或导出。取消在当前设备组结束后生效。")}

`}${j.parent_job_id&&finished?`
`:''}
${['HTML','JSON','CSV'].map(f=>``).join('')}
${T("报告保留验收范围、报文证据与判定。模拟报告始终标记 SIMULATED。")}

${T("任务记录")}

${j.events.map(e=>`
${esc(e.type==='result'?(verdicts[e.message]||e.message):T(e.message))}
`).join('')}
`;} function structuredEvidence(result) { const attempts = result.attempts || [], supporting = result.supporting_checks || []; - return (attempts.length ? `
全部探测记录 (${attempts.length})
${esc(JSON.stringify(attempts, null, 2))}
` : '') + - (supporting.length ? `
载体检查证据 (${supporting.length})

载体检查支持本次设备判定,不扩大选定设备范围。

${esc(JSON.stringify(supporting, null, 2))}
` : ''); + return (attempts.length ? `
${T("全部探测记录 (")}${attempts.length})
${esc(JSON.stringify(attempts, null, 2))}
` : '') + + (supporting.length ? `
${T("载体检查证据 (")}${supporting.length})

${T("载体检查支持本次设备判定,不扩大选定设备范围。")}

${esc(JSON.stringify(supporting, null, 2))}
` : ''); } -function evidence(index){const r=S.job.results[index];$('#evidence-body').innerHTML=`
${pill(r.verdict)}${r.simulated?'SIMULATED':''}

${esc(r.name)}

${[['设备标识',r.device_id],['验收范围',scopes[r.scope]],['接口',r.interface||r.connection_type],['协议',r.protocol||'PCI 被动检查'],['判定依据',r.summary],['耗时',`${r.duration_ms} ms`]].map(([k,v])=>`
${k}
${esc(v)}
`).join('')}
${esc(r.request||'无报文 / 被动检查')}
${esc(r.response||'无响应记录')}
${structuredEvidence(r)}${r.verdict!=='PASS'?`
排查建议
${esc(r.suggestion)}
基于规则的建议,需要现场确认。
`:''}
此项结果只覆盖 ${esc(scopes[r.scope])},不覆盖电气动作或完整业务功能。
`;$('#evidence').showModal();} -async function compare(baseline,current){const c=(await api(`/api/compare?baseline=${encodeURIComponent(baseline)}¤t=${encodeURIComponent(current)}`)).comparison;$('#comparison-host').innerHTML=`

复测结果比较

${id(baseline)} → ${id(current)}
${!c.comparable?`
不满足可比条件:${esc(c.reasons.join(' · '))}。以下差异仅供查看。
`:c.partial_scope?'
部分范围比较:只检查共同设备,未宣称整套设备已恢复。
':'
模式、配置和选定设备范围一致,可比较验收状态。
'}
${[['恢复',c.recovered],['退化',c.regressed],['仍失败',c.still_failed],['未变化',c.unchanged]].map(([n,ids])=>`
${c.comparable?'':'观察到的'}${n}${ids.length}
`).join('')}
${[['恢复',c.recovered],['退化',c.regressed],['新增',c.added],[c.partial_scope?'未纳入本次复测':'移除',c.removed],['其他变化',c.changed]].filter(([,v])=>v.length).map(([n,ids])=>`
${n}${ids.map(v=>`${esc(v)}`).join('')}
`).join('')}
`;$('#comparison-host').scrollIntoView({behavior:'smooth'});} -async function download(format){const headers=S.token?{Authorization:`Bearer ${S.token}`}:{},r=await fetch(`/api/jobs/${S.job.id}/export?format=${format}`,{headers});if(!r.ok)throw new Error('报告导出失败');const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=`acceptance-${id(S.job.id)}.${format}`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),3000);} +function evidence(index){S.evidenceIndex=index;const r=S.job.results[index];$('#evidence-body').innerHTML=`
${pill(r.verdict)}${r.simulated?'SIMULATED':''}

${esc(r.name)}

${[[T("设备标识"),r.device_id],[T("验收范围"),scopes[r.scope]],[T("接口"),r.interface||r.connection_type],[T("协议"),r.protocol||T("PCI 被动检查")],[T("判定依据"),r.summary],[T("耗时"),`${r.duration_ms} ms`]].map(([k,v])=>`
${k}
${esc(v)}
`).join('')}
${esc(r.request||T("无报文 / 被动检查"))}
${esc(r.response||T("无响应记录"))}
${structuredEvidence(r)}${r.verdict!=='PASS'?`
${T("排查建议")}
${esc(r.suggestion)}
${T("基于规则的建议,需要现场确认。")}
`:''}
${T("此项结果只覆盖 ")}${esc(scopes[r.scope])}${T(",不覆盖电气动作或完整业务功能。")}
`;if(!$('#evidence').open)$('#evidence').showModal();} +async function compare(baseline,current){const c=(await api(`/api/compare?baseline=${encodeURIComponent(baseline)}¤t=${encodeURIComponent(current)}`)).comparison;S.lastComparison={c,baseline,current};renderComparison(c,baseline,current);$('#comparison-host').scrollIntoView({behavior:'smooth'});} +function renderComparison(c,baseline,current){$('#comparison-host').innerHTML=`

${T("复测结果比较")}

${id(baseline)} → ${id(current)}
${!c.comparable?`
${T("不满足可比条件:")}${esc(c.reasons.map(T).join(' · '))}${T("。以下差异仅供查看。")}
`:c.partial_scope?`
${T("部分范围比较:只检查共同设备,未宣称整套设备已恢复。")}
`:`
${T("模式、配置和选定设备范围一致,可比较验收状态。")}
`}
${[[T("恢复"),c.recovered],[T("退化"),c.regressed],[T("仍失败"),c.still_failed],[T("未变化"),c.unchanged]].map(([n,ids])=>`
${c.comparable?'':T("观察到的")}${n}${ids.length}
`).join('')}
${[[T("恢复"),c.recovered],[T("退化"),c.regressed],[T("新增"),c.added],[c.partial_scope?T("未纳入本次复测"):T("移除"),c.removed],[T("其他变化"),c.changed]].filter(([,v])=>v.length).map(([n,ids])=>`
${n}${ids.map(v=>`${esc(v)}`).join('')}
`).join('')}
`;} +async function download(format){const headers=S.token?{Authorization:`Bearer ${S.token}`}:{},r=await fetch(`/api/jobs/${S.job.id}/export?format=${format}&lang=${QLI18N.language}`,{headers});if(!r.ok)throw new Error(T("报告导出失败"));const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=`acceptance-${id(S.job.id)}.${format}`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),3000);} document.addEventListener('click',async event=>{const b=event.target.closest('button');if(!b||b.disabled)return;try{if(b.dataset.view)return await navigate(b.dataset.view);switch(b.dataset.action){case 'open':await openJob(b.dataset.id);break;case 'evidence':evidence(Number(b.dataset.index));break;case 'close-evidence':$('#evidence').close();break;case 'credentials':$('#credentials').showModal();break;case 'close-credentials':$('#credentials').close();break;case 'filter':S.query=$('#query').value;S.filter=$('#status').value;S.sort=$('#sort').value;S.offset=0;await history();break;case 'prev':S.offset=Math.max(0,S.offset-20);await history();break;case 'next':S.offset+=20;await history();break;case 'cancel':await api(`/api/jobs/${S.job.id}/cancel`,'POST',{});await openJob(S.job.id);break;case 'retest':{b.disabled=true;const body={};if($('#retest-scenario'))body.scenario=$('#retest-scenario').value;const d=await api(`/api/jobs/${S.job.id}/retest`,'POST',body);await openJob(d.job.id);break;}case 'compare-parent':await compare(S.job.parent_job_id,S.job.id);break;case 'export':await download(b.dataset.format);break;case 'load-config':$('textarea[name="config"]').value=JSON.stringify(S.catalog.config_snapshot,null,2);break;}}catch(e){notice(e.message,true);if(b.isConnected)b.disabled=false;}}); -document.addEventListener('submit',async event=>{event.preventDefault();const form=event.target,b=form.querySelector('[type="submit"]');try{if(b)b.disabled=true;const v=Object.fromEntries(new FormData(form));if(form.id==='job-form')await openJob((await api('/api/jobs','POST',v)).job.id);if(form.id==='compare-form'){if(v.baseline===v.current)throw new Error('请选择两个不同任务');await compare(v.baseline,v.current);}if(form.id==='config-form'){const d=await api('/api/config/validate','POST',{config:JSON.parse(v.config)});$('#validation').hidden=false;$('#validation').textContent=JSON.stringify(d,null,2);}if(form.id==='credentials-form'){S.token=v.token;sessionStorage.setItem('qldc.token',S.token);$('#credentials').close();await boot();}}catch(e){notice(e.message,true);}finally{if(b?.isConnected)b.disabled=false;}}); -async function boot(){try{S.catalog=await api('/api/catalog');$('#station-mode').textContent=S.catalog.mode==='demo'?'离线演示工位':'本地现场工位';$('#mode-badge').textContent=S.catalog.mode==='demo'?'SIMULATED / DEMO':'LIVE / LOCAL';await navigate('overview');}catch(e){notice(e.message,true);$('#main').innerHTML=empty('连接工位后开始验收','服务不可用或需要访问凭证,请通过右上角输入令牌。');}} +document.addEventListener('submit',async event=>{event.preventDefault();const form=event.target,b=form.querySelector('[type="submit"]');try{if(b)b.disabled=true;const v=Object.fromEntries(new FormData(form));if(form.id==='job-form')await openJob((await api('/api/jobs','POST',v)).job.id);if(form.id==='compare-form'){if(v.baseline===v.current)throw new Error(T("请选择两个不同任务"));await compare(v.baseline,v.current);}if(form.id==='config-form'){const d=await api('/api/config/validate','POST',{config:JSON.parse(v.config)});$('#validation').hidden=false;$('#validation').textContent=JSON.stringify(d,null,2);}if(form.id==='credentials-form'){S.token=v.token;sessionStorage.setItem('qldc.token',S.token);$('#credentials').close();await boot();}}catch(e){notice(e.message,true);}finally{if(b?.isConnected)b.disabled=false;}}); +async function boot(){try{S.catalog=await api('/api/catalog');$('#station-mode').textContent=S.catalog.mode==='demo'?T("离线演示工位"):T("本地现场工位");$('#mode-badge').textContent=S.catalog.mode==='demo'?'SIMULATED / DEMO':'LIVE / LOCAL';await navigate('overview');}catch(e){notice(e.message,true);$('#main').innerHTML=empty(T("连接工位后开始验收"),T("服务不可用或需要访问凭证,请通过右上角输入令牌。"));}} +async function refreshLanguage(){ + // Repaint from existing task/catalog/list snapshots; keep hardware requests, + // polling, pagination, credentials and entered form data independent of language. + const controls=[...$('#main').querySelectorAll('input,textarea,select')].map(element=>({ + id:element.id,name:element.name,form:element.form?.id,value:element.value, + start:element.selectionStart,end:element.selectionEnd,focused:element===document.activeElement + })); + const validation=$('#validation'), validationState=validation?{text:validation.textContent,hidden:validation.hidden}:null; + const openEvidence=$('#evidence').open; + const pageNames={overview:'工位总览',new:'新建验收',catalog:'设备清单',history:'任务历史',task:'任务详情',config:'配置校验'}; + $('#page-name').textContent=T(pageNames[S.view]); + if(!S.catalog)return; + $('#station-mode').textContent=T(S.catalog.mode==='demo'?'离线演示工位':'本地现场工位'); + if(S.view==='overview')await overview(false); + if(S.view==='new')newJob(); + if(S.view==='catalog')catalog(); + if(S.view==='history')await history(false); + if(S.view==='config')config(); + if(S.view==='task'&&S.job)task(); + const rendered=[...$('#main').querySelectorAll('input,textarea,select')]; + for(const saved of controls){ + const element=rendered.find(e=>saved.id?e.id===saved.id:e.name===saved.name&&e.form?.id===saved.form); + if(!element)continue; + element.value=saved.value; + if(saved.focused){element.focus({preventScroll:true});if(saved.start!==null&&element.setSelectionRange)element.setSelectionRange(saved.start,saved.end);} + } + if(validationState&&$('#validation')){Object.assign($('#validation'),{textContent:validationState.text,hidden:validationState.hidden});} + if(S.lastComparison&&$('#comparison-host')){const {c,baseline,current}=S.lastComparison;renderComparison(c,baseline,current);} + if(openEvidence&&S.job&&S.evidenceIndex!==null)evidence(S.evidenceIndex); +} +QLI18N.apply(); +QLI18N.onChange(()=>refreshLanguage().catch(e=>notice(e.message,true))); +$('#language-switch').addEventListener('change',event=>QLI18N.setLanguage(event.target.value)); boot(); diff --git a/web/assets/i18n.js b/web/assets/i18n.js new file mode 100644 index 0000000..5e80d6c --- /dev/null +++ b/web/assets/i18n.js @@ -0,0 +1,178 @@ +'use strict'; +// Only explicit interface phrases are translated. User/configuration/evidence data +// never passes through this dictionary or a document-wide text replacement. +globalThis.QLI18N = (() => { + const en = { + 'QL DeviceCheck · 现场验收工作台':'QL DeviceCheck · Field acceptance workbench', + '工控机设备检测':'Industrial device diagnostics', + '界面语言':'Interface language', '工位工作台':'STATION WORKBENCH', + '主导航':'Main navigation','工位总览':'Station overview','新建验收':'New inspection', + '设备清单':'Device catalog','任务历史':'Task history','配置校验':'Configuration validation', + '▦ 工位总览':'▦ Station overview','+ 新建验收':'+ New inspection', + '◫ 设备清单':'◫ Device catalog','◷ 任务历史':'◷ Task history','≋ 配置校验':'≋ Configuration validation', + '连接中':'Connecting','应检设备 · 协议证据 · 复测追溯':'Expected devices · Protocol evidence · Retest history', + '验收中心 / ':'Acceptance center / ','访问凭证':'Access credentials', + '现场记录保存在当前工位 · v3.1':'Records stay on this station · v3.1', + '设备测试证据':'Device test evidence','关闭证据':'Close evidence', + '连接受保护的工位':'Connect to a protected station', + '令牌仅用于本次浏览器会话,不写入报告。':'The token is used only for this browser session and is excluded from reports.', + '访问令牌':'Access token','取消':'Cancel','保存并连接':'Save and connect', + '访问令牌(仅保存在当前浏览器会话)':'Access token (stored only for this browser session)', + '扫描设备':'Scan devices','测试全部':'Test all','保存':'Save','等待扫描':'Waiting for scan', + '串':'SER','网':'NET','报':'RPT','串口':'Serial','网口':'Network','测试报告':'Test report', + '0 个设备':'0 devices','尚未扫描':'Not scanned yet','尚未生成报告':'No report yet', + '正在读取工位数据…':'Loading station data…', + '等待执行':'Queued','执行中':'Running','正在取消':'Cancelling','已完成':'Completed', + '已取消':'Cancelled','执行故障':'Failed','已中断':'Interrupted', + '通过':'Pass','失败':'Fail','待确认':'Review required','未执行':'Not run', + '全部正常':'All healthy','混合故障':'Mixed faults','响应超时':'Response timeout', + 'CRC 错误':'CRC error','设备缺失':'Missing device','通信路径验证':'Communication path verification', + 'PCI 被动检查':'Passive PCI inspection','CRC 校验失败':'CRC validation failed', + '应检设备未响应':'Expected device did not respond','载体通信失败':'Carrier communication failed', + '响应不匹配':'Response mismatch','接口或设备不可用':'Interface or device unavailable','尚未执行':'Not run yet', + '请求失败 (':'Request failed (','FIELD ACCEPTANCE / 工位验收':'FIELD ACCEPTANCE', + 'SIMULATED · 当前为离线演示,使用合成设备与报文,不连接现场硬件。':'SIMULATED · Offline demo with synthetic devices and messages. No field hardware is connected.', + '工位 / 批次':'Station / batch','任务 / 创建时间':'Task / created','执行状态':'Execution status', + '验收结果':'Inspection verdict','操作':'Actions','未命名工位':'Unnamed station','无批次':'No batch', + '未记录操作者':'Operator not recorded',' 通过':' passed','查看任务 →':'View task →','任务详情':'Task details', + '正在读取工位数据':'Loading station data','暂时无法读取工位':'Station unavailable', + '检查服务和访问凭证后重试。':'Check the service and access credentials, then retry.', + '让每一次验收,有据可查。':'Make every inspection traceable.', + '从应检清单到协议证据,再到交付后的复测对比。所有统计来自当前工位保存的任务记录。':'From the expected device list to protocol evidence and retest comparisons after delivery. All statistics come from task records saved on this station.', + '先演示一轮故障,再验证恢复。':'Demonstrate faults, then verify recovery.', + '新一轮验收,从完整设备清单开始。':'Start the next inspection with the complete device list.', + '配置中应存在的设备都会进入验收结果。未响应设备保留失败记录,复测后可检查恢复、退化与证据变化。':'Every configured device appears in the results. Devices that do not respond retain a failed result. Retest to check recovery, regression and changes in evidence.', + '开始验收 ↗':'Start inspection ↗','应检设备':'Expected devices', + '当前配置中的完整清单':'Complete list in the current configuration','已完成任务':'Completed tasks', + '当前模式的已完成验收':'Completed inspections in this mode','通过任务':'Passed tasks', + '所有选定设备均通过':'All selected devices passed','失败任务':'Failed tasks', + '至少一项设备验收失败':'At least one device failed','最近验收':'Recent inspections', + '全部任务 →':'All tasks →','等待第一份验收记录':'Waiting for the first inspection record', + '创建任务后,这里会展示工位、批次和结果。':'Create a task to see its station, batch and results here.', + '验收流程':'Inspection workflow','完整清单':'Complete device list', + '未发现、超时或无响应设备也保留结果。':'Results include missing devices, timeouts and devices that do not respond.', + '可追溯证据':'Traceable evidence','接口、请求和响应与本次配置快照一起保存。':'Interfaces, requests and responses are saved with the configuration snapshot.', + '复测与比较':'Retest and compare','检查配置和范围,区分部分复测与整套验收。':'Check configuration and scope to distinguish a partial retest from a whole-unit inspection.', + '设备组成':'Device composition','按配置统计':'From configuration','串口 / RS-485':'Serial / RS-485', + '网络 / TCP · UDP':'Network / TCP · UDP','PCI / 被动状态':'PCI / passive status', + '通信响应证明当前通信路径可用;PCI 被动状态不代表完整业务功能或电气动作正常。':'A response verifies the current communication path. Passive PCI status does not establish full application functionality or electrical operation.', + '查看应检清单':'View expected devices','当前配置':'Current configuration', + '任务创建时固定配置快照,历史结果不会随配置修改而改变。':'Each task retains its configuration snapshot. Later configuration changes do not alter historical results.', + '新建现场验收':'New field inspection','记录工位与批次,按当前配置检查全部应检设备。':'Record the station and batch, then inspect all expected devices in the current configuration.', + '验收信息':'Inspection information','工位 / 设备编号':'Station / equipment ID', + 'FAT-01 / 设备序列号':'FAT-01 / equipment serial number','批次编号':'Batch ID','操作者':'Operator', + '验收工程师':'Inspection engineer','演示场景':'Demo scenario','验收备注':'Inspection notes', + '现场环境、变更或本次验收目的':'Site conditions, changes or inspection purpose', + '同一工位同一时间执行一个任务。':'One task runs on this station at a time.', + '创建验收任务':'Create inspection task','本次应检':'Devices in this inspection', + '未响应设备保留为失败项;取消后未执行的设备单独标记。':'Devices that do not respond retain failed results. Devices skipped after cancellation are marked separately.', + '应检设备清单':'Expected device catalog', + '这份清单决定验收覆盖范围。设备无需先被扫描发现,才进入验收结果。':'This catalog defines inspection coverage. Devices appear in the results even if a scan has not discovered them.', + '新建验收任务':'New inspection task',' 个应检设备':' expected devices','设备 / 标识':'Device / ID', + '接口类型':'Interface type','接口 / 槽位':'Interface / slot','协议':'Protocol','验收范围':'Inspection scope', + '按配置匹配':'Match configuration','被动检查':'Passive inspection','配置摘要 ':'Configuration hash ', + '打开原有设备扫描与单接口检查界面 →':'Open legacy device scanning and interface diagnostics →', + '按工位、批次或操作者查找验收记录,选择两次任务查看恢复与退化。':'Find records by station, batch or operator. Compare two tasks to inspect recovery and regression.', + '搜索记录':'Search records','工位 / 批次 / 操作者':'Station / batch / operator','全部状态':'All statuses', + '时间排序':'Time order','最新在前':'Newest first','最早在前':'Oldest first','筛选':'Filter', + '未找到任务':'No tasks found','调整筛选条件或创建新任务。':'Adjust the filters or create a task.', + '共 ':'Total: ',' 个任务':' tasks','上一页':'Previous page','下一页':'Next page', + '两次任务比较':'Compare two tasks','基线任务':'Baseline task','当前任务':'Current task', + '选择任务':'Select a task','比较任务':'Compare tasks','未命名':'Unnamed', + '校验设备配置':'Validate device configuration', + '检查设备标识、接口参数和只读验收规则。校验不会修改工位当前配置。':'Check device IDs, interface parameters and read-only inspection rules. Validation does not change the station configuration.', + '配置内容':'Configuration content','设备配置 JSON':'Device configuration JSON', + '载入当前配置':'Load current configuration','校验配置':'Validate configuration','正在读取任务':'Loading task', + '验收任务':'Inspection task','任务 ':'Task ','指定设备复测':'Selected-device retest','设备验收':'Device inspection', + '返回历史':'Back to history','取消任务':'Cancel task','SIMULATED / 合成验收记录':'SIMULATED / SYNTHETIC RECORD', + 'LIVE / 现场验收记录':'LIVE / FIELD RECORD','复测验收':'Retest inspection','工位验收':'Station inspection', + '操作者 ':'Operator: ','未记录':'Not recorded','创建 ':'Created: ','执行 ':'Duration: ', + '设备进度 ':'Device progress: ','本次配置摘要 ':'Task configuration hash ', + '本任务只复测原任务未通过设备。部分复测通过不代表整套设备验收通过。':'This task retests only devices that did not pass the original task. A passed partial retest does not establish that the whole unit passed.', + '本次选定的验收范围':'Selected scope for this task','保留对应通信 / 被动证据':'Communication / passive evidence retained', + '失败 / 待确认':'Failed / review required','需要排查或现场确认':'Investigation or site confirmation needed', + '等待执行或提前结束':'Waiting or ended before execution','设备验收结果':'Device inspection results', + '本次范围 ':'Task scope: ','设备':'Device','接口 / 协议':'Interface / protocol','判定依据':'Decision basis', + '耗时':'Duration','查看证据':'View evidence','复测与交付':'Retest and delivery','复测场景':'Retest scenario', + '复测未通过设备':'Retest devices that did not pass', + '已完成当前选定范围检查,证据与配置快照已保留。':'The selected scope is complete. Evidence and the configuration snapshot are retained.', + '执行结束后可复测或导出。取消在当前设备组结束后生效。':'Retest or export when execution ends. Cancellation takes effect after the current device group finishes.', + '与原任务比较':'Compare with original task','导出 ':'Export ', + '报告保留验收范围、报文证据与判定。模拟报告始终标记 SIMULATED。':'Reports retain scope, protocol evidence and verdicts. Demo reports always carry the SIMULATED marker.', + '任务记录':'Task log','全部探测记录 (':'All probe records (','载体检查证据 (':'Carrier check evidence (', + '载体检查支持本次设备判定,不扩大选定设备范围。':'Carrier checks support this device verdict without expanding the selected scope.', + '设备标识':'Device ID','接口':'Interface','请求 / 检查方式':'Request / inspection method', + '无报文 / 被动检查':'No message / passive inspection','响应 / 采集结果':'Response / collected result', + '无响应记录':'No response recorded','排查建议':'Troubleshooting guidance', + '基于规则的建议,需要现场确认。':'Rule-based guidance requires confirmation on site.', + '此项结果只覆盖 ':'This result covers only ',',不覆盖电气动作或完整业务功能。':'; it does not cover electrical operation or full application functionality.', + '复测结果比较':'Retest comparison','不满足可比条件:':'Comparison conditions not met: ', + '。以下差异仅供查看。':'. Differences below are observations only.', + '部分范围比较:只检查共同设备,未宣称整套设备已恢复。':'Partial-scope comparison: only common devices are checked; whole-unit recovery is not established.', + '模式、配置和选定设备范围一致,可比较验收状态。':'Mode, configuration and selected scope match. Inspection states are comparable.', + '恢复':'Recovered','退化':'Regressed','仍失败':'Still failed','未变化':'Unchanged','观察到的':'Observed: ', + '新增':'Added','未纳入本次复测':'Outside this retest','移除':'Removed','其他变化':'Other changes', + '报告导出失败':'Report export failed','请选择两个不同任务':'Select two different tasks', + '离线演示工位':'Offline demo station','本地现场工位':'Local field station', + '连接工位后开始验收':'Connect to the station to start inspections', + '服务不可用或需要访问凭证,请通过右上角输入令牌。':'The service is unavailable or requires credentials. Enter an access token using the control above.', + '操作失败':'Operation failed','报告地址无效':'Invalid report address','报告读取失败 (':'Report read failed (', + '),请检查访问凭证':'). Check the access credentials','已连接':'Connected','正常':'Healthy','异常':'Fault', + '等待测试':'Waiting for test','连通正常':'Communication verified','无响应':'No response','接口不存在':'Interface missing', + '测试失败':'Test failed',' · 经':' · via ','可用':'Available','未确定接口':'Unspecified interface', + '已发现已知设备':'Known device discovered',' 个设备':' devices','未发现接口':'No interfaces found', + '未发现已知设备':'No known devices found','测试':'Test','通信方式':'Communication method','状态':'Status', + '点击“测试全部”后生成报告':'Select “Test all” to generate a report','通过 ':'Passed: ',',失败 ':'; failed: ', + '网页':'HTML',',发现 ':'; found ','测试中':'Testing','正在测试设备连通性':'Testing device communication', + '设备连通正常':'Device communication verified','设备测试失败':'Device test failed','扫描中':'Scanning', + '正在扫描接口 ':'Scanning interface ',' 扫描完成,发现 ':' scan complete; found ', + '接口扫描失败':'Interface scan failed','访问令牌已保存,可继续扫描':'Access token saved. Scanning can continue.', + '正在扫描已知设备':'Scanning known devices','扫描完成':'Scan complete', + '正在扫描并测试全部已发现设备':'Scanning and testing all discovered devices','全部测试完成':'All tests complete', + '报告读取失败':'Report read failed' + }; + let language; + try { language=localStorage.getItem('qldc.lang'); } catch (_) {} + if (!['en','zh'].includes(language)) language=/^zh\b/i.test(globalThis.navigator?.language||'en')?'zh':'en'; + const listeners=new Set(); + const zh={ + 'Missing station/unit identity':'缺少工位 / 设备标识','Station/unit identity mismatch':'工位 / 设备标识不一致', + 'Batch mismatch':'批次不一致','Mode mismatch':'模式不一致','Configuration mismatch':'配置不一致', + 'Selected scope mismatch':'选定范围不一致','Result scope mismatch':'结果范围不一致','Jobs are not finished':'任务尚未结束', + 'Acceptance queued':'验收任务已排队','Acceptance started':'验收任务已开始', + 'Acceptance completed':'验收任务已完成','Acceptance cancelled':'验收任务已取消', + 'Acceptance failed':'验收执行故障','Acceptance interrupted':'验收任务已中断', + 'Service restarted; no automatic hardware retry':'服务已重启;不会自动重试硬件操作', + 'Cancellation requested; effective at a device/group boundary':'已请求取消;在当前设备 / 设备组结束后生效', + 'Access token required':'需要访问令牌','Invalid access token':'访问令牌无效', + 'Legacy hardware operations are unavailable in demo mode':'演示模式不提供旧版硬件操作' + }; + const t=phrase=>language==='en'?(en[phrase]??phrase):(zh[phrase]??phrase); + function apply(root=document) { + if (root===document) { + document.documentElement.lang=language==='zh'?'zh-CN':'en'; + const title=document.documentElement.dataset.i18nTitle; + if(title)document.title=t(title); + } + for(const [attribute,target] of [['data-i18n',null],['data-i18n-aria','aria-label'],['data-i18n-placeholder','placeholder']]) { + root.querySelectorAll(`[${attribute}]`).forEach(element=>{ + const text=t(element.getAttribute(attribute)); + if(target)element.setAttribute(target,text);else element.textContent=text; + }); + } + const control=root.querySelector('#language-switch');if(control)control.value=language; + } + function setLanguage(value) { + if(!['en','zh'].includes(value))return; + language=value; + try {localStorage.setItem('qldc.lang',value);} catch (_) {} + apply();listeners.forEach(listener=>listener(value)); + } + // Getters keep status/scenario label maps current without changing their IDs. + function translatedLabels(map) { + const result={}; + for(const [key,value] of Object.entries(map))Object.defineProperty(result,key,{enumerable:true,get:()=>t(value)}); + return result; + } + return {get language(){return language;},t,labels:translatedLabels,apply,setLanguage,onChange:callback=>listeners.add(callback)}; +})(); diff --git a/web/assets/style.css b/web/assets/style.css index 12083a6..bbc6306 100644 --- a/web/assets/style.css +++ b/web/assets/style.css @@ -1,3 +1,5 @@ :root{--nav:#142b38;--ink:#1a3442;--muted:#6b8190;--line:#dfe7eb;--accent:#087d79;--bg:#f3f6f8;--red:#b8413b;--mono:Consolas,monospace}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.6 "Segoe UI","Microsoft YaHei",sans-serif}button,input,textarea,select{font:inherit}button{cursor:pointer;border:0}button:disabled{opacity:.5;cursor:wait}a{color:var(--accent);text-decoration:none}h1,h2,h3,p{margin-top:0}h1{font-size:30px;line-height:1.3;letter-spacing:-.8px;margin-bottom:12px}h2{font-size:19px;margin-bottom:12px}h3{font-size:14px}small{font-size:11px}.muted{color:var(--muted)}.mono{font-family:var(--mono);font-size:11px;overflow-wrap:anywhere}.eyebrow{display:block;font:10px var(--mono);letter-spacing:2px;color:var(--accent);margin-bottom:10px}aside{width:234px;position:fixed;inset:0 auto 0 0;background:var(--nav);padding:28px 18px;display:flex;flex-direction:column;color:#a8c0cb}.brand{display:flex;gap:11px;align-items:center;color:#fff;font-weight:700;font-size:14px;letter-spacing:1px;margin:0 8px 46px}.brand b{position:relative;width:42px;height:42px;display:grid;place-items:center;border:1px solid #557582;border-radius:9px;background:#1b3b4b;font-size:19px;letter-spacing:-1px}.brand b span{color:#68dbb9;position:absolute;right:3px;top:-7px}.brand small{display:block;font-size:8px;letter-spacing:2px;color:#87a5b5}.nav-label{font-size:10px;letter-spacing:2px;padding:0 14px;margin-bottom:12px;color:#6c8a9b}nav button{display:block;width:100%;text-align:left;padding:13px 14px;border-radius:6px;background:none;color:#c2d2db;margin-bottom:5px;font-size:13px}nav button:hover{background:#234454}nav button.active{background:#244b58;color:#90dfc7}.station{margin-top:auto;border-top:1px solid #2e4754;padding:20px 10px 0;font-size:11px}.station i{display:inline-block;width:7px;height:7px;background:#5dd3b1;border-radius:50%;margin-right:8px}.station small{display:block;margin-top:7px;font-size:9px;color:#7894a3}.workspace{margin-left:234px}header{height:72px;padding:0 35px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}header span{color:var(--muted)}header strong{font-weight:500}header code{font:9px var(--mono);letter-spacing:1px;background:#edf8f4;border:1px solid #bfded3;color:#397b65;padding:4px 8px;border-radius:4px}.actions{display:flex;gap:9px;flex-wrap:wrap;align-items:center}.text{color:var(--muted);background:none;font-size:11px;padding:5px}.primary,.secondary,.danger{display:inline-flex;align-items:center;justify-content:center;min-height:39px;padding:9px 16px;border-radius:5px;font-size:12px;font-weight:600;white-space:nowrap}.primary{background:var(--accent);color:white}.primary:hover{background:#05635f}.secondary{background:white;border:1px solid #ccd9df;color:#476572}.danger{background:#fff0ee;border:1px solid #efcbc5;color:var(--red)}.link{background:transparent;color:var(--accent);font-size:11px;padding:5px 0;white-space:nowrap}main{max-width:1500px;padding:34px 35px;min-height:calc(100vh - 132px);margin:auto}.heading{display:flex;justify-content:space-between;gap:22px;align-items:flex-start;margin-bottom:26px}.heading p{color:var(--muted);font-size:13px;max-width:700px;margin:0}.heading .actions{align-self:center}.hero{background:linear-gradient(110deg,#173a4a,#23525a);border-radius:9px;color:white;padding:30px;display:flex;gap:30px;justify-content:space-between;margin-bottom:25px}.hero h2{font-size:24px;margin-bottom:9px}.hero p{color:#b7d3d9;font-size:12px;margin:0;max-width:650px}.hero .eyebrow{color:#85e0c1}.hero .primary{align-self:center;background:#b5ead5;color:#1b5349}.tag{display:inline-block;font:9px var(--mono);letter-spacing:2px;border:1px solid #51737b;padding:4px 8px;border-radius:4px;color:#b9dfd8;margin-bottom:18px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:26px}.metric{border:1px solid var(--line);border-radius:8px;background:white;padding:20px;position:relative}.metric-label{font-size:12px;color:var(--muted)}.metric strong{display:block;font:33px/1.4 var(--mono);letter-spacing:-1px;margin:8px 0 4px}.metric small{font-size:10px;color:#8295a1}.metric:after{content:'';position:absolute;right:18px;top:23px;width:6px;height:6px;border-radius:50%;background:#8faeba}.metric.good:after{background:#38a979}.metric.bad:after{background:#ce625b}.metric.warn:after{background:#cea04a}.columns{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(270px,1fr);gap:21px}.card{background:white;border:1px solid var(--line);border-radius:8px;margin-bottom:22px;min-width:0}.card-head{padding:18px 21px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}.card-head h2,.card-head h3{margin:0;font-size:15px}.card-head small{color:var(--muted)}.pad{padding:23px}.note{background:#eef6f6;border-left:3px solid var(--accent);padding:12px 15px;color:#477275;border-radius:0 4px 4px 0;font-size:12px;margin:18px 0;overflow-wrap:anywhere}.note.warn{background:#fff6e5;border-color:#cba040;color:#8a672c}.empty{padding:40px 24px;text-align:center;color:var(--muted);font-size:12px}.empty strong{display:block;font-size:15px;color:#486574;margin-bottom:7px}.empty button{margin-top:14px}.table-wrap{overflow:auto;max-width:100%}table{border-collapse:collapse;width:100%;text-align:left}th{font-size:10px;font-weight:600;color:#7b909b;background:#f8fafb;padding:12px 17px;white-space:nowrap;border-bottom:1px solid var(--line)}td{padding:15px 17px;font-size:12px;border-bottom:1px solid #edf1f3;vertical-align:middle}td small{display:block;color:var(--muted);font-size:10px;margin-top:3px}tbody tr:last-child td{border-bottom:0}.device{display:flex;align-items:center;gap:10px;min-width:190px}.symbol{flex-shrink:0;width:33px;height:33px;border-radius:7px;display:grid;place-items:center;background:#edf4f7;color:#7697a5;font-size:18px}.pill{display:inline-flex;align-items:center;gap:5px;font-size:10px;white-space:nowrap;padding:3px 9px;border-radius:4px;background:#edf2f5;color:#698190}.pill:before{content:'';width:4px;height:4px;border-radius:50%;background:currentColor}.pill.PASS,.pill.completed{background:#e7f4ed;color:#26744e}.pill.FAIL,.pill.failed{background:#fbece9;color:#b8413b}.pill.REVIEW,.pill.cancelling{background:#fff2dc;color:#986a19}.pill.running,.pill.queued{background:#e6f1f8;color:#387f9a}.pill.NOT_RUN,.pill.cancelled,.pill.interrupted{background:#edf1f4;color:#778b97}.row{display:flex;justify-content:space-between;align-items:center;padding:13px 0;border-bottom:1px solid #edf1f3;gap:14px}.row:last-child{border-bottom:0}.row strong{font-size:12px}.row span{font:20px var(--mono);color:#527c8b}.row small{display:block;color:var(--muted)}.step{display:flex;gap:14px;margin-bottom:18px}.step>span{width:27px;height:27px;flex-shrink:0;display:grid;place-items:center;border-radius:50%;background:#edf6f4;color:var(--accent);border:1px solid #d7e9e2;font:11px var(--mono)}.step strong{font-size:12px}.step p{font-size:11px;color:var(--muted);margin:4px 0 0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:19px}label{display:block;font-size:12px;font-weight:600;color:#466573}input,select,textarea{width:100%;min-width:0;margin-top:7px;padding:10px 12px;border:1px solid #cad8de;border-radius:5px;color:var(--ink);background:white;font-weight:400}textarea{resize:vertical;min-height:105px}input:focus,textarea:focus,select:focus{outline:2px solid #6baba5;outline-offset:1px}.full{grid-column:span 2}.form-actions{margin-top:20px;justify-content:flex-end}.form-foot{padding:18px 23px;border-top:1px solid var(--line);display:flex;gap:16px;align-items:center;justify-content:space-between}.form-foot small{color:var(--muted)}.filters{display:flex;align-items:flex-end;gap:12px;padding:16px 21px;flex-wrap:wrap;border-bottom:1px solid var(--line)}.filters label{font-size:10px}.filters input{width:220px}.filters select{width:150px}.pager{padding:15px 21px;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted);gap:12px}.task-top{display:flex;justify-content:space-between;gap:16px}.task-top h2{margin-bottom:4px}.task-info{display:flex;gap:15px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-top:15px}.progress{background:#e6eef0;height:6px;border-radius:6px;overflow:hidden;margin:20px 0 8px}.progress>div{height:100%;background:var(--accent);transition:width .3s}.progress-label{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}.hash{background:#f9fbfc;border-top:1px solid var(--line);padding:12px 21px;font-size:10px;color:#7b909c;overflow-wrap:anywhere}.event{display:flex;gap:12px;font-size:11px;padding:9px 0;overflow-wrap:anywhere}.event time{font-family:var(--mono);color:#91a0aa;white-space:nowrap}.compare-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.compare-tile{background:#f3f7f8;padding:17px;border-radius:6px}.compare-tile span{font-size:11px;color:var(--muted);display:block}.compare-tile strong{display:block;font:27px var(--mono);color:var(--accent);margin-top:7px}.compare-list{display:flex;gap:9px;flex-wrap:wrap;margin-top:15px;font-size:11px}.compare-list code{background:#edf4f5;padding:4px 7px;border-radius:4px;overflow-wrap:anywhere}.retake{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap}.retake label{width:180px;font-size:10px}#notice{margin:18px 35px 0;border:1px solid #ebd7b1;background:#fff6e7;color:#885f22;padding:12px 16px;border-radius:6px;overflow-wrap:anywhere}#notice.error{border-color:#eccbc6;background:#fff0ed;color:#a63b32}dialog{padding:0;width:min(680px,calc(100vw - 30px));border:1px solid var(--line);border-radius:10px;color:var(--ink);max-height:90vh;overflow:auto;box-shadow:0 20px 60px #142b3840}dialog::backdrop{background:#0f2c3e70;backdrop-filter:blur(2px)}dialog .card-head h2{font-size:19px}.evidence-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px}.evidence-grid dt{font-size:10px;color:var(--muted)}.evidence-grid dd{font-size:12px;margin:4px 0 0;overflow-wrap:anywhere}pre{font:11px/1.8 var(--mono);background:#15313f;color:#c6e3e8;padding:16px;border-radius:6px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:230px;overflow:auto}footer{padding:18px 35px;border-top:1px solid var(--line);font-size:10px;color:#8a9da8;display:flex;justify-content:space-between;gap:12px} @media(max-width:1100px){aside{width:210px}.workspace{margin-left:210px}main{padding:27px 23px}header{padding:0 23px}.columns{grid-template-columns:1fr}.metrics{gap:10px}.metric{padding:17px}.metric strong{font-size:29px}} @media(max-width:760px){aside{position:relative;width:100%;padding:15px 16px 9px}.brand{margin:0 0 14px;font-size:12px}.brand b{width:33px;height:33px;font-size:16px}.nav-label,.station{display:none}nav{display:flex;overflow-x:auto;gap:4px}nav button{width:auto;white-space:nowrap;flex-shrink:0;padding:8px 9px;font-size:11px;margin:0}.workspace{margin-left:0}header{height:58px;padding:0 17px;font-size:11px}header>div>span{display:none}header code{font-size:8px;letter-spacing:0}header .actions{gap:6px}.text{font-size:10px}main{padding:23px 16px}h1{font-size:25px}.heading{display:block;margin-bottom:22px}.heading p{font-size:12px}.heading .actions{margin-top:15px}.hero{padding:22px;display:block}.hero h2{font-size:21px}.hero .primary{margin-top:20px}.hero p{font-size:11px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:20px}.metric{padding:16px}.metric strong{font-size:28px}.metric small{font-size:9px}.metric-label{font-size:11px}.columns{gap:0}.card-head{padding:15px 16px}.pad{padding:17px}.form-grid{grid-template-columns:1fr;gap:15px}.full{grid-column:span 1}.form-foot{padding:17px;display:block}.form-foot .primary{width:100%;margin-top:14px}.filters{padding:15px;gap:10px}.filters label{flex:1}.filters input,.filters select{width:100%}td,th{padding:12px}.task-top{display:block}.task-top>.pill,.task-top>[data-testid]{display:inline-block;margin-top:12px}.task-info{gap:8px}.hash{padding:12px 16px}.event{display:block}.event time{display:block;margin-bottom:3px}.evidence-grid{grid-template-columns:1fr}.compare-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.compare-tile{padding:13px}.retake label{flex:1;min-width:140px}.retake .primary{width:100%}footer{padding:17px;display:block}footer span{display:block;margin-top:4px}#notice{margin:14px 16px 0;font-size:12px}.pager{padding:15px}} +.language-control{display:flex;align-items:center;gap:6px;font-size:10px;font-weight:400;white-space:nowrap}.language-control select{width:84px;margin:0;padding:4px 6px;font-size:11px} +@media(max-width:760px){header{height:auto;min-height:58px;flex-wrap:wrap;padding:10px 17px}header .actions{margin-left:auto}.language-control>span{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.language-control select{width:78px}} diff --git a/web/index.html b/web/index.html index 0716fa5..1086b34 100644 --- a/web/index.html +++ b/web/index.html @@ -1,8 +1,8 @@ -QL DeviceCheck · 现场验收工作台 +QL DeviceCheck · 现场验收工作台 - -
验收中心 / 工位总览
CONNECTING
正在读取工位数据…
QL DeviceCheck 现场记录保存在当前工位 · v3.0
-
PROTOCOL EVIDENCE

设备测试证据

-

连接受保护的工位

令牌仅用于本次浏览器会话,不写入报告。

+ +
验收中心 / 工位总览
CONNECTING
正在读取工位数据…
QL DeviceCheck 现场记录保存在当前工位 · v3.1
+
PROTOCOL EVIDENCE

设备测试证据

+

连接受保护的工位

令牌仅用于本次浏览器会话,不写入报告。

diff --git a/web/legacy.html b/web/legacy.html index 343756d..626c564 100644 --- a/web/legacy.html +++ b/web/legacy.html @@ -1,10 +1,13 @@ - + 工控机设备检测 +
-

工控机设备检测

+

工控机设备检测

- - - + + +
-
+
- 等待扫描 + 等待扫描
-
串

串口

-
0 个设备
+
串

串口

+
0 个设备
-
尚未扫描
+
尚未扫描
-
网

网口

-
0 个设备
+
网

网口

+
0 个设备
-
尚未扫描
+
尚未扫描
PCI

PCI

-
0 个设备
+
0 个设备
-
尚未扫描
+
尚未扫描
-
报

测试报告

+
报

测试报告

-
尚未生成报告
+
尚未生成报告