| name | test-via-api |
| description | How to verify lunica changes end-to-end without asking the user to click. Trigger whenever a UI flow needs verification — a new diagram, a fix to drill-in, a screenshot to confirm a regression, a smoke test of any reflect-registered Event command. The workbench exposes a small HTTP API on `--api PORT`; this skill is the runbook for driving it from curl, capturing screenshots, diagnosing failures, and adding new commands when the existing surface isn't enough. Also trigger when you catch yourself about to `pkill lunica`, write a temp `.rs` test binary to inspect rumoca state, chain a `sleep 30 && tail` poll, or ask the user "can you check the screenshot?". The right move is always: send a command, take a screenshot, read it, decide.
|
Test the workbench via API
The lunica exposes a reflect-registered Event API on
--api PORT (default 4101). UI verification — diagrams rendering,
drill-ins, simulations, file ops — should be driven from this API
rather than asking the user to click.
⚠️ NEVER kill the user's running workbench
Default rule: if a workbench is already running on port 4101,
DO NOT send Exit and DO NOT start a new one. Take the screenshot
/ run the API command against the existing instance.
Why: the user's session holds their state — open tabs, the menu
they have open right now, an in-progress drag, the canvas zoom they
set up. Killing it destroys that state and renders the screenshot
useless. Many things (open context menus, hover tooltips, drag
previews) cannot be reproduced via API because they only exist
during user interaction.
When you need to restart:
- The user explicitly says "restart" / "start fresh" / "kill it".
- The running binary is verifiably stale (you just rebuilt and the
user wants to see the new behaviour). Even then: ask first.
- The port is bound by a zombie that's not responding to API calls.
Try a quick
FitCanvas ping; if it answers, that's the user's
session — leave it alone.
If you need state inside the workbench that isn't there (a drilled-
in tab, a loaded file, a plot), drive the API to add it. NEVER
restart to "start clean."
Lifecycle (start → drive → stop)
cargo run --bin lunica -- --api 4101
until curl -s -o /dev/null -X POST http://127.0.0.1:4101/api/commands \
-H "Content-Type: application/json" \
-d '{"command":"Ping","params":{}}' 2>/dev/null; do sleep 1; done
curl -s -X POST http://127.0.0.1:4101/api/commands \
-H "Content-Type: application/json" \
-d '{"command":"Exit","params":{}}'
Curl shape
Always include "params":{} even for parameterless commands. Without
it the API logs Deserialization error: invalid type: null, expected reflected struct value and the command silently no-ops.
curl -s -X POST http://127.0.0.1:4101/api/commands \
-H "Content-Type: application/json" \
-d '{"command":"OpenClass","params":{"qualified":"Modelica.Blocks.Continuous.PID"}}'
Successful response: {"command_id": N}. Error: {"error":"..."}.
With the command spelling, arguments MUST be nested inside params.
{"command":"X","eye":[...]} is accepted with HTTP 200 and the arguments are
silently discarded, so the command runs with Default::default() for every
field.
Which spelling you use decides this, and only one of the two is forgiving
(crates/lunco-api/src/transports/envelope.rs):
| you send | top-level extras |
|---|
{"command":"X", …} | dropped — this branch reads the named params slot and nothing else |
{"type":"X", …} | promoted into params when you did not send params yourself |
So {"type":"SetCamera","eye":[…]} works while {"command":"SetCamera","eye":[…]}
does not. Nesting under params is correct for both, which is why the examples
here always do — it is the one shape that cannot bite you.
Symptom to recognise: the log shows zeroed arguments, e.g.
SET_CAMERA: eye=(0.00,0.00,0.00) target=(0.00,0.00,0.00)
which reads as "the geometry is broken" when it actually means "the camera never
moved". If a command appears to do nothing, grep the log for zeroed arguments
before you touch the asset.
{"command_id": N} means QUEUED, not succeeded. It is not an
acknowledgement that the command ran, validated, or had any effect. Rejections
and warnings appear only in the log, so the log is the real return channel —
tail it after every batch. QueryCommandResult (by command_id) exists for
commands that produce values.
Sandbox: loading a model
Two commands, two different argument types, and mixing them up is a silent no-op:
| command | takes | notes |
|---|
OpenTwin | a folder containing twin.toml | auto-loads [usd] default_scene |
LoadScene | a scheme address — twin://<name>/<rel>, lunco://<rel> — or a path under the assets root | for an arbitrary file on disk, use OpenFile |
OpenFile | any .usda path on disk | resolves the owning root, registers the document, mounts through the overlay |
Passing the .usda file to OpenTwin fails the twin.toml check and is
refused with a warn!.
LoadScene is not a general file opener. An absolute path under the assets
dir is accepted and rewritten asset-relative; anything else is refused with
[scene] `…` is a bare filesystem path — `LoadScene` takes scheme addresses
and the load is a no-op — the previous scene stays up, so a screenshot taken
afterwards shows the OLD scene rather than an empty one. That is the trap: it
looks like the command worked until you read the status bar. Reach for OpenFile
instead; LoadScene has no access to the workspace layer, so routing a raw path
through it would mount a base-only stage and silently drop runtime edits.
CaptureScreenshot returns the PNG as the response body; write those bytes
yourself rather than relying on save_to_file.
Command catalog
All commands live under crates/lunco-modelica/src/ui/commands/ as
reflect-registered Event structs, grouped by area (inspect.rs,
compile.rs, lifecycle.rs, diagram.rs, nav.rs, sim.rs,
plot.rs, doc.rs, …). Add new ones in the matching file if a flow
needs them.
| Command | Params | Purpose |
|---|
OpenFile | {path} | Open any .mo file from disk into a new tab. Use this for non-MSL examples (assets/models/*.mo) — OpenClass only works on MSL paths. |
OpenClass | {qualified, action?} | MSL drill-in by qualified name OR (with the open-doc fallback) drill into a class within an already-loaded doc. action: {Duplicate: {name: ""}} = open as an editable workspace copy (empty name → derives <short>Copy); default View = read-only drill-in. |
FormatDocument | {doc} (0=active) | Run rumoca-tool-fmt on active doc; replaces source via ReplaceSource. |
GetFile | {path} | Read file from disk and log contents at INFO. |
InspectActiveDoc | {} | Log parsed AST class tree of active doc — use to diagnose "0 nodes" projections. |
Exit | {} | AppExit. Always use instead of pkill. |
FitCanvas | {doc} | Fit-all in active canvas. Defers to next render so widget rect is correct. |
CaptureScreenshot | {} | Returns raw PNG bytes. Save with curl ... -o /tmp/foo.png then read with the Read tool. |
PanCanvas | {doc, x, y} | Pan to (x,y) in canvas world coords. |
SetZoom | {doc, zoom} | Set zoom factor. |
SetViewMode | {doc, mode} | mode = "Diagram" / "Icon" / "Text". |
MoveComponent | {class, name, x, y, width, height} | Modelica-coord drag. class empty = active. width=height=0 = preserve size. |
Undo / Redo | {doc} | Document op stack. |
AutoArrangeDiagram | {doc} | Re-layout. |
FocusDocumentByName | {pattern} | Switch active tab (field is pattern, NOT name). |
ConfirmClassPicker | {qualified?, cancel?} | Confirm/dismiss the "Which class should Compile/Fast Run …?" picker that opens when a package has >1 model. qualified = pick that class (omit → dialog's pre-selected); cancel:true = dismiss without running. Headless equivalent of clicking the dialog. Gotcha: the picker only opens once the doc's AST has parsed AND there are >1 candidates — FastRunActiveModel on a just-opened package logs no compilable top-level class and opens NO picker if you fire it before parse completes. Wait for async parse complete doc=N in the log, THEN FastRunActiveModel, THEN ConfirmClassPicker. |
Verification workflow
1. Start workbench (run_in_background:true).
2. Monitor until READY.
3. OpenFile or OpenClass to load model.
4. Wait ~3-5s for rumoca parse + projection (background tasks).
5. OpenClass or drill action if scoping to a sub-class.
6. Wait ~3-5s for the post-drill projection to land.
7. FitCanvas + sleep 1.
8. CaptureScreenshot → /tmp/foo.png.
9. Read the PNG to inspect.
10. Check the process log for lines like `[Projection] import done in Xms: N nodes M edges`.
11. Exit when done.
Diagnosing common failures
- "0 nodes 0 edges" after drill-in: the target class resolved but
conversion dropped nodes. Check:
InspectActiveDoc → are the components really there in the AST?
If not, parse failed.
- If components exist: their TYPES probably aren't in
local_classes_by_short or the MSL palette. The diagram-builder
registers the target's nested + sibling classes (sibling-pass in
panels/canvas_projection.rs, the local_classes_by_short
registration); connector types need to be in
msl_index.json (regenerate via cargo run --bin msl_indexer).
- "Command 'X' not found or not API-accessible": the Event isn't
reflect-registered. Give the struct the
#[Command] attribute, mark
its observer with #[on_command(X)], and list that observer in the
register_commands!(...) block in
crates/lunco-modelica/src/ui/commands/mod.rs (see § Add a command).
- API returns 500 / silent no-op: check
params includes the
empty object {} even for parameterless commands.
- Projection deadline exceeded (60s): rumoca parse stall, usually
from a sync MSL load inside the worker pool. Move heavy loads to a
separate
std::thread::spawn and use the cache-only resolver in the
projection (peek_msl_class_cached).
- Workbench seems stale after rebuild: it didn't restart. Send
Exit, verify port 4101 freed, then start.
Add a command
When testing reveals a missing API surface, add the command immediately
rather than asking the user:
- In the matching file under
crates/lunco-modelica/src/ui/commands/,
define the struct with the #[Command] attribute and the observer
with #[on_command(...)] (both from lunco_core):
use lunco_core::{Command, on_command};
#[Command(default)]
pub struct MyCommand { pub foo: String }
#[on_command(MyCommand)]
pub fn on_my_command(trigger: On<MyCommand>, mut commands: Commands) {
let foo = trigger.event().foo.clone();
commands.queue(move |world: &mut World| { });
}
#[Command] emits the Event/Reflect/reflect(Event) derives and
#[on_command] generates the register_type + add_observer wiring —
you don't write them by hand.
- Add the observer fn to the
register_commands!(...) list in
crates/lunco-modelica/src/ui/commands/mod.rs (use the
module::fn path form, e.g. inspect::on_my_command).
- Build, restart workbench, curl it.
What NOT to do
- Don't
pkill -f lunica. The user has to confirm; use
Exit command.
- Don't write standalone test binaries / temp
.rs files to verify
rumoca behaviour. Add an Inspect* command if the workbench can't
already surface what you need.
- Don't chain
sleep 30 && tail .... Use Monitor with an until loop.
- Don't ask the user to take a screenshot or check anything visually
unless API verification is genuinely impossible.