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_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