Hammerhall EN Сколько стоит Войти

Объекты-значения: приведение, проверки, маскировка

Статья 8 · читать минут 30

Для кого. Ты прочитал статьи 0–7, и у тебя есть песочница game-day после статьи 7. Особенно пригодятся запись, компактный конструктор, фабричный метод и тесты по таблице (статья 0). Самим объектам-значениям Docker не нужен.

Что будет. Как сделать из строки объект, который сам приводит себя к одному виду и не бывает неверным; почему toString() записи выдаёт секреты и как это закрыть; как перевести чужой код в своё перечисление — и как всё это проверить тестами.

Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От Spring к системе» — следующий шаг после «От Java к Spring Boot»: как приложение живёт среди других программ. Примеры — учебные: их можно повторить у себя.


Зачем объекты-значения#

Посмотри, где в журнале живёт имя дежурного. В DutyRoster — строка "duty-1". В Notifier.send(String dutyName, String text) — строка. В IncidentLog, который спрашивает, кто дежурит, — тоже строка. Везде String.

Пока имя приходит из одного места, GameDayConfig, всё работает. Теперь представь, что оно приходит ещё и из настроек, как стоп-слово в статье 2. А ещё из таблицы дежурств, которую тимлид набрал руками, и из запроса к журналу. Кто-то напишет Duty-1, кто-то вставит duty-1 с пробелом из копипасты. Для String.equals это разные строки.

Журнал хранит происшествия в базе, и поиск по Duty-1 вернёт пустой список — без ошибки, без следа в логе. Дежурный «чист», хотя у него три открытых происшествия.

У такого «строкового» кода три беды:

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

В рабочих проектах таких типов много: адрес почты, код валюты, номер заказа, ключ задачи — TaskKey из статьи 0 как раз такой. Пишут их обычно джуны: маленькая задача с таблицей случаев. Статья объясняет, откуда берутся строки такой таблицы: разные написания, секрет в toString(), незнакомый чужой код.

Сквозной пример — журнал игрового дня, учебный пример серии. Построим три вещи: имя дежурного DutyName, стоп-слово игры StopWord и исход атаки AttackOutcome.


Часть 1. DutyName: привести, проверить, сравнить

Значение, а не «кто-то»#

Происшествие в журнале — «кто-то». Два раза записали «брокер не отвечает» — это два разных происшествия, даже если всё остальное совпало: брокер падал дважды. Различают их по номеру, а не по тексту; такой объект называют сущностью (entity). Объект-значение устроен наоборот: его узнают только по содержимому. Как дату в календаре: «5 октября» на двух листках — одна и та же дата, какой листок ни возьми.

Что от объекта-значения ждут:

Последние два запись даёт даром. Первые два пишем сами.

В коротких примерах статьи нет строк import; полные файлы — в конце.

Фабрика of: сначала привести#

public static DutyName of(String raw) {
    if (raw == null) {                                          // сначала null: иначе strip() бросит NullPointerException
        throw new IllegalArgumentException("имя дежурного не указано");
    }
    return new DutyName(raw.strip().toLowerCase(Locale.ROOT)); // привести; проверит конструктор
}

Фабричный метод ты знаешь по TaskKey.parse из статьи 0. Имя of — обычное для фабрики, которая берёт значение почти как есть: так зовут и фабрики самой Java — List.of, Path.of. Фабрика, которая разбирает строку на части, — parse, как TaskKey.parse и LocalDate.parse.

Порядок важен: сначала привести, потом проверить. Наоборот — и duty-1 с пробелом из копипасты получит отказ из-за пробела, хотя имя хорошее. Проверка должна видеть ровно то, что будет храниться.

⚠️ strip(), а не trim()#

В интернете пробелы по краям срезают trim() — он в Java с самого начала. У нас — strip(), он появился в Java 11 и считает пробелом другое:

После trim() широкий пробел останется: голая строка не совпадёт с duty-1, а наш DutyName откажет хорошему имени. Служебный знак после strip() тоже останется, но его отвергнет проверка формы ниже. Это честнее, чем молча срезать мусор. Поэтому в объектах-значениях — strip().

