diff --git a/Makefile b/Makefile index feba83a..bae24ab 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ DATA_FILES := \ examples/mdview/SAMPLE.MD .PHONY: all tools lib tests examples check clean sdcc floppy \ - size-check size-baseline $(TESTS) $(APPS) + size-check size-baseline host-tests $(TESTS) $(APPS) all: tools lib tests examples @@ -73,6 +73,14 @@ floppy: tests examples tests/seek/big.txt @echo "Floppy ready: $(FLOPPY_IMG)" @echo "Run: cd $(MAME_DIR) && ./run_mame.sh" +# Модульные тесты под ucsim_z80. Обвязка — testkit/, сами наборы лежат +# рядом с кодом, который проверяют. MAME не нужна, идут за секунды; +# ucsim идёт в комплекте нашего SDCC. +HOST_TEST_DIRS := testkit applications/PoP/roomtest/tests-host + +host-tests: + @for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done + # Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv. size-check: python3 toolchain/size_check.py diff --git a/applications/PoP/docs/README.md b/applications/PoP/docs/README.md index c768673..b973442 100644 --- a/applications/PoP/docs/README.md +++ b/applications/PoP/docs/README.md @@ -11,6 +11,7 @@ | [`levels_plan.md`](levels_plan.md) | Следующий этап: уровни 2+, второй тайлсет, читы SDLPoP | | [`layout_plan_v2.md`](layout_plan_v2.md) | Раскладка кода по окнам/банкам/страницам + замеры скорости отрисовки | | [`room_model_plan.md`](room_model_plan.md) | `kid_room ≠ drawn_room` (straddle): сделан S1, остальное впереди | +| [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии из `bug_closed.md`, дифф против SDLPoP | | [`ideas_backlog.md`](ideas_backlog.md) | Осознанно отложенные гипотезы (мышь, PRNG) | | [`prng_alternatives.md`](prng_alternatives.md) | Запасные генераторы, если упрёмся в бюджет кадра | diff --git a/applications/PoP/docs/host_tests_plan.md b/applications/PoP/docs/host_tests_plan.md new file mode 100644 index 0000000..1f3f101 --- /dev/null +++ b/applications/PoP/docs/host_tests_plan.md @@ -0,0 +1,142 @@ +# План: модульные тесты движка roomtest под ucsim_z80 + +Обвязка общая — `testkit/` в корне репозитория (там же объяснение, почему +прогон именно под z80, а не хостовым gcc). Наборы лежат в +`../roomtest/tests-host/`. + +Задача плана: **перестать чинить одно и то же дважды**. За два прогона +уровня 1 (2026-08-03) закрыто восемь корней, и часть из них — регрессии +соседней механики, внесённые предыдущим фиксом. Такие вещи ловятся тестом +за миллисекунды, а в MAME — часами ручного вождения Кида. + +## Что уже есть + +| набор | модуль | статус | +|-------|--------|--------| +| `t_geom` | `pop_geom.c` | 39 проверок, включая побитовую сверку asm-LCG с 32-битной формулой на 128 шагах | + +`pop_geom.c` выбран первым, потому что не тянет ничего за собой. Дальше +начинаются швы. + +## Фаза 1. Два шва (блокирует всё остальное) + +### 1.1 Доступ к странице уровня + +`pop_level.c` ходит по абсолютным адресам: `gfx_w0_map(lvl_page)`, затем +разыменование `(uint8_t *)(LVL_DATA_OFF + …)`. В тестовом бинаре это +обращение в никуда. + +Нужен макрос `W0PTR(off)`: + +- на таргете — `((uint8_t *)(off))`, то есть ровно как сейчас; +- в тестах — смещение в обычном массиве-подложке. + +Правка механическая и компайл-таймовая, на размер продукта не влияет. +Заодно снимает магию абсолютных констант из тела функций. + +Тестовая подложка должна уметь: загрузить синтетическую комнату (10×3 +байта fg + mod) и целый синтетический уровень на 24 комнаты, чтобы +проверять межкомнатные вещи. + +### 1.2 Журналирующий рендерер + +Вместо `pop_bg.c`/`pop_gdraw.c` в тестовый бинарь линкуется модуль с теми +же прототипами, который **не рисует, а записывает вызовы**: какой тайл +помечен к перерисовке, каким кодом, с каким счётчиком страниц. + +Это не обход проблемы, а самостоятельная ценность: `BUG-GATE-ANIM-1` был +ровно такой формы — ворота меняли состояние, но пометка на перерисовку не +ставилась. Проверяется утверждением, а не глазами. + +Минимум, который надо перехватывать: `pop_set_redraw`, +`pop_set_redraw_above`, `pop_loose_mob_spawn`, `pop_gate_redraw`. + +## Фаза 2. Регрессионные кейсы из `bug_closed.md` + +После швов `bug_closed.md` превращается в готовую спецификацию: у каждой +записи есть симптом и ожидаемое поведение. Кандидаты, которые ловятся +логикой (без отрисовки и без железа): + +| баг | что закрепить тестом | +|-----|----------------------| +| `BUG-LVLSTATE-1` | запись тайла переживает выход из комнаты | +| `BUG-RESPAWN-1` | рестарт уровня возвращает ВСЕ тайлы из эталонной копии | +| `BUG-RESPAWN-2` | рестарт возвращает таблицу стражей; убитый снова жив | +| `BUG-GATE-ANIM-1` | смена состояния ворот ставит пометку `POP_RD_GATE`; закрывающиеся — на обе страницы, открывающиеся — на одну | +| `BUG-COLL-1` | `check_collisions` сканирует ряд справа налево и выбирает НАИМЕНЬШУЮ занятую колонку | +| `BUG-STANDUP-1` | `bumped_floor` у трупа (`alive >= 0`) только выравнивает и не трогает последовательность | +| `BUG-DEATH-1` | `hitp_curr == 0` при живом Киде переводит его в «умирает» ровно один раз | +| `BUG-LOOSE-2` | кусок, начавший падать, долетает и кладёт щебень ПОСЛЕ смены комнаты | +| `BUG-CEIL-2` | loose-плита ряда 2 верхнего соседа живёт как «ряд −1» | + +`BUG-LOOSE-2` стоит взять первым: он до сих пор помечен в `bug_list.md` +как непроверенный именно потому, что гонку «уйти из комнаты раньше, чем +долетит плита» через мост MAME воспроизвести не удалось. На уровне логики +это несколько строк — заспавнить кусок, сменить комнату, тикать до +приземления, проверить щебень в данных уровня. + +Не берутся (нужна картинка либо железо): `BUG-DOOR-CLIP`, `BUG-CEIL-1`, +`BUG-CEIL-3`, `BUG-OCCL-1`, `BUG-KBD-4`, `BUG-3`. + +## Фаза 3. Сценарные тесты + +Сейчас шаг кадра размазан по `main()` в `roomtest.c`. Вынести его в +`pop_frame_tick()` — тогда появляются тесты вида «поставить Кида в +известное состояние, скормить N тиков ввода, проверить итог»: + +``` +дано: комната 5, Кид на кнопке (0,6) +когда: 40 тиков без ввода +тогда: комната по-прежнему 5, Кид на полу ряда 2 +``` + +Это тот самый BUG-STANDUP-1, который ловили потиковой трассой в MAME. + +Ввод подаётся не через `kbd_raw_down()`, а через подменяемый источник — +это же даст возможность проигрывать записанные сценарии. + +## Фаза 4. Дифф против SDLPoP + +`SDLPoP/src/` лежит в дереве, собирается на хосте, и там **уже стоят +отладочные трассы** (`DBG kidobj tilepos=…` в seg008, `DBG make_loose_fall` +в seg007). Значит эталон можно заставить печатать потиковую трассу +автоматически. + +Схема: общий формат скрипта ввода и общий формат трассы (тик, frame, x, y, +room, col, row, action, alive, hp). Гоняем обе реализации, диффим, первое +расхождение — номер тика и есть баг. Это ровно то, что делалось руками +через MAME, только бесплатно и повторяемо: `BUG-COLL-1` и `BUG-STANDUP-1` +такой дифф нашёл бы за секунды. + +**Лицензия.** SDLPoP — GPLv3, правило подпроекта — читать и переписывать, +не линковать. Оракул обязан быть **отдельным исполняемым файлом**, +общающимся через файлы трасс, а не слинкованным с нашим кодом в один +бинарь. + +Требование к детерминизму: сиды PRNG должны совпадать. У нас +`POP_PRANDOM_EXACT` даёт ту же последовательность, что в оригинале, и это +уже закреплено тестом `geom_lcg_matches_reference`. + +## Чего эти тесты не поймают + +Отрисовку, банки и W-окна, тайминги, клавиатуру — за этим остаётся MAME. + +И отдельный класс: **баги порядка вызовов**. Свежий пример — окно +fore-клипа (`pop_fore_set_clip`) одно на всех, и его ставит каждый, кто +рисует персонажа; когда порядок «Кид/страж» стал переменным, окно осталось +стражьим, и Кид нарисовался поверх передних столбов. Это не «функция +вернула не то», unit-тест такое не видит. Ловится инвариантом, +вкомпилированным в safe-сборку: «в момент `pop_fore_over_kid` окно клипа +принадлежит Киду». Отдельный инструмент, дополняющий тесты. + +## Порядок работ + +1. Шов `W0PTR` + подложка уровня. +2. Журналирующий рендерер. +3. `BUG-LOOSE-2` — закрыть висящий вопрос. +4. Остальные кейсы из таблицы фазы 2. +5. `pop_frame_tick()` + сценарные тесты. +6. Дифф против SDLPoP. + +Правило приёмки: тест не считается написанным, пока не проверен мутацией — +сломать проверяемое место и убедиться, что набор краснеет. diff --git a/applications/PoP/roomtest/CLAUDE.md b/applications/PoP/roomtest/CLAUDE.md index e311df7..f46d999 100644 --- a/applications/PoP/roomtest/CLAUDE.md +++ b/applications/PoP/roomtest/CLAUDE.md @@ -21,8 +21,13 @@ ``` make # собрать roomtest.exe (упаковав ассеты через toolchain/) make run # exe + EXTRA_DATA на дискету + запуск MAME (см. корневой док) +make -C tests-host # модульные тесты движка под ucsim_z80 (секунды, без MAME) ``` +Логику, которую можно проверить без железа, покрывать в `tests-host/` +(обвязка — `testkit/`, там же почему прогон именно под z80). MAME остаётся +для отрисовки, банков, таймингов и клавиатуры. + `MEMORY=small`, `--gfx 256`. Ассеты (`pop_env0..4.atl`, `pop_wall.atl`, `pop_fore.atl`, `kid0..27.atl`, `kid.pal`) генерируются python-скриптами `../toolchain/` — Makefile дёргает их сам при изменении. Данные комнаты — diff --git a/applications/PoP/roomtest/tests-host/.gitignore b/applications/PoP/roomtest/tests-host/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/applications/PoP/roomtest/tests-host/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/applications/PoP/roomtest/tests-host/Makefile b/applications/PoP/roomtest/tests-host/Makefile new file mode 100644 index 0000000..bf39a89 --- /dev/null +++ b/applications/PoP/roomtest/tests-host/Makefile @@ -0,0 +1,8 @@ +# Модульные тесты движка roomtest под ucsim_z80. Обвязка общая (testkit/), +# здесь — только сами наборы и список модулей, которые в них линкуются. +TESTKIT := $(abspath $(CURDIR)/../../../../testkit) +ENGINE_DIR := $(abspath $(CURDIR)/..) + +OBJS_geom := build/eng_pop_geom.rel + +include $(TESTKIT)/host-tests.mk diff --git a/applications/PoP/roomtest/tests-host/README.md b/applications/PoP/roomtest/tests-host/README.md new file mode 100644 index 0000000..a42fec7 --- /dev/null +++ b/applications/PoP/roomtest/tests-host/README.md @@ -0,0 +1,46 @@ +# tests-host — модульные тесты движка roomtest + +Прогоняются под `ucsim_z80`, обвязка общая — `testkit/` (там же объяснение, +почему под z80, а не хостовым gcc, и грабли). + +``` +make host-tests # из корня, вместе с остальными +make -C applications/PoP/roomtest/tests-host # только эти +make -C applications/PoP/roomtest/tests-host t_geom +``` + +## Что покрыто + +| набор | модуль | что проверяет | +|-------|--------|---------------| +| `t_geom` | `pop_geom.c` | геометрия комнаты (`x_bump`, `y_land`, `y_to_row`) и PRNG оригинала | + +Главный тест в `t_geom` — `geom_lcg_matches_reference`. LCG оригинала +(`s = s*214013 + 2531011`) написан в `pop_geom.c` на ассемблере по схеме +Горнера ради обхода `__mullong`, и заявка «бит-в-бит как в SDLPoP» до +появления теста держалась только на комментарии. Тест сверяет рукописный +asm с наивной 32-битной формулой на 128 шагах — и по возвращаемому +значению, и по обеим половинам сида. + +## Что нужно, чтобы двинуться дальше + +Следующие на очереди модули (`pop_trob`, `pop_level`, `pop_map`, логика +падающих плит) упираются в два шва, и оба затрагивают продукт: + +1. **Доступ к странице уровня.** `pop_level.c` ходит по абсолютным адресам + (`(uint8_t *)(LVL_DATA_OFF + …)` после `gfx_w0_map`) — в тестах это + обращение в никуда. Нужен макрос вида `W0PTR(off)`: на таргете как + сейчас, в тестах — смещение в обычном массиве. +2. **Журналирующий рендерер** вместо `pop_bg`/`pop_gdraw` — не заглушка, а + запись вызовов, чтобы проверять «какой тайл помечен к перерисовке, каким + кодом, с каким счётчиком страниц». `BUG-GATE-ANIM-1` был ровно такой + формы. + +После этого `bug_closed.md` превращается в готовый список регрессионных +кейсов: у каждой записи там есть симптом и ожидаемое поведение. + +Отдельно стоит завести тест на `BUG-LOOSE-2` — он до сих пор помечен в +`bug_list.md` как непроверенный именно потому, что гонку «уйти из комнаты +раньше, чем долетит плита» через мост MAME воспроизвести не удалось. На +уровне логики это несколько строк: заспавнить кусок, сменить комнату, +тикать до приземления, проверить щебень в данных уровня. diff --git a/applications/PoP/roomtest/tests-host/t_geom.c b/applications/PoP/roomtest/tests-host/t_geom.c new file mode 100644 index 0000000..00b247b --- /dev/null +++ b/applications/PoP/roomtest/tests-host/t_geom.c @@ -0,0 +1,146 @@ +/* + * t_geom.c — тесты pop_geom.c (геометрия комнаты + PRNG оригинала). + * + * Модуль выбран первым, потому что не тянет за собой ничего: ни графики, + * ни данных уровня, ни клавиатуры — только свой заголовок. + * + * Главный тест здесь — geom_lcg_matches_reference. В pop_geom.c LCG + * оригинала (s = s*214013 + 2531011) написан на ассемблере по схеме + * Горнера ради обхода __mullong, и заявка «бит-в-бит как в SDLPoP» до сих + * пор держалась только на комментарии. Тест сверяет рукописный asm с + * наивной 32-битной формулой на каждом шаге — и по возвращаемому значению, + * и по обеим половинам сида. + */ +#include "tcheck.h" +#include "pop_geom.h" + +/* ---- геометрия ------------------------------------------------------ */ + +TC_TEST(geom_xbump_layout) +{ + /* Шаг колонок равномерный и равен TILE_SIZEX — на этом стоит вся + * арифметика колонок (x_bump[col + FIRST_ONSCREEN_COLUMN]). */ + uint8_t i; + for (i = 1; i < 20; i++) + TC_EQ(pop_x_bump[i] - pop_x_bump[i - 1], TILE_SIZEX); + + /* Центр тайла колонки 0 — та самая величина, которую кладут в Char.x + * do_startpos и pos_guards (seg003). */ + TC_EQ(pop_x_bump[0 + FIRST_ONSCREEN_COLUMN] + TILE_SIZEX, 72); + TC_EQ(pop_x_bump[9 + FIRST_ONSCREEN_COLUMN] + TILE_SIZEX, 198); +} + +TC_TEST(geom_yland_rows) +{ + /* y_land[row + 1]; [0] — ряд «над комнатой». */ + TC_EQ(pop_y_land[0], -8); + TC_EQ(pop_y_land[1], 55); + TC_EQ(pop_y_land[2], 118); + TC_EQ(pop_y_land[3], 181); + TC_EQ(pop_y_land[4], 244); + /* Шаг между рядами — TILE_SIZEY. */ + TC_EQ(pop_y_land[2] - pop_y_land[1], TILE_SIZEY); + TC_EQ(pop_y_land[3] - pop_y_land[2], TILE_SIZEY); +} + +TC_TEST(geom_y_to_row) +{ + /* Пол каждого ряда должен давать номер этого ряда. */ + TC_EQ(pop_y_to_row(pop_y_land[1]), 0); + TC_EQ(pop_y_to_row(pop_y_land[2]), 1); + TC_EQ(pop_y_to_row(pop_y_land[3]), 2); + /* Ряд над комнатой. */ + TC_EQ(pop_y_to_row(pop_y_land[0]), -1); + /* Оборот mod 4: ряд «под комнатой» сворачивается обратно в -1 — + * ровно на это опирается спавн падающего куска у нижней кромки. */ + TC_EQ(pop_y_to_row(pop_y_land[4]), -1); +} + +/* ---- PRNG: сверка asm-LCG с эталонной формулой ---------------------- */ + +#if POP_PRANDOM_EXACT + +#define LCG_ITERS 128 + +TC_TEST(geom_lcg_matches_reference) +{ + pop_rnd_t s; + unsigned long ref = 12345UL; /* произвольный ненулевой старт */ + uint16_t i, got, want; + + pop_prandom_set(s, 12345u); + + for (i = 0; i < LCG_ITERS; i++) { + got = pop_prandom(&s, 255u); + ref = ref * 214013UL + 2531011UL; + want = (uint16_t)((uint16_t)(ref >> 16) % 256u); + + /* Обрываемся на первом расхождении: иначе одна ошибка в умножении + * забила бы весь буфер отчёта однотипными строками. */ + if (got != want || + s.lo != (uint16_t)ref || + s.hi != (uint16_t)(ref >> 16)) { + TC_EQ(got, want); + TC_EQ(s.lo, (uint16_t)ref); + TC_EQ(s.hi, (uint16_t)(ref >> 16)); + break; + } + } + TC_EQ(i, LCG_ITERS); /* прошли всю дистанцию без расхождений */ +} + +TC_TEST(geom_lcg_seed_zero) +{ + /* Ноль — законный сид (кладку сеют номером комнаты + ряд + колонка, + * что вполне даёт 0). У LCG ноль не является неподвижной точкой — + * убеждаемся, что asm это воспроизводит. */ + pop_rnd_t s; + unsigned long ref = 0UL; + pop_prandom_set(s, 0u); + pop_prandom(&s, 255u); + ref = ref * 214013UL + 2531011UL; + TC_EQ(s.lo, (uint16_t)ref); + TC_EQ(s.hi, (uint16_t)(ref >> 16)); +} + +#endif /* POP_PRANDOM_EXACT */ + +TC_TEST(geom_prandom_range) +{ + /* Оба генератора обязаны укладываться в 0..maxv, в том числе когда + * maxv+1 НЕ степень двойки (там другая ветка pop_rnd_fit). */ + pop_rnd_t s; + uint16_t i, v, seen_hi = 0; + + uint16_t out_of_range = 0; + + pop_prandom_set(s, 1u); + for (i = 0; i < 200; i++) { + v = pop_prandom(&s, 4u); /* n = 5, не степень двойки */ + if (v > 4) out_of_range++; + if (v == 4) seen_hi = 1; + } + TC_EQ(out_of_range, 0); + TC_TRUE(seen_hi); /* верхняя граница достижима */ + + pop_prandom_set(s, 7u); + out_of_range = 0; + for (i = 0; i < 64; i++) { + v = pop_prandom(&s, 1u); /* n = 2, маска */ + if (v > 1) out_of_range++; + } + TC_EQ(out_of_range, 0); +} + +int main(void) +{ + TC_RUN(geom_xbump_layout); + TC_RUN(geom_yland_rows); + TC_RUN(geom_y_to_row); +#if POP_PRANDOM_EXACT + TC_RUN(geom_lcg_matches_reference); + TC_RUN(geom_lcg_seed_zero); +#endif + TC_RUN(geom_prandom_range); + return 0; +} diff --git a/docs/TODO.md b/docs/TODO.md index 6b694c9..8e94ac5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,6 +5,15 @@ ## Ближайшее +- [ ] **Модульные тесты libc и libbgi под ucsim_z80** — план: + `docs/host-tests-plan.md`. Обвязка уже готова и обкатана + (`testkit/`, первый потребитель — PoP/roomtest). Не срочно, но + обязательно: это основной продукт репозитория, а покрыт он сейчас + только интеграционными тестами в MAME. Начинать с `time/` и + `stdio/` (нулевые швы), затем геометрия `libbgi/common`, затем + фейковые ESTEX/BIOS в тестовом crt0 — они открывают `file/`, `io/`, + `conio/` и проверку гардов (`_fd_guard`). + Порядок реализации связки: **сначала цепочка irq, потом FPS-делитель поверх неё** (делитель — просто один слот цепи; так снимается EBUSY на сосуществование со своим тиком приложения). diff --git a/docs/host-tests-plan.md b/docs/host-tests-plan.md new file mode 100644 index 0000000..0cc50fd --- /dev/null +++ b/docs/host-tests-plan.md @@ -0,0 +1,129 @@ +# План: модульные тесты libc и libbgi под ucsim_z80 + +Статус: **не начато**, задача на будущее. Обвязка уже готова и обкатана — +`testkit/` (см. `testkit/README.md`); первый потребитель — +`applications/PoP/roomtest/tests-host/`. Этот документ — про то, как накрыть +тем же способом основной продукт репозитория. + +## Что это НЕ заменяет + +В `tests/` уже лежат 62 каталога — это **интеграционные** тесты: одна фича = +одна программа, которая пакуется на дискету и гоняется в MAME +(`docs/mame-autotest.md`). Они проверяют, что API работает на живой машине: +ESTEX, BIOS, диск, экран, тайминги. + +Модульные тесты их не отменяют, а дополняют с другой стороны: + +| | `tests/` (MAME) | `tests-host` (ucsim) | +|---|---|---| +| что проверяет | работает ли на машине | верна ли логика | +| граничные случаи | 1–2 на фичу | десятки, дёшево | +| время прогона | десятки секунд | миллисекунды | +| ловит | железо, тайминги, банки | арифметику, краевые условия, регрессии | + +Правило разделения то же, что уже записано для PoP: **что можно проверить +без железа — проверять в ucsim, MAME оставить железу.** + +## Три группы модулей + +### 1. Чистая логика — тестируется как есть, швов не нужно + +Здесь можно начинать в тот же день, когда задачу возьмут в работу. + +**libc:** + +- `time/` — `_tm_is_leap`, `_tm_mdays`, `_tm_month_days`, `_tm_year_days`, + `mktime`, `gmtime`, `localtime`, `asctime`, `ctime`. Классическая + календарная арифметика: високосные годы, границы месяцев, переходы через + год, круговой прогон `mktime(gmtime(t)) == t`. Идеальный первый набор — + много краевых случаев и ноль зависимостей. +- `stdio/` — `dec_print`, `hex8/16/32`, `_scanf_core`, `sscanf`. Формат и + разбор: ширина, знак, переполнение, мусор на входе. +- `string/`, `stdlib/` — `strlwr`, `strupr`, `max`, `min`. + +**libbgi/common** (115 модулей, почти всё mode-agnostic): + +- `_bgi_isqrt`, `_bgi_trig` — сверить с эталонной формулой на всём + диапазоне, ровно как сделано для LCG в `t_geom` у PoP; +- `_bgi_lineseg`, `_bgi_styled_line`, `_bgi_poly_edge`, `_bgi_arc_draw` — + геометрия и отсечение: линия целиком вне окна, по диагонали через угол, + вырожденная в точку; +- `_spr_ysort` — порядок сортировки спрайтов; +- `_bgi_hspan`, `_bgi_fill_span` — заливка: краевые span'ы, нулевая ширина. + +### 2. Нужны швы — но швы дешёвые + +**Фейковый ESTEX и BIOS прямо в тестовом crt0.** Оба вызываются через +`rst #0x10` и `rst #0x08`, то есть через фиксированные векторы в первых +байтах памяти. В тестовом бинаре эти адреса наши: можно положить туда +обработчик, который эмулирует крошечную файловую систему в ОЗУ и текстовый +экран в буфере. Это открывает: + +- `file/` (32 модуля) и `io/` (21) — `fopen`/`fread`/`fwrite`/`fseek`, + буферизация (`docs/file-buffering-design.md`), поведение на EOF и + ошибках, `errno`; +- `conio/` (38) — вывод в буфер вместо экрана, проверка атрибутов, + скроллинга, границ окна. + +Отдельно ценно: **гарды**, которые обязаны быть в обеих сборках. `_fd_guard` +(девятый `OPEN` вешает DSS) — это ровно тот случай, где нужен тест, а не +вера в комментарий: открыть восемь, убедиться, что девятый вернул ошибку и +не дошёл до ESTEX. + +**Кадровый буфер в ОЗУ для libbgi.** Если рисующие ядра умеют писать в +обычный буфер, а не только в видеопамять, растеризацию можно проверять +снимком: нарисовать фигуру, сравнить с эталонным массивом. Начинать с +маленьких (8×8, 16×16) — эталон читаемый прямо в исходнике. + +### 3. Только MAME + +Банки и W-окна, EMM, реальный ESTEX/DSS, клавиатурный трамплин и IM2, +CBL-звук (`cbl/`), мышь (`mouse/`), `mem/` (это банки и страницы, а не +куча), тайминги и бюджет кадра, ускоритель. + +## Обе сборки: fast и safe + +Библиотеки собираются в двух вариантах (`-D*_NOCHECK` вырезает +валидацию параметров). Наборы стоит гонять **против обоих**: + +- в safe — что валидация ловит мусорные аргументы и ставит `errno`; +- в fast — что вырезание валидации не поменяло поведение на корректных + входах. + +Это дешёвая параметризация Makefile (тот же набор, два `OBJS_*`), и она +пресекает целый класс расхождений между вариантами. + +## Фазы + +1. **Календарь и формат.** `time/`, `stdio/`. Нулевые швы, максимальная + плотность краевых случаев. Цель — обкатать поток работы на libc. +2. **Геометрия libbgi.** `common/`: isqrt, тригонометрия, отсечение линий, + рёбра полигонов. Тоже без швов. +3. **Фейковые ESTEX/BIOS в crt0.** Открывает `file/`, `io/`, `conio/` и + гарды. Самый крупный кусок работы и самая большая отдача. +4. **Растр libbgi по снимкам** — если окажется, что ядра можно нацелить на + буфер в ОЗУ без правок продукта; иначе отложить. +5. **Параметризация fast/safe** — после того, как наборов станет заметно. + +## Критерии + +- Тест не считается написанным, пока не проверен мутацией: сломать + проверяемое место, убедиться, что набор краснеет. Пустые тесты хуже + отсутствующих. +- Каждый закрытый баг libc/libbgi получает регрессионный кейс, если он + ловится без железа. +- `make host-tests` остаётся быстрым: если суммарно перевалит за несколько + секунд, делить на быстрый и полный прогон. + +## Организация + +Наборы кладутся рядом с кодом, обвязка общая: + +``` +libc/tests-host/ наборы по областям: t_time.c, t_stdio.c, … +libbgi/tests-host/ t_geom.c, t_raster.c, … +``` + +Каждому — `Makefile` на пять строк (`TESTKIT`, `ENGINE_DIR`, `OBJS_*`, +`include`), и добавить каталог в `HOST_TEST_DIRS` корневого `Makefile`. +Подробности — `testkit/README.md`. diff --git a/testkit/.gitignore b/testkit/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/testkit/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/testkit/Makefile b/testkit/Makefile new file mode 100644 index 0000000..c63d04b --- /dev/null +++ b/testkit/Makefile @@ -0,0 +1,5 @@ +# Самопроверка обвязки: t_selftest.c. Модулей под тестом здесь нет — +# проверяется, что бинарь собирается, исполняется под ucsim и что +# семантика действительно z80 (sizeof(int) == 2). +TESTKIT := $(CURDIR) +include $(TESTKIT)/host-tests.mk diff --git a/testkit/README.md b/testkit/README.md new file mode 100644 index 0000000..de3a8ea --- /dev/null +++ b/testkit/README.md @@ -0,0 +1,110 @@ +# testkit — модульные тесты под ucsim_z80 + +Обвязка для быстрых тестов plain-C кода: секунды вместо прогона в MAME, без +образа диска и без эмуляции всего Sprinter. + +Сама по себе к подпроектам не привязана — так можно тестировать и модули +libc, и логику приложений. Наборы тестов лежат **рядом с кодом, который они +проверяют**, а не здесь: + +| каталог | что проверяет | +|---------|---------------| +| `testkit/` | самопроверка обвязки (`t_selftest.c`) | +| `applications/PoP/roomtest/tests-host/` | движок PoP | + +``` +make host-tests # из корня: все каталоги с наборами +make -C testkit # только самопроверка обвязки +make -C <каталог> t_geom # один набор +``` + +## Почему под z80, а не хостовым gcc + +Первым порывом было собирать те же `.c` обычным gcc — но у SDCC z80 +`int` 16 бит, а у хоста 32, и это расходится **не в объявлениях, а в +выражениях**: integer promotion повышает операнды до `int` независимо от +того, объявлены они как `uint8_t` или `uint16_t`. + +```c +uint16_t a = 60000, b = 10000; +if (a + b > 65000) { … } // z80: 4464 > 65000 → false + // host: 70000 > 65000 → true +``` + +Перевод всего кода на `intN_t` эту разницу НЕ убирает. Убирает её только +исполнение с z80-семантикой. Отсюда `ucsim_z80` — он идёт в комплекте +нашего же SDCC (`third_party/sdcc/bin`), так что новых зависимостей нет. + +Побочные выгоды того же решения: + +- проверяется **кодогенерация SDCC**, а не только исходник (см. memory + `sdcc_z80_cmp_store_a_bug`); +- модули с inline-asm тестируются как есть — например `pop_geom.c`, где + LCG оригинала написан на ассемблере ради обхода `__mullong`. + +`t_selftest.c` начинается с `TC_EQ(sizeof(int), 2)` — если обвязка однажды +переедет на хостовый компилятор, это заметят сразу. + +## Как устроено + +| файл | роль | +|------|------| +| `crt0_ucsim.s` | голый z80-старт: SP, зануление `_DATA`/`_BSS`, `main`, `halt` | +| `tcheck.h/.c` | микро-фреймворк: итог кладётся в `tc_result` в ОЗУ | +| `run_ucsim.py` | гоняет ucsim, дампит `tc_result`, печатает отчёт | +| `host-tests.mk` | общие правила сборки и прогона | + +Вывода через `printf` нет: тестовый бинарь линкуется **без** Sprinter-libc +(там ESTEX, BIOS, файлы — в ucsim этого нет). Результат складывается в +структуру в ОЗУ, а раннер читает её командой `dump` после остановки по +`halt`. Через ucsim-simif сознательно не идём: номера его команд плавают +от версии к версии, а `halt` + `dump` работают везде. + +## Завести наборы в новом месте + +Создать каталог рядом с проверяемым кодом и положить туда `Makefile`: + +```make +TESTKIT := $(abspath $(CURDIR)/../../testkit) +ENGINE_DIR := $(abspath $(CURDIR)/..) # где лежат модули под тестом +OBJS_geom := build/eng_pop_geom.rel # что доложить в набор t_geom +include $(TESTKIT)/host-tests.mk +``` + +Добавить каталог в `HOST_TEST_DIRS` корневого `Makefile`. Сам набор — +`t_<имя>.c` с `main()`, который зовёт `TC_RUN`; wildcard подхватит его сам. + +```c +#include "tcheck.h" + +TC_TEST(что_проверяем) +{ + TC_EQ(получено, ожидание); + TC_TRUE(условие); +} + +int main(void) { TC_RUN(что_проверяем); return 0; } +``` + +## Грабли + +- **`.area _HEADER (ABS)` объявлять ровно один раз и первой.** Если + продублировать её в списке порядка областей, sdas заведёт `_HEADER0`, и + линкер положит стартовый код не по `0x0000` (уезжал за `_DATA` на + `0x830A`, и с PC=0 исполнялся мусор). +- **Файл грузить командой `file`, а не аргументом командной строки:** при + передаче аргументом ucsim успевает выполнить `run` до загрузки. +- **Константы SDCC сворачивает.** `TC_EQ` над константным выражением + проверит компилятор, а не код (`warning 110`). Входные данные для + арифметических тестов держать в `static volatile`. +- Если тест зациклился, раннер снимет его по таймауту (60 с по умолчанию) + и скажет об этом явно. + +## Чего эти тесты не поймают + +Банки и W-окна, тайминги, корректность блитов, поведение реальной +клавиатуры, отрисовку — для этого остаётся MAME (`docs/mame-autotest.md`). + +Отдельный класс — баги **порядка вызовов** между модулями: общее +глобальное состояние, затираемое тем, кто отработал последним. Такое ловит +не unit-тест, а инвариант, вкомпилированный в safe-сборку. diff --git a/testkit/crt0_ucsim.s b/testkit/crt0_ucsim.s new file mode 100644 index 0000000..822b04e --- /dev/null +++ b/testkit/crt0_ucsim.s @@ -0,0 +1,103 @@ +;; ---------------------------------------------------------------------- +;; crt0_ucsim.s — стартовый код для тестовых бинарей, исполняемых в ucsim. +;; +;; Зачем отдельный crt0, а не runtime/crt0*.s: те завязаны на Sprinter — +;; префикс ESTEX, argv из командной строки, выход через ESTEX EXIT +;; (rst #0x10). В ucsim нет ни ESTEX, ни DSS; нужен голый z80: поставить +;; SP, занулить данные, позвать main, встать. +;; +;; Контракт с раннером (tests-host/run_ucsim.sh): +;; - выполнение заканчивается инструкцией `halt` — по ней ucsim +;; останавливает симуляцию, и раннер получает управление; +;; - результат тестов раннер читает из ОЗУ командой `dump` (адрес берётся +;; из карты линковки, символ _tc_result — см. tcheck.c). +;; Никакого simif не используем: номера его команд от версии к версии +;; плавают, а halt + dump работают везде. +;; ---------------------------------------------------------------------- + + .module crt0_ucsim + .globl _main + + ;; Символы, которые расставляет линкер. + .globl s__INITIALIZER + .globl l__INITIALIZER + .globl s__INITIALIZED + .globl s__DATA + .globl l__DATA + .globl s__BSS + .globl l__BSS + +;; ========================================================================= +;; Точка входа: ucsim стартует с PC = 0x0000. +;; +;; ВАЖНО: `.area _HEADER (ABS)` должна встречаться РОВНО ОДИН РАЗ и первой. +;; Если продублировать её в списке порядка областей ниже, sdas заведёт +;; вторую область _HEADER0, и линкер положит стартовый код не по 0x0000, а +;; куда придётся (проверено: уезжал за _DATA на 0x830A, и с PC=0 +;; исполнялся мусор). +;; ========================================================================= + .area _HEADER (ABS) + .org 0x0000 + di + ld sp, #0xFEF0 ; стек под верхней границей ОЗУ ucsim + call gsinit + call _main + halt ; ucsim остановит симуляцию здесь +1$: jr 1$ ; страховка, если halt проигнорирован + +;; ========================================================================= +;; Порядок остальных областей — объявляем заранее, чтобы линкер разложил их +;; так (без _HEADER, см. комментарий выше). +;; ========================================================================= + .area _HOME + .area _CODE + .area _INITIALIZER + .area _GSINIT + .area _GSFINAL + + .area _DATA + .area _INITIALIZED + .area _BSEG + .area _BSS + .area _HEAP + +;; ========================================================================= +;; gsinit — занулить _DATA/_BSS и скопировать _INITIALIZER -> _INITIALIZED. +;; SDCC вставляет между gsinit и ret в _GSFINAL код инициализации модулей. +;; ========================================================================= + .area _CODE +;; zero_area — заполнить BC байт по адресу HL нулями (BC может быть 0). +zero_area: + ld a, b + or a, c + ret Z + ld (hl), #0 + dec bc + ld a, b + or a, c + ret Z + ld d, h + ld e, l + inc de + ldir + ret + + .area _GSINIT +gsinit:: + ld hl, #s__DATA + ld bc, #l__DATA + call zero_area + ld hl, #s__BSS + ld bc, #l__BSS + call zero_area + ld bc, #l__INITIALIZER + ld a, b + or a, c + jr Z, gsinit_done + ld de, #s__INITIALIZED + ld hl, #s__INITIALIZER + ldir +gsinit_done: + + .area _GSFINAL + ret diff --git a/testkit/host-tests.mk b/testkit/host-tests.mk new file mode 100644 index 0000000..bae7b07 --- /dev/null +++ b/testkit/host-tests.mk @@ -0,0 +1,83 @@ +# ---------------------------------------------------------------------- +# host-tests.mk — общие правила модульных тестов, исполняемых под ucsim_z80. +# +# Сама обвязка (crt0_ucsim.s, tcheck.*, run_ucsim.py) лежит в testkit/ и ни +# к какому подпроекту не привязана: тестировать так можно и libc, и логику +# приложений — всё, что на plain C и не трогает железо. +# +# Подключается из каталога с наборами t_*.c: +# +# TESTKIT := $(abspath ../../../../testkit) +# ENGINE_DIR := $(abspath ..) # где лежат модули под тестом +# OBJS_geom := build/eng_pop_geom.rel # что доложить в этот набор +# include $(TESTKIT)/host-tests.mk +# +# Набор t_<имя>.c подхватывается wildcard'ом, регистрировать не надо. +# ---------------------------------------------------------------------- + +ifndef TESTKIT +$(error перед include надо задать TESTKIT — путь к каталогу testkit/) +endif + +PROJ_ROOT ?= $(abspath $(TESTKIT)/..) +SDCC := $(PROJ_ROOT)/third_party/sdcc/bin/sdcc +SDASZ80 := $(PROJ_ROOT)/third_party/sdcc/bin/sdasz80 +UCSIM := $(PROJ_ROOT)/third_party/sdcc/bin/ucsim_z80 +RUNNER := $(TESTKIT)/run_ucsim.py + +BUILD := build + +# --code-loc: стартовый код занимает 13 байт по 0x0000, код с 0x0200. +# --data-loc 0x8000: данные заведомо выше кода. +CFLAGS := -mz80 --no-std-crt0 --std-c99 --opt-code-size -I$(TESTKIT) \ + $(if $(ENGINE_DIR),-I$(ENGINE_DIR)) +LDFLAGS := -mz80 --no-std-crt0 --code-loc 0x0200 --data-loc 0x8000 + +SUITES := $(patsubst t_%.c,%,$(notdir $(wildcard t_*.c))) +COMMON := $(BUILD)/crt0_ucsim.rel $(BUILD)/tcheck.rel + +.PHONY: all clean $(addprefix t_,$(SUITES)) +# Иначе make считает .rel/.ihx промежуточными и удаляет их после прогона — +# следующий `make` пересобирал бы всё заново. +.SECONDARY: +# OBJS_<набор> подставляется в правило по имени цели — нужен второй проход +# раскрытия, иначе модуль не попадёт в зависимости и не соберётся. +.SECONDEXPANSION: + +# Ярлык каталога в отчёте — с родителем: «tests-host» само по себе +# неоднозначно, когда таких каталогов несколько. +LABEL := $(notdir $(patsubst %/,%,$(dir $(CURDIR))))/$(notdir $(CURDIR)) + +all: $(addprefix run-,$(SUITES)) + @echo "$(LABEL): все наборы прошли ($(words $(SUITES)))" + +# Прогон одного набора. Раннер сам разбирает tc_result и возвращает код. +run-%: $(BUILD)/%.ihx + @python3 $(RUNNER) $(UCSIM) $(BUILD)/$*.ihx $(BUILD)/$*.noi + +# Удобный псевдоним: `make t_geom` вместо `make run-geom`. +t_%: run-% ; + +$(BUILD)/%.ihx: $(BUILD)/%.rel $(COMMON) $$(OBJS_$$*) + @$(SDCC) $(LDFLAGS) -o $@ $(COMMON) $(BUILD)/$*.rel $(OBJS_$*) + +$(BUILD)/%.rel: t_%.c $(TESTKIT)/tcheck.h | $(BUILD) + @$(SDCC) $(CFLAGS) -c -o $@ $< + +# Модули под тестом собираются ТЕМ ЖЕ компилятором и с той же семантикой, +# что и продукт, — включая модули с inline-asm, которые хостовой сборкой +# не проверить в принципе. +$(BUILD)/eng_%.rel: $(ENGINE_DIR)/%.c | $(BUILD) + @$(SDCC) $(CFLAGS) -c -o $@ $< + +$(BUILD)/tcheck.rel: $(TESTKIT)/tcheck.c $(TESTKIT)/tcheck.h | $(BUILD) + @$(SDCC) $(CFLAGS) -c -o $@ $< + +$(BUILD)/crt0_ucsim.rel: $(TESTKIT)/crt0_ucsim.s | $(BUILD) + @$(SDASZ80) -plosgff $@ $< + +$(BUILD): + @mkdir -p $(BUILD) + +clean: + @rm -rf $(BUILD) diff --git a/testkit/run_ucsim.py b/testkit/run_ucsim.py new file mode 100755 index 0000000..6e0eafc --- /dev/null +++ b/testkit/run_ucsim.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +run_ucsim.py — прогнать тестовый .ihx под ucsim_z80 и разобрать результат. + +Как это устроено (см. crt0_ucsim.s и tcheck.h): тестовый бинарь кладёт итог +в структуру tc_result в ОЗУ и заканчивается инструкцией halt. Мы находим +адрес _tc_result в карте линковки (.noi), гоняем ucsim, дампим структуру и +печатаем отчёт. + +Через simif сознательно не идём: номера его команд меняются от версии к +версии, а halt + dump работают одинаково везде. + +Код возврата: 0 — все проверки прошли, 1 — есть провалы или сбой прогона. +""" + +import re +import subprocess +import sys +from pathlib import Path + +TC_MAGIC = 0x5A5A +HDR = 8 # magic, total, failed, logn — по 2 байта +LOGSZ = 768 # TC_LOGSZ из tcheck.h +BYTES_PER_LINE = 16 + +# Строка дампа ucsim: "0x8000 5a 5a 07 00 ... ZZ......" +# ASCII-хвост тоже может выглядеть как hex, поэтому берём РОВНО столько +# токенов, сколько байт положено в строке, а не «сколько нашлось». +DUMP_RE = re.compile(r"^0x([0-9A-Fa-f]+)\s+((?:[0-9A-Fa-f]{2}\s+)+)") + + +def find_symbol(noi_path: Path, name: str) -> int: + m = re.search(rf"^DEF {re.escape(name)} (0x[0-9A-Fa-f]+)$", + noi_path.read_text(), re.M) + if not m: + raise SystemExit(f"{noi_path.name}: символ {name} не найден " + f"(тест не слинкован с tcheck.c?)") + return int(m.group(1), 16) + + +def run(ucsim: Path, ihx: Path, start: int, length: int, timeout: int): + stop = start + length - 1 + script = (f'file "{ihx}"\n' + f'reset\n' + f'run\n' + f'dump 0x{start:04X} 0x{stop:04X} {BYTES_PER_LINE}\n' + f'quit\n') + try: + p = subprocess.run([str(ucsim), "-t", "z80"], input=script, + capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + raise SystemExit(f"ТАЙМАУТ {timeout} с — тест зациклился " + f"(нет halt / бесконечный цикл в коде)") + return p.stdout + + +def parse_dump(out: str, start: int, length: int) -> bytes: + mem = bytearray(length) + seen = bytearray(length) + for line in out.splitlines(): + m = DUMP_RE.match(line.strip()) + if not m: + continue + addr = int(m.group(1), 16) + off = addr - start + if off < 0 or off >= length: + continue + want = min(BYTES_PER_LINE, length - off) + toks = m.group(2).split()[:want] + for i, t in enumerate(toks): + mem[off + i] = int(t, 16) + seen[off + i] = 1 + if not any(seen): + raise SystemExit("не удалось разобрать дамп ucsim:\n" + out) + return bytes(mem) + + +def main() -> int: + if len(sys.argv) < 4: + print("usage: run_ucsim.py [timeout]", + file=sys.stderr) + return 2 + ucsim, ihx, noi = (Path(a) for a in sys.argv[1:4]) + timeout = int(sys.argv[4]) if len(sys.argv) > 4 else 60 + name = ihx.stem + + addr = find_symbol(noi, "_tc_result") + out = run(ucsim, ihx, addr, HDR + LOGSZ, timeout) + + if "Halted" not in out: + print(f"[{name}] ПРОГОН НЕ ЗАВЕРШИЛСЯ штатным halt:\n{out}") + return 1 + + mem = parse_dump(out, addr, HDR + LOGSZ) + u16 = lambda o: mem[o] | (mem[o + 1] << 8) + magic, total, failed, logn = u16(0), u16(2), u16(4), u16(6) + + if magic != TC_MAGIC: + print(f"[{name}] tc_result не заполнен (magic=0x{magic:04X}) — " + f"main() не позвал ни одной проверки?") + return 1 + + if failed: + log = mem[HDR:HDR + min(logn, LOGSZ)].decode("utf-8", "replace") + print(f"[{name}] ПРОВАЛ: {failed} из {total}") + for line in log.rstrip("\n").split("\n"): + print(f" {line}") + return 1 + + print(f"[{name}] ok: {total}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/testkit/t_selftest.c b/testkit/t_selftest.c new file mode 100644 index 0000000..becb582 --- /dev/null +++ b/testkit/t_selftest.c @@ -0,0 +1,45 @@ +/* + * t_smoke.c — проверка самого каркаса, а не движка. + * + * Главное здесь — TC_EQ(sizeof(int), 2): она доказывает, что тест реально + * исполняется с z80-семантикой, а не собран хостовым компилятором. Ради + * этого весь прогон под ucsim и затевался (см. обсуждение 16/32 бит). + */ +#include "tcheck.h" + +TC_TEST(smoke_z80_widths) +{ + TC_EQ(sizeof(int), 2); /* мы под z80, int 16 бит */ + TC_EQ(sizeof(void *), 2); + TC_EQ(sizeof(long), 4); +} + +TC_TEST(smoke_arith) +{ + static volatile uint8_t a, b; /* volatile: иначе SDCC свернёт всё + * в константу и тест ничего не + * проверит (warning 110) */ + int16_t s; + a = 200; b = 100; + s = (int16_t)(a + b); /* байты повышаются до int */ + TC_EQ(s, 300); + TC_EQ((uint8_t)(a + b), 44); /* явный заворот в байт */ +} + +TC_TEST(smoke_wraparound_is_16bit) +{ + /* Ровно тот случай, который на хосте посчитался бы в 32 битах: + * здесь заворот обязан произойти. */ + static volatile uint16_t a, b; + a = 60000; b = 10000; + TC_EQ((uint16_t)(a + b), 4464); + TC_TRUE((uint16_t)(a + b) < 65000); +} + +int main(void) +{ + TC_RUN(smoke_z80_widths); + TC_RUN(smoke_arith); + TC_RUN(smoke_wraparound_is_16bit); + return 0; +} diff --git a/testkit/tcheck.c b/testkit/tcheck.c new file mode 100644 index 0000000..aab9323 --- /dev/null +++ b/testkit/tcheck.c @@ -0,0 +1,89 @@ +/* + * tcheck.c — реализация микро-фреймворка (см. tcheck.h). + * + * Всё пишется в tc_result — раннер читает эту структуру из ОЗУ. Ничего + * не инициализируем явно: crt0_ucsim зануляет _DATA/_BSS, и magic ставится + * первым же tc_begin (правило проекта про статики — memory + * sdcc_static_storage_gotcha). + */ +#include "tcheck.h" + +tc_result_t tc_result; +const char *tc_curr; + +static void tc_putc(char c) +{ + if (tc_result.logn < TC_LOGSZ - 1) + tc_result.log[tc_result.logn++] = (uint8_t)c; +} + +static void tc_puts(const char *s) +{ + while (*s) tc_putc(*s++); +} + +static void tc_puthex(uint16_t v) +{ + const char *d = "0123456789ABCDEF"; + tc_puts(" (0x"); + tc_putc(d[(v >> 12) & 15]); tc_putc(d[(v >> 8) & 15]); + tc_putc(d[(v >> 4) & 15]); tc_putc(d[v & 15]); + tc_putc(')'); +} + +/* Знаковое десятичное: значения тестов маленькие, но делаем честно для + * всего диапазона int16_t. */ +static void tc_putd(int16_t v) +{ + char buf[7]; + uint8_t n = 0; + uint16_t u; + if (v < 0) { tc_putc('-'); u = (uint16_t)(-v); } + else { u = (uint16_t)v; } + do { buf[n++] = (char)('0' + (u % 10u)); u /= 10u; } while (u); + while (n) tc_putc(buf[--n]); +} + +void tc_begin(const char *name) +{ + tc_result.magic = TC_MAGIC; + tc_curr = name; +} + +static void tc_fail_head(const char *expr, uint16_t line) +{ + tc_result.failed++; + tc_puts("FAIL "); + tc_puts(tc_curr ? tc_curr : "?"); + tc_puts(":"); + tc_putd((int16_t)line); + tc_puts(" "); + tc_puts(expr); +} + +void tc_check(int16_t got, int16_t want, const char *expr, uint16_t line) +{ + tc_result.magic = TC_MAGIC; + tc_result.total++; + if (got == want) return; + tc_fail_head(expr, line); + /* Печатаем и десятичное, и hex: сравнение идёт как int16_t, поэтому + * беззнаковые величины (сид PRNG, координаты > 32767) в десятичном + * виде выглядят отрицательными и сбивают с толку. */ + tc_puts(" = "); + tc_putd(got); + tc_puthex((uint16_t)got); + tc_puts(", ждали "); + tc_putd(want); + tc_puthex((uint16_t)want); + tc_putc('\n'); +} + +void tc_checkp(const void *got, const void *want, const char *expr, uint16_t line) +{ + tc_result.magic = TC_MAGIC; + tc_result.total++; + if (got == want) return; + tc_fail_head(expr, line); + tc_puts(" — указатели различаются\n"); +} diff --git a/testkit/tcheck.h b/testkit/tcheck.h new file mode 100644 index 0000000..2622618 --- /dev/null +++ b/testkit/tcheck.h @@ -0,0 +1,56 @@ +/* + * tcheck.h — микро-фреймворк для тестов, исполняемых под ucsim_z80. + * + * Почему не printf: тестовый бинарь линкуется БЕЗ Sprinter-libc (там ESTEX, + * BIOS, файлы — ничего этого в ucsim нет). Поэтому результат складывается в + * структуру tc_result в ОЗУ, а раннер (run_ucsim.py) читает её командой + * `dump` после остановки по halt и печатает по-человечески. + * + * Использование: + * + * #include "tcheck.h" + * TC_TEST(имя_теста) { ... TC_EQ(получено, ожидание); ... } + * TC_MAIN(имя_теста, другой_тест, ...) + * + * Значения сравниваются как int16_t — этого хватает на всё, чем оперирует + * движок (координаты, тайлы, кадры, счётчики). + */ +#ifndef TCHECK_H +#define TCHECK_H + +#include + +#define TC_LOGSZ 768 +#define TC_MAGIC 0x5A5Au + +typedef struct { + uint16_t magic; /* TC_MAGIC — раннер убеждается, что нашёл нас */ + uint16_t total; /* сколько проверок выполнено */ + uint16_t failed; /* сколько провалилось */ + uint16_t logn; /* занято в log */ + uint8_t log[TC_LOGSZ]; /* ASCII-отчёт (только про провалы) */ +} tc_result_t; + +extern tc_result_t tc_result; + +/* Текущий тест — чтобы в отчёте было видно, где именно упало. */ +extern const char *tc_curr; + +void tc_begin(const char *name); +void tc_check(int16_t got, int16_t want, const char *expr, uint16_t line); +void tc_checkp(const void *got, const void *want, const char *expr, uint16_t line); + +/* Основные макросы. __LINE__ хватает для локализации: имя теста уже в + * отчёте, а выражение печатается текстом. */ +#define TC_EQ(got, want) tc_check((int16_t)(got), (int16_t)(want), #got, __LINE__) +#define TC_TRUE(cond) tc_check((int16_t)((cond) ? 1 : 0), 1, #cond, __LINE__) +#define TC_FALSE(cond) tc_check((int16_t)((cond) ? 1 : 0), 0, #cond, __LINE__) +#define TC_PEQ(got, want) tc_checkp((got), (want), #got, __LINE__) + +#define TC_TEST(name) static void name(void) + +/* main() тестового бинаря: перечислить функции тестов. Вариадика через + * список вызовов — у SDCC нет проблем с таким раскрытием. */ +#define TC_RUN(fn) do { tc_begin(#fn); fn(); } while (0) + +#endif