diff --git a/.kiro/specs/scheduler-sim-repl-verbosity/design.md b/.kiro/specs/scheduler-sim-repl-verbosity/design.md index e6944546..cae74207 100644 --- a/.kiro/specs/scheduler-sim-repl-verbosity/design.md +++ b/.kiro/specs/scheduler-sim-repl-verbosity/design.md @@ -110,7 +110,9 @@ flowchart TD - `src/scheduler/include/repl.h` — no functional change expected; existing inline wrappers already cover the gating points this feature needs. ### New/Modified Test Files -- `src/scheduler/testsuite/test_repl_execution_control.c` — extended with child-process output-capture tests covering Requirements 1, 2, 3, and the Requirement 5 no-regression checks (this file already drives `sched_sim_driver_run()` end-to-end and captures real process output via `child_continue_then_inspect`-style helpers); also gains the new prompt-content tests (Requirement 5.5) and `help`-listing structure tests (Requirement 7.1-7.4) — the natural home given it already exercises `readline()`/prompt-adjacent behavior and no `help` test exists anywhere today. +**Correction (post-review, task 2.1)**: `test_repl_execution_control.c` was already over this project's 800-line cap (937 lines) before this feature's work began. Landing task 2.1's Round Rollup tests there per the original plan below pushed it to 1563 lines — nearly double the cap, and past the ~1000-line threshold this exact spec family already treats as a split trigger (`test_repl_snapshot_params.c` was split out of `test_repl_breakpoints_inspection.c` for precisely this reason; see that file's own meson.build comment). Round Rollup's tests (task 2.1, and any Round-Rollup-internal portion of later tasks, e.g. Requirement 3.5's flush-on-structural-event/breakpoint interaction) now live in a new `src/scheduler/testsuite/test_repl_round_rollup.c` instead, duplicating (not sharing) whatever pre-existing static fixture helpers it needs — the established convention this same file pair (`test_repl_breakpoints_inspection.c`/`test_repl_snapshot_params.c`) already follows, rather than factoring out a shared test-helper header. `test_repl_execution_control.c` remains the target for Requirement 1's Event Echo Gate tests, the `sim_print_decision` gate tests, the Requirement 5 no-regression checks, the new prompt-content tests (Requirement 5.5), and `help`-listing structure tests (Requirement 7.1-7.4) — but re-check its line count before landing each of those; if it approaches the cap again, split further rather than growing past it. +- `src/scheduler/testsuite/test_repl_execution_control.c` — child-process output-capture tests covering Requirement 1, the Requirement 5 no-regression checks (this file already drives `sched_sim_driver_run()` end-to-end and captures real process output via `child_continue_then_inspect`-style helpers); also gains the new prompt-content tests (Requirement 5.5) and `help`-listing structure tests (Requirement 7.1-7.4) — the natural home given it already exercises `readline()`/prompt-adjacent behavior and no `help` test exists anywhere today. +- `src/scheduler/testsuite/test_repl_round_rollup.c` (new, per the Correction above) — child-process output-capture tests for Requirements 2 and 3 (per-round decision summary, collapsing, the shared flush entry point, and its call from `xce_repl_on_replay_complete`). - `src/scheduler/testsuite/test_repl_output_compression.c` (new) — focused unit tests for the jobid-range-compression helper (Requirement 4): a pure function, tested without the process harness. - `src/scheduler/testsuite/test_repl_resource_formatting.c` (new) — focused unit tests for the byte/megabyte classification and adaptive human-scaling helpers (Requirement 6.1, 6.2, 6.5): pure functions, tested without the process harness, mirroring `test_repl_output_compression.c`'s pattern. - `src/scheduler/testsuite/test_repl_breakpoints_inspection.c` — extended with `info worker`/`info resource` table-layout and units-mode-display tests (Requirement 6.6, alongside its existing `info worker`/`info resource` tests) and `break`'s enriched unrecognized-event-type error test (Requirement 7.5, alongside the existing `test_break_unrecognized_event_type_rejected`). diff --git a/.kiro/specs/scheduler-sim-repl-verbosity/tasks.md b/.kiro/specs/scheduler-sim-repl-verbosity/tasks.md index 5d7dd762..046cfbbf 100644 --- a/.kiro/specs/scheduler-sim-repl-verbosity/tasks.md +++ b/.kiro/specs/scheduler-sim-repl-verbosity/tasks.md @@ -13,7 +13,7 @@ - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_ - [ ] 2. Core: per-round decision summary, repetitive-round collapsing, and the shared range-flush capability -- [ ] 2.1 Write failing tests for round-level decision summaries, collapsing, and the shared flush capability +- [x] 2.1 Write failing tests for round-level decision summaries, collapsing, and the shared flush capability - Cover: a round with at least one decision produces one summary line naming decision counts and affected job identifiers; a round with zero decisions produces no individual line; two or more consecutive idle rounds collapse into a single range notification; two or more consecutive rounds with an identical decision outcome collapse into a single range notification; a collapsed range notification states its round bounds, round count, and covered time span; a change in outcome ends an active collapsed range; a job's status change between rounds is reflected in the following round's summary even without a dedicated dispatch event to observe; the shared flush capability safely does nothing when no range is active and is safe to call more than once in a row; an active collapsed range open at replay completion is flushed and printed before `xce_repl_on_replay_complete()`'s existing completion banner - Observable: the new test cases fail against today's unconditional per-decision echo, which has no summarizing, collapsing, or flush behavior - _Requirements: 2.1, 2.2, 2.4, 3.1, 3.2, 3.3, 3.4, 3.6_ diff --git a/src/scheduler/testsuite/meson.build b/src/scheduler/testsuite/meson.build index 6680db9a..988dcbf7 100644 --- a/src/scheduler/testsuite/meson.build +++ b/src/scheduler/testsuite/meson.build @@ -901,6 +901,36 @@ sched_repl_execution_control_test = executable( dependencies: [scheduler_test_deps, libedit_dep], install: false) +# Contract test for the interactive simulation REPL's Round Rollup +# component (scheduler-sim-repl-verbosity spec, task 2.1, Requirements 2 +# and 3 "per-round decision summary"/"collapsing repetitive rounds"): a +# round with at least one decision produces a summary naming decision +# counts and affected jobids; idle or identical-outcome consecutive rounds +# collapse into a single range notification; and the shared +# repl_round_collapse_flush() entry point is exercised directly (a safe +# no-op when nothing is active) and indirectly (flushed before a structural +# event/replay-complete banner). Same white-box #include pattern as +# sched_repl_execution_control_test above (core.c, core_clock.c, +# core_events.c, core_session.c, decision.c, plugin.c, +# plugins/builtin_fcfs.c, recording.c, recording_session.c, sim.c, +# sim_loop.c, sim_record.c, sim_verify.c, +# snapshot.c/snapshot_wire.c/snapshot_load.c, repl.c, repl_break.c, +# repl_info.c, repl_params.c). Split into its own file rather than appended +# to test_repl_execution_control.c -- that file was already 936 lines +# (pre-existing, over this project's 800-line cap on its own), and this +# task's own tests (seven forked-child, real-stdout-capture fixtures plus +# their own fork/capture helper pair) would have pushed it to 1563 lines, +# past the ~1000-line threshold this exact spec family already treats as a +# split trigger (see sched_repl_snapshot_params_test's own comment below +# for the precedent). See design.md's "**Correction (post-review, task +# 2.1)**" note. +sched_repl_round_rollup_test = executable( + 'sched_repl_round_rollup_test', + ['test_repl_round_rollup.c'], + include_directories:[incdir], + dependencies: [scheduler_test_deps, libedit_dep], + install: false) + # Contract test for the interactive simulation REPL's breakpoint and # state-inspection slice (scheduler-core spec, task 12.2, Req 5 AC 22 # "Breakpoints"/"State inspection"): setting a breakpoint on a specific @@ -1351,6 +1381,9 @@ test('scheduler sim e2e original motivation test', test('scheduler repl execution control test', sched_repl_execution_control_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) +test('scheduler repl round rollup test', + sched_repl_round_rollup_test, suite: 'scheduler', + env: ['ASAN_OPTIONS=detect_leaks=0']) test('scheduler repl breakpoints inspection test', sched_repl_breakpoints_inspection_test, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0']) diff --git a/src/scheduler/testsuite/test_repl_round_rollup.c b/src/scheduler/testsuite/test_repl_round_rollup.c new file mode 100644 index 00000000..5938e8df --- /dev/null +++ b/src/scheduler/testsuite/test_repl_round_rollup.c @@ -0,0 +1,1021 @@ +/* + * test_repl_round_rollup.c - contract test for the interactive simulation + * REPL's Round Rollup component (scheduler-sim-repl-verbosity spec, task + * 2.1, Requirements 2 and 3): per-round decision summaries, collapsing of + * repetitive rounds, and the shared repl_round_collapse_flush() flush entry + * point. + * + * Split out of test_repl_execution_control.c -- see design.md's own + * "**Correction (post-review, task 2.1)**" note in its "New/Modified Test + * Files" section. Landing these tests there per the original plan pushed + * that file from 937 to 1563 lines, well past this project's 800-line cap + * and past the ~1000-line threshold this exact spec family already treats + * as a split trigger -- the same reason test_repl_snapshot_params.c was + * split out of test_repl_breakpoints_inspection.c (see that file pair's own + * meson.build comments). Following that established convention, this file + * duplicates (rather than shares via a new header) whatever pre-existing + * static fixture helpers it needs from test_repl_execution_control.c. + * + * Fixture strategy and white-box #include set: identical to + * test_repl_execution_control.c (see that file's own header comment) -- + * this file exercises the same real sched_sim_driver_run() end-to-end, + * forked-child, real-stdout-capture technique. + * + * See the section header comment below (carried over verbatim from + * test_repl_execution_control.c, task 2.1's original landing spot) for the + * RED-phase rationale and the assumed output contract these tests pin. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "controller.capnp.h" +#include "kvmgr.h" +#include "scheduler.h" +#include "xstring.h" + +/* Pull in the real functions under test into this TU (white-box #include, + * same convention as test_repl_execution_control.c/ + * test_repl_breakpoints_inspection.c). */ +#include "../src/core.c" +#include "../src/core_clock.c" +#include "../src/core_events.c" +#include "../src/core_session.c" +#include "../src/decision.c" +#include "../src/plugin.c" +#include "../src/plugins/builtin_fcfs.c" +#include "../src/recording.c" +#include "../src/recording_session.c" +#include "../src/repl.c" +#include "../src/repl_break.c" +#include "../src/repl_info.c" +#include "../src/repl_params.c" +#include "../src/sim.c" +#include "../src/sim_loop.c" +#include "../src/sim_picker.c" /* bug-1567: sched_sim_driver_run() now calls sim_bootstrap_picker() (sim_loop.c-cap sibling) as an extern -- undefined reference at link time otherwise */ +#include "../src/sim_record.c" +#include "../src/sim_verify.c" +#include "../src/snapshot.c" +#include "../src/snapshot_load.c" +#include "../src/snapshot_wire.c" + +void kill_sched_job(void* arg) { (void)arg; } +void kill_sched_queue(void* arg) { (void)arg; } +void kill_sched_worker(void* arg) { (void)arg; } +void kill_sched_user(void* arg) { (void)arg; } +void kill_sched_project(void* arg) { (void)arg; } +void kill_sched_resval(xce_val_t* val) { (void)val; } + +/* resource-accounting.md's reconciliation (resource.c, not linked here -- + * this file only #includes core_events.c). core_events.c's + * XCE_CORE_EVENT_JOB_RUSAGE handler calls this whenever job->accounts != + * NULL; link-satisfying stub only, same rationale as every other stub in + * this block (see test_job_update.c's identical stub). */ +int sched_reconcile_job_resource_usage(xce_sched_core_t* c, + xce_job_account_t* a, + xce_job_rusages_t* r) +{ + (void)c; (void)a; (void)r; return 0; +} + +void sched_worker_release_host_ctx(xce_sched_worker_t* w) { (void)w; } + +int sched_fill_limit_requests(xce_scheduler_t* sched, xvec_idval_t* vec, + xlist_t* limits) +{ + (void)sched; + (void)vec; + (void)limits; + return 0; +} + +/* sched_sim_driver_run()'s (sim_loop.c, scheduler-sim-decision-parity task + * 2.1) new round-boundary call to the relocated sched_account_jobs() + * (core.h) -- a no-op stand-in here since this file does not link + * scheduler.c/resource.c, same rationale test_sim_equivalence.c's own copy + * documents. */ +int sched_account_jobs(xce_sched_core_t* core) +{ + (void)core; + return 0; +} + +/* Real reimplementation of schedobj.c's sched_resolve_job_picker -- same + * copy test_sim_alt_events.c/test_sim_verify.c/test_sim_event_loop.c/ + * test_repl_execution_control.c each carry (schedobj.c is not linked here: + * it would need plugin_register(), which requires a genuine + * xce_scheduler_t* neither this file's fixtures nor sim_loop.c's own + * core-scoped registration path have, Req 5 AC 17). */ +int sched_resolve_job_picker(xce_sched_core_t* core, xce_sched_plugin_t** out) +{ + xce_sched_plugin_t* plugin = NULL; + int rc = 0; + + if ((core == NULL) || (out == NULL)) { + return -EINVAL; + } + + *out = NULL; + + if (core->job_pick_plugin == NULL) { + return -EINVAL; + } + + lock(core->plugins, RTTI_LOCK_READ); + rc = key_find(core->plugins, core->job_pick_plugin, (void**)&plugin); + unlock(core->plugins); + + if (rc <= 0) { + return (rc < 0) ? rc : -ENOENT; + } + + if ((plugin == NULL) || (plugin->picker_iface == NULL) + || (plugin->picker_iface->next_job == NULL) + || (plugin->picker_iface->begin_session == NULL) + || (plugin->picker_iface->end_session == NULL) + || (plugin->picker_iface->on_dispatch == NULL) + || (plugin->picker_iface->on_pend == NULL)) { + return -EINVAL; + } + + *out = plugin; + + return 0; +} + +void setUp(void) { return; } +void tearDown(void) { return; } + +/* ---------------------------------------------------------------------- */ +/* Fixture helpers -- duplicated from test_repl_execution_control.c (same */ +/* techniques test_sim_alt_events.c/test_sim_verify.c already establish), */ +/* per this file pair's established split convention (see header comment */ +/* above): copied, not shared via a new header. */ +/* ---------------------------------------------------------------------- */ + +static void make_tmp_dir(char* out, size_t out_len, const char* suffix) +{ + char tmpl[PATH_MAX]; + + snprintf(tmpl, sizeof(tmpl), "/tmp/sched_replexec%s_XXXXXX", suffix); + TEST_ASSERT_NOT_NULL(mkdtemp(tmpl)); + TEST_ASSERT_TRUE(strlen(tmpl) < out_len); + strcpy(out, tmpl); +} + +static void build_fixture_scheduler(xce_scheduler_t* sched, char* record_dir) +{ + int rc; + + memset(sched, 0, sizeof(*sched)); + mylock_init(&(sched->record_lock)); + + rc = xce_sched_core_init(&(sched->core)); + TEST_ASSERT_EQUAL_INT(0, rc); + + sched->core.job_pick_plugin = (char*)"builtin_fcfs"; + sched->record_dir = record_dir; +} + +static FILE* write_header_and_snapshot(char* out_path, size_t out_path_len, + const char* suffix) +{ + char record_dir[PATH_MAX]; + xce_scheduler_t sched; + int rc; + + make_tmp_dir(record_dir, sizeof(record_dir), suffix); + build_fixture_scheduler(&sched, record_dir); + + rc = sched_record_start(&sched, "fixture.replay"); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_TRUE(strlen(sched.record_file_path) < out_path_len); + strcpy(out_path, sched.record_file_path); + + return sched.record_file; +} + +static void append_queue_update_event(FILE* f, uint64_t ts, + const char* queue_name) +{ + xce_queue_t queue; + replay_event_t src; + struct capn c; + struct capn_segment* cs; + ReplayEvent_ptr p; + + memset(&queue, 0, sizeof(queue)); + queue.queue = (char*)queue_name; + queue.status = QUEUE_STAT_OPEN; + queue.priority = 1; + + memset(&src, 0, sizeof(src)); + src.ts = ts; + src.body_which = ReplayEvent_body_queueUpdate; + src.body.queue_update = &queue; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + encode_ReplayEvent_ptr(cs, &p, &src); + capn_setp(capn_root(&c), 0, p.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +static void append_jobs_push_event(FILE* f, uint64_t ts, uint64_t jobid, + const char* queue_name) +{ + job_submit_data_t submit; + xce_job_t job; + xce_job_t* jobs[1]; + xce_jobs_data_t jobs_data; + replay_event_t src; + struct capn c; + struct capn_segment* cs; + ReplayEvent_ptr p; + + memset(&submit, 0, sizeof(submit)); + submit.queue = (char*)queue_name; + submit.user = (char*)"alice"; + submit.mintasks = 1; + submit.maxtasks = 1; + + memset(&job, 0, sizeof(job)); + job.jobid = jobid; + job.state = JOB_STAT_PEND; + job.submit = &submit; + + jobs[0] = &job; + + memset(&jobs_data, 0, sizeof(jobs_data)); + jobs_data.n_total = 1; + jobs_data.count = 1; + jobs_data.jobs = jobs; + + memset(&src, 0, sizeof(src)); + src.ts = ts; + src.body_which = ReplayEvent_body_jobsPush; + src.body.jobs_push = &jobs_data; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + encode_ReplayEvent_ptr(cs, &p, &src); + capn_setp(capn_root(&c), 0, p.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +static void append_replay_session(FILE* f, uint64_t begin_round) +{ + struct capn c; + struct capn_segment* cs; + ReplaySession_ptr sp; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + sp = new_ReplaySession(cs); + ReplaySession_set_beginRound(sp, begin_round); + capn_setp(capn_root(&c), 0, sp.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +static void append_end_of_stream_marker(FILE* f) +{ + replay_event_t src; + struct capn c; + struct capn_segment* cs; + ReplayEvent_ptr p; + + memset(&src, 0, sizeof(src)); + src.body_which = ReplayEvent_body_endSession; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + encode_ReplayEvent_ptr(cs, &p, &src); + capn_setp(capn_root(&c), 0, p.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +static void write_text_file(const char* path, const char* content) +{ + FILE* f = fopen(path, "w"); + + TEST_ASSERT_NOT_NULL(f); + TEST_ASSERT_TRUE(fputs(content, f) >= 0); + fclose(f); +} + +static char* read_whole_file(const char* path) +{ + FILE* f = fopen(path, "rb"); + long len; + char* buf; + + TEST_ASSERT_NOT_NULL(f); + TEST_ASSERT_EQUAL_INT(0, fseek(f, 0, SEEK_END)); + len = ftell(f); + TEST_ASSERT_TRUE(len >= 0); + rewind(f); + + buf = (char*)malloc((size_t)len + 1); + TEST_ASSERT_NOT_NULL(buf); + if (len > 0) { + TEST_ASSERT_EQUAL_INT((int)len, (int)fread(buf, 1, (size_t)len, f)); + } + buf[len] = '\0'; + fclose(f); + + return buf; +} + +/* Same dup()/dup2()-around-STDOUT_FILENO capture technique + * test_sim_verify.c's own capture_stdout_around() establishes. */ +static char* capture_stdout_around(void (*fn)(void)) +{ + char tmp[] = "/tmp/xce_replexec_stdoutcap_XXXXXX"; + int fd = mkstemp(tmp); + int saved_stdout; + off_t len; + char* buf; + + TEST_ASSERT_TRUE_MESSAGE(fd >= 0, "mkstemp for stdout capture must " + "succeed"); + + fflush(stdout); + saved_stdout = dup(STDOUT_FILENO); + TEST_ASSERT_TRUE_MESSAGE(saved_stdout >= 0, "dup(STDOUT_FILENO) must " + "succeed"); + TEST_ASSERT_TRUE_MESSAGE(dup2(fd, STDOUT_FILENO) >= 0, + "dup2 redirect of STDOUT_FILENO must succeed"); + + fn(); + + fflush(stdout); + dup2(saved_stdout, STDOUT_FILENO); + close(saved_stdout); + + len = lseek(fd, 0, SEEK_CUR); + TEST_ASSERT_TRUE(len >= 0); + + buf = (char*)calloc(1, (size_t)len + 1); + TEST_ASSERT_NOT_NULL(buf); + TEST_ASSERT_TRUE(lseek(fd, 0, SEEK_SET) == 0); + if (len > 0) { + ssize_t nread = read(fd, buf, (size_t)len); + TEST_ASSERT_TRUE(nread == len); + } + close(fd); + unlink(tmp); + + return buf; +} + +/* ---------------------------------------------------------------------- */ +/* scheduler-sim-repl-verbosity spec, task 2.1 -- RED-phase tests for the */ +/* Round Rollup (design.md's component of that name): per-round decision */ +/* summaries, collapsing of repetitive rounds, and the shared */ +/* repl_round_collapse_flush() flush entry point (Requirements 2, 3). */ +/* */ +/* THIS TASK (2.1) IS RED-PHASE ONLY: repl_round_collapse_flush() does not */ +/* exist anywhere in this codebase yet (task 2.2, a separate later task, */ +/* implements it in repl.c and wires it into xce_repl_post_session_check()/*/ +/* xce_repl_on_replay_complete()). Declaring it below as a local `extern` */ +/* (same technique test_repl_output_compression.c's own header comment */ +/* establishes for repl_fmt_jobid_range() before task 1.2 existed) makes */ +/* test_round_collapse_flush_is_a_safe_noop_when_nothing_active() below */ +/* fail this WHOLE FILE at LINK time (undefined reference) until task 2.2 */ +/* lands -- exactly the same RED-phase shape task 1.1's own file took, and */ +/* exactly what this task's own dispatch text calls out as valid evidence */ +/* ("a build/link failure if you reference a new helper ... that doesn't */ +/* exist yet"). Every other test below is a full end-to-end, forked-child, */ +/* real-stdout-capture proof (same technique test_repl_execution_control.c's*/ +/* own Tests 2/3 establish), since the Round Rollup's own boundary is */ +/* behavioral, not a pure function -- those tests build and link fine */ +/* today (they reference only already-existing symbols) but FAIL THEIR OWN */ +/* ASSERTIONS at runtime, because today's REPL still prints the old */ +/* unconditional per-decision echo with no summarizing/collapsing/flush */ +/* behavior at all. */ +/* */ +/* Assumed output contract (design.md's Round Rollup component documents */ +/* the fingerprint/collapse ALGORITHM and the repl_round_collapse_flush() */ +/* service-interface contract, but not a byte-exact print format -- this */ +/* comment fixes the literal format these tests pin, for task 2.2 to */ +/* satisfy, mirroring test_repl_output_compression.c's own "Assumed output */ +/* contract" precedent): */ +/* */ +/* - Every completed round is tracked as part of an "active" fingerprint */ +/* range (dispatch/pend/preempt counts from the existing last_n_* */ +/* tally, PLUS the current pend-jobid set queried directly from */ +/* core->jobs for JOB_IS_PEND -- design.md's state-derived-pend-set */ +/* decision, Requirement 2.4). A round whose fingerprint MATCHES the */ +/* active range silently extends it (no print). A round whose */ +/* fingerprint DIFFERS flushes (prints) the active range first, then */ +/* begins tracking the new round as a fresh active range of its own */ +/* (not yet printed -- print is always one step deferred until the */ +/* range is known to have ended, via a later mismatch, a structural */ +/* event/breakpoint hit (repl_break.c, task 3's own scope), or replay */ +/* completion). */ +/* - A NON-IDLE range (its fingerprint has at least one decision) is */ +/* ALWAYS eventually flushed and printed, even if its own round count */ +/* never exceeds 1 (Requirement 2.1's "one summary line" for a round */ +/* with at least one decision). */ +/* - An IDLE range (zero decisions, empty pend-jobid set) is printed */ +/* ONLY once its own round count reaches 2 or more (Requirement 3.1); */ +/* a genuinely isolated single idle round produces NO output at all, */ +/* ever (Requirement 2.2's "shall not print an individual line"). */ +/* - Print format, non-idle: "round - ( round(s), */ +/* t=..): dispatch,