💡 Неразрывный пробел U+00A0 Character.isWhitespace пробелом не считает, а его номер больше U+0020: его не снимет ни strip(), ни trim(). Для DutyName это не беда: проверка формы отвергнет такое имя. Ошибка лучше, чем тихое «другое имя».

⚠️ Нижний регистр — только с Locale.ROOT

Локаль (locale) — языковые настройки машины: как писать числа и даты, как менять регистр букв. Частая ошибка — просто toLowerCase(): он берёт локаль машины, а у некоторых языков правила свои. В турецком у заглавной I строчная пара — ı, «и без точки»:

"KIRA".toLowerCase(Locale.forLanguageTag("tr"));   // турецкая локаль: "kıra" — i без точки
"KIRA".toLowerCase(Locale.ROOT);                   // "kira" — на любой машине

Locale.ROOT — «без языка»: одни правила везде. На машине с турецкой локалью приведение без Locale.ROOT сделало бы из KIRA строку kıra. Проверка формы отвергла бы её как не латиницу, хотя набрали чистую латиницу.

Сервер, ноутбук коллеги, сборка — у каждого своя локаль. Такая ошибка видна на одной машине из многих. Документация Java прямо советует toLowerCase(Locale.ROOT) для строк, которые не зависят от языка: имён, ключей, кодов.

Так же приводят адреса почты и коды. В рабочем проекте к такому типу бывает тест с турецкой локалью по умолчанию (Locale.setDefault). Локаль по умолчанию одна на весь запуск и достанется всем тестам, что пойдут следом. Поэтому такой тест обязан вернуть прежнюю.

Проверки — в компактном конструкторе

public record DutyName(String value) {

    private static final int MAX_LENGTH = 50;                   // столько вместит столбец VARCHAR(50) в базе

    public DutyName {
        if (value == null || value.isEmpty()) {                 // пусто — первым: у пустого нечего проверять
            throw new IllegalArgumentException("имя дежурного не указано");
        }
        if (value.length() > MAX_LENGTH) {
            throw new IllegalArgumentException("имя дежурного длиннее " + MAX_LENGTH + " знаков");
        }
        if (!value.matches("[a-z][a-z0-9-]*")) {                // буква, дальше буквы, цифры, дефис
            throw new IllegalArgumentException(
                    "имя дежурного: латинские строчные буквы, цифры и дефис, первой — буква; создавай через DutyName.of");
        }
    }

    // of — выше
}

💡 duty- и d--1 форма пропустит — для журнала не беда. Строки таблицы в задаче — минимум, а не потолок: нашёл такой край у себя — напиши о нём в пул-реквесте.

Почему приведение в of, а проверки в конструкторе? Конструктор записи всегда открыт: new DutyName(" Duty-1 ") написать можно, мимо of. Но форма сама требует приведённого вида — заглавной букве и пробелу в неё не попасть. У DutyName неверный объект не создаётся ни через of, ни через new, а текст ошибки подскажет, как надо.

В рабочем коде встретишь и другие раскладки. Бывает, конструктор сторожит только «не null и уже приведён», а проверки формы живут в of. Тогда new создаст приведённое, но негодное значение, и в таком проекте значения создают только через of. Бывает, приводить нечего: все проверки — в конструкторе, а parse только режет строку на части. Раскладки разные, и все годятся, пока соблюдено общее:

🔑 Приводят один раз, на входе, и неприведённого объекта не бывает — ни через фабрику, ни через new.

Неизменяемость#

Поля записи final, сеттеров нет, String сам неизменяем. Проверенный DutyName годен навсегда: его можно отдать в любой метод, положить ключом в Map — никто по дороге его не испортит.

Равенство: даром — и правильное

equals и hashCode запись пишет сама, по полям (статья 0). Сами по себе они сравнивают строки как есть. Но внутри DutyName всегда приведённое значение — и обычное равенство становится правильным:

