JavaRush /Курсы /Kotlin SELF /Сценарии поведения и отладка: ищем ошибки в попытках и ве...

Сценарии поведения и отладка: ищем ошибки в попытках и ветвлениях

Kotlin SELF
15 уровень , 4 лекция
Открыта

1. Сценарий поведения: что это и зачем он нужен

Когда программа маленькая, очень хочется тестировать её так: запустил, ввёл пару чисел, порадовался, что «вроде работает», и пошёл пить чай. Проблема в том, что «вроде» — это технический термин, означающий «через 15 минут оно сломается, но не сейчас». Сценарий поведения — это попытка сделать запуск предсказуемым: вы заранее задаёте ситуацию и заранее знаете, что программа обязана сделать.

Сценарий — это не про автоматизированные тесты и не про «супер-инженерию» (мы их сегодня не трогаем). Это про привычку мыслить так: «Если пользователь вводит не число — что будет?», «Если он вводит минимальную границу диапазона — что будет?», «Если попытки закончились — что будет?». По сути, сценарий — это маленький договор между вами и вашей программой: вы обещаете дать определённый ввод, а программа обязана дать определённый результат.

Чтобы было проще держать в голове, можно мыслить в стиле мини-истории: «Дано → Когда → Тогда». Дано: секрет фиксированный, попыток 3, диапазон 1..10. Когда: пользователь вводит abc, затем 11, затем 7. Тогда: программа дважды ругается на ввод и только в третий раз сравнивает число с секретом. (И да, уже здесь всплывает важнейший вопрос: «неверный ввод тратит попытку или нет?» — вы это решали раньше, а теперь вы это проверяете.)

2. Предсказуемость и debug-логи

Фиксируем секрет и отключаем «рандом»

Любая отладка страдает, когда результат меняется сам по себе. Random — отличный инструмент для игры, но ужасный компаньон для проверки логики: сегодня секрет 42, завтра 17, а вы пытаетесь понять, почему на третьей попытке программа внезапно проиграла. Поэтому первый приём в отладке такой: на время проверок сделайте секрет фиксированным, чтобы можно было воспроизводить поведение снова и снова.

Самый простой вариант — завести флаг debug и выбирать секрет по нему. Это «честный» способ: в обычном режиме игра остаётся игрой, в debug-режиме — становится лабораторной мышью (в хорошем смысле).

val debug = true

val secret = if (debug) {
    42
} else {
    generateSecret(min, max)
}

println("Игра началась!") // Игра началась!

Обратите внимание на важный психологический момент: вы не «ломаете игру», вы временно меняете источник секрета ради воспроизводимости. Это как прикрутить велосипед к станку, чтобы настроить тормоза: кататься так нельзя, но чинить — очень удобно.

Ещё один удобный приём — вывести подсказку о том, что мы в debug-режиме, чтобы потом не сидеть в недоумении, почему «я всегда угадываю за одну попытку, я гений» (а потому что секрет вы сами написали).

if (debug) {
    println("DEBUG: секрет зафиксирован = $secret") // DEBUG: секрет зафиксирован = 42
}

Только, пожалуйста, не забудьте потом выключить debug. Игра, которая честно пишет «секрет = 42», — это не игра, а спойлер-тренажёр.

Отладочные сообщения: куда ставить println("DEBUG: ...")

Отладочный println — инструмент древний, как первые баги (то есть очень древний). Он хорош, если вы ставите его точечно и печатаете то, что реально помогает. Он плох, если вы начинаете печатать всё подряд и превращаете консоль в водопад текста, где теряется самое важное.

Главная идея: при каждом «шаге игры» вас обычно интересует три-четыре значения: номер попытки, введённая догадка, секрет, и результат сравнения (например -1/0/1). Поэтому имеет смысл сделать маленькую функцию для debug-вывода — чтобы не повторять одну и ту же строку, и чтобы можно было выключить весь debug одним флагом.

fun debugState(debug: Boolean, attempt: Int, secret: Int, guess: Int?) {
    if (!debug) return
    println("DEBUG: attempt=$attempt secret=$secret guess=$guess")
    // DEBUG: attempt=1 secret=42 guess=10
}

