diff --git a/drivers/freshness/freshness.c b/drivers/freshness/freshness.c new file mode 100644 index 0000000..b13b855 --- /dev/null +++ b/drivers/freshness/freshness.c @@ -0,0 +1,186 @@ +/* + * The UI Freshness Contract engine (ADR 0004) — core, LVGL-free. + * See freshness.h for the contract. LVGL binding lives in freshness_lv.c. + */ +#include "freshness.h" + +#include +#include + +/* The unknown mark the engine renders when a source cannot be evaluated (from + * freshness.h so the LVGL layer and tests share the exact literal). */ +#define FRESH_UNKNOWN_MARK WARDEN_FRESH_UNKNOWN_MARK + +/* Max simultaneous live bindings. Bindings belong to visible pages; the whole + * navigable set of a screen is small, so this is generous. A full table drops + * the binding (returns NULL) rather than silently overflowing — the LVGL layer + * turns that into a visible fault, never a stale value. */ +#ifndef FRESH_MAX +#define FRESH_MAX 96 +#endif + +#define FRESH_BUFSZ 64 + +struct warden_fresh { + void * page; + warden_fresh_produce_cb produce; + void * ud; + uint32_t max_stale_ms; + const char * source; + warden_fresh_render_cb render; + void * widget; + uint32_t last_ok_ms; /* time of last OK/SAME produce */ + char last[FRESH_BUFSZ]; /* last good value string */ + bool ever_ok; + bool showing_unknown; + bool visible; + bool used; +}; + +/* A fixed table scanned in full: bindings are torn down all at once by + * warden_fresh_reset (like the screen timers), never individually, so a running + * high-water bound would only hide the free-slot arms from tests without saving + * real work — the visible set per screen is a handful. */ +static struct warden_fresh s_vals[FRESH_MAX]; + +warden_fresh_render_t warden_fresh_decide(warden_fresh_result_t produced, + bool ever_ok, bool showing_unknown, + uint32_t age_ms, uint32_t max_stale_ms) +{ + switch(produced) { + case FRESH_OK: + return FRESH_RENDER_VALUE; + case FRESH_SAME: + /* Unchanged and fresh: normally nothing to do. But if the widget is + * currently showing UNKNOWN (it went stale), an unchanged value + * still has to be repainted to clear the mark. */ + return showing_unknown ? FRESH_RENDER_VALUE : FRESH_RENDER_NOCHANGE; + case FRESH_UNKNOWN: + default: + /* Never had a value, or the last good value is now older than its + * budget: stop asserting a confident number. Otherwise tolerate a + * brief blip and hold the last value until the budget expires. */ + if(!ever_ok) return FRESH_RENDER_UNKNOWN; + if(age_ms > max_stale_ms) return FRESH_RENDER_UNKNOWN; + return FRESH_RENDER_NOCHANGE; + } +} + +static void refresh_one(struct warden_fresh *v, uint32_t now) +{ + char buf[FRESH_BUFSZ]; + buf[0] = '\0'; + warden_fresh_result_t r = v->produce(buf, sizeof buf, v->ud); + + warden_fresh_render_t what = warden_fresh_decide( + r, v->ever_ok, v->showing_unknown, now - v->last_ok_ms, v->max_stale_ms); + + if(r == FRESH_OK) { + /* Keep the last good string so a later SAME-recovery can repaint it. + * snprintf truncates and null-terminates; buf and last are both + * FRESH_BUFSZ, so this cannot overflow. */ + snprintf(v->last, sizeof v->last, "%s", buf); + } + if(r == FRESH_OK || r == FRESH_SAME) { + v->last_ok_ms = now; + v->ever_ok = true; + } + + switch(what) { + case FRESH_RENDER_VALUE: + v->render(v->widget, FRESH_RENDER_VALUE, + (r == FRESH_OK) ? buf : v->last, v->ud); + v->showing_unknown = false; + break; + case FRESH_RENDER_UNKNOWN: + v->render(v->widget, FRESH_RENDER_UNKNOWN, FRESH_UNKNOWN_MARK, v->ud); + v->showing_unknown = true; + break; + case FRESH_RENDER_NOCHANGE: + break; + } +} + +warden_fresh_t *warden_fresh_bind(void *page, warden_fresh_produce_cb produce, + void *ud, uint32_t max_stale_ms, + const char *source, + warden_fresh_render_cb render, void *widget) +{ + if(!produce || !render) return NULL; + for(uint32_t i = 0; i < FRESH_MAX; i++) { + if(s_vals[i].used) continue; + struct warden_fresh *v = &s_vals[i]; + memset(v, 0, sizeof *v); + v->page = page; + v->produce = produce; + v->ud = ud; + v->max_stale_ms = max_stale_ms; + v->source = source; + v->render = render; + v->widget = widget; + v->visible = true; /* bound while building the page that's about to show */ + v->used = true; + return v; + } + return NULL; /* table full — caller surfaces a fault, never a stale value */ +} + +void warden_fresh_set_visible(void *page, bool visible) +{ + for(uint32_t i = 0; i < FRESH_MAX; i++) { + if(s_vals[i].used && s_vals[i].page == page) s_vals[i].visible = visible; + } +} + +void warden_fresh_page_show(void *page, uint32_t now_ms) +{ + for(uint32_t i = 0; i < FRESH_MAX; i++) { + struct warden_fresh *v = &s_vals[i]; + if(v->used && v->page == page) { + v->visible = true; + refresh_one(v, now_ms); + } + } +} + +void warden_fresh_tick(uint32_t now_ms) +{ + for(uint32_t i = 0; i < FRESH_MAX; i++) { + if(s_vals[i].used && s_vals[i].visible) refresh_one(&s_vals[i], now_ms); + } +} + +void warden_fresh_invalidate(const char *source, uint32_t now_ms) +{ + if(!source) return; + for(uint32_t i = 0; i < FRESH_MAX; i++) { + struct warden_fresh *v = &s_vals[i]; + if(v->used && v->visible && v->source && strcmp(v->source, source) == 0) { + refresh_one(v, now_ms); + } + } +} + +void warden_fresh_reset(void) +{ + memset(s_vals, 0, sizeof s_vals); +} + +uint32_t warden_fresh_count(void) +{ + uint32_t n = 0; + for(uint32_t i = 0; i < FRESH_MAX; i++) if(s_vals[i].used) n++; + return n; +} + +uint32_t warden_fresh_min_budget_ms(void) +{ + uint32_t best = 0; + for(uint32_t i = 0; i < FRESH_MAX; i++) { + struct warden_fresh *v = &s_vals[i]; + if(v->used && v->visible && (best == 0 || v->max_stale_ms < best)) { + best = v->max_stale_ms; + } + } + return best; +} diff --git a/drivers/freshness/freshness.h b/drivers/freshness/freshness.h new file mode 100644 index 0000000..d914de0 --- /dev/null +++ b/drivers/freshness/freshness.h @@ -0,0 +1,93 @@ +/* + * The UI Freshness Contract engine (ADR 0004) — core, LVGL-free. + * + * The panel is read on-site to judge whether hardware is healthy, so a silently + * *stale* number is worse than a missing one: a stale IP or hashrate reads as + * ground truth and sends a technician the wrong way. This engine is the only + * sanctioned way to show a live value. It guarantees a bound value is refreshed + * (a) the instant its page becomes visible, (b) periodically while visible + * within a declared max-staleness, and (c) promptly when a declared source + * changes — and it renders a value whose source cannot be evaluated as an + * explicit UNKNOWN, never as its confident last-known number. + * + * This header is deliberately LVGL-free so the engine and every producer are + * unit-testable headlessly. The LVGL label convenience lives in freshness_lv.h. + */ +#ifndef WARDEN_FRESHNESS_H +#define WARDEN_FRESHNESS_H + +#include +#include +#include + +/* The mark shown when a value's source cannot be evaluated (em dash). The LVGL + * layer additionally dims the widget. Public so the label wrapper and tests can + * reference the same literal. */ +#define WARDEN_FRESH_UNKNOWN_MARK "\xE2\x80\x94" + +/* What a producer reports after being asked to produce the current value. */ +typedef enum { + FRESH_OK = 0, /* wrote the current value into buf */ + FRESH_UNKNOWN, /* source unavailable — no value can be produced now */ + FRESH_SAME, /* source read fine; value unchanged (cheap re-render) */ +} warden_fresh_result_t; + +/* Produce the current value of a live datum into buf. Pure w.r.t. LVGL: a plain + * function of the model behind `ud`, which is exactly why it is the test seam. */ +typedef warden_fresh_result_t (*warden_fresh_produce_cb)(char *buf, size_t n, + void *ud); + +/* What the engine decided the widget should show this cycle. */ +typedef enum { + FRESH_RENDER_VALUE = 0, /* show the produced/last-good value */ + FRESH_RENDER_UNKNOWN, /* show the explicit-unknown mark ("—", dimmed) */ + FRESH_RENDER_NOCHANGE, /* leave the widget exactly as it is */ +} warden_fresh_render_t; + +/* The one pure decision at the heart of the contract — no state, no I/O, no + * LVGL, so every branch is unit-testable. `showing_unknown` is whether the + * widget is currently displaying the UNKNOWN mark (so recovery from a stale + * blip re-renders the value even when the producer reports it unchanged). */ +warden_fresh_render_t warden_fresh_decide(warden_fresh_result_t produced, + bool ever_ok, bool showing_unknown, + uint32_t age_ms, uint32_t max_stale_ms); + +/* The widget-render seam: the engine calls this to actually update a widget. + * `buf` is valid only for FRESH_RENDER_VALUE. LVGL lives behind this callback. */ +typedef void (*warden_fresh_render_cb)(void *widget, warden_fresh_render_t what, + const char *buf, void *ud); + +typedef struct warden_fresh warden_fresh_t; + +/* Bind a producer+widget to a page. `source` (may be NULL) is a named change + * channel for warden_fresh_invalidate. Returns NULL if the table is full. */ +warden_fresh_t *warden_fresh_bind(void *page, warden_fresh_produce_cb produce, + void *ud, uint32_t max_stale_ms, + const char *source, + warden_fresh_render_cb render, void *widget); + +/* A page became visible/hidden (tiles via notify_active, subnav leaves via + * on_open). Hidden pages are skipped by the periodic tick. */ +void warden_fresh_set_visible(void *page, bool visible); + +/* Refresh every bound value on `page` right now (the on-show guarantee). Also + * marks the page visible. */ +void warden_fresh_page_show(void *page, uint32_t now_ms); + +/* The shared periodic tick: refresh every currently-visible bound value. */ +void warden_fresh_tick(uint32_t now_ms); + +/* A producer of change fired: refresh every visible value bound to `source`. */ +void warden_fresh_invalidate(const char *source, uint32_t now_ms); + +/* Drop all bindings — called on a theme/screen rebuild, like the screen timers. */ +void warden_fresh_reset(void); + +/* Number of live bindings (introspection / tests). */ +uint32_t warden_fresh_count(void); + +/* Smallest max_stale_ms among visible bindings, or 0 if none — lets the LVGL + * layer size the shared tick to the tightest budget actually on screen. */ +uint32_t warden_fresh_min_budget_ms(void); + +#endif /* WARDEN_FRESHNESS_H */ diff --git a/drivers/freshness/test/Makefile b/drivers/freshness/test/Makefile new file mode 100644 index 0000000..f7138ca --- /dev/null +++ b/drivers/freshness/test/Makefile @@ -0,0 +1,40 @@ +# MC/DC unit harness for drivers/freshness/freshness.c (the UI stale-number guard). +# +# make check — build, run, FAIL unless freshness.c hits 100% MC/DC + all checks. +# make report — per-condition gcov annotation. +# make clean +# +# FRESH_MAX is forced to 2 so the "binding table full -> NULL" path is reachable +# with two binds (the production default is 96). Requires gcc >= 14. +CC ?= gcc +GCOV ?= gcov +CFLAGS := -O0 -g -Wall -Wextra -I.. -DFRESH_MAX=2 +COVFLAGS := --coverage -fcondition-coverage +BUILD := build + +.PHONY: check report clean +.DEFAULT_GOAL := check + +$(BUILD): + @mkdir -p $(BUILD) + +$(BUILD)/test: test_freshness.c ../freshness.c ../freshness.h | $(BUILD) + @$(CC) $(CFLAGS) $(COVFLAGS) -c ../freshness.c -o $(BUILD)/freshness.o + @$(CC) $(CFLAGS) -c test_freshness.c -o $(BUILD)/test_freshness.o + @$(CC) $(COVFLAGS) $(BUILD)/freshness.o $(BUILD)/test_freshness.o -o $(BUILD)/test + +check: $(BUILD)/test + @echo "== running freshness MC/DC harness ==" + @rm -f $(BUILD)/freshness.gcda + @$(BUILD)/test; echo $$? > $(BUILD)/test.rc + @echo + @echo "== MC/DC (condition) coverage of freshness.c ==" + @$(GCOV) --conditions --branch-probabilities -o $(BUILD) ../freshness.c >$(BUILD)/gcov.log 2>&1 || true + @mv -f *.gcov $(BUILD)/ 2>/dev/null || true + @bash enforce-mcdc.sh $(BUILD)/gcov.log $(BUILD)/freshness.c.gcov $(BUILD)/test.rc + +report: check + @grep -nE "condition.*not covered|conditions covered" $(BUILD)/freshness.c.gcov || true + +clean: + @rm -rf $(BUILD) diff --git a/drivers/freshness/test/enforce-mcdc.sh b/drivers/freshness/test/enforce-mcdc.sh new file mode 100755 index 0000000..13eb3c8 --- /dev/null +++ b/drivers/freshness/test/enforce-mcdc.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Gate: fail unless every unit check passed AND freshness.c reached 100% MC/DC +# (condition) coverage with no uncovered executable lines. +# $1 = gcov stdout log $2 = freshness.c.gcov $3 = test exit-code file +set -uo pipefail +LOG="$1"; GCOV="$2"; RCFILE="$3" +rc=0 + +testrc="$(cat "$RCFILE" 2>/dev/null || echo 1)" +if [ "$testrc" != "0" ]; then + echo "RESULT: unit checks FAILED (test exit $testrc)"; rc=1 +else + echo "RESULT: all unit checks passed" +fi + +if [ ! -f "$GCOV" ]; then + echo "RESULT: no coverage file ($GCOV) produced"; exit 1 +fi + +notcov="$(grep -nE "condition[s]? .*not covered" "$GCOV" || true)" +uncov_lines="$(grep -nE "^ +#####:" "$GCOV" || true)" + +# Summary lines for freshness.c from gcov stdout. Match the exact file so +# 'test_freshness.c' (which also contains "freshness.c") is NOT picked up. +FMATCH="File '([^']*/)?freshness[.]c'" +cond_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Condition outcomes covered:/{print; f=0}' "$LOG")" +line_line="$(awk -v patt="$FMATCH" '$0 ~ patt {f=1} f&&/Lines executed:/{print; f=0}' "$LOG")" +echo " ${line_line:-Lines executed: (n/a)}" +echo " ${cond_line:-Condition outcomes covered: (n/a)}" + +if [ -n "$notcov" ]; then + echo "RESULT: MC/DC gaps (conditions not covered):" + echo "$notcov" | sed 's/^/ /' + rc=1 +fi +if [ -n "$uncov_lines" ]; then + echo "RESULT: uncovered executable lines in freshness.c:" + echo "$uncov_lines" | sed 's/^/ /' + rc=1 +fi + +if ! echo "$cond_line" | grep -q "100.00%"; then + echo "RESULT: condition coverage is below 100%" + rc=1 +fi + +[ "$rc" = "0" ] && echo "RESULT: 100% MC/DC + all checks green ✓" +exit "$rc" diff --git a/drivers/freshness/test/test_freshness.c b/drivers/freshness/test/test_freshness.c new file mode 100644 index 0000000..c46fce1 --- /dev/null +++ b/drivers/freshness/test/test_freshness.c @@ -0,0 +1,173 @@ +/* MC/DC harness for drivers/freshness/freshness.c (built with -DFRESH_MAX=2). + * + * freshness.c is pure logic with produce/render callbacks — no hardware seam + * needed, the callbacks ARE the seam. We drive the decision function directly and + * the bind/tick/invalidate state machine through programmable fakes, covering + * every decision (incl. the compound `used && visible`, `!produce || !render`, + * `used && visible && source && strcmp==0`, `best==0 || max_stale +#include + +static int g_fail = 0, g_checks = 0; +#define EXPECT(c) do { g_checks++; if(!(c)) { g_fail++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); } } while(0) + +/* --- programmable produce/render fakes --- */ +static warden_fresh_result_t g_prod_ret; +static const char *g_prod_val; +static warden_fresh_result_t fake_produce(char *buf, size_t n, void *ud) { + (void)ud; + if(g_prod_val) snprintf(buf, n, "%s", g_prod_val); + return g_prod_ret; +} +static int g_render_calls; +static warden_fresh_render_t g_render_what; +static char g_render_buf[64]; +static void fake_render(void *widget, warden_fresh_render_t what, const char *buf, void *ud) { + (void)widget; (void)ud; + g_render_calls++; g_render_what = what; + snprintf(g_render_buf, sizeof g_render_buf, "%s", buf ? buf : ""); +} +static void set_produce(warden_fresh_result_t r, const char *v) { g_prod_ret = r; g_prod_val = v; } +static void reset_render(void) { g_render_calls = 0; g_render_what = FRESH_RENDER_NOCHANGE; g_render_buf[0] = 0; } + +/* --- 1. the pure decision function: every switch arm + inner condition --- */ +static void test_decide(void) { + EXPECT(warden_fresh_decide(FRESH_OK, false, false, 0, 100) == FRESH_RENDER_VALUE); + /* SAME: showing_unknown both ways */ + EXPECT(warden_fresh_decide(FRESH_SAME, true, true, 0, 100) == FRESH_RENDER_VALUE); + EXPECT(warden_fresh_decide(FRESH_SAME, true, false, 0, 100) == FRESH_RENDER_NOCHANGE); + /* UNKNOWN: !ever_ok true; then age>max true; then age<=max */ + EXPECT(warden_fresh_decide(FRESH_UNKNOWN, false, false, 0, 100) == FRESH_RENDER_UNKNOWN); + EXPECT(warden_fresh_decide(FRESH_UNKNOWN, true, false, 200, 100) == FRESH_RENDER_UNKNOWN); + EXPECT(warden_fresh_decide(FRESH_UNKNOWN, true, false, 50, 100) == FRESH_RENDER_NOCHANGE); +} + +/* --- 2. bind: !produce, !render, valid, and table-full (FRESH_MAX=2) --- */ +static void test_bind(void) { + warden_fresh_reset(); + EXPECT(warden_fresh_bind((void*)1, NULL, NULL, 100, "s", fake_render, (void*)9) == NULL); /* !produce */ + EXPECT(warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", NULL, (void*)9) == NULL); /* !render */ + warden_fresh_t *a = warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9); + warden_fresh_t *b = warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9); + EXPECT(a && b); /* both slots taken */ + EXPECT(warden_fresh_count() == 2); + EXPECT(warden_fresh_bind((void*)1, fake_produce, NULL, 100, "t", fake_render, (void*)9) == NULL); /* full */ +} + +/* --- 3. refresh_one via tick: OK / SAME(recover) / UNKNOWN-hold / UNKNOWN-stale --- */ +static void test_refresh_paths(void) { + warden_fresh_reset(); + warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9); + + /* OK at t=0 -> render VALUE(buf), last saved, ever_ok set */ + set_produce(FRESH_OK, "42"); reset_render(); + warden_fresh_tick(0); + EXPECT(g_render_calls == 1 && g_render_what == FRESH_RENDER_VALUE && strcmp(g_render_buf, "42") == 0); + + /* UNKNOWN, ever_ok, age<=max -> NOCHANGE (r==OK||SAME both false; what NOCHANGE) */ + set_produce(FRESH_UNKNOWN, NULL); reset_render(); + warden_fresh_tick(50); + EXPECT(g_render_calls == 0); + + /* UNKNOWN, age>max -> UNKNOWN render, showing_unknown=true */ + reset_render(); + warden_fresh_tick(500); + EXPECT(g_render_calls == 1 && g_render_what == FRESH_RENDER_UNKNOWN); + + /* SAME while showing_unknown -> VALUE render of v->last (r!=OK ternary false-arm) */ + set_produce(FRESH_SAME, NULL); reset_render(); + warden_fresh_tick(520); + EXPECT(g_render_calls == 1 && g_render_what == FRESH_RENDER_VALUE && strcmp(g_render_buf, "42") == 0); +} + +/* --- 4. set_visible / page_show / tick(used&&visible) / count(used) --- */ +static void test_visibility(void) { + warden_fresh_reset(); + warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9); /* page 1 */ + warden_fresh_bind((void*)2, fake_produce, NULL, 100, "s", fake_render, (void*)9); /* page 2 */ + + /* set_visible: page match vs no-match (used true both; page==page T/F) */ + warden_fresh_set_visible((void*)1, false); /* page 1 hidden */ + set_produce(FRESH_OK, "7"); reset_render(); + warden_fresh_tick(0); /* only page-2 (visible) refreshes */ + EXPECT(g_render_calls == 1); + + /* page_show forces visible + refreshes just that page */ + reset_render(); + warden_fresh_page_show((void*)1, 1); + EXPECT(g_render_calls == 1); + + /* count sees used slots; an unused slot exercises the `used` false arm in the + * count/tick loops */ + EXPECT(warden_fresh_count() == 2); +} + +/* --- 5. invalidate: !source; source match/no-match; and a NULL-source binding --- */ +static void test_invalidate(void) { + warden_fresh_reset(); + warden_fresh_bind((void*)1, fake_produce, NULL, 100, "alpha", fake_render, (void*)9); + warden_fresh_bind((void*)1, fake_produce, NULL, 100, NULL, fake_render, (void*)9); /* source NULL */ + + warden_fresh_invalidate(NULL, 0); /* !source -> early return */ + set_produce(FRESH_OK, "1"); reset_render(); + warden_fresh_invalidate("beta", 0); /* no source matches -> no refresh */ + EXPECT(g_render_calls == 0); + reset_render(); + warden_fresh_invalidate("alpha", 0); /* matches the first binding only */ + EXPECT(g_render_calls == 1); +} + +/* --- 6. min_budget: best==0 first, then max_stale300 */ + warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9); /* 100<300 -> 100 */ + EXPECT(warden_fresh_min_budget_ms() == 100); + /* a third can't bind (full at 2); rebind fresh with the larger-first order so the + * `max_stale < best` FALSE arm (200 !< 100) is taken */ + warden_fresh_reset(); + warden_fresh_bind((void*)1, fake_produce, NULL, 100, "s", fake_render, (void*)9); + warden_fresh_bind((void*)1, fake_produce, NULL, 200, "s", fake_render, (void*)9); /* 200<100 false */ + EXPECT(warden_fresh_min_budget_ms() == 100); + /* a hidden binding exercises min_budget's `visible` false arm */ + warden_fresh_set_visible((void*)1, false); + EXPECT(warden_fresh_min_budget_ms() == 0); +} + +/* --- 7. the `used`/`visible` FALSE arms of the scan loops: bind ONE (leaving a + * slot unused) and hide it, so set_visible/page_show/invalidate/count each + * see an unused and an invisible slot. --- */ +static void test_false_arms(void) { + warden_fresh_reset(); + warden_fresh_bind((void*)1, fake_produce, NULL, 100, "x", fake_render, (void*)9); /* slot0 used; slot1 unused */ + + EXPECT(warden_fresh_count() == 1); /* count: slot1 used==false */ + warden_fresh_set_visible((void*)2, false); /* set_visible: slot0 page-mismatch, slot1 used==false */ + warden_fresh_page_show((void*)2, 0); /* page_show: slot1 used==false */ + + set_produce(FRESH_OK, "1"); reset_render(); + warden_fresh_invalidate("x", 0); /* invalidate: slot0 matches, slot1 used==false */ + EXPECT(g_render_calls == 1); + + warden_fresh_set_visible((void*)1, false); /* hide the used binding */ + reset_render(); + warden_fresh_invalidate("x", 0); /* invalidate: slot0 used but visible==false */ + EXPECT(g_render_calls == 0); +} + +int main(void) { + test_decide(); + test_bind(); + test_refresh_paths(); + test_visibility(); + test_invalidate(); + test_min_budget(); + test_false_arms(); + fprintf(stderr, "%d checks, %d failures\n", g_checks, g_fail); + return g_fail ? 1 : 0; +}