Глава 18 · Тестирование

Debug Labs — типичные ошибки рисовалки

Каждая ошибка здесь встречается в реальных студенческих проектах — научитесь узнавать симптом раньше, чем откроете отладчик.

Небольшая коллекция типичных ошибок рисовалок на Canvas — каждая с симптомом и исправлением. Часть из них вы уже видели раньше в главе; здесь они собраны как справочник.

Debug Lab 1: Y растёт вверх, как у Turtle
turtle_privychka.py
def on_drag(event):
    # 'привычная' логика: чем выше на экране, тем БОЛЬШЕ Y
    if event.y > start_y:
        status.config(text="Двигаемся ВВЕРХ")  # неверно для Canvas!
# Курсор двигается вниз по экрану, а статус говорит 'ВВЕРХ' —
# логика была написана в терминах Turtle, а не Canvas.
Что видно на экране

У Canvas Y растёт ВНИЗ (раздел 18.9) — event.y > start_y означает «курсор ниже, чем был», а не выше. Перенос интуиции с Turtle без пересчёта знака — частая ошибка именно у тех, кто уже уверенно рисовал Turtle в главах 6–7.

Исправленный код
canvas_napravlenie.py
def on_drag(event):
    if event.y > start_y:
        status.config(text="Двигаемся ВНИЗ")  # верно: Y растёт вниз
Debug Lab 2: Не запомнили точку начала жеста
zabyli_start.py
def on_press(event):
    pass  # забыли сохранить event.x, event.y

def on_drag(event):
    canvas.create_line(start_x, start_y, event.x, event.y)  # start_x не определена
# NameError: name 'start_x' is not defined —
# программа падает на первом же движении мыши после нажатия.
Что видно на экране

on_press() обязан сохранить точку начала (раздел 18.12) — без неё on_drag() просто не может знать, откуда рисовать линию или превью фигуры.

Исправленный код
sohranili_start.py
def on_press(event):
    global start_x, start_y
    start_x, start_y = event.x, event.y
Debug Lab 3: <Motion> вместо <B1-Motion>
lyuboe_dvizhenie.py
canvas.bind("<Motion>", on_drag)   # сработает при ЛЮБОМ движении мыши
# Линия рисуется, даже если кнопка мыши вообще не нажата —
# достаточно просто провести курсором над холстом.
Что видно на экране

<Motion> реагирует на любое движение мыши над виджетом. Для рисования только во время перетаскивания нужен именно <B1-Motion> — раздел 18.12 объясняет разницу.

Исправленный код
b1_motion.py
canvas.bind("<B1-Motion>", on_drag)   # только при зажатой левой кнопке
Debug Lab 4: Тысячи несвязанных точек вместо линии
tochki_vmesto_linii.py
def on_drag(event):
    canvas.create_oval(
        event.x - 2, event.y - 2, event.x + 2, event.y + 2,
        fill="black",
    )
# Быстрое движение мыши оставляет заметные разрывы между кружками —
# штрих выглядит рваным, а не гладким.
Что видно на экране

Кружок на каждое отдельное событие не учитывает путь МЕЖДУ двумя последовательными точками. Раздел 18.14 показывает решение — соединять новую точку с предыдущей через create_line().

Исправленный код
linii_mezhdu_tochkami.py
def on_drag(event):
    canvas.create_line(last_x, last_y, event.x, event.y, width=3, capstyle=tk.ROUND)
    # last_x, last_y обновляются после каждого отрезка
Debug Lab 5: Превью пересоздаётся на каждый Motion без необходимости
peresozdanie_prevyu.py
def on_drag(event):
    canvas.delete(preview_id)
    preview_id = canvas.create_rectangle(start_x, start_y, event.x, event.y)
# Работает, но на каждое из десятков событий Motion в секунду
# Canvas выбрасывает и заново строит один и тот же элемент.
Что видно на экране

Удалить и создать заново — не ошибка в смысле «сломано», но лишняя работа: у Canvas уже есть coords() именно для обновления существующего элемента (раздел 18.18) — без пересоздания.

Исправленный код
coords_vmesto_peresozdaniya.py
def on_drag(event):
    canvas.coords(preview_id, start_x, start_y, event.x, event.y)
Debug Lab 6: Потеряли id превью-фигуры
poteryan_id.py
def on_press(event):
    canvas.create_rectangle(event.x, event.y, event.x, event.y, dash=(4, 2))
    # id не сохранён — preview_id нигде не появился

def on_drag(event):
    canvas.coords(preview_id, ...)  # NameError: preview_id не определена
# NameError на первом же движении мыши —
# ссылаться не на что, id черновой фигуры потерян.
Что видно на экране

create_*() возвращает item_id (раздел 18.10), но только если его СОХРАНИТЬ — иначе фигура нарисуется, но обратиться к ней позже будет нечем.

Исправленный код
sohranili_id.py
def on_press(event):
    global preview_id
    preview_id = canvas.create_rectangle(event.x, event.y, event.x, event.y, dash=(4, 2))
