From 5eaea7154858e593740f7f7305419ea8b9101424 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:06:59 +0300 Subject: [PATCH 1/4] =?UTF-8?q?zavod=20#55,=20=D0=BA=D1=80=D1=83=D0=B3=201?= =?UTF-8?q?:=20=D0=9A=D0=BE=D0=B4=20=D0=B2=20=D0=B2=D0=BE=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=B0=D1=85=20=E2=80=94=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=BA=D0=BE=D0=B4=D0=B0,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B6=D0=B8=D1=80=D0=BD=D1=8B=D0=BC=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_question_prompt_code_block.py | 238 +++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/test_question_prompt_code_block.py diff --git a/tests/test_question_prompt_code_block.py b/tests/test_question_prompt_code_block.py new file mode 100644 index 0000000..1e0bd07 --- /dev/null +++ b/tests/test_question_prompt_code_block.py @@ -0,0 +1,238 @@ +import html +import json +import re +import shutil +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_JS = REPO_ROOT / "app" / "static" / "app.js" +STYLES_CSS = REPO_ROOT / "app" / "static" / "styles.css" + +# Грузит app.js в vm с заглушкой DOM и рисует questionTemplate для каждого вопроса. +RENDER_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 context = vm.createContext({ + document: stub, 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' }); +const questionTemplate = vm.runInContext('questionTemplate', context); +const rendered = JSON.parse(payload).map((question) => questionTemplate(question, 1)); +console.log(JSON.stringify(rendered)); +""" + + +def _app_js() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def _css_rules() -> list[tuple[str, str]]: + css = STYLES_CSS.read_text(encoding="utf-8") + return re.findall(r"([^{}]+)\{([^{}]*)\}", css) + + +def _render(questions: list[dict]) -> list[str] | None: + node = shutil.which("node") + if node is None: + return None + result = subprocess.run( + [node, "-e", RENDER_SCRIPT, str(APP_JS), json.dumps(questions, ensure_ascii=False)], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _prompt_inner(rendered: str) -> str: + match = re.search(r'
(.*?)
', rendered, re.DOTALL) + assert match, rendered + return match.group(1) + + +def _code_bodies(rendered: str) -> list[str]: + bodies = re.findall(r'
(.*?)
', rendered, re.DOTALL) + return [html.unescape(body) for body in bodies] + + +def _option_tags(rendered: str) -> list[tuple[str, str]]: + options = [] + for tag in re.findall(r']*\bdata-value="[^"]*"[^>]*>', rendered): + value = re.search(r'data-value="([^"]*)"', tag) + assert value, tag + options.append((tag, html.unescape(value.group(1)))) + return options + + +def _applied_style(tag: str) -> str: + chunks = [] + classes = re.search(r'class="([^"]*)"', tag) + for cls in classes.group(1).split() if classes else []: + for selector, body in _css_rules(): + if re.search(rf"\.{re.escape(cls)}(?![\w-])", selector): + chunks.append(body) + style = re.search(r'style="([^"]*)"', tag) + if style: + chunks.append(style.group(1)) + return "\n".join(chunks) + + +def _assert_source_has_code_block_template() -> None: + js = _app_js() + count = len(re.findall(r'
\$\{esc\(', js))
+    assert count >= 2, "в questionTemplate нет блока кода с esc() для prompt"
+
+
+def test_c1_choice_prompt_first_line_is_text_and_rest_is_code_block() -> None:
+    question = {
+        "id": "c1-choice",
+        "kind": "choice",
+        "prompt": 'Что выведет код?\n\n\nx = `y`\nprint(x < 2 and "a" in x)\n\n',
+        "options": ["1", "2"],
+    }
+    rendered = _render([question])
+    if rendered is None:
+        _assert_source_has_code_block_template()
+        return
+    html_out = rendered[0]
+    assert html.unescape(_prompt_inner(html_out)) == "Что выведет код?"
+    assert _code_bodies(html_out) == ['x = `y`\nprint(x < 2 and "a" in x)']
+
+
+def test_c1_input_prompt_strips_wrapping_backticks_from_code_block() -> None:
+    question = {
+        "id": "c1-input",
+        "kind": "input",
+        "prompt": "Что выведет код?\n`for i in range(2):\n    print(i)`",
+        "placeholder": "Введите ответ",
+    }
+    rendered = _render([question])
+    if rendered is None:
+        _assert_source_has_code_block_template()
+        return
+    html_out = rendered[0]
+    assert html.unescape(_prompt_inner(html_out)) == "Что выведет код?"
+    assert _code_bodies(html_out) == ["for i in range(2):\n    print(i)"]
+
+
+def test_c1_first_line_keeps_inline_code_rendering() -> None:
+    question = {
+        "id": "c1-rich",
+        "kind": "input",
+        "prompt": "Какой тип у `x`?\nx = 1",
+        "placeholder": "Введите ответ",
+    }
+    rendered = _render([question])
+    if rendered is None:
+        _assert_source_has_code_block_template()
+        return
+    html_out = rendered[0]
+    assert 'x' in _prompt_inner(html_out)
+    assert _code_bodies(html_out) == ["x = 1"]
+
+
+def test_c2_single_line_prompt_has_no_code_block() -> None:
+    questions = [
+        {
+            "id": "c2-line",
+            "kind": "choice",
+            "prompt": "Что выведет `print(1)`?",
+            "options": ["1", "2"],
+        },
+        {
+            "id": "c2-trailing",
+            "kind": "input",
+            "prompt": "Что выведет код?\n\n",
+            "placeholder": "Введите ответ",
+        },
+    ]
+    rendered = _render(questions)
+    if rendered is None:
+        assert "rich(question.prompt)" in _app_js()
+        return
+    single, trailing = rendered
+    assert 'print(1)' in _prompt_inner(single)
+    assert "code-example" not in single
+    assert html.unescape(_prompt_inner(trailing)) == "Что выведет код?"
+    assert "code-example" not in trailing
+
+
+def test_c2_code_question_prompt_is_rendered_whole() -> None:
+    question = {
+        "id": "c2-code",
+        "kind": "code",
+        "prompt": "Напиши функцию\n\ndef f():\n    pass",
+        "starter": "def f():\n    pass",
+        "hint": "Подсказка",
+    }
+    rendered = _render([question])
+    if rendered is None:
+        assert 'class="code-editor"' in _app_js()
+        return
+    html_out = rendered[0]
+    assert html.unescape(_prompt_inner(html_out)) == "Напиши функцию\n\ndef f():\n    pass"
+    assert "code-example" not in html_out
+    assert 'class="code-editor"' in html_out
+
+
+def test_c3_multiline_option_is_monospace_pre_wrap_and_single_line_is_not() -> None:
+    question = {
+        "id": "c3-choice",
+        "kind": "choice",
+        "prompt": "Какой вывод?",
+        "options": ["x = 1\ny = 2", "print(x)"],
+    }
+    rendered = _render([question])
+    if rendered is None:
+        assert (
+            any(
+                "pre-wrap" in body and "Source Code Pro" in body and ".option" in selector
+                for selector, body in _css_rules()
+            )
+            or "white-space: pre-wrap" in _app_js()
+        )
+        return
+    options = _option_tags(rendered[0])
+    multi = [tag for tag, value in options if value == "x = 1\ny = 2"]
+    single = [tag for tag, value in options if value == "print(x)"]
+    assert len(multi) == 1 and len(single) == 1, rendered[0]
+    multi_style = _applied_style(multi[0])
+    assert "pre-wrap" in multi_style
+    assert "Source Code Pro" in multi_style or "monospace" in multi_style
+    single_style = _applied_style(single[0])
+    assert "pre-wrap" not in single_style
+    assert "Source Code Pro" not in single_style
+
+
+def test_c4_split_logic_table_and_option_css_rules() -> None:
+    cases = [
+        ("Вопрос\nprint(1)", "Вопрос", ["print(1)"]),
+        ("Вопрос\n\n  \nprint(1)\n", "Вопрос", ["print(1)"]),
+        ("Вопрос\n`a = 1\nb = 2`", "Вопрос", ["a = 1\nb = 2"]),
+        ("Вопрос `x`", 'Вопрос x', []),
+        ("Вопрос\n", "Вопрос", []),
+    ]
+    questions = [
+        {"id": f"c4-{index}", "kind": "choice", "prompt": prompt, "options": ["1", "2"]}
+        for index, (prompt, _, _) in enumerate(cases)
+    ]
+    rendered = _render(questions)
+    if rendered is None:
+        _assert_source_has_code_block_template()
+        return
+    for html_out, (prompt, prompt_html, bodies) in zip(rendered, cases, strict=True):
+        assert _prompt_inner(html_out) == prompt_html, prompt
+        assert _code_bodies(html_out) == bodies, prompt

From f62495661e77eb67a8ea29d625a11bb326e3770b Mon Sep 17 00:00:00 2001
From: t 
Date: Fri, 23 Oct 2026 02:10:13 +0300
Subject: [PATCH 2/4] =?UTF-8?q?zavod=20#55,=20=D0=BA=D1=80=D1=83=D0=B3=201?=
 =?UTF-8?q?:=20=D0=9A=D0=BE=D0=B4=20=D0=B2=20=D0=B2=D0=BE=D0=BF=D1=80?=
 =?UTF-8?q?=D0=BE=D1=81=D0=B0=D1=85=20=E2=80=94=20=D0=B1=D0=BB=D0=BE=D0=BA?=
 =?UTF-8?q?=D0=BE=D0=BC=20=D0=BA=D0=BE=D0=B4=D0=B0,=20=D0=B0=20=D0=BD?=
 =?UTF-8?q?=D0=B5=20=D0=B6=D0=B8=D1=80=D0=BD=D1=8B=D0=BC=20=D1=82=D0=B5?=
 =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=BE=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/static/app.js                 |  29 ++++-
 app/static/styles.css             |   2 +-
 tests/test_question_code_block.py | 179 ++++++++++++++++++++++++++++++
 3 files changed, 207 insertions(+), 3 deletions(-)
 create mode 100644 tests/test_question_code_block.py

diff --git a/app/static/app.js b/app/static/app.js
index e0303f1..f81e9a4 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -94,12 +94,37 @@ function renderModule(module) {
   `;
 }
 
+// Делит prompt на первую строку-вопрос и остаток-код. Обёрнутые целиком в бэктики
+// фрагменты (встречаются в уроках 57–64) освобождаются от них.
+function splitQuestionPrompt(value = '') {
+  const text = String(value);
+  const breakAt = text.indexOf('\n');
+  const question = breakAt === -1 ? text : text.slice(0, breakAt);
+  let code = breakAt === -1 ? '' : text.slice(breakAt + 1).trim();
+  if (code.length >= 2 && code.startsWith('`') && code.endsWith('`')) code = code.slice(1, -1).trim();
+  return { question, code };
+}
+
+function questionPromptTemplate(question) {
+  if (question.kind === 'choice' || question.kind === 'input') {
+    const parts = splitQuestionPrompt(question.prompt);
+    const code = parts.code ? `
${esc(parts.code)}
` : ''; + return `
${rich(parts.question)}
${code}`; + } + return `
${rich(question.prompt)}
`; +} + function questionTemplate(question, number, options = {}) { const lessonMode = Boolean(options.lessonId); - const head = `
Задание ${number}
${rich(question.prompt)}
`; + const head = `
Задание ${number}
${questionPromptTemplate(question)}`; let field = ''; if (question.kind === 'choice') { - field = `
${question.options.map((option) => ``).join('')}
`; + field = `
${question.options.map((option) => { + const value = esc(option); + return String(option).includes('\n') + ? `` + : ``; + }).join('')}
`; } else if (question.kind === 'input') { field = ``; } else { diff --git a/app/static/styles.css b/app/static/styles.css index 83454f8..b74305b 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -69,7 +69,7 @@ h3 { margin: 0; font-size: 17px; } .lesson-head { display: flex; justify-content: space-between; align-items: start; gap: 24px; margin-bottom: 25px; }.back-link { color: var(--muted); display: inline-flex; gap: 6px; align-items: center; font-size: 13px; font-weight: 900; margin-bottom: 14px; }.back-link:hover { color: var(--green-dark); }.lesson-meta { color: var(--muted); margin-top: 10px; font-size: 13px; font-weight: 800; }.lesson-meta span { margin-right: 12px; } .theory-card { margin: 14px 0; padding: 22px; }.theory-card p { color: #4e5a71; line-height: 1.55; margin: 10px 0; }.code-example { overflow-x: auto; margin: 14px 0 0; padding: 14px; border-radius: 11px; background: #202b42; color: #e8f5ff; font: 13px/1.55 "Source Code Pro", monospace; white-space: pre; }.tip { margin: 14px 0 0; color: #688151; background: #eff9e7; padding: 10px 12px; border-radius: 10px; font-size: 12px; font-weight: 700; }.tip:before { content: "💡 "; } -.practice-title { margin-top: 34px; }.question-card { padding: 22px; margin: 14px 0; }.question-number { color: var(--green-dark); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; font-weight: 900; }.question-prompt { margin: 8px 0 17px; font-size: 17px; line-height: 1.4; font-weight: 800; }.inline-code { font-family: "Source Code Pro", monospace; font-size: .89em; background: #eff3f8; padding: 1px 5px; border-radius: 5px; }.options { display: grid; gap: 9px; }.option { padding: 13px 14px; border: 2px solid var(--line); background: white; color: var(--ink); border-radius: 12px; text-align: left; font-weight: 800; }.option:hover { border-color: #b7e89a; background: #f7fff1; }.option.selected { border-color: var(--blue); color: #0b6f9c; background: #eaf7ff; }.option.is-right { border-color: var(--green); color: var(--green-dark); background: #effbe7; }.option.is-wrong { border-color: var(--red); color: #b02a3a; background: #fff0f1; }.answer-input, .code-editor { width: 100%; border: 2px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; transition: border-color .15s; }.answer-input { padding: 13px; }.answer-input:focus, .code-editor:focus { border-color: var(--blue); }.code-editor { min-height: 145px; padding: 15px; resize: vertical; background: #202b42; color: #eaf6ff; border: 0; font: 13px/1.55 "Source Code Pro", monospace; }.code-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 11px; }.code-actions .button { padding: 9px 13px; font-size: 12px; }.hint { margin-top: 10px; color: var(--muted); font-size: 12px; }.hint summary { cursor: pointer; font-weight: 900; }.hint p { margin: 7px 0 0; }.inline-result { display: none; margin-top: 12px; padding: 11px 13px; border-radius: 10px; font-size: 13px; font-weight: 800; }.inline-result.visible { display: block; }.inline-result.ok { background: #eaf9df; color: #438413; }.inline-result.no { background: #fff0f1; color: #c33e51; }.submit-row { display: flex; align-items: center; gap: 15px; margin-top: 24px; }.submit-row .button { min-width: 190px; }.submit-note { color: var(--muted); font-size: 12px; font-weight: 700; } +.practice-title { margin-top: 34px; }.question-card { padding: 22px; margin: 14px 0; }.question-number { color: var(--green-dark); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; font-weight: 900; }.question-prompt { margin: 8px 0 17px; font-size: 17px; line-height: 1.4; font-weight: 800; }.inline-code { font-family: "Source Code Pro", monospace; font-size: .89em; background: #eff3f8; padding: 1px 5px; border-radius: 5px; }.options { display: grid; gap: 9px; }.option { padding: 13px 14px; border: 2px solid var(--line); background: white; color: var(--ink); border-radius: 12px; text-align: left; font-weight: 800; }.option-multiline { font-family: "Source Code Pro", monospace; font-weight: 500; white-space: pre-wrap; }.option:hover { border-color: #b7e89a; background: #f7fff1; }.option.selected { border-color: var(--blue); color: #0b6f9c; background: #eaf7ff; }.option.is-right { border-color: var(--green); color: var(--green-dark); background: #effbe7; }.option.is-wrong { border-color: var(--red); color: #b02a3a; background: #fff0f1; }.answer-input, .code-editor { width: 100%; border: 2px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; transition: border-color .15s; }.answer-input { padding: 13px; }.answer-input:focus, .code-editor:focus { border-color: var(--blue); }.code-editor { min-height: 145px; padding: 15px; resize: vertical; background: #202b42; color: #eaf6ff; border: 0; font: 13px/1.55 "Source Code Pro", monospace; }.code-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 11px; }.code-actions .button { padding: 9px 13px; font-size: 12px; }.hint { margin-top: 10px; color: var(--muted); font-size: 12px; }.hint summary { cursor: pointer; font-weight: 900; }.hint p { margin: 7px 0 0; }.inline-result { display: none; margin-top: 12px; padding: 11px 13px; border-radius: 10px; font-size: 13px; font-weight: 800; }.inline-result.visible { display: block; }.inline-result.ok { background: #eaf9df; color: #438413; }.inline-result.no { background: #fff0f1; color: #c33e51; }.submit-row { display: flex; align-items: center; gap: 15px; margin-top: 24px; }.submit-row .button { min-width: 190px; }.submit-note { color: var(--muted); font-size: 12px; font-weight: 700; } .code-output { margin-top: 12px; padding: 11px 13px; border-radius: 10px; background: #202b42; color: #eaf6ff; font: 13px/1.55 "Source Code Pro", monospace; }.code-output b { display: block; margin-bottom: 5px; color: #b7e89a; }.code-output pre { margin: 0; white-space: pre-wrap; }.result-panel { padding: 22px; margin: 22px 0; border-radius: 17px; border: 2px solid #bdeaa1; background: #f4ffec; }.result-panel.fail { border-color: #ffc2cb; background: #fff5f6; }.result-panel h2 { margin-bottom: 8px; }.result-panel p { margin: 0; color: #526142; line-height: 1.45; }.result-list { display: grid; gap: 7px; margin-top: 16px; }.result-item { padding: 10px; border-radius: 9px; background: rgba(255,255,255,.67); font-size: 12px; }.result-item b { margin-right: 5px; }.result-item.ok b { color: var(--green-dark); }.result-item.no b { color: var(--red); } .practice-wrap, .exam-wrap { max-width: 760px; }.practice-hero, .exam-hero { padding: 26px; margin-bottom: 22px; }.practice-hero { background: linear-gradient(120deg, #e9f9ff, #f6fcff); border-color: #cceaf7; }.exam-hero { background: linear-gradient(120deg, #fff8de, #fffdf6); border-color: #ffe8a3; }.empty { text-align: center; padding: 55px 20px; }.empty-icon { font-size: 48px; }.empty p { color: var(--muted); }.loading { color: var(--muted); padding: 70px; text-align: center; font-weight: 800; }.loading i { width: 8px; height: 8px; display: inline-block; background: var(--green); border-radius: 99px; margin: 0 2px; animation: bounce .9s infinite alternate; }.loading i:nth-child(2) { animation-delay: .2s; }.loading i:nth-child(3) { animation-delay: .4s; }@keyframes bounce { to { transform: translateY(-7px); opacity: .55; } } diff --git a/tests/test_question_code_block.py b/tests/test_question_code_block.py new file mode 100644 index 0000000..8773762 --- /dev/null +++ b/tests/test_question_code_block.py @@ -0,0 +1,179 @@ +import html +import json +import re +import shutil +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_JS = REPO_ROOT / "app" / "static" / "app.js" +STYLES_CSS = REPO_ROOT / "app" / "static" / "styles.css" + +# Грузит app.js в vm с заглушкой DOM и отдаёт splitQuestionPrompt + questionTemplate. +VM_SCRIPT = """ +const fs = require('fs'); +const vm = require('vm'); +const [file, mode, 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 context = vm.createContext({ + document: stub, 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' }); +if (mode === 'split') { + const split = vm.runInContext('splitQuestionPrompt', context); + console.log(JSON.stringify(JSON.parse(payload).map((prompt) => split(prompt)))); +} else { + const questionTemplate = vm.runInContext('questionTemplate', context); + console.log(JSON.stringify(JSON.parse(payload).map((question) => questionTemplate(question, 1)))); +} +""" + + +def _node() -> str | None: + return shutil.which("node") + + +def _run_vm(mode: str, payload: object) -> object | None: + node = _node() + if node is None: + return None + result = subprocess.run( + [node, "-e", VM_SCRIPT, str(APP_JS), mode, json.dumps(payload, ensure_ascii=False)], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _css_rules() -> list[tuple[str, str]]: + return re.findall(r"([^{}]+)\{([^{}]*)\}", STYLES_CSS.read_text(encoding="utf-8")) + + +def _rule_bodies(selector: str) -> str: + return "\n".join(body for sel, body in _css_rules() if selector in sel) + + +def _prompt_inner(rendered: str) -> str: + match = re.search(r'
(.*?)
', rendered, re.DOTALL) + assert match, rendered + return match.group(1) + + +def _code_bodies(rendered: str) -> list[str]: + return [ + html.unescape(body) + for body in re.findall( + r'
(.*?)
', rendered, re.DOTALL + ) + ] + + +def test_split_question_prompt_cases() -> None: + cases = [ + ("Вопрос\nprint(1)", "Вопрос", "print(1)"), + ("Вопрос\n\n \nprint(1)\n", "Вопрос", "print(1)"), + ("Вопрос\n`a = 1\nb = 2`", "Вопрос", "a = 1\nb = 2"), + ("Вопрос\n`print('a`b')`", "Вопрос", "print('a`b')"), + ("Вопрос `x`", "Вопрос `x`", ""), + ("Вопрос\n", "Вопрос", ""), + ("Вопрос\n \n\n", "Вопрос", ""), + ("", "", ""), + ] + splits = _run_vm("split", [prompt for prompt, _, _ in cases]) + if splits is None: + source = APP_JS.read_text(encoding="utf-8") + assert "function splitQuestionPrompt" in source + assert "code.startsWith('`') && code.endsWith('`')" in source + return + for actual, (prompt, question, code) in zip(splits, cases, strict=True): + assert actual == {"question": question, "code": code}, prompt + + +def test_split_function_is_pure_and_does_not_touch_question() -> None: + # Первая строка уходит в rich(), поэтому бэктики в ней не снимаются, а код + # рендерится через esc() — угловые скобки не должны становиться разметкой. + rendered = _run_vm( + "render", + [ + { + "id": "escape", + "kind": "input", + "prompt": "Найди ошибку\n`if a < b and c > d:`", + "placeholder": "Ответ", + } + ], + ) + if rendered is None: + assert "esc(parts.code)" in APP_JS.read_text(encoding="utf-8") + return + html_out = rendered[0] + assert html.unescape(_prompt_inner(html_out)) == "Найди ошибку" + assert _code_bodies(html_out) == ["if a < b and c > d:"] + assert "< b and c >" in html_out + + +def test_choice_and_input_split_prompt_code_questions_do_not() -> None: + rendered = _run_vm( + "render", + [ + { + "id": "choice", + "kind": "choice", + "prompt": "Что выведет код?\n\n`for i in range(2):\n print(i)`", + "options": ["1", "2"], + }, + { + "id": "code", + "kind": "code", + "prompt": "Напиши функцию\n\ndef f():\n pass", + "starter": "def f():\n pass", + "hint": "Подсказка", + }, + ], + ) + if rendered is None: + source = APP_JS.read_text(encoding="utf-8") + assert "question.kind === 'choice' || question.kind === 'input'" in source + assert "rich(question.prompt)" in source + return + choice, code = rendered + assert _code_bodies(choice) == ["for i in range(2):\n print(i)"] + assert html.unescape(_prompt_inner(code)) == "Напиши функцию\n\ndef f():\n pass" + assert "code-example" not in code + + +def test_multiline_option_keeps_original_data_value() -> None: + option = "x = 1\ny = 2" + rendered = _run_vm( + "render", + [{"id": "options", "kind": "choice", "prompt": "Какой вывод?", "options": [option]}], + ) + if rendered is None: + assert "option-multiline" in APP_JS.read_text(encoding="utf-8") + return + match = re.search(r']*\bdata-value="([^"]*)"[^>]*>', rendered[0]) + assert match, rendered[0] + assert "option-multiline" in match.group(0) + assert html.unescape(match.group(1)) == option + + +def test_option_multiline_css_is_monospace_pre_wrap_and_plain_option_is_not() -> None: + multiline = _rule_bodies(".option-multiline") + assert "Source Code Pro" in multiline + assert "white-space: pre-wrap" in multiline + plain = [body for selector, body in _css_rules() if re.search(r"\.option(?![\w-])", selector)] + assert plain + for body in plain: + assert "pre-wrap" not in body + assert "Source Code Pro" not in body From ec745f60b2cec3158756462839dbf76ead4e381f Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:12:20 +0300 Subject: [PATCH 3/4] =?UTF-8?q?zavod=20#55,=20=D0=BA=D1=80=D1=83=D0=B3=202?= =?UTF-8?q?:=20=D0=9A=D0=BE=D0=B4=20=D0=B2=20=D0=B2=D0=BE=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=B0=D1=85=20=E2=80=94=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=BA=D0=BE=D0=B4=D0=B0,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B6=D0=B8=D1=80=D0=BD=D1=8B=D0=BC=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_question_code_block.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_question_code_block.py b/tests/test_question_code_block.py index 8773762..ec4cdfc 100644 --- a/tests/test_question_code_block.py +++ b/tests/test_question_code_block.py @@ -4,6 +4,7 @@ import shutil import subprocess from pathlib import Path +from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] APP_JS = REPO_ROOT / "app" / "static" / "app.js" @@ -41,7 +42,7 @@ def _node() -> str | None: return shutil.which("node") -def _run_vm(mode: str, payload: object) -> object | None: +def _run_vm(mode: str, payload: object) -> Any: node = _node() if node is None: return None From e67cccdadd1b11ddeb3a6a426a4abcde1fdcf675 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 23 Oct 2026 02:18:06 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B4=D1=83=D0=B1=D0=BB=D1=8C=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2:=20=D0=B7=D0=B0=D0=BB=D0=BE=D1=87=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20test=5Fquestion=5Fprompt=5Fcode=5Fblock.py=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D1=81=D1=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HdAtUFHuCYJ3tWqyvJHqGt --- tests/test_question_code_block.py | 180 ------------------------------ 1 file changed, 180 deletions(-) delete mode 100644 tests/test_question_code_block.py diff --git a/tests/test_question_code_block.py b/tests/test_question_code_block.py deleted file mode 100644 index ec4cdfc..0000000 --- a/tests/test_question_code_block.py +++ /dev/null @@ -1,180 +0,0 @@ -import html -import json -import re -import shutil -import subprocess -from pathlib import Path -from typing import Any - -REPO_ROOT = Path(__file__).resolve().parents[1] -APP_JS = REPO_ROOT / "app" / "static" / "app.js" -STYLES_CSS = REPO_ROOT / "app" / "static" / "styles.css" - -# Грузит app.js в vm с заглушкой DOM и отдаёт splitQuestionPrompt + questionTemplate. -VM_SCRIPT = """ -const fs = require('fs'); -const vm = require('vm'); -const [file, mode, 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 context = vm.createContext({ - document: stub, 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' }); -if (mode === 'split') { - const split = vm.runInContext('splitQuestionPrompt', context); - console.log(JSON.stringify(JSON.parse(payload).map((prompt) => split(prompt)))); -} else { - const questionTemplate = vm.runInContext('questionTemplate', context); - console.log(JSON.stringify(JSON.parse(payload).map((question) => questionTemplate(question, 1)))); -} -""" - - -def _node() -> str | None: - return shutil.which("node") - - -def _run_vm(mode: str, payload: object) -> Any: - node = _node() - if node is None: - return None - result = subprocess.run( - [node, "-e", VM_SCRIPT, str(APP_JS), mode, json.dumps(payload, ensure_ascii=False)], - capture_output=True, - text=True, - timeout=60, - check=False, - ) - assert result.returncode == 0, result.stderr - return json.loads(result.stdout) - - -def _css_rules() -> list[tuple[str, str]]: - return re.findall(r"([^{}]+)\{([^{}]*)\}", STYLES_CSS.read_text(encoding="utf-8")) - - -def _rule_bodies(selector: str) -> str: - return "\n".join(body for sel, body in _css_rules() if selector in sel) - - -def _prompt_inner(rendered: str) -> str: - match = re.search(r'
(.*?)
', rendered, re.DOTALL) - assert match, rendered - return match.group(1) - - -def _code_bodies(rendered: str) -> list[str]: - return [ - html.unescape(body) - for body in re.findall( - r'
(.*?)
', rendered, re.DOTALL - ) - ] - - -def test_split_question_prompt_cases() -> None: - cases = [ - ("Вопрос\nprint(1)", "Вопрос", "print(1)"), - ("Вопрос\n\n \nprint(1)\n", "Вопрос", "print(1)"), - ("Вопрос\n`a = 1\nb = 2`", "Вопрос", "a = 1\nb = 2"), - ("Вопрос\n`print('a`b')`", "Вопрос", "print('a`b')"), - ("Вопрос `x`", "Вопрос `x`", ""), - ("Вопрос\n", "Вопрос", ""), - ("Вопрос\n \n\n", "Вопрос", ""), - ("", "", ""), - ] - splits = _run_vm("split", [prompt for prompt, _, _ in cases]) - if splits is None: - source = APP_JS.read_text(encoding="utf-8") - assert "function splitQuestionPrompt" in source - assert "code.startsWith('`') && code.endsWith('`')" in source - return - for actual, (prompt, question, code) in zip(splits, cases, strict=True): - assert actual == {"question": question, "code": code}, prompt - - -def test_split_function_is_pure_and_does_not_touch_question() -> None: - # Первая строка уходит в rich(), поэтому бэктики в ней не снимаются, а код - # рендерится через esc() — угловые скобки не должны становиться разметкой. - rendered = _run_vm( - "render", - [ - { - "id": "escape", - "kind": "input", - "prompt": "Найди ошибку\n`if a < b and c > d:`", - "placeholder": "Ответ", - } - ], - ) - if rendered is None: - assert "esc(parts.code)" in APP_JS.read_text(encoding="utf-8") - return - html_out = rendered[0] - assert html.unescape(_prompt_inner(html_out)) == "Найди ошибку" - assert _code_bodies(html_out) == ["if a < b and c > d:"] - assert "< b and c >" in html_out - - -def test_choice_and_input_split_prompt_code_questions_do_not() -> None: - rendered = _run_vm( - "render", - [ - { - "id": "choice", - "kind": "choice", - "prompt": "Что выведет код?\n\n`for i in range(2):\n print(i)`", - "options": ["1", "2"], - }, - { - "id": "code", - "kind": "code", - "prompt": "Напиши функцию\n\ndef f():\n pass", - "starter": "def f():\n pass", - "hint": "Подсказка", - }, - ], - ) - if rendered is None: - source = APP_JS.read_text(encoding="utf-8") - assert "question.kind === 'choice' || question.kind === 'input'" in source - assert "rich(question.prompt)" in source - return - choice, code = rendered - assert _code_bodies(choice) == ["for i in range(2):\n print(i)"] - assert html.unescape(_prompt_inner(code)) == "Напиши функцию\n\ndef f():\n pass" - assert "code-example" not in code - - -def test_multiline_option_keeps_original_data_value() -> None: - option = "x = 1\ny = 2" - rendered = _run_vm( - "render", - [{"id": "options", "kind": "choice", "prompt": "Какой вывод?", "options": [option]}], - ) - if rendered is None: - assert "option-multiline" in APP_JS.read_text(encoding="utf-8") - return - match = re.search(r']*\bdata-value="([^"]*)"[^>]*>', rendered[0]) - assert match, rendered[0] - assert "option-multiline" in match.group(0) - assert html.unescape(match.group(1)) == option - - -def test_option_multiline_css_is_monospace_pre_wrap_and_plain_option_is_not() -> None: - multiline = _rule_bodies(".option-multiline") - assert "Source Code Pro" in multiline - assert "white-space: pre-wrap" in multiline - plain = [body for selector, body in _css_rules() if re.search(r"\.option(?![\w-])", selector)] - assert plain - for body in plain: - assert "pre-wrap" not in body - assert "Source Code Pro" not in body