И использовать её в цикле аккуратно, сразу после ввода (когда guess может быть null) — так вы видите, что реально пришло с клавиатуры.

val guess = readGuessOrNull(min, max)
debugState(debug, attempt, secret, guess)

if (guess == null) {
    println("Некорректный ввод")
    continue
}

Чуть более «умный» вариант — печатать ещё и compareResult. Это особенно полезно, когда подсказки кажутся «перепутанными»: например, вы вводите число больше секрета, а программа пишет «Слишком мало». Часто проблема в сравнении или в том, что ветки перепутаны местами.

val result = compareGuess(guess, secret)
if (debug) {
    println("DEBUG: compareResult=$result") // DEBUG: compareResult=1
}
println(hintText(result))

3. Таблица сценариев: победа, проигрыш, ввод и границы

Когда сценарии живут только в голове, они быстро превращаются в кашу: «кажется, я это уже проверял… или это было в прошлой жизни?». Поэтому полезно оформлять сценарии хотя бы в виде таблицы: коротко, без романов, но так, чтобы вы могли повторить проверку.

Ниже — пример таблицы для нашей игры. Здесь я намеренно включаю «неприятные» случаи (не число, пробелы, выход за диапазон), потому что именно они чаще всего и ломают программу.

Сценарий Настройки Ввод пользователя (по шагам) Ожидаемое поведение Что проверяем
Победа сразу secret=42, maxAttempts=7 42 вывод «Угадал!» + конец игры корректное завершение по победе
Победа не сразу secret=42, maxAttempts=7 10, 50, 42 «Слишком мало», «Слишком много», «Угадал!» сравнение и подсказки
Проигрыш secret=42, maxAttempts=3 1, 2, 3 три подсказки + итог «проиграли» лимит попыток и финальное сообщение
Не число secret=42, maxAttempts=7 abc, потом 42 сообщение об ошибке ввода, затем нормальная игра toIntOrNull() и обработка null
Пустая строка/пробелы secret=42, maxAttempts=7 "   ", потом 42 ошибка ввода (после trim() строка пустая) нормализация trim()
Выход за диапазон диапазон 1..100, secret=42 101, потом 42 ошибка «вне диапазона», затем нормальная игра проверка guess in min..max
Граница минимума диапазон 1..100, secret=1 1 победа корректность границ
Граница максимума диапазон 1..100, secret=100 100 победа корректность верхней границы

Заметьте: даже без автоматизации эта таблица уже даёт вам системность. Вы не просто «тыкали числа», а прошли набор обязательных ситуаций. Если потом появится баг, вы сможете сказать не «что-то сломалось», а «сломался сценарий “выход за диапазон”» — и это уже очень близко к тому, как думают разработчики в реальных командах.

4. Логика игры: попытки, границы и ветвления

Ошибки «на 1»: попытки, <= и место attempt++

Большинство логических багов в играх “угадай число” — это не мистические проблемы, а банальные ошибки «на 1» (off-by-one). Они возникают там, где вы считаете попытки или работаете с границами диапазонов. И неприятны они тем, что программа почти работает: иногда проигрывает на одну попытку раньше, иногда сообщает «победа за 4 попытки», хотя попыток было 3, иногда «зависает» на одной и той же попытке при неверном вводе.

Самая частая зона риска — место attempt++. Если неверный ввод не тратит попытку, то инкремент должен происходить только тогда, когда попытка действительно «сыграла», то есть когда у вас есть корректное число и вы его сравнили с секретом.

Вот характерный фрагмент с правильным расположением attempt++ (политика: неверный ввод попытку не тратит):

val guess = readGuessOrNull(min, max)
if (guess == null) {
    println("Некорректный ввод")
    continue
}

val result = compareGuess(guess, secret)
println(hintText(result))

if (result == 0) break

attempt++ // попытка потрачена только здесь

Теперь представьте типичный баг: attempt++ стоит в начале цикла или сразу после вывода номера попытки. Тогда неверный ввод начнёт «жечь» попытки (возможно, вы этого не хотели), а иногда вы получите победу «за попытку больше», потому что счётчик увеличился ещё до проверки победы.

