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

Debug Labs — типичные ошибки событийной игры

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

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

Debug Lab 1: Своя привязка виджета с break глушит клавиши игры
focus_problem.py
root.bind("<Key>", on_key)

entry = tk.Entry(root)
entry.pack()
entry.bind("<Key>", lambda e: "break")   # останавливает цепочку bindtags
entry.focus_set()
# Пока фокус в entry, нажатия цифр не долетают до on_key —
# entry.bind(..., 'break') обрывает цепочку прямо здесь.
Что видно на экране

Клавиатурное событие сначала идёт в сфокусированный виджет и дальше по цепочке bindtags: сам виджет → его класс → root → "all" (раздел 17.11). Если КАКОЙ-ТО обработчик на этом пути явно вернёт "break" — дальнейшие привязки в цепочке (включая root.bind) для этого события просто не вызовутся. Без такого "break" событие обычно продолжило бы путь и до root тоже — фокус сам по себе не блокирует привязку верхнего уровня.

Исправленный код
focus_fixed.py
root.bind("<Key>", on_key)

entry = tk.Entry(root)
entry.pack()
entry.focus_set()   # без своей <Key>-привязки событие доходит и до root
Debug Lab 2: lambda без i=indeks — поздняя привязка
lambda_pozdnyaya_privyazka.py
for indeks in range(9):
    btn = tk.Button(frame, command=lambda: attempt_move(indeks))
    btn.grid(row=indeks // 3, column=indeks % 3)
# Клик по ЛЮБОЙ из девяти кнопок ставит отметку в клетку 8 —
# потому что все lambda читают ОДНУ И ТУ ЖЕ переменную indeks.
Что видно на экране

К моменту клика цикл давно закончился, и indeks равен 8 для всех девяти замыканий разом — они не «запомнили» значение на момент создания, они ссылаются на переменную. Раздел 17.4 разбирал именно эту ошибку.

Исправленный код
lambda_fixed.py
for indeks in range(9):
    btn = tk.Button(frame, command=lambda i=indeks: attempt_move(i))
    btn.grid(row=indeks // 3, column=indeks % 3)
Debug Lab 3: Наведение путает превью с настоящим ходом
hover_kak_hod.py
def on_cell_enter(self, index):
    self.buttons[index].config(text=self.state.current_player)

def find_winner_from_widgets(self):
    board = [b["text"] for b in self.buttons]   # читает ТЕКСТ КНОПОК
    return find_winner(board)
# Просто наведя курсор на три пустые клетки одной линии подряд,
# можно получить объявление победы без единого клика.
Что видно на экране

Если проверка победителя читает состояние из button["text"], а наведение временно пишет туда текст — превью становится неотличимо от настоящего хода. Раздел 17.18 объясняет, почему модель обязана быть источником истины.

Исправленный код
hover_ne_hod.py
def on_cell_enter(self, index):
    if self.state.game_over or self.state.board[index]:
        return
    self.buttons[index].config(text=self.state.current_player, fg=HOVER_COLOR)

def find_winner_call(self):
    return find_winner(self.state.board)   # читает МОДЕЛЬ, не кнопки
Debug Lab 4: Игрок переключается ДО проверки победителя
smena_do_proverki.py
state.board[index] = state.current_player
state.current_player = "O" if state.current_player == "X" else "X"

winner, line = find_winner(state.board)
if winner:
    status_var.set(f"Победил игрок {winner}!")   # но current_player уже сменился
# Статус верный ('Победил X'), но если где-то дальше по коду
# используется state.current_player как 'кто только что выиграл' — там уже 'O'.
Что видно на экране

Порядок операций важен: смена игрока должна произойти ПОСЛЕ проверки победителя и ТОЛЬКО если игра продолжается. Иначе любой код, который смотрит на current_player сразу после хода, увидит уже следующего игрока, а не автора выигрышного хода.

Исправленный код
smena_posle_proverki.py
state.board[index] = state.current_player
winner, line = find_winner(state.board)
if winner:
    state.winner = winner   # 'кто выиграл' зафиксировано ДО смены игрока
    state.game_over = True
elif not is_draw(state.board):
    state.current_player = "O" if state.current_player == "X" else "X"
Debug Lab 5: Ничья проверяется раньше победы
nichya_do_pobedy.py
if all(state.board):
    status_var.set("Ничья!")
elif find_winner(state.board)[0]:
    status_var.set("Победа!")
# Девятый ход одновременно заполняет поле и выигрывает диагональ —
# программа объявляет 'Ничья!', хотя есть явный победитель.
Что видно на экране

Раздел 17.17 показывал именно этот сценарий. Проверка «заполнено ли поле» должна идти ПОСЛЕ проверки победителя, иначе выигрышный последний ход теряется за ложной ничьей.

Исправленный код
pobeda_do_nichyej.py
winner, line = find_winner(state.board)
if winner:
    status_var.set(f"Победил игрок {winner}!")
elif all(state.board):
    status_var.set("Ничья!")
Debug Lab 6: Ход в занятую клетку разрешён
zanyataya_kletka.py
def attempt_move(index):
    state.board[index] = state.current_player   # нет проверки!
# Повторный клик по уже занятой клетке перезаписывает X на O
# (или наоборот) — чужой ход стирается.
Что видно на экране

Валидация должна идти ПЕРВОЙ строкой, до любого изменения состояния — раздел 17.15 формулирует это как обязательный первый шаг алгоритма хода.

Исправленный код
zanyataya_kletka_fixed.py
def attempt_move(index):
    if state.game_over or state.board[index]:
        return
    state.board[index] = state.current_player
Debug Lab 7: Игрок переключается даже при невалидном клике
smena_pri_nevalidnom_klike.py
def attempt_move(index):
    if not state.board[index]:
        state.board[index] = state.current_player
    state.current_player = "O" if state.current_player == "X" else "X"   # снаружи if!
# Клик по занятой клетке ничего не рисует —
# но ход всё равно передаётся сопернику. Игрок теряет очередь ни за что.
Что видно на экране

Строка смены игрока оказалась ВНЕ блока if — она выполняется независимо от того, был ли ход валидным. Раздел 17.15: невалидный ход не должен переключать игрока.

Исправленный код
smena_pri_nevalidnom_klike_fixed.py
def attempt_move(index):
    if state.board[index]:
        return
    state.board[index] = state.current_player
    state.current_player = "O" if state.current_player == "X" else "X"
Debug Lab 8: Нет проверки game_over — игра продолжается после победы
net_game_over.py
def attempt_move(index):
    if state.board[index]:
        return
    state.board[index] = state.current_player
    # ... проверка победителя, но НЕТ return/guard в начале функции
# После объявления победы X можно продолжать кликать по пустым клеткам —
# и даже 'перезаписать' исход партии дополнительными ходами O.
Что видно на экране

Без явной проверки state.game_over в начале attempt_move() ничего не мешает продолжать партию после того, как она уже закончилась.

Исправленный код
game_over_guard.py
def attempt_move(index):
    if state.game_over or state.board[index]:
        return
    ...
Debug Lab 9: Опечатка в WINNING_LINES
opechatka_v_linii.py
WINNING_LINES = (
    (0, 1, 2), (3, 4, 5), (6, 7, 8),
    (0, 3, 6), (1, 4, 7), (2, 5, 8),
    (0, 4, 6), (2, 4, 6),   # первая диагональ должна быть (0, 4, 8)!
)
# X ставит 0, 4 и 8 — три подряд по настоящей диагонали —
# но игра не объявляет победителя, потому что такой кортеж не в списке.
Что видно на экране

Опечатка тихая: код синтаксически верный и даже проходит поверхностный тест на «какая-то» диагональ. Только регрессионный тест, проверяющий ВСЕ восемь линий (раздел 17.28), поймал бы конкретно эту ошибку.

Исправленный код
linii_fixed.py
WINNING_LINES = (
    (0, 1, 2), (3, 4, 5), (6, 7, 8),
    (0, 3, 6), (1, 4, 7), (2, 5, 8),
    (0, 4, 8), (2, 4, 6),
)
Debug Lab 10: Сбросили интерфейс, но не модель
reset_tolko_ui.py
def new_round(self):
    for btn in self.buttons:
        btn.config(text="")   # кнопки очищены...
    # ...но self.state.board по-прежнему хранит старые X/O!
# Поле выглядит пустым, но следующий attempt_move()
# натыкается на 'клетка уже занята' там, где на экране пусто.
Что видно на экране

Визуальный сброс — не то же самое, что сброс модели. Раздел 17.18: кнопки лишь отображают состояние; если очистить только их, модель продолжает врать об истинном состоянии партии.

Исправленный код
reset_model_i_ui.py
def new_round(self):
    self.state.board = [""] * 9
    self.state.current_player = "X"
    self.state.game_over = False
    self.render()   # UI обновляется ИЗ модели, а не отдельно
Debug Lab 11: Сбросили модель, но не вызвали render()
reset_tolko_model.py
def new_round(self):
    self.state.board = [""] * 9
    self.state.current_player = "X"
    self.state.game_over = False
    # забыли self.render() в конце!
# Модель для нового раунда готова правильно —
# но на экране всё ещё видны старые X и O из прошлой партии.
Что видно на экране

Обратная сторона той же ошибки: без вызова render() изменения модели никогда не попадают на экран. Раздел 17.20 формулирует этот принцип: render() — место, которое рисует виджеты ИЗ модели (state), и его нужно вызывать явно после каждого изменения этой модели.

Исправленный код
reset_s_render.py
def new_round(self):
    self.state.board = [""] * 9
    self.state.current_player = "X"
    self.state.game_over = False
    self.render()
Debug Lab 12: «Новая игра» случайно обнуляет счёт матча
novaya_igra_obnulyaet_schet.py
def new_round(self):
    self.state.score_x = 0   # это должно быть только в new_match()!
    self.state.score_o = 0
    self.state.board = [""] * 9
# После каждой победы счёт тут же обнуляется —
# турнирный счёт невозможно накопить.
Что видно на экране

Раздел 17.27: New Round и New Match — разные операции. Обнуление счёта принадлежит ИСКЛЮЧИТЕЛЬНО new_match().

Исправленный код
new_round_bez_scheta.py
def new_round(self):
    self.state.board = [""] * 9
    # счёт (score_x/score_o/draws) здесь не трогаем
Debug Lab 13: on_cell_leave стирает уже сделанный ход
leave_stiraet_hod.py
def on_cell_leave(self, index):
    self.buttons[index].config(text="")   # наивно "убрать превью"
# После того как игрок делает настоящий ход и потом уводит курсор,
# отметка на клетке пропадает с экрана — хотя в модели ход остался.
Что видно на экране

on_cell_leave не знает, было ли на клетке превью или настоящий ход — он просто стирает текст безусловно. Правильный подход: не гадать, а перерисовать из модели, которая точно знает истину.

Исправленный код
leave_fixed.py
def on_cell_leave(self, index):
    self.render()   # модель решает, что должно быть на клетке
Debug Lab 14: event.widget используется как игровое состояние
widget_kak_state.py
def on_click(self, event):
    event.widget.config(text=self.state.current_player)
    # где здесь запись в board? Её нет — состояние живёт ТОЛЬКО в виджете
# find_winner(self.state.board) никогда не видит эти ходы —
# победа не определяется, потому что модель не менялась.
Что видно на экране

event.widget — источник UI-события (раздел 17.8), а не домен игры. Запись хода обязана попасть в state.board, иначе вся доменная логика (поиск победителя, ничьей) работает вслепую.

Исправленный код
widget_i_model.py
def on_click(self, event, index):
    self.attempt_move(index)   # меняет state.board, потом render()
Debug Lab 15: Голый <Button-1> подменяет семантическое действие Button
goloj_button1.py
btn.bind("<Button-1>", lambda e, i=index: attempt_move(i))
# command= не задан вообще
# Обработчик связан только с конкретным событием мыши.
# Основное действие Button больше не описано через command=.
Что видно на экране

Раздел 17.10: command= описывает основное действие Button, а встроенное поведение класса виджета решает, какие способы активации его вызывают. Конкретные клавиши зависят от вида Button, темы и платформы. Голая привязка <Button-1> знает только о клике мышью и обходит эту семантику — поэтому не используйте её вместо command по умолчанию.

Исправленный код
command_fixed.py
btn.config(command=lambda i=index: attempt_move(i))
Debug Lab 16: time.sleep() в анимации победы замораживает окно
sleep_v_animacii.py
import time

def pulse_winning_line(self):
    for _ in range(6):
        self.buttons[0].config(bg="green")
        time.sleep(0.15)
        self.buttons[0].config(bg="white")
        time.sleep(0.15)
# Окно перестаёт отвечать почти на две секунды анимации —
# та же ошибка, что и в главе 16 (раздел 16.32).
Что видно на экране

time.sleep() блокирует событийный цикл целиком. Для анимации без блокировки нужен self-rescheduling через after() — раздел 17.30.

Исправленный код
after_fixed.py
def pulse_winning_line(self, tick=0):
    color = PULSE_BG if tick == 0 else WIN_BG
    for i in self.state.winning_line:
        self.buttons[i].config(bg=color)
    if tick == 0:
        self.root.after(400, self.pulse_winning_line, 1)
Debug Lab 17: Дополнительный bind(..., add="+") поверх command= вызывает reset дважды
dublirovannaya_privyazka.py
btn = ttk.Button(controls, text="Новый раунд", command=self.new_round)
btn.bind("<Button-1>", lambda e: self.new_round(), add="+")
# теперь один клик запускает new_round() ЧЕРЕЗ command И через bind — дважды подряд
# Само по себе new_round() дважды подряд не ломает игру —
# но если бы это была, например, функция увеличения счёта, счёт скакнул бы на 2.
Что видно на экране

add="+" добавляет ЕЩЁ ОДИН обработчик, не заменяя существующий (раздел 17.12 упоминал это как «чуть глубже»). Если один и тот же клик уже обрабатывается через command=, дублирующая привязка через bind(..., add="+") на том же событии запускает логику повторно — используйте add="+" осознанно, только когда действительно нужно НЕСКОЛЬКО независимых обработчиков одного события.

Исправленный код
bez_dublirovaniya.py
btn = ttk.Button(controls, text="Новый раунд", command=self.new_round)
# и всё — одной привязки достаточно
Практика: находим баг по симптому
Автоматическая проверка — для набора описанных симптомов выбираем правильную причину/исправление
Открыть практику →