Debug Lab 7: Прямоугольник «переворачивается» при перетаскивании вверх-влево
ne_normalizovano.py
def get_bounds():
    return start_x, start_y, current_x, current_y  # без учёта направления

left, top, right, bottom = get_bounds()
if left > right:
    raise ValueError("это невозможно")  # но перетаскивание вверх-влево именно это и даёт!
# Приложение падает с ValueError, если пользователь начал
# перетаскивание из правого нижнего угла будущей фигуры.
Что видно на экране

Мышь можно тянуть в любую из четырёх сторон (раздел 18.16) — код, ожидающий left <= right без предварительной нормализации, работает только для одного из четырёх направлений.

Исправленный код
normalizovano.py
left, top, right, bottom = normalize_bounds(start_x, start_y, current_x, current_y)
# теперь left <= right и top <= bottom гарантированно, независимо от направления
Debug Lab 8: item_id использован как индекс списка
id_kak_indeks.py
shapes = []
item_id = canvas.create_rectangle(10, 10, 50, 50)
shapes.append("rectangle")
print(shapes[item_id])   # IndexError, если item_id больше len(shapes) - 1
# IndexError: list index out of range —
# item_id не совпадает с порядковым номером в собственном списке кода.
Что видно на экране

item_id — внутренний номер Canvas (раздел 18.10), а не позиция в вашем собственном списке фигур. Использовать его как индекс shapes[item_id] совпадёт только случайно.

Исправленный код
id_kak_klyuch_slovarya.py
shapes_by_id = {}
item_id = canvas.create_rectangle(10, 10, 50, 50)
shapes_by_id[item_id] = "rectangle"   # словарь — id как КЛЮЧ, а не индекс
Debug Lab 9: Undo убирает только последний отрезок штриха, а не весь штрих
chastichnaya_otmena.py
def undo(self):
    item_id = self.undo_stack.pop()   # предполагает ОДИН id на действие
    self.canvas.delete(item_id)
# После Ctrl+Z длинный карандашный штрих теряет только
# последний маленький отрезок, а не исчезает целиком.
Что видно на экране

Один карандашный штрих — это МНОГО отрезков create_line() (раздел 18.14), а не один элемент. Undo обязан хранить список id (или фигур) на КАЖДОЕ действие целиком, а не один id (раздел 18.24).

Исправленный код
otmena_celym_deystviem.py
def undo(self):
    shapes = self.undo_stack.pop()   # список фигур ОДНОГО действия
    del self.document[len(self.document) - len(shapes):]
    self.render_document()
Debug Lab 10: Redo воскрешает несвязанное действие
redo_bez_ochistki.py
def _commit_action(self, shapes):
    self.document.extend(shapes)
    self.undo_stack.append(shapes)
    # забыли self.redo_stack.clear()
# Undo, затем новый рисунок, затем Redo —
# и на холсте внезапно появляется фигура, никак не связанная с текущим рисунком.
Что видно на экране

Если после Undo нарисовать что-то новое, старая ветка redo больше не соответствует текущему документу. Раздел 18.25 требует явно очищать redo_stack при любом новом действии.

Исправленный код
redo_s_ochistkoj.py
def _commit_action(self, shapes):
    self.document.extend(shapes)
    self.undo_stack.append(shapes)
    self.redo_stack.clear()
Debug Lab 11: Очистили холст, но не историю
ochistka_bez_istorii.py
def clear_canvas(self):
    self.canvas.delete("all")
    self.document.clear()
    # undo_stack и redo_stack не тронуты!
# После 'Очистить' нажатие Ctrl+Z не делает ничего видимого —
# а следующий Ctrl+Y возвращает на холст фигуры, которые
# пользователь только что удалил кнопкой 'Очистить'.
Что видно на экране

Ошибка тихая: del document[len(document) - len(shapes):] на пустом документе — это del document[-3:], что ничего не удаляет и ничего не ломает. Зато отменённое «действие» уезжает в redo_stack, и следующий Ctrl+Y возвращает на холст фигуры, стёртые кнопкой «Очистить». История описывает документ, которого больше нет: как только document очищается, undo_stack и redo_stack обязаны очиститься вместе с ним (раздел 18.26).

Исправленный код
ochistka_s_istoriej.py
def clear_canvas(self):
    self.document.clear()
    self.undo_stack.clear()
    self.redo_stack.clear()
    self.render_document()
Debug Lab 12: Отмена colorchooser записывает None как цвет
otmena_colorchooser.py
def choose_custom_color(self):
    _rgb, hex_color = colorchooser.askcolor()
    self.set_color(hex_color)   # не проверили hex_color на None!
# Пользователь нажал 'Отмена' в диалоге —
# следующая же попытка нарисовать линию падает: fill=None недопустим.
Что видно на экране

askcolor() возвращает (None, None) при отмене (раздел 18.21) — присвоить это как текущий цвет без проверки значит сломать рисование при следующей же попытке.