pend, */ +/* preempt -- jobids=" (prefixed */ +/* "[sim] ", this file family's universal convention). / */ +/* are the range's bounding round numbers (rs->session_count values, */ +/* 1-based, the same number `info session` reports); ts-human is */ +/* repl_format_ts_human() applied to each bounding round's own */ +/* begin_round value (Requirement 3.4's "covered time span"). */ +/* - Print format, idle (count >= 2 only): identical bounds/count/span */ +/* preamble, body "idle" instead of a decision-count/jobids clause. */ +/* - Tests below use substring containment (strstr()), not full-line */ +/* equality, wherever more than one job's identifier could appear in */ +/* the same rendering -- repl_fmt_jobid_range()'s grouping order */ +/* depends on core->jobs' own kvmgr iteration order, which these tests */ +/* do not assume anything about beyond "every affected id appears */ +/* somewhere in its own round's rendering." */ +/* ---------------------------------------------------------------------- */ + +/* + * repl_round_collapse_flush (task 2.2, NOT YET IMPLEMENTED -- see this + * section's own header comment above). Signature copied verbatim from + * design.md's Round Rollup Service Interface. + */ +extern void repl_round_collapse_flush(xce_sched_sim_driver_t* sim); + +/* + * append_job_state_change_event - same capnp-encode-one-ReplayEvent shape + * append_queue_update_event()/append_jobs_push_event() above establish, + * for a JobStateChangeEvent (controller.capnp, task 7.2) body. Used to + * mutate a job's state directly via the wire event core_events.c's + * XCE_CORE_EVENT_JOB_STATE_CHANGE handler applies -- WITHOUT ever routing + * through a live decision object, the exact mechanism this section's tests + * use to exercise Requirement 2.4's state-derived (not decision-tally- + * derived) pend-jobid set, independent of bug-1611. + */ +static void append_job_state_change_event(FILE* f, uint64_t ts, + uint64_t jobid, uint32_t old_state, + uint32_t new_state) +{ + job_state_change_event_t change; + replay_event_t src; + struct capn c; + struct capn_segment* cs; + ReplayEvent_ptr p; + + memset(&change, 0, sizeof(change)); + change.jobid = jobid; + change.old_state = old_state; + change.new_state = new_state; + change.exit_code = 0; + + memset(&src, 0, sizeof(src)); + src.ts = ts; + src.body_which = ReplayEvent_body_jobStateChange; + src.body.job_state_change = &change; + + capn_init_malloc(&c); + cs = capn_root(&c).seg; + encode_ReplayEvent_ptr(cs, &p, &src); + capn_setp(capn_root(&c), 0, p.p); + + TEST_ASSERT_EQUAL_INT(0, write_capnp_message(f, &c)); + capn_free(&c); +} + +/* build_round_marker - the bounds+count PREFIX of this section's own + * pinned collapsed-range/round-summary format (see header comment above), + * e.g. build_round_marker(buf, sz, 1, 3) -> "round 1-3 (3 round(s)". Used + * as a strstr() needle -- deliberately stops short of the timestamp/ + * decision-count/jobids tail, which individual tests check separately + * with their own needles. */ +static void build_round_marker(char* out, size_t outsz, uint64_t start_round, + uint64_t end_round) +{ + snprintf(out, outsz, "round %llu-%llu (%llu round(s)", + (unsigned long long)start_round, (unsigned long long)end_round, + (unsigned long long)(end_round - start_round + 1)); +} + +/* child_run_repl_and_exit / run_repl_fixture_and_capture - the same fork/ + * freopen/waitpid/capture shape child_continue_then_inspect()/Test 2 + * (test_repl_execution_control.c) establish, factored into one shared pair + * since this section's seven fixtures all follow the identical "run a + * scripted continue+quit to completion, capture real stdout" shape with + * nothing test-specific in the fork/capture plumbing itself -- only the + * replay fixture content and the post-hoc assertions differ per test. */ +static void child_run_repl_and_exit(const char* replay_path, + const char* script_path, + const char* capture_path) +{ + xce_sched_sim_driver_t sim; + sched_sim_args_t args; + + if (freopen(script_path, "r", stdin) == NULL) { + _exit(120); + } + if (freopen(capture_path, "w", stdout) == NULL) { + _exit(121); + } + + memset(&sim, 0, sizeof(sim)); + memset(&args, 0, sizeof(args)); + args.replay_path = replay_path; + args.sim_start_ts = SCHED_SIM_START_TS_LATEST; + args.sim_speed = 0; + args.interactive = true; + + (void)sched_sim_driver_run(&sim, &args); + + fflush(stdout); + _exit(111); /* unreachable -- the script's `quit` must already have + * exited */ +} + +static char* run_repl_fixture_and_capture(const char* replay_path, + const char* script, + const char* aux_suffix) +{ + char aux_dir[PATH_MAX]; + char script_path[PATH_MAX]; + char capture_path[PATH_MAX]; + pid_t pid; + int status; + + make_tmp_dir(aux_dir, sizeof(aux_dir), aux_suffix); + snprintf(script_path, sizeof(script_path), "%s/script.txt", aux_dir); + snprintf(capture_path, sizeof(capture_path), "%s/capture.out", aux_dir); + write_text_file(script_path, script); + + pid = fork(); + TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork() must succeed"); + + if (pid == 0) { + child_run_repl_and_exit(replay_path, script_path, capture_path); + _exit(112); /* unreachable -- child_run_repl_and_exit never returns */ + } + + TEST_ASSERT_EQUAL_INT_MESSAGE(pid, waitpid(pid, &status, 0), + "waitpid must reap the REPL child"); + TEST_ASSERT_TRUE_MESSAGE(WIFEXITED(status), + "the REPL child must exit normally via quit's " + "exit(0)"); + TEST_ASSERT_EQUAL_INT(0, WEXITSTATUS(status)); + + return read_whole_file(capture_path); +} + +/* ---------------------------------------------------------------------- */ +/* Test: a round with >=1 decision produces one summary line naming */ +/* decision counts and the affected job's identifier (Requirement 2.1). */ +/* ---------------------------------------------------------------------- */ + +static void test_round_with_decisions_prints_summary_with_counts_and_jobid( + void) +{ + char replay_path[PATH_MAX]; + FILE* f; + char* captured; + char jobid_x[64]; + char round_marker[64]; + + f = write_header_and_snapshot(replay_path, sizeof(replay_path), + "_rrsingle"); + TEST_ASSERT_NOT_NULL(f); + + append_queue_update_event(f, 1000ULL, "normal"); + append_jobs_push_event(f, 1500ULL, 5001ULL, "normal"); + append_replay_session(f, 2000000000ULL); /* round 1: 1 pend (X) */ + append_end_of_stream_marker(f); + fclose(f); + + captured = run_repl_fixture_and_capture(replay_path, "continue\nquit\n", + "_rrsingleaux"); + + fmt_jobid(jobid_x, sizeof(jobid_x), 5001ULL); + build_round_marker(round_marker, sizeof(round_marker), 1, 1); + + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(captured, round_marker), + "a round with a decision must be reported with its own round " + "bounds/count (Requirement 2.1)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(captured, "1 pend"), + "the round summary must name the decision-type count (Requirement " + "2.1)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(captured, jobid_x), + "the round summary must name the affected job's identifier " + "(Requirement 2.1)"); + + free(captured); + + printf("test_round_with_decisions_prints_summary_with_counts_and_jobid: " + "PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test: a round with zero decisions produces no individual line, and two */ +/* or more consecutive idle rounds collapse into a single range */ +/* notification (Requirements 2.2, 3.1, 3.5). */ +/* ---------------------------------------------------------------------- */ + +static void +test_consecutive_idle_rounds_collapse_and_suppress_individual_lines(void) +{ + char replay_path[PATH_MAX]; + FILE* f; + char* captured; + char jobid_x[64]; + char idle_marker[64]; + char round3_marker[64]; + char* idle_pos; + char* round3_pos; + + f = write_header_and_snapshot(replay_path, sizeof(replay_path), + "_rridle"); + TEST_ASSERT_NOT_NULL(f); + + append_queue_update_event(f, 1000ULL, "normal"); + append_replay_session(f, 2000000000ULL); /* round 1: idle, no jobs yet */ + append_replay_session(f, 3000000000ULL); /* round 2: idle too */ + append_jobs_push_event(f, 3500000000ULL, 5002ULL, "normal"); + append_replay_session(f, 4000000000ULL); /* round 3: 1 pend (X) */ + append_end_of_stream_marker(f); + fclose(f); + + captured = run_repl_fixture_and_capture(replay_path, "continue\nquit\n", + "_rridleaux"); + + fmt_jobid(jobid_x, sizeof(jobid_x), 5002ULL); + build_round_marker(idle_marker, sizeof(idle_marker), 1, 2); + build_round_marker(round3_marker, sizeof(round3_marker), 3, 3); + + idle_pos = strstr(captured, idle_marker); + round3_pos = strstr(captured, round3_marker); + + TEST_ASSERT_NOT_NULL_MESSAGE( + idle_pos, "two consecutive idle rounds must collapse into ONE " + "range notification covering rounds 1-2 (Requirement " + "3.1)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + round3_pos, "round 3's own decision must still be reported once " + "the idle streak ends (Requirement 2.1)"); + TEST_ASSERT_TRUE_MESSAGE( + idle_pos < round3_pos, + "the idle range must be flushed and printed BEFORE round 3's own " + "line is eventually reported (Requirement 3.5)"); + TEST_ASSERT_NULL_MESSAGE( + strstr(captured, "round 1-1"), + "round 1 must never be reported as its own individual line -- " + "zero-decision rounds produce no individual line (Requirement " + "2.2)"); + TEST_ASSERT_NULL_MESSAGE( + strstr(captured, "round 2-2"), + "round 2 must never be reported as its own individual line either " + "(Requirement 2.2)"); + TEST_ASSERT_NOT_NULL_MESSAGE(strstr(captured, jobid_x), + "round 3's summary must name its own " + "affected job (Requirement 2.1)"); + + free(captured); + + printf("test_consecutive_idle_rounds_collapse_and_suppress_individual_" + "lines: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test: two or more consecutive rounds with an identical nonzero decision */ +/* outcome collapse into a single range notification that states its */ +/* round bounds, round count, and covered time span (Requirements 3.2, */ +/* 3.3, 3.4). */ +/* ---------------------------------------------------------------------- */ + +static void +test_identical_nonzero_outcome_rounds_collapse_with_bounds_count_and_span( + void) +{ + char replay_path[PATH_MAX]; + FILE* f; + char* captured; + char jobid_x[64]; + char round_marker[64]; + char ts_start_human[32]; + char ts_end_human[32]; + + f = write_header_and_snapshot(replay_path, sizeof(replay_path), + "_rrspan"); + TEST_ASSERT_NOT_NULL(f); + + append_queue_update_event(f, 1000ULL, "normal"); + append_jobs_push_event(f, 1500ULL, 5003ULL, "normal"); + append_replay_session(f, 2000000000ULL); /* round 1: 1 pend (X) */ + append_replay_session(f, 3000000000ULL); /* round 2: same outcome */ + append_replay_session(f, 4000000000ULL); /* round 3: same outcome again */ + append_jobs_push_event(f, 4500000000ULL, 5004ULL, "normal"); + append_replay_session(f, 5000000000ULL); /* round 4: outcome changes */ + append_end_of_stream_marker(f); + fclose(f); + + captured = run_repl_fixture_and_capture(replay_path, "continue\nquit\n", + "_rrspanaux"); + + fmt_jobid(jobid_x, sizeof(jobid_x), 5003ULL); + build_round_marker(round_marker, sizeof(round_marker), 1, 3); + repl_format_ts_human(2000000000ULL, ts_start_human, + sizeof(ts_start_human)); + repl_format_ts_human(4000000000ULL, ts_end_human, sizeof(ts_end_human)); + + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(captured, round_marker), + "three consecutive rounds sharing the same nonzero decision " + "outcome must collapse into ONE range covering rounds 1-3, " + "stating the round count (Requirement 3.2, 3.3)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(captured, ts_start_human), + "the collapsed range must state the covered time span's START " + "(Requirement 3.4)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(captured, ts_end_human), + "the collapsed range must state the covered time span's END " + "(Requirement 3.4)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(captured, jobid_x), + "the collapsed range must still name the affected job (Requirement " + "2.1, applied to the collapsed range)"); + + free(captured); + + printf( + "test_identical_nonzero_outcome_rounds_collapse_with_bounds_count_" + "and_span: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test: a change in decision outcome ends the active collapsed range and */ +/* reports it before the new outcome's own content (Requirement 3.5). */ +/* ---------------------------------------------------------------------- */ + +static void test_change_in_outcome_ends_active_collapsed_range(void) +{ + char replay_path[PATH_MAX]; + FILE* f; + char* captured; + char round1_marker[64]; + char round2_marker[64]; + char* round1_pos; + char* round2_pos; + + f = write_header_and_snapshot(replay_path, sizeof(replay_path), + "_rrchange"); + TEST_ASSERT_NOT_NULL(f); + + append_queue_update_event(f, 1000ULL, "normal"); + append_jobs_push_event(f, 1500ULL, 5005ULL, "normal"); + append_replay_session(f, 2000000000ULL); /* round 1: 1 pend (X) */ + append_jobs_push_event(f, 2500000000ULL, 5006ULL, "normal"); + append_replay_session(f, 3000000000ULL); /* round 2: 2 pend (X,Y) -- + * a DIFFERENT outcome */ + append_end_of_stream_marker(f); + fclose(f); + + captured = run_repl_fixture_and_capture(replay_path, "continue\nquit\n", + "_rrchangeaux"); + + build_round_marker(round1_marker, sizeof(round1_marker), 1, 1); + build_round_marker(round2_marker, sizeof(round2_marker), 2, 2); + + round1_pos = strstr(captured, round1_marker); + round2_pos = strstr(captured, round2_marker); + + TEST_ASSERT_NOT_NULL_MESSAGE( + round1_pos, "round 1's outcome (1 pend) must be reported once its " + "own distinct streak ends (Requirement 2.1)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + round2_pos, "round 2's DIFFERENT outcome (2 pend) must be reported " + "as its own entry (Requirement 2.1)"); + TEST_ASSERT_TRUE_MESSAGE( + round1_pos < round2_pos, + "a change in decision outcome must END and flush the active " + "collapsed range BEFORE the new round's own content is reported " + "(Requirement 3.5)"); + + free(captured); + + printf("test_change_in_outcome_ends_active_collapsed_range: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test: a job's tracked status change between rounds is reflected in the */ +/* FOLLOWING round's summary even though no dispatch decision is ever */ +/* produced for it anywhere (this simulator's own picker thread never */ +/* truly dispatches, sim_loop.c's own task-11.2 documented scope */ +/* boundary) -- exercises design.md's state-derived (not decision-tally- */ +/* derived) pend-jobid set, independent of bug-1611 (Requirement 2.4). */ +/* ---------------------------------------------------------------------- */ + +static void +test_job_status_change_reflected_in_following_round_without_dispatch_event( + void) +{ + char replay_path[PATH_MAX]; + FILE* f; + char* captured; + char jobid_x[64]; + char jobid_y[64]; + char round2_marker[64]; + char* round2_pos; + + f = write_header_and_snapshot(replay_path, sizeof(replay_path), + "_rrstatechg"); + TEST_ASSERT_NOT_NULL(f); + + append_queue_update_event(f, 1000ULL, "normal"); + append_jobs_push_event(f, 1500ULL, 5007ULL, "normal"); /* X */ + append_jobs_push_event(f, 1600ULL, 5008ULL, "normal"); /* Y */ + append_replay_session(f, 2000000000ULL); /* round 1: 2 pend (X,Y) */ + /* X leaves PEND state via a bare state-change notification -- no + * dispatch decision is ever produced anywhere for X. */ + append_job_state_change_event(f, 2500000000ULL, 5007ULL, JOB_STAT_PEND, + JOB_STAT_RUN); + append_replay_session(f, 3000000000ULL); /* round 2: only Y still pend */ + append_end_of_stream_marker(f); + fclose(f); + + captured = run_repl_fixture_and_capture(replay_path, "continue\nquit\n", + "_rrstatechgaux"); + + fmt_jobid(jobid_x, sizeof(jobid_x), 5007ULL); + fmt_jobid(jobid_y, sizeof(jobid_y), 5008ULL); + build_round_marker(round2_marker, sizeof(round2_marker), 2, 2); + + round2_pos = strstr(captured, round2_marker); + + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(captured, jobid_x), + "round 1's summary must still name X while it was pending"); + TEST_ASSERT_NOT_NULL_MESSAGE( + round2_pos, "round 2's (now different) outcome must be reported " + "once round 1's streak ends (Requirement 2.1)"); + TEST_ASSERT_NOT_NULL_MESSAGE( + strstr(round2_pos, jobid_y), + "round 2's summary must name Y, still pending (Requirement 2.1)"); + TEST_ASSERT_NULL_MESSAGE( + strstr(round2_pos, jobid_x), + "round 2's summary must NOT name X any more -- its pending status " + "changed via a bare JOB_STATE_CHANGE with no dispatch decision " + "ever produced for it, yet the round summary must still reflect " + "the change (Requirement 2.4, design.md's state-derived pend-set " + "decision)"); + + free(captured); + + printf( + "test_job_status_change_reflected_in_following_round_without_" + "dispatch_event: PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test: repl_round_collapse_flush() is a safe no-op when no range is */ +/* active, and calling it twice in a row prints nothing either time */ +/* (design.md's Round Rollup Service Interface -- Requirement 3's shared */ +/* flush capability). */ +/* ---------------------------------------------------------------------- */ + +static xce_sched_sim_driver_t* g_round_rollup_flush_sim; + +static void call_round_collapse_flush_twice(void) +{ + repl_round_collapse_flush(g_round_rollup_flush_sim); + repl_round_collapse_flush(g_round_rollup_flush_sim); +} + +static void test_round_collapse_flush_is_a_safe_noop_when_nothing_active( + void) +{ + xce_sched_sim_driver_t sim; + char* captured; + + memset(&sim, 0, sizeof(sim)); + sim.interactive = true; + sim.repl_state = repl_state_ensure(&sim); + TEST_ASSERT_NOT_NULL(sim.repl_state); + + g_round_rollup_flush_sim = ∼ + captured = capture_stdout_around( + call_round_collapse_flush_twice); + + TEST_ASSERT_EQUAL_STRING_MESSAGE( + "", captured, + "repl_round_collapse_flush() must print nothing when no collapsed " + "range is active, and calling it twice in a row must not print " + "anything either time"); + + free(captured); + free(sim.repl_state); + + printf( + "test_round_collapse_flush_is_a_safe_noop_when_nothing_active: " + "PASS\n"); +} + +/* ---------------------------------------------------------------------- */ +/* Test: an active collapsed range still open at replay completion is */ +/* flushed and printed BEFORE xce_repl_on_replay_complete()'s existing */ +/* completion banner (Requirement 3.6). */ +/* ---------------------------------------------------------------------- */ + +static void +test_active_collapsed_range_flushed_before_replay_complete_banner(void) +{ + char replay_path[PATH_MAX]; + FILE* f; + char* captured; + char jobid_x[64]; + char round_marker[64]; + char* round_pos; + char* banner_pos; + + f = write_header_and_snapshot(replay_path, sizeof(replay_path), + "_rrcomplete"); + TEST_ASSERT_NOT_NULL(f); + + append_queue_update_event(f, 1000ULL, "normal"); + append_jobs_push_event(f, 1500ULL, 5009ULL, "normal"); + append_replay_session(f, 2000000000ULL); /* round 1: 1 pend (X) */ + append_replay_session(f, 3000000000ULL); /* round 2: same outcome -- + * still active, no round 3 + * ever arrives to force a + * flush */ + append_end_of_stream_marker(f); + fclose(f); + + captured = run_repl_fixture_and_capture(replay_path, "continue\nquit\n", + "_rrcompleteaux"); + + fmt_jobid(jobid_x, sizeof(jobid_x), 5009ULL); + build_round_marker(round_marker, sizeof(round_marker), 1, 2); + + round_pos = strstr(captured, round_marker); + banner_pos = strstr(captured, "[sim] replay complete."); + + TEST_ASSERT_NOT_NULL_MESSAGE( + round_pos, "the still-open collapsed range (rounds 1-2) must be " + "flushed at replay completion (Requirement 3.6)"); + TEST_ASSERT_NOT_NULL_MESSAGE(banner_pos, + "replay completion must still print its " + "existing banner"); + TEST_ASSERT_TRUE_MESSAGE( + round_pos < banner_pos, + "an active collapsed range open at replay completion must be " + "flushed and printed BEFORE xce_repl_on_replay_complete()'s " + "existing completion banner (Requirement 3.6)"); + TEST_ASSERT_NOT_NULL_MESSAGE(strstr(captured, jobid_x), + "the flushed range must still name its " + "affected job"); + + free(captured); + + printf( + "test_active_collapsed_range_flushed_before_replay_complete_" + "banner: PASS\n"); +} + +int main(void) +{ + UNITY_BEGIN(); + + RUN_TEST(test_round_with_decisions_prints_summary_with_counts_and_jobid); + RUN_TEST( + test_consecutive_idle_rounds_collapse_and_suppress_individual_lines); + RUN_TEST( + test_identical_nonzero_outcome_rounds_collapse_with_bounds_count_and_span); + RUN_TEST(test_change_in_outcome_ends_active_collapsed_range); + RUN_TEST( + test_job_status_change_reflected_in_following_round_without_dispatch_event); + RUN_TEST(test_round_collapse_flush_is_a_safe_noop_when_nothing_active); + RUN_TEST( + test_active_collapsed_range_flushed_before_replay_complete_banner); + + return UNITY_END(); +}