/* test_rubo.c — host tests for the language, the framer and the executor. * * The emphasis is on the properties the design leans on: the DAG rule, the * depth bound, admission with no queue, and abort. Those are the ones that * stop being theoretical the moment there are motors attached. */ #include "rubo/hal_sim.h" #include "rubo/rubo.h" #include #include static int g_run, g_fail; static const char *g_case = ""; #define CHECK(cond, ...) \ do { \ g_run++; \ if (!(cond)) { \ g_fail++; \ printf("FAIL [%s] %s:%d: ", g_case, __FILE__, __LINE__); \ printf(__VA_ARGS__); \ printf("\n"); \ } \ } while (0) /* ----------------------------------------------------------- output capture */ #define MAXL 256 static char g_lines[MAXL][256]; static int g_nl; static void sink(void *ctx, const char *line) { (void)ctx; if (g_nl < MAXL) snprintf(g_lines[g_nl++], sizeof g_lines[0], "%s", line); } static void clr(void) { g_nl = 0; } static bool saw(const char *sub) { for (int i = 0; i < g_nl; i++) if (strstr(g_lines[i], sub)) return true; return false; } static void dump(void) { for (int i = 0; i < g_nl; i++) printf(" | %s\n", g_lines[i]); } /* ------------------------------------------------------------------ fixture */ static uint32_t g_now; static rubo_t *mk(void) { rubo_hal_sim_reset(); g_now = 1000; clr(); rubo_t *r = rubo_create(rubo_hal_sim(), sink, NULL); rubo_note_traffic(r, g_now); rubo_load_stdlib(r, rubo_stdlib); clr(); return r; } /* Advance time with the link alive. */ static void run(rubo_t *r, uint32_t ms) { for (uint32_t i = 0; i < ms; i += 5) { g_now += 5; rubo_note_traffic(r, g_now); rubo_tick(r, g_now); } } /* Advance time with nothing arriving — the deadman's domain. */ static void run_quiet(rubo_t *r, uint32_t ms) { for (uint32_t i = 0; i < ms; i += 5) { g_now += 5; rubo_tick(r, g_now); } } static rubo_err_t sub(rubo_t *r, const char *s) { return rubo_submit(r, s, g_now); } /* Submit and run to completion (or until an implausible amount of simulated * time has passed, which counts as a hang). */ static rubo_err_t subrun(rubo_t *r, const char *s) { rubo_err_t e = sub(r, s); for (int i = 0; i < 4000 && rubo_state(r) != RUBO_IDLE; i++) { g_now += 5; rubo_note_traffic(r, g_now); rubo_tick(r, g_now); } return e; } #define CASE(name) do { g_case = (name); clr(); } while (0) /* ------------------------------------------------------------------- tests */ static void test_basics(void) { rubo_t *r = mk(); CASE("stdlib loaded"); CHECK(sub(r, "STAT") == RUBO_OK, "STAT failed"); CHECK(saw("proc=9"), "expected 6 natives + 3 stdlib procedures"); CASE("control words bypass the language"); clr(); CHECK(sub(r, "PING") == RUBO_OK, "PING failed"); CHECK(saw("PONG"), "no PONG"); clr(); CHECK(sub(r, "@7 PING") == RUBO_OK, "tagged PING failed"); CHECK(saw("@7 PONG"), "tag not echoed"); CASE("ack then done"); clr(); CHECK(subrun(r, "@1 STOP") == RUBO_OK, "STOP failed"); CHECK(saw("@1 ACK"), "no ACK"); CHECK(saw("@1 DONE"), "no DONE"); CASE("unknown callable"); clr(); CHECK(sub(r, "WOBBLE") == RUBO_E_UNKNOWN, "expected E_UNKNOWN"); CHECK(saw("ERR 2"), "wrong code"); CASE("empty sequence is legal"); clr(); CHECK(subrun(r, "[]") == RUBO_OK, "empty sequence rejected"); CHECK(saw("DONE"), "no DONE for empty sequence"); rubo_destroy(r); } static void test_named_args(void) { rubo_t *r = mk(); CASE("named args are order-independent"); clr(); CHECK(subrun(r, "MOVE dur=20 speed=50 dir=FWD") == RUBO_OK, "reorder failed"); CHECK(saw("DONE"), "did not complete"); uint32_t named_changes = rubo_hal_sim_state()->change_count; rubo_hal_sim_reset(); clr(); CHECK(subrun(r, "MOVE FWD 50 20") == RUBO_OK, "positional failed"); CHECK(rubo_hal_sim_state()->change_count == named_changes, "positional and named forms behaved differently (%u vs %u)", rubo_hal_sim_state()->change_count, named_changes); CASE("mixed positional then named"); clr(); CHECK(subrun(r, "MOVE FWD speed=50 dur=20") == RUBO_OK, "mixed failed"); CASE("positional after named is rejected"); clr(); CHECK(sub(r, "MOVE dir=FWD 50 20") == RUBO_E_SYNTAX, "expected E_SYNTAX"); CHECK(saw("positional"), "unhelpful message"); CASE("defaults fill in"); clr(); CHECK(subrun(r, "SQUARE side=20") == RUBO_OK, "default speed not applied"); CHECK(saw("DONE"), "did not complete"); CASE("missing required argument"); clr(); CHECK(sub(r, "SQUARE") == RUBO_E_ARITY, "expected E_ARITY"); /* Identifiers come back canonicalised to upper case (docs/grammar.md §3.4). */ CHECK(saw("ERR 3") && saw("SIDE"), "message should name the parameter"); CASE("unknown parameter name"); clr(); CHECK(sub(r, "SQUARE side=10 velocity=3") == RUBO_E_ARGNAME, "expected E_ARGNAME"); CHECK(saw("ERR 4"), "wrong code"); CASE("duplicate argument"); clr(); CHECK(sub(r, "SQUARE side=10 side=20") == RUBO_E_ARGNAME, "expected E_ARGNAME"); CASE("too many positionals"); clr(); CHECK(sub(r, "STOP 1") == RUBO_E_ARITY, "expected E_ARITY"); CASE("literal type mismatch is caught before execution"); clr(); CHECK(sub(r, "MOVE 5 50 20") == RUBO_E_ARGTYPE, "expected E_ARGTYPE"); CHECK(saw("ERR 5"), "wrong code"); CASE("a bad word value is a range error at runtime"); clr(); CHECK(subrun(r, "MOVE dir=SIDEWAYS speed=10 dur=10") == RUBO_OK, "should parse; the word is only wrong at run time"); CHECK(saw("ERR 6"), "expected E_RANGE"); rubo_destroy(r); } static void test_definitions(void) { rubo_t *r = mk(); CASE("define and call"); clr(); CHECK(sub(r, "DEF BOX $s [ MOVE dir=FWD speed=30 dur=$s ]") == RUBO_OK, "DEF failed"); CHECK(saw("ACK") && saw("DONE"), "DEF should ACK and DONE"); clr(); CHECK(subrun(r, "BOX s=20") == RUBO_OK, "call failed"); CHECK(saw("DONE"), "did not complete"); CASE("forward references are refused (docs/grammar.md §5.1)"); clr(); CHECK(sub(r, "DEF LATER [ NOTYET ]") == RUBO_E_UNDEFINED, "expected E_UNDEFINED"); CHECK(saw("ERR 7"), "wrong code"); CASE("$ must name a parameter of the enclosing procedure"); clr(); CHECK(sub(r, "DEF BAD $a [ MOVE dir=FWD speed=$b dur=10 ]") == RUBO_E_SYNTAX, "expected E_SYNTAX"); clr(); CHECK(sub(r, "MOVE dir=FWD speed=$a dur=10") == RUBO_E_SYNTAX, "no parameters at the top level"); CASE("duplicate parameter"); clr(); CHECK(sub(r, "DEF DUP $a $a [ STOP ]") == RUBO_E_ARGNAME, "expected E_ARGNAME"); CASE("defaults must come last"); clr(); CHECK(sub(r, "DEF ORD $a=1 $b [ STOP ]") == RUBO_E_SYNTAX, "expected E_SYNTAX"); CASE("natives are neither redefinable nor deletable"); clr(); CHECK(sub(r, "DEF MOVE $x [ STOP ]") == RUBO_E_READONLY, "expected E_READONLY"); clr(); CHECK(sub(r, "DEL MOVE") == RUBO_E_READONLY, "expected E_READONLY"); CASE("stdlib procedures are STATIC, so also read-only"); clr(); CHECK(sub(r, "DEL SQUARE") == RUBO_E_READONLY, "expected E_READONLY"); CASE("redefinition with the same signature is fine"); clr(); CHECK(sub(r, "DEF BOX $s [ MOVE dir=BACK speed=30 dur=$s ]") == RUBO_OK, "same-signature redefinition rejected"); CASE("cycles are refused (docs/grammar.md §5.2)"); clr(); CHECK(sub(r, "DEF A [ STOP ]") == RUBO_OK, "DEF A failed"); CHECK(sub(r, "DEF B [ A ]") == RUBO_OK, "DEF B failed"); clr(); CHECK(sub(r, "DEF A [ B ]") == RUBO_E_CYCLE, "expected E_CYCLE"); CHECK(saw("ERR 8"), "wrong code"); CASE("signature cannot change while a caller exists"); clr(); CHECK(sub(r, "DEF A $x [ STOP ]") == RUBO_E_ARITY, "expected E_ARITY"); CHECK(saw("B"), "message should name the caller"); CASE("DEL refuses to dangle a caller, then succeeds once free"); clr(); CHECK(sub(r, "DEL A") == RUBO_E_READONLY, "A is still called by B"); clr(); CHECK(sub(r, "DEL B") == RUBO_OK, "DEL B failed"); CHECK(sub(r, "DEL A") == RUBO_OK, "DEL A failed after its caller went"); clr(); CHECK(sub(r, "A") == RUBO_E_UNKNOWN, "A should be gone"); CASE("depth limit is enforced at definition time"); clr(); CHECK(sub(r, "DEF P1 [ STOP ]") == RUBO_OK, "P1"); CHECK(sub(r, "DEF P2 [ P1 ]") == RUBO_OK, "P2"); CHECK(sub(r, "DEF P3 [ P2 ]") == RUBO_OK, "P3"); CHECK(sub(r, "DEF P4 [ P3 ]") == RUBO_OK, "P4"); CHECK(sub(r, "DEF P5 [ P4 ]") == RUBO_OK, "P5"); CHECK(sub(r, "DEF P6 [ P5 ]") == RUBO_OK, "P6"); CHECK(sub(r, "DEF P7 [ P6 ]") == RUBO_OK, "P7 should sit exactly at the limit"); clr(); CHECK(sub(r, "DEF P8 [ P7 ]") == RUBO_E_LIMIT, "expected E_LIMIT"); CHECK(saw("ERR 10"), "wrong code"); rubo_destroy(r); } static void test_repeat(void) { rubo_t *r = mk(); CASE("REPEAT runs the body exactly n times"); clr(); rubo_hal_sim_reset(); CHECK(subrun(r, "REPEAT 3 [ DRIVE left=10 right=10 dur=10 ]") == RUBO_OK, "REPEAT failed"); CHECK(saw("DONE"), "did not complete"); /* Each iteration engages (0->10) then releases (10->0). */ CHECK(rubo_hal_sim_state()->change_count == 6, "expected 6 motor changes, got %u", rubo_hal_sim_state()->change_count); CASE("REPEAT 0 skips the body"); clr(); rubo_hal_sim_reset(); CHECK(subrun(r, "REPEAT 0 [ DRIVE left=10 right=10 dur=10 ]") == RUBO_OK, "REPEAT 0 failed"); CHECK(rubo_hal_sim_state()->change_count == 0, "body should not have run"); CASE("REPEAT nests"); clr(); rubo_hal_sim_reset(); CHECK(subrun(r, "REPEAT 2 [ REPEAT 3 [ DRIVE left=10 right=10 dur=5 ] ]") == RUBO_OK, "nested REPEAT failed"); CHECK(rubo_hal_sim_state()->change_count == 12, "expected 12 motor changes, got %u", rubo_hal_sim_state()->change_count); CASE("REPEAT takes a parameter"); clr(); CHECK(sub(r, "DEF NTIMES $n [ REPEAT $n [ WAIT dur=1 ] ]") == RUBO_OK, "DEF with REPEAT $n failed"); clr(); CHECK(subrun(r, "NTIMES n=3") == RUBO_OK, "call failed"); CHECK(saw("DONE"), "did not complete"); CASE("a negative REPEAT count is a range error"); clr(); CHECK(subrun(r, "NTIMES n=-1") == RUBO_OK, "should parse"); CHECK(saw("ERR 6"), "expected E_RANGE at run time"); rubo_destroy(r); } static void test_admission_and_abort(void) { rubo_t *r = mk(); CASE("busy: no queue (docs/flow.md §9)"); clr(); CHECK(sub(r, "@1 MOVE dir=FWD speed=50 dur=300") == RUBO_OK, "MOVE failed"); run(r, 20); CHECK(sub(r, "@2 STOP") == RUBO_E_BUSY, "second statement should be refused"); CHECK(saw("@2 ERR 12"), "wrong code"); CASE("definitions are refused while running, too"); clr(); CHECK(sub(r, "@3 DEF X [ STOP ]") == RUBO_E_BUSY, "DEF should be refused"); CASE("control words are never refused"); clr(); CHECK(sub(r, "PING") == RUBO_OK, "PING refused while busy"); CHECK(saw("PONG"), "no PONG"); CASE("abort has exactly one victim"); clr(); CHECK(sub(r, "ABORT") == RUBO_OK, "ABORT failed"); CHECK(saw("EVT ABORT"), "no EVT ABORT"); CHECK(saw("@1 ERR 13"), "the running program should report aborted"); CHECK(rubo_hal_sim_state()->left == 0 && rubo_hal_sim_state()->right == 0, "motors still engaged after ABORT"); CHECK(rubo_state(r) == RUBO_IDLE, "should be idle after ABORT"); CASE("the robot accepts work again immediately"); clr(); CHECK(subrun(r, "@4 STOP") == RUBO_OK, "should accept after abort"); CHECK(saw("@4 DONE"), "no DONE"); CASE("motor cap"); clr(); CHECK(subrun(r, "MOVE dir=FWD speed=50 dur=20000") == RUBO_OK, "should parse"); CHECK(saw("ERR 6"), "expected E_RANGE past RUBO_MOTOR_MAX_MS"); rubo_destroy(r); } static void test_deadman(void) { rubo_t *r = mk(); CASE("a dropped link stops the robot (docs/flow.md §10)"); clr(); /* dur=0 means "until countermanded": the motors stay on. */ CHECK(subrun(r, "MOVE dir=FWD speed=50 dur=0") == RUBO_OK, "MOVE failed"); CHECK(rubo_hal_sim_state()->left != 0, "motors should be engaged"); clr(); run_quiet(r, RUBO_LINK_TIMEOUT_MS + 200); CHECK(saw("EVT LINKLOST"), "deadman did not fire"); CHECK(rubo_hal_sim_state()->left == 0, "motors not stopped by deadman"); CASE("the deadman is not armed when the motors are idle"); clr(); run_quiet(r, RUBO_LINK_TIMEOUT_MS + 200); CHECK(!saw("EVT LINKLOST"), "deadman fired on an idle robot"); rubo_destroy(r); } static void test_reserved(void) { rubo_t *r = mk(); CASE("IF is reserved but not implemented (docs/grammar.md §7)"); clr(); CHECK(sub(r, "IF DIST < 30 [ STOP ]") == RUBO_E_SYNTAX, "expected E_SYNTAX"); CHECK(saw("reserved"), "message should say it is reserved"); CASE("a bare ';' at the top level (docs/flow.md §4.4)"); clr(); CHECK(sub(r, "STOP; STOP") == RUBO_E_SYNTAX, "expected E_SYNTAX"); CHECK(saw("brackets"), "message should suggest bracketing"); rubo_destroy(r); } /* -------------------------------------------------------------- the framer */ static int feed(rubo_framer_t *f, const char *s, char *out, size_t outlen) { int emitted = 0; for (const char *p = s; *p; p++) { int st = rubo_framer_push(f, *p, 1000); if (st == 1) { emitted++; if (out) snprintf(out, outlen, "%s", f->buf); } else if (st < 0) { return st; } } return emitted; } static void test_framer(void) { rubo_framer_t f; char got[RUBO_MAX_STMT + 1]; CASE("a statement ends at a newline only at depth zero"); rubo_framer_reset(&f); CHECK(feed(&f, "STOP\n", got, sizeof got) == 1, "one-liner not emitted"); CHECK(strcmp(got, "STOP") == 0, "got '%s'", got); CASE("a multi-line DEF is one statement"); rubo_framer_reset(&f); int n = feed(&f, "DEF SQ $s [\n" " REPEAT 4 [\n" " MOVE dir=FWD speed=50 dur=$s;\n" " TURN dir=RIGHT deg=90\n" " ]\n" "]\n", got, sizeof got); CHECK(n == 1, "expected exactly one statement, got %d", n); CHECK(strstr(got, "DEF SQ") && strstr(got, "TURN"), "reassembled wrongly: '%s'", got); CASE("comments cannot desynchronise the framer"); rubo_framer_reset(&f); CHECK(feed(&f, "# a [ bracket in a comment\n", got, sizeof got) == 0, "a comment-only line should produce no statement"); CHECK(feed(&f, "STOP\n", got, sizeof got) == 1, "the framer was left out of step by a comment"); CHECK(strcmp(got, "STOP") == 0, "got '%s'", got); CASE("a trailing comment is dropped"); rubo_framer_reset(&f); CHECK(feed(&f, "STOP # and then\n", got, sizeof got) == 1, "not emitted"); CHECK(strstr(got, "#") == NULL, "comment leaked into the statement"); CASE("an unbalanced ']' is rejected and resynchronises"); rubo_framer_reset(&f); CHECK(feed(&f, "]\n", got, sizeof got) == -(int)RUBO_E_SYNTAX, "expected a syntax error"); rubo_framer_reset(&f); CHECK(feed(&f, "] junk\nSTOP\n", got, sizeof got) < 0, "expected an error"); CASE("an over-long statement is rejected"); rubo_framer_reset(&f); int rc = 0; for (int i = 0; i < RUBO_MAX_STMT + 8 && rc >= 0; i++) rc = rubo_framer_push(&f, 'A', 1000); CHECK(rc == -(int)RUBO_E_LIMIT, "expected E_LIMIT, got %d", rc); CASE("a partial statement goes stale"); rubo_framer_reset(&f); for (const char *p = "DEF X ["; *p; p++) rubo_framer_push(&f, *p, 1000); CHECK(rubo_framer_timeout(&f, 1000) == 0, "should not be stale yet"); CHECK(rubo_framer_timeout(&f, 1000 + RUBO_FRAME_TIMEOUT_MS + 1) < 0, "should have gone stale"); CASE("chunked arrival reassembles identically"); rubo_framer_reset(&f); const char *msg = "[ STOP; WAIT dur=1 ]\n"; int emitted = 0; for (const char *p = msg; *p; p++) if (rubo_framer_push(&f, *p, 1000) == 1) { emitted++; snprintf(got, sizeof got, "%s", f.buf); } CHECK(emitted == 1, "expected one statement"); CHECK(strcmp(got, "[ STOP; WAIT dur=1 ]") == 0, "got '%s'", got); } /* -------------------------------------------------------- end-to-end sanity */ static void test_end_to_end(void) { rubo_t *r = mk(); rubo_framer_t f; rubo_framer_reset(&f); CASE("wire to motors"); clr(); const char *wire = "@1 DEF ZIGZAG $deg=45 $reps=2 [\n" " REPEAT $reps [\n" " TURN dir=LEFT deg=$deg;\n" " TURN dir=RIGHT deg=$deg\n" " ]\n" "]\n" "@2 ZIGZAG deg=10 reps=2\n"; for (const char *p = wire; *p; p++) { if (rubo_framer_push(&f, *p, g_now) == 1) { rubo_submit(r, f.buf, g_now); run(r, 300); } } if (!saw("@1 DONE") || !saw("@2 DONE")) dump(); CHECK(saw("@1 ACK") && saw("@1 DONE"), "the definition did not complete"); CHECK(saw("@2 ACK") && saw("@2 DONE"), "the call did not complete"); CHECK(!saw("ERR"), "unexpected error"); CHECK(rubo_hal_sim_state()->left == 0, "motors left running"); rubo_destroy(r); } int main(void) { test_basics(); test_named_args(); test_definitions(); test_repeat(); test_admission_and_abort(); test_deadman(); test_reserved(); test_framer(); test_end_to_end(); printf("%d checks, %d failures\n", g_run, g_fail); return g_fail ? 1 : 0; }