DutyName.of(" Duty-1 ").equals(DutyName.of("duty-1"));   // true: внутри у обоих "duty-1"
" Duty-1 ".equals("duty-1");                             // false: строки разные

Отсюда главное: коллекции начинают понимать, что это одно и то же. Set — набор без повторов, а Map хранит пары «ключ → значение». Их обычные реализации — HashSet и HashMap.

Set<DutyName> onDuty = new HashSet<>();
onDuty.add(DutyName.of("duty-1"));
onDuty.add(DutyName.of(" DUTY-1 "));        // false: такой уже есть
onDuty.size();                              // 1; с двумя строками было бы 2

Map<DutyName, Integer> openByDuty = new HashMap<>();
openByDuty.put(DutyName.of("duty-1"), 3);
openByDuty.get(DutyName.of("DUTY-1"));      // 3: ключ найден

hashCode — число, которое объект считает из своих полей. HashSet ищет элемент так: сначала по hashCode выбирает «ящик», потом внутри ящика сравнивает equals. Поэтому у Java есть договор: равные по equals объекты обязаны иметь равный hashCode. Запись выполняет его сама.

⚠️ В обычном классе легко переопределить equals и забыть hashCode. Тогда два «равных» объекта попадут в разные ящики, и HashSet сохранит оба. Пишешь объект-значение классом — переопределяй оба метода вместе.

Почему не сравнивать строки через equalsIgnoreCase? Пробелов он не снимет, а в HashSet и ключ Map его не подставишь: там сравнивают equals и hashCode. Привести один раз дешевле, чем помнить о регистре и пробелах в каждом сравнении и каждом запросе.

Где приводить в журнале#

В этой статье журнал не переделываем: DutyRoster и Notifier по-прежнему работают со строкой. Будь DutyName в деле, приводить пришлось бы на обоих концах — и при записи, и при поиске. Журнал хранит происшествия в базе (статья 3б), и искать их пришлось бы тем же видом, каким записывали: по DutyName.of(raw).value(), а не по raw, как пришло.

Хранить в столбце сам объект, а не строку, умеет преобразователь (конвертер). До него серия ещё дойдёт.

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


Часть 2. 🔴 StopWord: секрет не печатается

toString() записи выдаёт всё#

Запись сама пишет toString(): имя класса, имена полей и их значения. Удобно, пока значения не секретны:

DutyName.of("duty-1").toString();     // "DutyName[value=duty-1]"

Точный вид этой строки документация Java не обещает — разбирать её в коде нельзя.

Теперь вспомни настройки игры из статьи 2: GameDayProperties(Duration length, String stopWord, int attackers). Там мы печатали поля по одному и стоп-слово не трогали. Но хватит одной небрежной строки:

System.out.println("настройки: " + gameDay);
// настройки: GameDayProperties[length=PT3H, stopWord=red-fox, attackers=2]

GameDayProperties в этой статье не трогаем: StopWord ниже показывает приём на отдельном типе.

toString() зовут неявно: склейка строк через +, печать, лог, текст исключения. Стоп-слово ушло в лог. А лог читают многие, хранят долго и копируют в общий сбор логов: остановить игру теперь может любой, кто видит логи стенда.

В рабочем коде в лог пишут не System.out, а логгером, как в статье 4: logger.info("настройки: {}", gameDay). На место {} логгер подставит строковое значение объекта, тот же toString(). Стоп-слово уйдёт в лог точно так же.

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

StopWord: значение — только по имени метода

public record StopWord(String value) {

    public StopWord {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("стоп-слово не задано");
        }
        if (value.contains("${")) {                              // ловушка из статьи 2 — теперь в самом типе
            throw new IllegalArgumentException("стоп-слово не задано: переменная окружения не подставилась");
        }
        if (!value.equals(value.strip())) {                       // мимо of с пробелами не пройти
            throw new IllegalArgumentException("стоп-слово с пробелами по краям — создавай через StopWord.of");
        }
    }

    public static StopWord of(String raw) {
        if (raw == null) {
            throw new IllegalArgumentException("стоп-слово не задано");
        }
        return new StopWord(raw.strip());
    }

    @Override
    public String toString() {                                    // своё вместо того, что пишет запись
        return "StopWord[скрыто]";
    }
}

