testkit: модульные тесты под ucsim_z80 + планы покрытия

Обвязка для быстрых тестов plain-C логики: секунды вместо прогона в MAME,
без образа диска.  ucsim_z80 идёт в комплекте нашего SDCC — новых
зависимостей нет.

ПОЧЕМУ ПОД Z80, А НЕ ХОСТОВЫМ GCC.  У SDCC z80 int 16 бит, у хоста 32, и
расходится это НЕ в объявлениях, а в выражениях: integer promotion
повышает операнды до int независимо от того, объявлены они как uint8_t
или uint16_t.  Перевод кода на фиксированные типы разницу не убирает —
убирает только исполнение с z80-семантикой.  Побочно проверяется
кодогенерация SDCC и модули с inline-asm, которых хостовая сборка не
видит в принципе.

Устройство: crt0_ucsim.s (SP, зануление, main, halt), tcheck.* (итог в
структуру в ОЗУ), run_ucsim.py (гоняет ucsim, дампит tc_result, печатает
отчёт), host-tests.mk (общие правила).  Вывода через printf нет: тестовый
бинарь линкуется без Sprinter-libc.  Через ucsim-simif не идём — номера
его команд плавают между версиями, halt + dump работают везде.

Наборы лежат РЯДОМ с проверяемым кодом, обвязка общая:
  testkit/t_selftest.c                     — самопроверка (sizeof(int)==2)
  applications/PoP/roomtest/tests-host/    — движок PoP

Первый содержательный набор — t_geom: сверяет рукописный asm-LCG из
pop_geom.c с наивной 32-битной формулой на 128 шагах.  Заявка «бит-в-бит
как в SDLPoP» до сих пор держалась на комментарии.  Тест проверен
мутацией: порча эталонной константы даёт красный.

Планы дальнейшего покрытия:
  docs/host-tests-plan.md                    — libc и libbgi (не начато)
  applications/PoP/docs/host_tests_plan.md   — движок PoP

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Александр Петров
2026-08-03 22:36:16 +03:00
parent b5d2a81ee3
commit 61d4255091
19 changed files with 1103 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
build/
+5
View File
@@ -0,0 +1,5 @@
# Самопроверка обвязки: t_selftest.c. Модулей под тестом здесь нет —
# проверяется, что бинарь собирается, исполняется под ucsim и что
# семантика действительно z80 (sizeof(int) == 2).
TESTKIT := $(CURDIR)
include $(TESTKIT)/host-tests.mk
+110
View File
@@ -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-сборку.
+103
View File
@@ -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
+83
View File
@@ -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)
+115
View File
@@ -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 <ucsim> <test.ihx> <test.noi> [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())
+45
View File
@@ -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;
}
+89
View File
@@ -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");
}
+56
View File
@@ -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 <stdint.h>
#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