From 85b87210353272c17b1c6ad48f4bc24a73a405c4 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:38:43 +0300 Subject: [PATCH 1/5] =?UTF-8?q?zavod=20#56,=20=D0=BA=D1=80=D1=83=D0=B3=201?= =?UTF-8?q?:=20=D0=9D=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BD=D0=B5=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_answer_reveal.py | 283 +++++++++++++++++++++++++++++++++++ tests/test_question_check.py | 9 +- 2 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 tests/test_answer_reveal.py diff --git a/tests/test_answer_reveal.py b/tests/test_answer_reveal.py new file mode 100644 index 0000000..6feef18 --- /dev/null +++ b/tests/test_answer_reveal.py @@ -0,0 +1,283 @@ +import json +import re +import shutil +import subprocess +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.content import EXAMS, LESSONS, QUESTION_BY_ID +from app.db import save_lesson +from app.main import app + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_JS = REPO_ROOT / "app" / "static" / "app.js" +STYLES_CSS = REPO_ROOT / "app" / "static" / "styles.css" + +# Вызывает showVerdict из app.js с заглушкой DOM и возвращает className и innerHTML узла результата. +VERDICT_SCRIPT = """ +const fs = require('fs'); +const vm = require('vm'); +const [file, payload] = process.argv.slice(1); +const stub = new Proxy(function () {}, { + get: (target, key) => (typeof key === 'symbol' || key === 'then' ? undefined : stub), + set: () => true, + apply: () => stub, + construct: () => stub, +}); +process.on('unhandledRejection', () => {}); +const rendered = JSON.parse(payload).map((result) => { + const fields = { className: '', innerHTML: '' }; + const node = new Proxy(fields, { + get: (target, key) => (key in target ? target[key] : stub), + set: (target, key, value) => { target[key] = value; return true; }, + }); + const document = new Proxy(function () {}, { + get: (target, key) => (key === 'querySelector' ? () => node : stub), + set: () => true, + apply: () => stub, + construct: () => stub, + }); + const context = vm.createContext({ + document, window: stub, location: { hash: '' }, localStorage: stub, + fetch: () => new Promise(() => {}), setTimeout: () => 0, clearTimeout: () => {}, + Element: class {}, console, + }); + vm.runInContext(fs.readFileSync(file, 'utf8'), context, { filename: 'app.js' }); + context.showVerdict('q', result); + return { className: fields.className, innerHTML: fields.innerHTML }; +}); +console.log(JSON.stringify(rendered)); +""" + +WRONG_LESSON_ANSWERS = [ + ("hello-choice", "text('Привет')"), + ("hello-term", "неверно"), + ("hello-code", "if True print('Привет')"), +] +NEUTRAL_MESSAGE = "Пока неверно. Перечитай код и попробуй ещё раз." + + +def _explanation(question_id: str) -> str: + explanation = QUESTION_BY_ID[question_id]["explanation"] + assert explanation.strip(), question_id + return explanation + + +def _strings(value: object) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return [text for item in value.values() for text in _strings(item)] + if isinstance(value, list): + return [text for item in value for text in _strings(item)] + return [] + + +def _assert_hides_explanation(body: dict, question_id: str) -> None: + explanation = _explanation(question_id) + assert body["correct"] is False, question_id + assert not body.get("explanation"), question_id + assert isinstance(body.get("message"), str) and body["message"].strip(), question_id + assert body["message"] != explanation, question_id + assert all(explanation not in text for text in _strings(body)), question_id + + +def _css_rules() -> list[tuple[str, str]]: + css = STYLES_CSS.read_text(encoding="utf-8") + return re.findall(r"([^{}]+)\{([^{}]*)\}", css) + + +def _margin_bottom(body: str) -> float | None: + longhand = re.findall(r"margin-bottom:\s*(-?\d+(?:\.\d+)?)px", body) + if longhand: + return float(longhand[-1]) + shorthand = re.findall(r"(? list[dict] | None: + node = shutil.which("node") + if node is None: + return None + result = subprocess.run( + [node, "-e", VERDICT_SCRIPT, str(APP_JS), json.dumps(results, ensure_ascii=False)], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_c1_wrong_lesson_check_hides_explanation_for_choice_input_and_code() -> None: + with TestClient(app) as client: + client.post("/api/reset") + wrong = { + question_id: client.post( + "/api/lessons/hello/check", json={"question_id": question_id, "answer": answer} + ) + for question_id, answer in WRONG_LESSON_ANSWERS + } + correct = client.post( + "/api/lessons/hello/check", + json={"question_id": "hello-choice", "answer": "print('Привет')"}, + ) + client.post("/api/reset") + + for question_id, response in wrong.items(): + assert response.status_code == 200, question_id + _assert_hides_explanation(response.json(), question_id) + + assert correct.status_code == 200 + assert correct.json()["correct"] is True + assert correct.json()["explanation"] == _explanation("hello-choice") + + +def test_c2_wrong_practice_and_code_check_hide_explanation() -> None: + with TestClient(app) as client: + client.post("/api/reset") + wrong_practice = { + question_id: client.post( + "/api/practice/submit", json={"question_id": question_id, "answer": answer} + ) + for question_id, answer in WRONG_LESSON_ANSWERS + } + wrong_code = client.post( + "/api/code/check", json={"question_id": "hello-code", "answer": "def broken(:"} + ) + correct_practice = client.post( + "/api/practice/submit", + json={"question_id": "hello-choice", "answer": "print('Привет')"}, + ) + correct_code = client.post( + "/api/code/check", + json={"question_id": "hello-code", "answer": "print('Я начинаю путь в Python!')"}, + ) + client.post("/api/reset") + + for question_id, response in wrong_practice.items(): + assert response.status_code == 200, question_id + _assert_hides_explanation(response.json(), question_id) + + assert wrong_code.status_code == 200 + _assert_hides_explanation(wrong_code.json(), "hello-code") + + assert correct_practice.status_code == 200 + assert correct_practice.json()["correct"] is True + assert correct_practice.json()["explanation"] == _explanation("hello-choice") + + assert correct_code.status_code == 200 + assert correct_code.json()["correct"] is True + assert correct_code.json()["explanation"] == _explanation("hello-code") + + +def test_c3_lesson_and_exam_grading_keep_explanation_per_question() -> None: + module_id = "start" + exam_question_ids = EXAMS[module_id]["question_ids"] + with TestClient(app) as client: + client.post("/api/reset") + + lesson = client.post( + "/api/lessons/hello/submit", + json={ + "answers": [ + {"question_id": qid, "answer": ans} for qid, ans in WRONG_LESSON_ANSWERS + ] + }, + ) + for item in LESSONS: + if item["module_id"] == module_id: + total = len(item["questions"]) + save_lesson(item["id"], total, total, item["xp"]) + exam = client.post( + f"/api/exams/{module_id}/submit", + json={"answers": [{"question_id": qid, "answer": ""} for qid in exam_question_ids]}, + ) + client.post("/api/reset") + + assert lesson.status_code == 200 + assert exam.status_code == 200 + for response, expected_ids in ( + (lesson, [qid for qid, _ in WRONG_LESSON_ANSWERS]), + (exam, exam_question_ids), + ): + results = response.json()["results"] + assert {item["question_id"] for item in results} == set(expected_ids) + for item in results: + explanation = _explanation(item["question_id"]) + assert item.get("message"), item["question_id"] + assert explanation in (item.get("explanation"), item.get("message")), item[ + "question_id" + ] + + +def test_c4_wrong_verdict_shows_neutral_phrase_without_empty_paragraphs() -> None: + cases = [ + {"correct": False, "message": NEUTRAL_MESSAGE}, + {"correct": False, "message": ""}, + { + "correct": False, + "message": NEUTRAL_MESSAGE, + "checks": [{"passed": False, "expected": "1", "actual": "2"}], + "error_hint": { + "title": "Синтаксическая ошибка", + "hint": "Проверь код", + "original": "SyntaxError: x", + }, + }, + {"correct": True, "message": "Все тесты пройдены!", "explanation": "Так работает print."}, + {"correct": False, "message": NEUTRAL_MESSAGE, "explanation": "Секретное объяснение"}, + ] + rendered = _render_verdicts(cases) + if rendered is None: + js = APP_JS.read_text(encoding="utf-8") + assert "↺ Пока неверно" in js + assert "✓ Верно" in js + return + + plain, empty, with_hint, correct, leaked = rendered + assert "no" in plain["className"].split() + assert "↺ Пока неверно" in plain["innerHTML"] + assert NEUTRAL_MESSAGE in plain["innerHTML"] + assert not re.search(r"