🔑 Маска в типе работает и внутри других объектов. Запись, которая держит StopWord, напечатает stopWord=StopWord[скрыто]. Спрятал один раз — и не нужно помнить об этом в каждой строке с +.

Бывает и обратное переопределение — у ключей и кодов, которые не секрет: toString() возвращает само значение, чтобы лог показывал ключ так, как его пишут люди, — GD-130, а не TaskKey[number=130].

Где ещё утекает#

toString() закрывает не всё. Вот три места, о которых помнят отдельно.

Текст исключения. "неверное стоп-слово: " + said — сказанное слово может оказаться настоящим с опечаткой, а текст исключения попадёт в лог. В тексте — причина, а не значение. Адрес почты в ошибку не вставляют; ответ чужого сервера целиком — тоже: в нём бывает адрес. А то, что не секрет и не личные данные, вставлять можно и нужно: оно помогает починить. TaskKey.parse из статьи 0 честно повторяет пришедший ключ — ключ задачи не секрет.

Объекты, которые печатают всё. Запись, у которой среди полей секрет или личное, выдаст его своим toString() — как GameDayProperties выше. Такую запись целиком в лог не выводят. А иногда запись не годится вовсе: если объект держит, скажем, список адресов, запись напечатала бы их и отдала бы наружу методом чтения. Тогда пишут обычный класс и сами решают, что он показывает.

JSON. Jackson переводит запись в JSON по полям, методами чтения, а не через toString(). Положишь StopWord в запись, которую приложение отдаёт наружу JSON-ом, — Jackson возьмёт значение методом чтения value(). Стоп-слово окажется в ответе, а секретам в ответах не место вообще.

Бывает и частичная маска: показать кусок значения, чтобы человек понял, о чём речь, — i***@example.com. Её пишут отдельным типом тем же приёмом: значение внутри, наружу — только маска.


Часть 3. AttackOutcome: чужой код — в свой исход

Три написания одного исхода#

Атаки в игровой день тимлид запускает скриптами. Чем кончилась атака, скрипт сообщает кодом выхода, как в статье 6. У тимлида заведено: 0 — атака удалась, 1 — стенд отбился. А ещё bash сам вернёт 127, если скрипт не нашёлся, и число больше 128, если скрипт прервали — скажем, нажали Ctrl+C.

Как число доходит до журнала, здесь неважно: его вносит тимлид или передаёт обёртка вокруг скрипта. Журнал получает int.

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

где пример кто решает
имя константы в Java HIT мы, в коде
наш код — для базы и JSON hit мы, договор с базой и клиентами
чужой код 0 автор скрипта

Перечисление со своим кодом ты знаешь по Area из статьи 0. Новое — разбор чужого:

public enum AttackOutcome {
    HIT("hit"), REPELLED("repelled"), SCRIPT_FAILED("script-failed");

    // поле code, конструктор и code() — как у Area в статье 0

    public static AttackOutcome fromExitCode(int exitCode) {
        return switch (exitCode) {              // switch-выражение: ветка сразу даёт значение
            case 0 -> HIT;
            case 1 -> REPELLED;
            default -> SCRIPT_FAILED;           // всё прочее: 2, 127, 130…
        };
    }
}

Это switch-выражение из статьи 7 (в Java оно с версии 14). Новое здесь — default. Ответ нужен на любое число, поэтому он обязателен: значений у int не перечесть, и без него код не скомпилируется. В статье 7 switch шёл по закрытому набору типов — там наоборот: default прячет забытые случаи.

Незнакомое — в самый осторожный исход