Ещё одна тонкость — условие цикла. Например, если попытки считаются от 1 до maxAttempts, то «логичная» проверка выглядит как attempt <= maxAttempts. Если вы случайно напишете attempt < maxAttempts, вы отрежете последнюю попытку и получите проигрыш на шаг раньше. Вроде мелочь, но именно из таких мелочей и состоят «почему пользователь недоволен».

Если вы хотите быстро проверить, корректно ли работает граница попыток, используйте сценарий «проигрыш на 1 попытку»: поставьте maxAttempts = 1, зафиксируйте секрет, введите неверное число и посмотрите, что произойдёт. В таких маленьких настройках ошибки «на 1» выпрыгивают наружу почти мгновенно.

Проверяем сравнение и when: где ломаются подсказки

Когда подсказки работают неправильно, виноват обычно не Kotlin, а наша логика. Но Kotlin может помочь тем, что заставляет писать ветвления более явно. В нашей игре есть два ключевых места: функция сравнения compareGuess и функция текста hintText. Они должны быть согласованы: если compareGuess возвращает -1 для «меньше секрета», то hintText(-1) должна говорить «Слишком мало», а не «Слишком много» (иначе игра превращается в психологический триллер).

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

fun hintText(compareResult: Int): String {
    return when (compareResult) {
        -1 -> "Слишком мало"
        1 -> "Слишком много"
        else -> "Угадал!"
    }
}

Чтобы проверять такие функции «в отрыве от игры», полезно сделать крошечную ручную проверку: вызвать compareGuess и hintText на заранее известных значениях. Это не unit-тесты, а просто маленький самоконтроль, который можно включить временно в main.

val secret = 42
println(hintText(compareGuess(10, secret))) // Слишком мало
println(hintText(compareGuess(99, secret))) // Слишком много
println(hintText(compareGuess(42, secret))) // Угадал!

Если здесь всё правильно, а в игре подсказки «пляшут», значит проблема не в hintText, а в том, что вы сравниваете не те значения (например, где-то перезаписали secret, или неправильно обработали guess, или попытка увеличивается не там и вы смотрите «не на ту итерацию»).

5. Инварианты и страховочные проверки: require и check

Инвариант — это правило, которое должно оставаться верным во время работы программы. В нашей игре типичные инварианты простые: диапазон должен быть корректным (min <= max), секрет должен попадать в диапазон, номер попытки не должен становиться отрицательным, а функция сравнения должна возвращать только -1, 0 или 1. В реальной жизни инварианты спасают от состояния «игра работает странно, но не падает, и это ещё хуже».

Проверки require и check — это способ сказать в коде: «если это не так, значит мы в неправильном мире». Мы уже использовали require в генерации секрета, и это отличный пример: лучше упасть сразу с понятным сообщением, чем получить секрет из “перепутанного диапазона” и потом часами ловить призраков.

fun generateSecret(min: Int, max: Int): Int {
    require(min <= max) { "min должен быть <= max (min=$min, max=$max)" }
    return kotlin.random.Random.nextInt(from = min, until = max + 1)
}

А вот пример «страховки» для compareGuess, если вы вдруг боитесь, что кто-то изменит код и начнёт возвращать странные значения. Это не обязательно для учебного проекта, но как приём отладки — полезно.

val result = compareGuess(guess, secret)
check(result == -1 || result == 0 || result == 1) {
    "compareGuess() вернул невозможный результат: $result"
}
println(hintText(result))

Смысл таких проверок не в том, чтобы «наказать пользователя», а в том, чтобы поймать баг как можно ближе к месту его рождения. Чем дальше баг успел «убежать», тем сложнее потом восстановить, где он появился.

6. Мини-дебаг в IntelliJ IDEA: цикл, breakpoint и значения

