-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Tab/автоотступ в редакторе + русский словарь ошибок (P1.4+P1.2) #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| """Короткие подсказки к распространённым ошибкам Python.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import re | ||
|
|
||
|
|
||
| def translate_error(error_text: str) -> dict[str, str]: | ||
| """Возвращает понятную новичку подсказку, сохраняя исходную ошибку.""" | ||
| text = error_text or "" | ||
| lower = text.casefold() | ||
| title, hint = ( | ||
| "Неизвестная ошибка", | ||
| "Прочитай текст ошибки и проверь последнюю изменённую строку кода.", | ||
| ) | ||
|
|
||
| if "unexpected eof while parsing" in lower: | ||
| title, hint = "Незаконченный код", "Проверь, закрыты ли скобки, кавычки и все блоки кода." | ||
| elif "eol while scanning string literal" in lower or "unterminated string literal" in lower: | ||
| title, hint = "Незакрытая строка", "Проверь, есть ли закрывающая кавычка у строки." | ||
| elif "invalid character" in lower: | ||
| title, hint = ( | ||
| "Недопустимый символ", | ||
| "Проверь строку: в код случайно мог попасть необычный символ.", | ||
| ) | ||
| elif "syntaxerror" in lower: | ||
| title, hint = ( | ||
| "Синтаксическая ошибка", | ||
| "Проверь знаки препинания, скобки, кавычки и написание команды.", | ||
| ) | ||
| elif "expected an indented block" in lower or "unindent does not match" in lower: | ||
| title, hint = ( | ||
| "Ошибка отступа", | ||
| "После двоеточия добавь отступ в 4 пробела и выровняй строки блока.", | ||
| ) | ||
| elif "taberror" in lower: | ||
| title, hint = ( | ||
| "Смешаны табы и пробелы", | ||
| "Используй для отступов только пробелы — по 4 на каждый уровень.", | ||
| ) | ||
| elif "nameerror" in lower: | ||
| name = re.search(r"name ['\"](.+?)['\"] is not defined", text) | ||
| variable = f" «{name.group(1)}»" if name else "" | ||
| title, hint = ( | ||
| "Неизвестное имя", | ||
| f"Проверь написание{variable}: переменную нужно объявить до использования.", | ||
| ) | ||
| elif "typeerror" in lower: | ||
| title, hint = ( | ||
| "Неподходящий тип данных", | ||
| "Проверь, какие значения участвуют в операции: строку и число нельзя сложить напрямую.", | ||
| ) | ||
| elif "valueerror" in lower: | ||
| title, hint = ( | ||
| "Некорректное значение", | ||
| "Проверь формат значения и то, подходит ли оно для этой операции.", | ||
| ) | ||
| elif "indexerror" in lower: | ||
| title, hint = ( | ||
| "Индекс вне диапазона", | ||
| "Проверь номер элемента: отсчёт в списке начинается с 0.", | ||
| ) | ||
| elif "keyerror" in lower: | ||
| title, hint = "Ключ не найден", "Проверь название ключа и убедись, что он есть в словаре." | ||
| elif "attributeerror" in lower: | ||
| title, hint = ( | ||
| "Нет такого свойства или метода", | ||
| "Проверь название метода и подходит ли он для этого типа данных.", | ||
| ) | ||
| elif "zerodivisionerror" in lower: | ||
| title, hint = "Деление на ноль", "Перед делением убедись, что делитель не равен нулю." | ||
| elif "modulenotfounderror" in lower: | ||
| title, hint = ( | ||
| "Модуль не найден", | ||
| "Проверь название модуля и его доступность в учебном редакторе.", | ||
| ) | ||
| elif "importerror" in lower: | ||
| title, hint = ( | ||
| "Ошибка импорта", | ||
| "Проверь название модуля или объекта, который хочешь импортировать.", | ||
| ) | ||
| elif "timeouterror" in lower or "слишком долго" in lower: | ||
| title, hint = ( | ||
| "Время выполнения истекло", | ||
| "Проверь условие цикла: оно должно когда-нибудь становиться ложным.", | ||
| ) | ||
|
|
||
| return {"title": title, "hint": hint, "original": text} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -134,7 +134,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); | ||
| showInline(questionId, result.correct, result.message, result.checks, result.error_hint); | ||
| } catch (error) { showInline(questionId, false, error.message); } | ||
| button.disabled = false; | ||
| button.textContent = '▷ Проверить код'; | ||
|
|
@@ -167,12 +167,15 @@ function showCodeOutput(questionId, result) { | |
| node.textContent = output.join('\n') || 'Нет вывода.'; | ||
| } | ||
|
|
||
| function showInline(questionId, correct, message, checks = []) { | ||
| function showInline(questionId, correct, message, checks = [], errorHint = null) { | ||
| const node = document.querySelector(`#result-${questionId}`); | ||
| if (!node) return; | ||
| const details = checks.length && !correct ? ` <small>(${checks.filter((item) => !item.passed).map((item) => `ожидалось ${esc(item.expected)}, получено ${esc(item.actual)}`).join('; ')})</small>` : ''; | ||
| node.className = `inline-result visible ${correct ? 'ok' : 'no'}`; | ||
| node.innerHTML = `${correct ? '✓' : '↺'} ${esc(message)}${details}`; | ||
| const hint = errorHint | ||
| ? `<div class="error-hint"><strong>${esc(errorHint.title)}</strong><span>${esc(errorHint.hint)}</span></div><small class="raw-error">${esc(errorHint.original)}</small>` | ||
| : `${correct ? '✓' : '↺'} ${esc(message)}`; | ||
| node.innerHTML = `${hint}${details}`; | ||
| } | ||
|
|
||
| function submissionResult(result, retryText = 'Попробовать ещё раз') { | ||
|
|
@@ -203,7 +206,7 @@ async function renderLesson(id) { | |
| const result = await api(`/api/lessons/${id}/submit`, { method: 'POST', body: JSON.stringify({ answers: getAnswers(form, lesson.questions) }) }); | ||
| document.querySelector('#submission-result')?.remove(); | ||
| form.insertAdjacentHTML('afterend', submissionResult(result)); | ||
| result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks)); | ||
| result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks, item.error_hint)); | ||
| await refreshDashboard(); | ||
| if (result.passed) toast(`+${result.xp_gained} XP — урок пройден!`); | ||
| } catch (error) { toast(error.message); } | ||
|
|
@@ -223,7 +226,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); | ||
| showInline(question.id, result.correct, result.message, result.checks, result.error_hint); | ||
| await refreshDashboard(); | ||
| if (result.correct) toast(`Верно! +${result.xp_gained} XP`); | ||
| } catch (error) { toast(error.message); } | ||
|
|
@@ -243,7 +246,7 @@ async function renderExam(moduleId) { | |
| const result = await api(`/api/exams/${moduleId}/submit`, { method: 'POST', body: JSON.stringify({ answers: getAnswers(form, exam.questions) }) }); | ||
| document.querySelector('#submission-result')?.remove(); | ||
| form.insertAdjacentHTML('afterend', submissionResult(result, 'Пересдать экзамен')); | ||
| result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks)); | ||
| result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks, item.error_hint)); | ||
| await refreshDashboard(); | ||
| if (result.passed) toast(`Экзамен сдан! +${result.xp_gained} XP`); | ||
| } catch (error) { toast(error.message); } | ||
|
|
@@ -272,5 +275,41 @@ document.querySelector('#reset-button').addEventListener('click', async () => { | |
| location.hash = '#/'; | ||
| render(); | ||
| }); | ||
|
|
||
| document.addEventListener('keydown', (event) => { | ||
| const editor = event.target.closest('.code-editor'); | ||
| if (!editor) return; | ||
| const { value, selectionStart: start, selectionEnd: end } = editor; | ||
| const lineStart = value.lastIndexOf('\n', start - 1) + 1; | ||
| const lineEndIndex = value.indexOf('\n', Math.max(start, end - 1)); | ||
| const lineEnd = lineEndIndex < 0 ? value.length : lineEndIndex; | ||
|
|
||
| if (event.key === 'Enter') { | ||
| const line = value.slice(lineStart, lineEnd); | ||
| const indent = (line.match(/^\s*/) || [''])[0] + (line.trimEnd().endsWith(':') ? ' ' : ''); | ||
| event.preventDefault(); | ||
| editor.setRangeText(`\n${indent}`, start, end, 'end'); | ||
| return; | ||
| } | ||
|
|
||
| if (event.key !== 'Tab') return; | ||
| event.preventDefault(); | ||
| const selected = value.slice(lineStart, lineEnd); | ||
| if (!event.shiftKey && !selected.includes('\n')) { | ||
| editor.setRangeText(' ', start, end, 'end'); | ||
| return; | ||
| } | ||
| if (event.shiftKey) { | ||
| const unindented = selected.replace(/^ {1,4}/gm, ''); | ||
| const removedBeforeCursor = Math.min(4, (value.slice(lineStart, start).match(/^ */) || [''])[0].length); | ||
| editor.value = value.slice(0, lineStart) + unindented + value.slice(lineEnd); | ||
| editor.setSelectionRange(start - removedBeforeCursor, end - (selected.length - unindented.length)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a multi-line selection ends inside the leading 1–3 spaces of its final line, this subtracts every space removed from that line even though some were after Useful? React with 👍 / 👎. |
||
| } else { | ||
| const indented = selected.replace(/^/gm, ' '); | ||
| const lines = selected.split('\n').length; | ||
| editor.value = value.slice(0, lineStart) + indented + value.slice(lineEnd); | ||
| editor.setSelectionRange(start + 4, end + lines * 4); | ||
| } | ||
| }); | ||
| window.addEventListener('hashchange', render); | ||
| render(); | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import pytest | ||
|
|
||
| from app.error_hints import translate_error | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("error_text", "title"), | ||
| [ | ||
| ("SyntaxError: invalid syntax", "Синтаксическая ошибка"), | ||
| ("SyntaxError: unexpected EOF while parsing", "Незаконченный код"), | ||
| ("SyntaxError: EOL while scanning string literal", "Незакрытая строка"), | ||
| ("SyntaxError: invalid character '№'", "Недопустимый символ"), | ||
| ("IndentationError: expected an indented block", "Ошибка отступа"), | ||
| ("IndentationError: unindent does not match any outer indentation level", "Ошибка отступа"), | ||
| ("TabError: inconsistent use of tabs and spaces in indentation", "Смешаны табы и пробелы"), | ||
| ("TypeError: unsupported operand type(s)", "Неподходящий тип данных"), | ||
| ("ValueError: invalid literal for int()", "Некорректное значение"), | ||
| ("IndexError: list index out of range", "Индекс вне диапазона"), | ||
| ("KeyError: 'name'", "Ключ не найден"), | ||
| ( | ||
| "AttributeError: 'str' object has no attribute 'append'", | ||
| "Нет такого свойства или метода", | ||
| ), | ||
| ("ZeroDivisionError: division by zero", "Деление на ноль"), | ||
| ("ImportError: cannot import name 'thing'", "Ошибка импорта"), | ||
| ("ModuleNotFoundError: No module named 'thing'", "Модуль не найден"), | ||
| ("TimeoutError: Код выполнялся слишком долго", "Время выполнения истекло"), | ||
| ("RuntimeError: something unusual", "Неизвестная ошибка"), | ||
| ], | ||
| ) | ||
| def test_translate_error_categories(error_text: str, title: str) -> None: | ||
| result = translate_error(error_text) | ||
|
|
||
| assert result["title"] == title | ||
| assert result["hint"] | ||
| assert result["original"] == error_text | ||
|
|
||
|
|
||
| def test_translate_error_extracts_name_from_name_error() -> None: | ||
| result = translate_error("NameError: name 'total_sum' is not defined") | ||
|
|
||
| assert result["title"] == "Неизвестное имя" | ||
| assert "total_sum" in result["hint"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user selects code within a single line and presses Tab,
selectedcontains the whole line and therefore has no newline, so this branch replaces the actual selection with four spaces. For example, selectingprint('hello')to indent it deletes the statement instead of indenting the selected line; distinguish a collapsed selection (start === end) from a nonempty one-line selection.Useful? React with 👍 / 👎.