Почему default — SCRIPT_FAILED, а не исключение? Скрипт чужой, вернуть он может что угодно, и падать на каждом новом коде журналу незачем. А «скрипт сломался» — самый осторожный вывод: мы не утверждаем ни что стенд упал, ни что он устоял.

Это выбор журнала. Где неверная догадка стоит дорого, незнакомый код делают ошибкой: скажем, когда от исхода зависит, отправлять ли письмо ещё раз. Что делать с незнакомым, в рабочем проекте всегда сказано в задаче — как и с Area в статье 0. Бывает и так, что своего кода у перечисления нет: в базу пишут имя константы, и потому её не переименовывают. Оба способа и чем ломается каждый — дальше в серии.

Сравни с Area.fromCode из статьи 0. Там код свой, и разбор строг: Backend — да, backend — нет. Свой код проверяют строго, а чужой переводят в свой — один раз, на входе. Дальше по коду ходит только AttackOutcome, и смена скрипта затронет один метод.

⚠️ Не по номеру константы#

Соблазн — покороче: AttackOutcome.values()[exitCode], «константа номер 0, номер 1». Переставишь константы местами — смысл тихо поменяется. А код 127 бросит ArrayIndexOutOfBoundsException посреди игры. Номер по порядку (ordinal()) по той же причине не кладут и в базу; как хранить перечисление кодом — дальше в серии.


Часть 4. Тесты объекта-значения

Что проверить, следует из свойств части 1:

свойство случаи
приведение разные написания — один объект: equals и hashCode
проверки на каждое правило — строка с нарушением; границы: 50 знаков годно, 51 — нет
null отдельный тест: @ValueSource его не примет (статья 0) — или @NullSource
обход фабрики new с неприведённым значением — ошибка
секрет toString() и текст ошибки — без значения

Здесь — по одной-две проверки на приём, а не полный набор (статья 0).

В тестах объектов-значений встретишь ещё два источника данных: @NullSource — подать null ещё одним запуском рядом с @ValueSource; @EnumSource — запустить тест по всем константам перечисления. @MethodSource из статьи 7 берут, когда строки удобнее построить кодом: имя ровно в 51 знак — "d".repeat(51), руками такое без ошибки не набрать.

Главная строка — «разные написания — один объект»:

@ParameterizedTest(name = "{0} — это duty-1")
@ValueSource(strings = {"duty-1", " duty-1 ", "Duty-1", "DUTY-1", "\tduty-1\n", "\u2003duty-1"})
void spellingsAreOneName(String raw) {
    DutyName name = DutyName.of(raw);
    assertThat(name).isEqualTo(DutyName.of("duty-1"));             // равны
    assertThat(name).hasSameHashCodeAs(DutyName.of("duty-1"));     // и для HashSet тоже
    assertThat(name.value()).isEqualTo("duty-1");                  // внутри — приведённое
}

\u2003 — знак по номеру в Unicode, здесь широкий пробел; \t и \n — табуляция и перевод строки. Именно строка с \u2003 покраснеет, если кто-то заменит strip() на trim(). hasSameHashCodeAs — проверка AssertJ «одинаковый hashCode».

Негодные — по строке на правило:

@ParameterizedTest(name = "{0} — ошибка")
@ValueSource(strings = {"", "   ", "duty 1", "1-duty", "duty_1", "дежурный-1", "\u00A0duty-1"})
void rejectsBadNames(String raw) {
    assertThatThrownBy(() -> DutyName.of(raw))
            .isInstanceOf(IllegalArgumentException.class);
}

Секрет проверяют там, где он мог бы утечь, — например, внутри другой записи:

@Test
void hidesValueInsideOtherRecords() {
    record Game(String title, StopWord stopWord) {}                      // запись прямо в методе
    Game game = new Game("игровой день", StopWord.of("red-fox"));
    assertThat(game.toString()).doesNotContain("red-fox");
}

Запись можно объявить прямо внутри метода — так удобно для одного теста. Для текстов ошибок есть hasMessageNotContaining("red-fox"): «в тексте этого нет».