Исправленный код
proverka_otmeny.py
def choose_custom_color(self):
    _rgb, hex_color = colorchooser.askcolor()
    if hex_color is not None:
        self.set_color(hex_color)
Debug Lab 13: Отмена диалога сохранения — Path('') вместо выхода
otmena_sohraneniya.py
def save_document(self):
    path_str = filedialog.asksaveasfilename()
    Path(path_str).write_text(...)   # не проверили пустую строку!
# Пользователь нажал 'Отмена' в диалоге сохранения —
# программа пытается записать файл по пути текущей директории.
Что видно на экране

asksaveasfilename()/askopenfilename() возвращают пустую строку при отмене, а не None и не исключение (раздел 18.30) — код обязан проверить это перед использованием пути.

Исправленный код
proverka_otmeny_faila.py
def save_document(self):
    path_str = filedialog.asksaveasfilename()
    if not path_str:
        return
    Path(path_str).write_text(...)
Debug Lab 14: JSON загружен, но Canvas не перерисован
zagruzka_bez_render.py
def load_from_path(self, path):
    data = json.loads(path.read_text(encoding="utf-8"))
    self.document = [Shape.from_dict(item) for item in data["items"]]
    # забыли self.render_document()!
# self.document правильно заполнен новыми фигурами,
# но на экране всё ещё виден старый рисунок — или пустой холст.
Что видно на экране

Как и в главе 17: изменение модели без вызова отрисовки не долетает до экрана. load_from_path() обязан заканчиваться render_document() (раздел 18.30).

Исправленный код
zagruzka_s_render.py
def load_from_path(self, path):
    data = json.loads(path.read_text(encoding="utf-8"))
    shapes = [Shape.from_dict(item) for item in data["items"]]
    self.document = shapes
    self.undo_stack.clear()
    self.redo_stack.clear()
    self.render_document()   # без этой строки экран не узнает о загрузке
    # (полная версия с обработкой повреждённого файла — в разделе 18.30)
Debug Lab 16: Повреждённый файл стирает текущий рисунок
zagruzka_bez_proverki.py
def load_from_path(self, path):
    data = json.loads(path.read_text(encoding="utf-8"))
    self.document = [Shape.from_dict(i) for i in data["items"]]  # уже заменили!
    self.undo_stack.clear()
    self.render_document()   # ...и только здесь падаем на плохом цвете
# Открыли чужой JSON — рисунок, над которым работали час, исчез,
# Ctrl+Z его не возвращает, а каждая новая линия снова падает:
# _tkinter.TclError: unknown color name "not-a-color"
Что видно на экране

Документ заменён ДО того, как стало известно, что файл рисуется. Разбирайте файл в локальную переменную и присваивайте self.document только после успеха (раздел 18.30) — иначе одна попытка открыть не тот файл уносит и рисунок, и историю, и возможность рисовать дальше.

Исправленный код
zagruzka_s_proverkoj.py
def load_from_path(self, path):
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        shapes = [Shape.from_dict(i) for i in data["items"]]
        for shape in shapes:
            self.canvas.winfo_rgb(shape.color)
    except (json.JSONDecodeError, KeyError, TypeError, ValueError, tk.TclError) as exc:
        messagebox.showerror("Не удалось открыть файл", str(exc))
        return
    self.document = shapes
    self.render_document()
Реальное окно: то же приложение в увеличенном виде — холст занимает больше места, но ранее нарисованные фигуры остались того же размера в тех же абсолютных координатах
Реальное окно после увеличения: холст стал больше, но нарисованные фигуры не выросли вместе с ним — именно то, что показывает Debug Lab 15 ниже.
Debug Lab 15: Изменение размера окна не масштабирует рисунок
resize_kak_masshtab.py
# ожидание: если растянуть окно, старые фигуры увеличатся вместе с ним
canvas.grid(row=1, column=0, sticky="nsew")
# ...но существующие create_line/rectangle/oval координаты никто не пересчитывает
# Сам виджет Canvas становится больше вместе с окном —
# но уже нарисованные фигуры остаются в прежних абсолютных координатах, не растут.
Что видно на экране

sticky="nsew" и веса строк/столбцов (глава 17) заставляют РАСТИ сам виджет Canvas — это не то же самое, что автоматическое масштабирование координат уже нарисованных фигур. Раздел 18.29 явно разбирает это различие: увеличенный холст не означает автоматически увеличенный рисунок.

Исправленный код
resize_kak_bolshe_mesta.py
# Честная модель: увеличенное окно даёт БОЛЬШЕ МЕСТА для новых фигур,
# а не растягивает уже существующие — если нужно последнее, координаты
# пришлось бы пересчитывать вручную при изменении размера.
Практика: воспроизводим и чиним баги рисовалки
Автоматическая проверка — Debug Lab 11 (очистка и история) и Debug Lab 10 (redo после нового действия)
Открыть практику →