\s*

", plain["innerHTML"]) + + assert "↺ Пока неверно" in empty["innerHTML"] + assert not re.search(r"

\s*

", empty["innerHTML"]) + + assert "Синтаксическая ошибка" in with_hint["innerHTML"] + assert "Ожидалось" in with_hint["innerHTML"] + assert not re.search(r"

\s*

", with_hint["innerHTML"]) + + assert "ok" in correct["className"].split() + assert "✓ Верно" in correct["innerHTML"] + assert "Так работает print." in correct["innerHTML"] + + assert "↺ Пока неверно" in leaked["innerHTML"] + assert "Секретное объяснение" not in leaked["innerHTML"] + + +def test_c5_question_code_block_has_gap_below_before_options() -> None: + values = [ + _margin_bottom(body) + for selector, body in _css_rules() + if re.search(r"\.question-card\b[^,{]*\.code-example\b", selector) + ] + values = [value for value in values if value is not None] + assert values, "нет правила отступа снизу для блока кода внутри .question-card" + assert values[-1] >= 12 + + +def test_c5_theory_code_block_margin_is_unchanged() -> None: + bodies = [body for selector, body in _css_rules() if selector.strip() == ".code-example"] + assert bodies + assert any(re.search(r"margin:\s*14px\s+0\s+0\b", body) for body in bodies) diff --git a/tests/test_question_check.py b/tests/test_question_check.py index eb4dec8..af26643 100644 --- a/tests/test_question_check.py +++ b/tests/test_question_check.py @@ -1,5 +1,6 @@ from fastapi.testclient import TestClient +from app.content import QUESTION_BY_ID from app.main import app FORBIDDEN_KEYS = {"answer", "answers", "reference", "tests"} @@ -13,7 +14,8 @@ def _all_keys(value: object) -> set[str]: return set() -def test_c1_check_returns_verdict_explanation_and_error_hint() -> None: +def test_c1_check_returns_verdict_and_error_hint_without_explanation_on_wrong() -> None: + wrong_explanation = QUESTION_BY_ID["hello-term"]["explanation"] with TestClient(app) as client: client.post("/api/reset") correct = client.post( @@ -39,7 +41,10 @@ def test_c1_check_returns_verdict_explanation_and_error_hint() -> None: assert wrong.status_code == 200 assert wrong.json()["question_id"] == "hello-term" assert wrong.json()["correct"] is False - assert wrong.json()["explanation"] + assert not wrong.json().get("explanation") + assert isinstance(wrong.json()["message"], str) + assert wrong.json()["message"] + assert wrong.json()["message"] != wrong_explanation assert broken_code.status_code == 200 assert broken_code.json()["correct"] is False From 30f501b1e335ff3a7a34c5da1706332acb6c6eed Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:42:03 +0300 Subject: [PATCH 2/5] =?UTF-8?q?zavod=20#56,=20=D0=BA=D1=80=D1=83=D0=B3=201?= =?UTF-8?q?:=20=D0=9D=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BD=D0=B5=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 26 ++++++++++++++++++++++---- app/static/app.js | 10 +++++----- app/static/styles.css | 1 + 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/main.py b/app/main.py index ae4abbc..4e9ad77 100644 --- a/app/main.py +++ b/app/main.py @@ -165,6 +165,24 @@ def require_lesson(lesson_id: str) -> dict: return lesson +RETRY_HINT = "Пока неверно. Перечитай условие и попробуй ещё раз." + + +def public_answer_result(question: dict, result: dict) -> dict: + """Готовит ответ на одиночную проверку, не раскрывая решение заранее. + + Объяснение показывается только после верного ответа. Итоговые проверки + урока и экзамена используют ``grade_answers`` и это правило не применяют. + """ + visible = dict(result) + if visible["correct"]: + visible["explanation"] = visible.get("explanation") or question.get("explanation", "") + else: + visible.pop("explanation", None) + visible["message"] = RETRY_HINT + return visible + + def grade_answers(items: list[Answer], allowed_ids: set[str]) -> tuple[list[dict], int]: provided = {item.question_id: item.answer for item in items if item.question_id in allowed_ids} results = [] @@ -241,13 +259,13 @@ def check_lesson_answer(lesson_id: str, payload: QuestionCheck) -> dict: ) if question is None: raise HTTPException(status_code=404, detail="Задание не найдено в этом уроке") - result = evaluate(question, payload.answer) + result = public_answer_result(question, evaluate(question, payload.answer)) record_attempt(question["id"], result["correct"]) response = { "question_id": question["id"], "correct": result["correct"], "message": result["message"], - "explanation": result.get("explanation") or question.get("explanation", ""), + "explanation": result.get("explanation", ""), } if "checks" in result: response["checks"] = result["checks"] @@ -283,7 +301,7 @@ def submit_practice(answer: Answer) -> dict: question = QUESTION_BY_ID.get(answer.question_id) if not question: raise HTTPException(status_code=404, detail="Задание не найдено") - result = evaluate(question, answer.answer) + result = public_answer_result(question, evaluate(question, answer.answer)) if error_text := result.get("error"): result["error_hint"] = translate_error(error_text) record_attempt(answer.question_id, result["correct"]) @@ -299,7 +317,7 @@ def check_code(payload: CodeCheck) -> dict: question = QUESTION_BY_ID.get(payload.question_id) if not question or question["kind"] != "code": raise HTTPException(status_code=404, detail="Кодовое задание не найдено") - result = evaluate(question, payload.answer) + result = public_answer_result(question, evaluate(question, payload.answer)) if error_text := result.get("error"): result["error_hint"] = translate_error(error_text) return result diff --git a/app/static/app.js b/app/static/app.js index f81e9a4..01f71db 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -162,7 +162,7 @@ function bindQuestionControls(scope) { button.textContent = 'Проверяем…'; try { const result = await api('/api/code/check', { method: 'POST', body: JSON.stringify({ question_id: questionId, answer: editor.value }) }); - showInline(questionId, result.correct, result.message, result.checks, result.error_hint); + showVerdict(questionId, result); } catch (error) { showInline(questionId, false, error.message); } button.disabled = false; button.textContent = '▷ Проверить код'; @@ -214,12 +214,12 @@ function showVerdict(questionId, result) { const node = document.querySelector(`#result-${questionId}`); if (!node) return; if (result.correct) { + const text = result.explanation || result.message || ''; node.className = 'inline-result visible ok'; - node.innerHTML = `

✓ Верно

${rich(result.explanation || result.message || '')}

`; + node.innerHTML = `

✓ Верно

${text ? `

${rich(text)}

` : ''}`; return; } const message = result.message || ''; - const explanation = result.explanation && result.explanation !== message ? `

${rich(result.explanation)}

` : ''; const hint = result.error_hint ? `
${esc(result.error_hint.title)}${esc(result.error_hint.hint)}
${esc(result.error_hint.original)}` : ''; @@ -227,7 +227,7 @@ function showVerdict(questionId, result) { ? `` : ''; node.className = 'inline-result visible no'; - node.innerHTML = `

↺ Пока неверно

${message ? `

${rich(message)}

` : ''}${explanation}${hint}${checks}`; + node.innerHTML = `

↺ Пока неверно

${message ? `

${rich(message)}

` : ''}${hint}${checks}`; } function markOptionVerdict(questionId, correct) { @@ -560,7 +560,7 @@ async function renderPractice() { const answer = getAnswers(form, [question])[0]; try { const result = await api('/api/practice/submit', { method: 'POST', body: JSON.stringify(answer) }); - showInline(question.id, result.correct, result.message, result.checks, result.error_hint); + showVerdict(question.id, result); await refreshDashboard(); if (result.correct) toast(`Верно! +${result.xp_gained} XP`); } catch (error) { toast(error.message); } diff --git a/app/static/styles.css b/app/static/styles.css index b74305b..710c96d 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -89,6 +89,7 @@ h3 { margin: 0; font-size: 17px; } .lesson-progress .progress-seg.current { background: var(--blue); } .step-caption { margin: 0; color: var(--muted); font-size: 12px; font-weight: 900; } .step-card, .step-question-card { margin: 0; } +.question-card .code-example { margin-bottom: 12px; } .step-kicker { margin: 0 0 8px; color: var(--green-dark); font-size: 11px; text-transform: uppercase; letter-spacing: 1.2px; font-weight: 900; } .step { display: flex; flex-direction: column; gap: 14px; } .step-nav { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: nowrap; margin-top: 16px; } From d62f8b7034f489b9d27b05b93e96fb9a8d0fa094 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:43:56 +0300 Subject: [PATCH 3/5] =?UTF-8?q?zavod=20#56,=20=D0=BA=D1=80=D1=83=D0=B3=201?= =?UTF-8?q?:=20=D0=9D=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BD=D0=B5=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python_path.db-journal | Bin 0 -> 16928 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 python_path.db-journal diff --git a/python_path.db-journal b/python_path.db-journal new file mode 100644 index 0000000000000000000000000000000000000000..bfb187b4986c0d96654f3ac7b7cb7e517b8b6b83 GIT binary patch literal 16928 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 Date: Fri, 23 Oct 2026 02:46:30 +0300 Subject: [PATCH 4/5] =?UTF-8?q?zavod=20#56,=20=D0=BA=D1=80=D1=83=D0=B3=201?= =?UTF-8?q?:=20=D0=9D=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BD=D0=B5=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_lesson_check.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_lesson_check.py b/tests/test_lesson_check.py index 1b01c95..bc4b775 100644 --- a/tests/test_lesson_check.py +++ b/tests/test_lesson_check.py @@ -1,6 +1,7 @@ from fastapi.testclient import TestClient -from app.main import app +from app.content import QUESTION_BY_ID +from app.main import RETRY_HINT, app ALLOWED_KEYS = {"question_id", "correct", "message", "explanation", "checks", "error_hint"} FORBIDDEN_KEYS = {"answer", "answers", "reference", "tests"} @@ -44,7 +45,9 @@ def test_check_choice_returns_verdict_and_explanation() -> None: assert "error_hint" not in correct assert wrong["correct"] is False - assert wrong["explanation"] + assert not wrong.get("explanation") + assert wrong["message"] == RETRY_HINT + assert QUESTION_BY_ID["hello-choice"]["explanation"] not in wrong["message"] def test_check_input_accepts_known_alternatives() -> None: From 24ba4b903c12a2eb84d62fa6b8dbfc0cc187bd06 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:47:03 +0300 Subject: [PATCH 5/5] =?UTF-8?q?zavod=20#56,=20=D0=BA=D1=80=D1=83=D0=B3=202?= =?UTF-8?q?:=20=D0=9D=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BD=D0=B5=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python_path.db-journal | Bin 16928 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 python_path.db-journal diff --git a/python_path.db-journal b/python_path.db-journal deleted file mode 100644 index bfb187b4986c0d96654f3ac7b7cb7e517b8b6b83..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16928 zcmeHOU2GiH72fsOv%B8Egak|yLNK2U<%Qq(?BgsP%SEw5GIsH#$4`cR-nZBeBnRV`JKsM-fwsVa(u5b8N|XLfdW zcS@?NQiXO!?45h=Ip^N*{9Zga67fG5um5Qoo<<{)A^5e$Ck(!QmJt{RgGVM(vytq@ z>>Jrel&X` zyEXIA?19v5N>43i-^qMA^ZU$eneSz489j40vm^W0?Du_se*4IK{M{oTCHE&claD3u z@0FL_I{MM*`=dWkTuZDa788dOKS_Ky@sGssJU#py{2%!r@z-O|#=aeUJk|oehfs6; z+4xlaT%5+oV!w&~AoiiZl9(8a$9Bd}cq>X|;xETvkAHwG`z#xEu(tsM|0f9C%pHy@ zvPo@2CseSAMr2AhZskfHqq3|{7%iL9EVs|4pd4y#=LGjzF)Wy&!b-28 zRK?JhmSquL*%Yp{NQ+R@*-po>W+<1bv5T7uOt#l7BG(1eFf@HHccKrC3Z`W!gi=DA zxVO4&%PW8k6>Lk^sjS$lp;HZU@=2y_$xUJti(bBoJJ>-`tXBO}a4sM%qYPP75z{r> zHUUUgFy)4-JCrp?y^m}L=7ETS9fzW{-{L&5o&`)sR=731gT&| zf#~b1W#~^&3JHt@;?UdLu6M zmih$wJo<|_zN&86Ex_PWRPPP8bk!E{fnLC2i!{iFKwB#y+y?hD^hZzx8(xPUfQ4c3 zVO$a$Y=9e@^mAxrHekv&m|dsm@8lMv)UIpl3OZ1|1%9%Q2&@s=ydtcq6u@3Wm+Loa zwWTXI_#{0SHbVt1&WyABxKn*)D2Aqi4)o545Fl3%L=F+^L{Xcv_8{7{a~wcl6VSm_ zKtQ2q5R^_jczoSxo<>u54g)L%gLV%J%P?7#2w*fre*nEWs7lr$3poa&Cz0JSAdu?ovaXQ2Kuy@PtO*d6 zYet>kkD(lx07kM6SOGnOIij0n0@^B}_ua-#2WhGf8B4WM$-F0%AAXqZ^-!TL!FibWS;-D-5vV=~fTd%4bm=4V-D0KZOE~#S(Q_d8or;ah3 zY}kVHd;}GDP#`$gARubEhbF`XN_PE71*|NaRx#c@k|`^fp>WU&itz@UvZ@P>mTc9_ zxE62NzNszj?xXjgv>YM?2wM_>?9y-#$$ zt!Sh$&YgpL(FD0`L`TU~fO=$uvjs-;=mCttNGpzEq()deJvqpgEvR|CBZK@XDry15^ta8)y|`XQ`g zh(JyuccSA4X)931F9QZTRD~AVhXECw0GUl$yTkFvfKZ~Tt9!AcG@*IHIwVkH(;NzD zk2uz9601S>^p4}I=M8y@Li} z0qfd{IQpab&C6vwFpJwQ(}^ZcgExnBwv;t!N=!{w$W4fACt20&WP{MH zPQHcY%fcB<3h+VMH$kMm1+fJecZn-dF9~#HSybf@!$hd+7KGfoib0yejrn95lJ^=E z1yxBWxn0oUIHf9u-rvrKG@0PWSRNZi0{R2-Ve#u95p(~OJP-~R)g6}U9~m>79C zlKpk|rEEQWIP-Dl_001bB{K=<{x{PvrXNXfOZ_(WeCm8^OY&FAuP4tW)1$u}{mSTx z(PZMy#B*?B&&J=5e=Gi2{Px&8u@_=zV_W&R_~-bC`LU6AM_wLzcqB3W+ORP!a)0Mu z;WSPh`f%vSLz_cKqaR0q8vSbYp6E#AXaDV~WqO7$MWfNhKml?K7RPa7UMvfRyda88 zqI9Gv9w`KX@0(kxovJ%{6fV@MNdrS zD1Q*ebW}JfkztILN-gg$zBltDgs*gQ`sFr^zYHJIg{{V5>sYc| zltY;ddC`S!rpnh)xmR<8r8`VOsZ^{~nf6+R7di!a1wM?mSgRIX)~PaExTo81145aL zd5^hTVt9MZZnF@^DOJUiyX6x_{v@v0FaP~oicmpfwNhqVuS#qQz96UZ2;&qhVx`15 zXGMNGxP)MP5-Orlt-8vm3Jg47x!1&mF&E0^LXqiT$n)n=f1n%b-|B=)D-=9aJaCvl z(??owLlh<}U#dv1GcQc`U0GnEZrc zM0xZMUg}+l)Bg75&QI2AB~kR!>|VD%1h#=qau{>9R`Hynoa4t)b-NM9RyvfgD3#rv zAKk;2+~)He?lA63wOo>z-yXfaAGgz%hjNQ~FNaL;W^G7NH*4O**vsXj=w;U17+!v7 z?zQ-#%=v1;Wj?-(`HasTI0%GED3$Vgw>%x$$sb0HR=xfqpW=66IJig1FphFf z@=CpwWEHo4@(4?o)v{FdT8qh1ehk$(9ekLQlGLsTQi3V*53ONLn2=p4CgV(rlN-Y3 zOZk%IdXE_6_c)94+JG>gLMh+gJUx26PBN>)WnM927)-mfM_4z!*o?E`HI`dPDUBn z&LKZ^exWG37+k#m=WoF2dkFtOKs5V71pXU*1_%rg7$7h}V1U2?fdK*o1O^BU5EvjZ zKwyBt0D;c}0$bs4?n8;a{Gs+A#wGV2Nc7x83-IT>9D5HVC&7)7Xf$#>my1M3o`J37 zwE_Hxmqmu%fAGusM7=NJzd^?23vZ?~A4OuxNF+OyzLt44^|e$j8A*N;&NZM65EvjZ zKwyBt0D%Dl0|W*L3=sHVA@J0bd~9rdJo;&jm=i1EaaF4|`c~S*_=m<{w$gJ$Uxy;_TTAxl{8O4)x51ccv_oJ9&C({>1#* z-03q*xzpzt7JBE|a6v^|R*aTz-@t4W-$kx3%QjnqCpMt{U@gXvEsjSc&L8XP6?oyA zEXyq$UXX@2sFwqqTrLD2;}4GTV-pk63l3~9E5+Mla3`hfkq*j-O;CK9GQ3*s%hHP`#lnn;*Ivf9zgfN?7%%Ydw%K6$H+aRn zZ8HzC-l