Перечисление — таблицей. Текст HIT JUnit сам превратит в константу по имени, как превращал 130 в число в статье 0:

@ParameterizedTest(name = "код выхода {0} → {1}")
@CsvSource(delimiter = '|', textBlock = """
        0   | HIT
        1   | REPELLED
        127 | SCRIPT_FAILED
        -1  | SCRIPT_FAILED
        """)
void readsExitCode(int exitCode, AttackOutcome expected) {
    assertThat(AttackOutcome.fromExitCode(exitCode)).isEqualTo(expected);
}

Коды выхода — от 0 до 255, но int шире; строка -1 проверяет, что и мусор не уронит разбор.

💡 Ветку value == null в конструкторе DutyName через of не пройти: of отвергнет null раньше. Нужна строка new DutyName(null) — как new TaskKey(0) в статье 0. Без неё JaCoCo покажет условие жёлтым; в учебном примере так и будет — там только приёмы.


Проверь себя#

Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.

  1. Чем объект-значение отличается от сущности? Объясни на двух DutyName «duty-1» и двух происшествиях с одним заголовком.
  2. Почему в DutyName.of сначала приводят, а потом проверяют? Что получил бы duty-1 при обратном порядке?
  3. Чем strip() отличается от trim()? Какой пробел не снимает ни тот, ни другой — и что с ним сделает DutyName?
  4. Что вернёт "KIRA".toLowerCase() на машине с турецкой локалью? Чем это кончилось бы для DutyName.of без Locale.ROOT — и почему с ним всё в порядке?
  5. Компактный конструктор DutyName ничего не приводит. Почему new DutyName(" Duty-1 ") всё равно не создастся?
  6. Почему HashSet из DutyName.of("duty-1") и DutyName.of("DUTY-1") хранит один элемент? Что, кроме equals, ему для этого нужно?
  7. Стоп-слово спрятано переопределённым toString(). Где оно всё ещё может утечь?
  8. Скрипт атаки вернул 137. Что запишет журнал, почему не исключение — и почему нельзя values()[exitCode]?

Что дальше#

Дальше в серии — данные: как положить перечисление в столбец базы кодом, подробности атаки — JSON-ом в столбце, выборки по времени и индексы под них.

Первоисточники#


Пример целиком — с импортами#

Учебный пример. Запускай в песочнице game-day — той, что осталась после статьи 7. Ни Docker, ни база, ни веб самим объектам-значениям не нужны, но прогон ./mvnw verify идёт по всей песочнице, а её тесты с базой ждут запущенный Docker, как в статье 3а. Новые классы — на чистой Java, тестам хватает spring-boot-starter-test (JUnit, AssertJ), который положил Initializr: в pom.xml ничего добавлять не надо. Java-код песочницы — без изменений.

Шесть файлов ниже. StopWord — настройка игры, поэтому лежит в example.gameday, рядом с GameDayProperties. DutyName и AttackOutcome — то, с чем работает журнал, они в example.gameday.journal, рядом с DutyRoster. Тесты — в тех же пакетах под src/test/java. Запуск: один класс — треугольником в IDE или ./mvnw test -Dtest=DutyNameTest; весь ./mvnw test прогонит и тесты песочницы.

// src/main/java/example/gameday/journal/DutyName.java
package example.gameday.journal;

import java.util.Locale;

public record DutyName(String value) {

    private static final int MAX_LENGTH = 50;

    public DutyName {
        if (value == null || value.isEmpty()) {
            throw new IllegalArgumentException("имя дежурного не указано");
        }
        if (value.length() > MAX_LENGTH) {
            throw new IllegalArgumentException("имя дежурного длиннее " + MAX_LENGTH + " знаков");
        }
        if (!value.matches("[a-z][a-z0-9-]*")) {
            throw new IllegalArgumentException(
                    "имя дежурного: латинские строчные буквы, цифры и дефис, первой — буква; создавай через DutyName.of");
        }
    }