Когда println("DEBUG...") уже не помогает (или помогает, но вам надоело вручную читать простыню текста), самое время вспомнить базовый отладчик. Мы уже знакомились с ним раньше, и сейчас нам достаточно самых простых действий: поставить breakpoint, сделать шаг по строкам и посмотреть значения переменных. Это похоже на «замедленную съёмку» вашей программы: вы видите, в какой момент attempt стал «не тем», где именно сработал continue, и почему ветка if не вошла туда, куда вы ожидали.

Самая удобная точка остановки в этой игре — внутри цикла попыток, сразу после ввода и сразу перед сравнением. Тогда вы в одном месте видите attempt, secret, guess, а также можете увидеть, почему guess оказался null (например, вы ввели пробелы, trim() дал пустую строку, toIntOrNull() вернул null — и это нормально). Если вы поставите breakpoint слишком рано, вы ещё не увидите интересные значения; если слишком поздно — вы уже пропустите момент, где всё пошло не так.

И ещё один нюанс: отладчик особенно хорошо ловит ошибки «на 1», потому что вы буквально видите изменение attempt. Если вы ожидали, что после неправильного ввода попытка не увеличится, а она увеличилась — вы сразу понимаете, в какой строке это произошло.

7. Типичные ошибки при сценариях и отладке

Ошибка №1: проверять “на удачу”, а не сценариями.
Если запускать игру каждый раз по-разному и надеяться, что «ну сейчас точно проявится», баг может исчезать или проявляться случайно, особенно когда в игре есть Random. Гораздо эффективнее зафиксировать секрет и пройти один и тот же сценарий несколько раз, пока не станет ясно, в каком месте логика расходится с ожиданием.

Ошибка №2: не фиксировать политику попыток при неверном вводе.
Очень часто программа «зависает» на одной попытке или, наоборот, сжигает попытки слишком быстро из‑за того, что разработчик сам не решил: неверный ввод тратит попытку или нет. В результате attempt++ оказывается то в одной ветке, то в другой, то вообще забывается рядом с continue, и поведение становится случайным.

Ошибка №3: ставить attempt++ в неправильном месте и ловить “победа за N+1 попыток”.
Если увеличивать attempt до проверки победы, можно получить красивое сообщение «Победа за 8 попыток!», когда попыток было 7 (и пользователь, возможно, тоже это заметит). Правило простое: сначала обработали ввод, сравнили, проверили победу; только потом увеличиваем счётчик, если игра продолжается.

Ошибка №4: печатать слишком много debug‑текста и перестать понимать, что происходит.
Отладочные сообщения должны отвечать на конкретный вопрос. Если вы печатаете 20 строк на каждую итерацию, мозг перестаёт искать закономерности и начинает скроллить как лента соцсетей. Лучше печатать 1–2 строки с ключевыми переменными (attempt, guess, secret, result) и обязательно уметь выключить debug одним флагом.

Ошибка №5: перепутать ветки сравнения (< и >) и “чинить” не там.
Классическая ситуация: подсказки всегда «не те». Начинающий разработчик начинает править hintText, потом readGuessOrNull, потом случайно ломает ввод… хотя проблема была в одном месте: перепутаны условия guess < secret и guess > secret. Проверка с фиксированным секретом и парой заранее выбранных значений (10, 99, 42) обычно вскрывает это за минуту.

Ошибка №6: оставить “спойлер” (печать секрета) в обычном режиме игры.
Во время отладки печатать секрет полезно. После отладки — это превращает игру в анекдот (причём не всегда смешной). Поэтому debug‑режим должен быть явно отключаемым, а строки вида println(secret) — жить только под if (debug) и исчезать из финальной версии.

1
Задача
Kotlin SELF, 15 уровень, 4 лекция
Недоступна
Режим отладки
Режим отладки
1
Задача
Kotlin SELF, 15 уровень, 4 лекция
Недоступна
Подсказка и лог
Подсказка и лог
1
Задача
Kotlin SELF, 15 уровень, 4 лекция
Недоступна
Ввод по шаблону
Ввод по шаблону
1
Задача
Kotlin SELF, 15 уровень, 4 лекция
Недоступна
Три попытки
Три попытки
1
Опрос
Мини‑проект, 15 уровень, 4 лекция
Недоступен
Мини‑проект
Мини‑проект
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