STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_state_script.cpp
Go to the documentation of this file.
1// The state-script language: lexer, parser, parse-time caps, and config-dependent validation.
2//
3// Pure — no sockets, no files, no clock. Everything is a string in and a verdict out.
4//
5// The accept cases pin a canonical s-expression of the parsed AST, not just "it parsed". Two
6// readings of a script can both be "accepted" while disagreeing about precedence — `a - b - c`,
7// `not a and b`, `-x * y` — and a precedence disagreement between what the operator was shown and
8// what the interpreter runs is the worst bug this feature can have. Pinning the shape catches it.
9//
10// The corpus lives inline rather than in files. The plan called for a shared on-disk corpus so a
11// TypeScript parser could run the same cases; there is no TypeScript parser (the frontend does
12// regex slug scans and the backend shells out to state_script_check), so a second consumer never
13// materialised and files would be machinery with no reader.
14
15#include <iostream>
16#include <string>
17#include <vector>
18
20
21using namespace fsw::script;
22
23static int g_failures = 0;
24
25static void check(bool ok, const std::string& what) {
26 std::cout << (ok ? " ok " : " FAIL ") << what << std::endl;
27 if (!ok)
28 g_failures++;
29}
30
31/** The tables every case validates against. Deliberately includes the cross-namespace collisions
32 * that exist on the shipped profiles: FUEL_VENT is both a valve and a state on `server`, and
33 * FUEL_UPSTREAM is both a valve and a sensor on `digital-twin`. */
35 SlugTables t;
36 t.actuators = {
37 {"GSE_HIGH_PRESS_CONTROL", "GSE High Press Control"},
38 {"GSE_HIGH_PRESS_VENT", "GSE High Press Vent"},
39 {"FUEL_VENT", "Fuel Vent"},
40 {"FUEL_MAIN", "Fuel Main"},
41 {"FUEL_UPSTREAM", "Fuel Upstream"},
42 };
43 t.sensors = {
44 {"GN2_HIGH", "GN2 High"},
45 {"GN2_REGULATED", "GN2 Regulated"},
46 {"CHAMBER", "Chamber"},
47 {"FUEL_UPSTREAM", "Fuel Upstream"},
48 };
49 t.states = {
50 {"PRESS_STANDBY", "Press Standby"}, {"IDLE", "Idle"}, {"VENT", "Vent"},
51 {"FUEL_VENT", "Fuel Vent"}, {"FIRE", "Fire"},
52 };
53 // Everything except FIRE is reachable from the state owning these scripts.
54 t.allowed_transitions = {"PRESS_STANDBY", "IDLE", "VENT", "FUEL_VENT"};
55 return t;
56}
57
58struct Accept {
59 const char* name;
60 const char* src;
61 const char* ast;
62};
63
64struct Reject {
65 const char* name;
66 const char* src;
68 uint32_t line;
69};
70
71// ─────────────────────────────────────────────────────────────────────────────────────────────
72static const std::vector<Accept> kAccept = {
73 // The two motivating scripts, verbatim in shape.
74 {"vent pulse (motivating example 1)",
75 "open_valve(GSE_HIGH_PRESS_VENT)\n"
76 "delay(0.5)\n"
77 "close_valve(GSE_HIGH_PRESS_VENT)\n"
78 "transition_to(PRESS_STANDBY)\n",
79 "(seq (open GSE_HIGH_PRESS_VENT) (delay 0.5) (close GSE_HIGH_PRESS_VENT) "
80 "(goto PRESS_STANDBY))"},
81
82 {"COPV press (motivating example 2)",
83 "target = 0.9 * pressure(GN2_HIGH)\n"
84 "while pressure(GN2_REGULATED) < target:\n"
85 " open_valve(GSE_HIGH_PRESS_CONTROL)\n"
86 " delay(0.2)\n"
87 " close_valve(GSE_HIGH_PRESS_CONTROL)\n"
88 " delay(0.2)\n"
89 "transition_to(PRESS_STANDBY)\n",
90 "(seq (set target (* 0.9 (pressure GN2_HIGH))) "
91 "(while (< (pressure GN2_REGULATED) target) "
92 "(seq (open GSE_HIGH_PRESS_CONTROL) (delay 0.2) (close GSE_HIGH_PRESS_CONTROL) (delay 0.2))) "
93 "(goto PRESS_STANDBY))"},
94
95 // ── Precedence. These are the whole reason the AST shape is pinned. ──────────────────────
96 {"subtraction is left-associative", "a = 1\nb = 2\nc = 3\nd = a - b - c\ndelay(1)\n",
97 "(seq (set a 1) (set b 2) (set c 3) (set d (- (- a b) c)) (delay 1))"},
98
99 {"multiplication binds tighter than addition", "a = 1\nb = 2\nc = 3\nd = a + b * c\ndelay(1)\n",
100 "(seq (set a 1) (set b 2) (set c 3) (set d (+ a (* b c))) (delay 1))"},
101
102 {"unary minus binds tighter than multiplication", "x = 2\ny = 3\nz = -x * y\ndelay(1)\n",
103 "(seq (set x 2) (set y 3) (set z (* (neg x) y)) (delay 1))"},
104
105 {"not binds looser than comparison, tighter than and",
106 "a = 1\nb = 2\nif not a < b and a == 1:\n delay(1)\n",
107 "(seq (set a 1) (set b 2) (if (and (not (< a b)) (== a 1)) (seq (delay 1))))"},
108
109 {"and binds tighter than or", "a = 1\nb = 2\nc = 3\nif a or b and c:\n delay(1)\n",
110 "(seq (set a 1) (set b 2) (set c 3) (if (or a (and b c)) (seq (delay 1))))"},
111
112 {"parentheses override precedence", "a = 1\nb = 2\nc = 3\nd = (a + b) * c\ndelay(1)\n",
113 "(seq (set a 1) (set b 2) (set c 3) (set d (* (+ a b) c)) (delay 1))"},
114
115 // ── Structure ────────────────────────────────────────────────────────────────────────────
116 {"elif desugars to a nested if in the else arm",
117 "if pressure(CHAMBER) > 500:\n"
118 " transition_to(VENT)\n"
119 "elif pressure(CHAMBER) > 100:\n"
120 " transition_to(IDLE)\n"
121 "else:\n"
122 " transition_to(PRESS_STANDBY)\n",
123 "(seq (if (> (pressure CHAMBER) 500) (seq (goto VENT)) "
124 "(seq (if (> (pressure CHAMBER) 100) (seq (goto IDLE)) (seq (goto PRESS_STANDBY))))))"},
125
126 {"conditional transition — the point of the feature",
127 "if pressure(GN2_HIGH) > 4000:\n transition_to(FUEL_VENT)\nelse:\n "
128 "transition_to(VENT)\n",
129 "(seq (if (> (pressure GN2_HIGH) 4000) (seq (goto FUEL_VENT)) (seq (goto VENT))))"},
130
131 {"comments and blank lines carry no structure",
132 "# lead-in\n\nopen_valve(FUEL_VENT) # trailing\n\n\ndelay(1)\n",
133 "(seq (open FUEL_VENT) (delay 1))"},
134
135 {"two-space indent is fine as long as it is consistent", "while elapsed() < 5:\n delay(0.1)\n",
136 "(seq (while (< (elapsed) 5) (seq (delay 0.1))))"},
137
138 {"nested blocks",
139 "while elapsed() < 10:\n"
140 " if pressure(CHAMBER) > 100:\n"
141 " open_valve(FUEL_VENT)\n"
142 " delay(0.1)\n",
143 "(seq (while (< (elapsed) 10) (seq (if (> (pressure CHAMBER) 100) (seq (open FUEL_VENT))) "
144 "(delay 0.1))))"},
145
146 // ── The collision cases that make positional resolution necessary ────────────────────────
147 {"FUEL_VENT resolves as a valve and as a state in the same script",
148 "open_valve(FUEL_VENT)\ndelay(0.5)\nclose_valve(FUEL_VENT)\ntransition_to(FUEL_VENT)\n",
149 "(seq (open FUEL_VENT) (delay 0.5) (close FUEL_VENT) (goto FUEL_VENT))"},
150
151 {"FUEL_UPSTREAM resolves as a valve and as a sensor in the same script",
152 "if pressure(FUEL_UPSTREAM) > 100:\n open_valve(FUEL_UPSTREAM)\ndelay(1)\n",
153 "(seq (if (> (pressure FUEL_UPSTREAM) 100) (seq (open FUEL_UPSTREAM))) (delay 1))"},
154
155 {"a loop whose every if-arm delays satisfies the delay rule",
156 "while elapsed() < 5:\n"
157 " if pressure(CHAMBER) > 100:\n"
158 " delay(0.1)\n"
159 " else:\n"
160 " delay(0.2)\n",
161 "(seq (while (< (elapsed) 5) (seq (if (> (pressure CHAMBER) 100) (seq (delay 0.1)) "
162 "(seq (delay 0.2))))))"},
163};
164
165// ─────────────────────────────────────────────────────────────────────────────────────────────
166static const std::vector<Reject> kReject = {
167 // Lexer
168 {"tab indentation", "while elapsed() < 5:\n\tdelay(1)\n", Diag::TabIndent, 2},
169 {"a quoted name", "open_valve(\"Fuel Vent\")\n", Diag::UnexpectedChar, 1},
170 {"exponent notation", "delay(1e3)\n", Diag::BadNumber, 1},
171 {"two decimal points", "delay(1.2.3)\n", Diag::BadNumber, 1},
172 {"stray character", "delay(1) @\n", Diag::UnexpectedChar, 1},
173 {"inconsistent indent unit",
174 "while elapsed() < 5:\n delay(1)\nif elapsed() > 1:\n delay(1)\n", Diag::BadIndentUnit,
175 4},
176 {"dedent to no enclosing block",
177 "while elapsed() < 5:\n if elapsed() > 1:\n delay(1)\n delay(2)\n", Diag::BadDedent,
178 4},
179
180 // Parser
181 {"missing colon", "while elapsed() < 5\n delay(1)\n", Diag::ExpectedColon, 1},
182 {"unclosed paren", "open_valve(FUEL_VENT\n", Diag::ExpectedRParen, 1},
183 {"unknown command", "opne_valve(FUEL_VENT)\n", Diag::UnknownCall, 1},
184 {"unknown function in an expression", "x = flowrate(FUEL_VENT)\ndelay(1)\n", Diag::UnknownCall,
185 1},
186 {"chained comparison", "a = 1\nif 1 < a < 3:\n delay(1)\n", Diag::ChainedComparison, 2},
187 {"pressure() as a bare statement", "pressure(GN2_HIGH)\n", Diag::CallNotAStatement, 1},
188 {"assigning to a built-in", "delay = 5\n", Diag::AssignToCall, 1},
189 {"a block with no body", "while elapsed() < 5:\ndelay(1)\n", Diag::ExpectedBlock, 2},
190 {"elapsed() takes no argument", "x = elapsed(5)\ndelay(1)\n", Diag::ExpectedRParen, 1},
191 {"a variable where a valve name belongs", "v = 1\nopen_valve(v)\ndelay(1)\n",
193
194 // Caps and bounded-ness
195 {"delay(0) is a spin", "delay(0)\n", Diag::DelayNotPositive, 1},
196 // -1 must fold to a negative literal, or this check looks straight past Unary(Neg, Number(1))
197 // and delay(-1) becomes a clamped 10 ms spin at runtime instead of a refusal at load.
198 {"negative delay", "delay(-1)\n", Diag::DelayNotPositive, 1},
199 {"loop with no delay", "while elapsed() < 5:\n open_valve(FUEL_VENT)\n",
201 {"loop whose if-arm delays but has no else",
202 "while elapsed() < 5:\n if elapsed() > 1:\n delay(1)\n", Diag::LoopWithoutDelay, 1},
203 {"variable read before assignment", "delay(x)\n", Diag::VarUsedBeforeAssign, 1},
204 {"division by a literal zero", "x = 1 / 0\ndelay(1)\n", Diag::DivideByZeroLiteral, 1},
205
206 // Config-dependent (validate())
207 {"unknown actuator", "open_valve(FUEL_VNT)\ndelay(1)\n", Diag::UnknownActuator, 1},
208 {"unknown sensor", "x = pressure(GN2_HIGHH)\ndelay(1)\n", Diag::UnknownSensor, 1},
209 {"unknown state", "transition_to(NOWHERE)\n", Diag::UnknownState, 1},
210 {"a real state the matrix forbids", "transition_to(FIRE)\n", Diag::TransitionNotAllowed, 1},
211 {"variable shadowing a slug", "GN2_HIGH = 1\ndelay(GN2_HIGH)\n", Diag::VarShadowsSlug, 1},
212};
213
214/** Parse then validate, returning every diagnostic in one list the way a caller would see them. */
215static std::vector<Diagnostic> run(const std::string& src, const SlugTables& t) {
216 ParseResult r = parse(src);
217 if (!r.ok())
218 return r.diagnostics;
219 return validate(r.program, t);
220}
221
222int main() {
223 const SlugTables t = tables();
224
225 std::cout << "── accept ──" << std::endl;
226 for (const Accept& a : kAccept) {
227 ParseResult r = parse(a.src);
228 if (!r.ok()) {
229 check(false, std::string(a.name) + " — parse failed: " + r.diagnostics[0].message +
230 " (line " + std::to_string(r.diagnostics[0].line) + ")");
231 continue;
232 }
233 const auto issues = validate(r.program, t);
234 if (!issues.empty()) {
235 check(false, std::string(a.name) + " — validate failed: " + issues[0].message);
236 continue;
237 }
238 const std::string got = print_canonical(r.program);
239 if (got != a.ast) {
240 check(false, std::string(a.name) + " — AST shape");
241 std::cout << " want " << a.ast << std::endl;
242 std::cout << " got " << got << std::endl;
243 continue;
244 }
245 check(true, a.name);
246 }
247
248 std::cout << "── reject ──" << std::endl;
249 for (const Reject& rj : kReject) {
250 const auto issues = run(rj.src, t);
251 if (issues.empty()) {
252 check(false, std::string(rj.name) + " — accepted, but should have been refused");
253 continue;
254 }
255 const Diagnostic& d = issues[0];
256 if (d.code != rj.code) {
257 check(false, std::string(rj.name) + " — wrong code: wanted " + diag_name(rj.code) +
258 ", got " + diag_name(d.code) + " (\"" + d.message + "\")");
259 continue;
260 }
261 if (d.line != rj.line) {
262 check(false, std::string(rj.name) + " — wrong line: wanted " + std::to_string(rj.line) +
263 ", got " + std::to_string(d.line));
264 continue;
265 }
266 check(true,
267 std::string(rj.name) + " -> " + diag_name(d.code) + " @ " + std::to_string(d.line));
268 }
269
270 std::cout << "── caps ──" << std::endl;
271 {
272 std::string deep = "";
273 std::string indent = "";
274 for (uint32_t i = 0; i <= kMaxNestingDepth; i++) {
275 deep += indent + "while elapsed() < 5:\n";
276 indent += " ";
277 }
278 deep += indent + "delay(1)\n";
279 const auto issues = run(deep, t);
280 check(!issues.empty() && issues[0].code == Diag::TooDeep,
281 "nesting deeper than " + std::to_string(kMaxNestingDepth) + " is refused");
282 }
283 {
284 std::string many;
285 for (uint32_t i = 0; i < kMaxLines + 5; i++)
286 many += "delay(1)\n";
287 const auto issues = run(many, t);
288 check(!issues.empty() && issues[0].code == Diag::TooManyLines,
289 "more than " + std::to_string(kMaxLines) + " lines is refused");
290 }
291 {
292 std::string big(kMaxSourceBytes + 1, 'x');
293 const auto issues = run(big, t);
294 check(!issues.empty() && issues[0].code == Diag::ProgramTooLarge,
295 "a script over " + std::to_string(kMaxSourceBytes) + " bytes is refused");
296 }
297
298 std::cout << "── slugify ──" << std::endl;
299 check(slugify("GN2 High") == "GN2_HIGH", "\"GN2 High\" -> GN2_HIGH");
300 check(slugify("GSE High Press Control") == "GSE_HIGH_PRESS_CONTROL",
301 "\"GSE High Press Control\" -> GSE_HIGH_PRESS_CONTROL");
302 check(slugify(" Fuel Vent ") == "FUEL_VENT", "whitespace is trimmed and collapsed");
303 check(slugify("Chamber Mid PT 1") == "CHAMBER_MID_PT_1", "digits survive");
304 check(is_valid_slug("GN2_HIGH"), "GN2_HIGH is a valid slug");
305 check(!is_valid_slug("2COLD"), "a slug may not start with a digit");
306 check(!is_valid_slug("FUEL-VENT"), "a punctuated name is refused rather than mangled");
307 check(!is_valid_slug(""), "an empty slug is refused");
308
309 std::cout << "── diagnostics are useful ──" << std::endl;
310 {
311 const auto issues = run("open_valve(FUEL_VNT)\ndelay(1)\n", t);
312 check(!issues.empty() && issues[0].message.find("FUEL_VENT") != std::string::npos,
313 "a near-miss actuator name suggests the real one");
314 }
315 {
316 // An empty script is structurally fine. It is the config layer's job to decide whether a
317 // state may have one, not the parser's.
318 ParseResult r = parse("");
319 check(r.ok() && r.program.empty(), "an empty script parses to an empty program");
320 }
321
322 std::cout << (g_failures == 0 ? "\nAll state-script checks passed.\n"
323 : "\nFAILURES: " + std::to_string(g_failures) + "\n");
324 return g_failures == 0 ? 0 : 1;
325}
static bool ok
Definition flight_e2e.c:435
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
const char * diag_name(Diag d)
Definition Parser.cpp:56
std::string print_canonical(const ScriptProgram &program)
A stable s-expression rendering of the parsed program.
Definition Parser.cpp:927
constexpr uint32_t kMaxLines
std::string slugify(const std::string &name)
Canonical name -> slug: trim, uppercase, collapse internal whitespace runs to a single '_'.
Definition Slug.cpp:8
Diag
Diagnostic codes.
ParseResult parse(const std::string &source)
Lex and parse, applying every check that does not need config.
Definition Parser.cpp:813
bool is_valid_slug(const std::string &slug)
True when a slug is usable as a bare identifier: ^[A-Z][A-Z0-9_]*$.
Definition Slug.cpp:29
constexpr uint32_t kMaxNestingDepth
std::vector< Diagnostic > validate(const ScriptProgram &program, const SlugTables &tables)
Config-dependent checks.
Definition Validate.cpp:69
constexpr size_t kMaxSourceBytes
Definition run.py:1
const char * name
const char * src
const char * ast
const char * name
const char * src
uint32_t line
std::string message
Human wording, including the offending text.
What a script's slugs are allowed to name, built from the loaded config.
static int g_failures
static boot_report_t r
static const std::vector< Accept > kAccept
static void check(bool ok, const std::string &what)
static SlugTables tables()
The tables every case validates against.
int main()
static const std::vector< Reject > kReject