    public static DutyName of(String raw) {
        if (raw == null) {
            throw new IllegalArgumentException("имя дежурного не указано");
        }
        return new DutyName(raw.strip().toLowerCase(Locale.ROOT));
    }
}
// src/main/java/example/gameday/StopWord.java
package example.gameday;

public record StopWord(String value) {

    public StopWord {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("стоп-слово не задано");
        }
        if (value.contains("${")) {
            throw new IllegalArgumentException("стоп-слово не задано: переменная окружения не подставилась");
        }
        if (!value.equals(value.strip())) {
            throw new IllegalArgumentException("стоп-слово с пробелами по краям — создавай через StopWord.of");
        }
    }

    public static StopWord of(String raw) {
        if (raw == null) {
            throw new IllegalArgumentException("стоп-слово не задано");
        }
        return new StopWord(raw.strip());
    }

    @Override
    public String toString() {
        return "StopWord[скрыто]";
    }
}
// src/main/java/example/gameday/journal/AttackOutcome.java
package example.gameday.journal;

public enum AttackOutcome {
    HIT("hit"), REPELLED("repelled"), SCRIPT_FAILED("script-failed");

    private final String code;

    AttackOutcome(String code) {
        this.code = code;
    }

    public String code() {
        return code;
    }

    public static AttackOutcome fromExitCode(int exitCode) {
        return switch (exitCode) {
            case 0 -> HIT;
            case 1 -> REPELLED;
            default -> SCRIPT_FAILED;
        };
    }
}
// src/test/java/example/gameday/journal/DutyNameTest.java
package example.gameday.journal;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class DutyNameTest {

    @ParameterizedTest(name = "{0} — это duty-1")
    @ValueSource(strings = {"duty-1", " duty-1 ", "Duty-1", "DUTY-1", "\tduty-1\n", "\u2003duty-1"})
    void spellingsAreOneName(String raw) {
        DutyName name = DutyName.of(raw);
        assertThat(name).isEqualTo(DutyName.of("duty-1"));
        assertThat(name).hasSameHashCodeAs(DutyName.of("duty-1"));
        assertThat(name.value()).isEqualTo("duty-1");
    }

    @ParameterizedTest(name = "{0} — ошибка")
    @ValueSource(strings = {"", "   ", "duty 1", "1-duty", "duty_1", "дежурный-1", "\u00A0duty-1"})
    void rejectsBadNames(String raw) {
        assertThatThrownBy(() -> DutyName.of(raw))
                .isInstanceOf(IllegalArgumentException.class);
    }
}
// src/test/java/example/gameday/StopWordTest.java
package example.gameday;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class StopWordTest {

    @Test
    void hidesValueInsideOtherRecords() {
        record Game(String title, StopWord stopWord) {}
        Game game = new Game("игровой день", StopWord.of("red-fox"));
        assertThat(game.toString()).doesNotContain("red-fox");
    }

    @Test
    void rejectsUnresolvedVariable() {
        assertThatThrownBy(() -> StopWord.of("${GAME_DAY_STOP_WORD}"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("не подставилась");
    }
}
// src/test/java/example/gameday/journal/AttackOutcomeTest.java
package example.gameday.journal;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.assertj.core.api.Assertions.assertThat;

class AttackOutcomeTest {

    @ParameterizedTest(name = "код выхода {0} → {1}")
    @CsvSource(delimiter = '|', textBlock = """
            0   | HIT
            1   | REPELLED
            2   | SCRIPT_FAILED
            127 | SCRIPT_FAILED
            130 | SCRIPT_FAILED
            -1  | SCRIPT_FAILED
            """)
    void readsExitCode(int exitCode, AttackOutcome expected) {
        assertThat(AttackOutcome.fromExitCode(exitCode)).isEqualTo(expected);
    }
}

← Все статьи

Попробовать руками

В кузницах Hammerhall — задачи по Java, которые проверяет сервер: решаешь в своей IDE, отправляешь одной командой, проверка запускает твои тесты и закрытые. Сложность растёт вместе с решённым, первые задачи бесплатны.