concinnity-dev 0.19.119

The Concinnity dev tooling library: world authoring, the in-engine editor, the debug server, docs and packaging
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! Committing and persisting an edit (`hook/edits.rs`): the preview rebuild an
//! entry change requests, the world a rebuild compiles in memory, the atomic
//! world.jsonl write SAVE performs, and the undo / redo stacks over the entry
//! list -- what a mark records, what a jump drops, and how the dirty flag
//! tracks the saved list across one.

use concinnity_cook::authoring::world::parse_world_jsonl;
use concinnity_core::components::FrameInput;
use concinnity_core::components::KeyEvent;
use concinnity_core::components::KeyMods;
use concinnity_core::components::KeyPress;
use concinnity_core::components::TextLabel;
use concinnity_core::ecs::World;

use super::fixtures::{entry, hook, set_field, world_with_fields, world_with_input};
use crate::editor::hook::{EditorHook, FormTarget};
use crate::frame_hook::FrameHook;

use crate::editor::hud::HudAction;

use crate::editor::panels::form_panel::{self, FormAction};

use crate::editor::panels::template_panel::TemplateAction;

use crate::editor::hook::tests::fixtures::entry_target;
use crate::editor::panels::registry::PanelKey;
use crate::editor::sim;
use crate::test_support::isolate_state_dir;

// Entry changes drive the live preview: a mutation flags a rebuild AND marks
// the world dirty (unsaved); a plain View toggle does neither.
#[test]
fn entry_changes_request_a_preview_rebuild() {
    let mut h = hook(Vec::new());
    h.apply_top(HudAction::ToggleView, &mut World::new());
    assert!(
        !h.rebuild_preview && !h.dirty,
        "a view toggle is not an entry change"
    );
    // Applying a template layers assets: preview rebuild requested + dirty.
    h.open_template_detail(0);
    h.apply_template_detail(TemplateAction::Apply);
    assert!(
        h.rebuild_preview && h.dirty,
        "applying a template updates the live preview and marks unsaved"
    );
}

// The live preview is rebuilt from the in-memory entries with no disk access:
// authored renderable entries build a rendering world directly, and an empty
// world is seeded so a window still shows. This is the swap's source of truth
// now that SAVE only persists.
#[test]
fn build_preview_world_renders_from_in_memory_entries() {
    let _guard = crate::test_support::lock();
    isolate_state_dir();
    // Authored renderable entries (a Room + camera) build a rendering world.
    let h = hook(vec![
        serde_json::json!({"type":"Camera3D","args":{"$id":"cam"}}),
        serde_json::json!({"type":"Room","args":{"$id":"room"}}),
    ]);
    assert!(
        h.build_preview_world()
            .expect("authored entries build")
            .0
            .renders(),
        "authored renderable entries render without disk"
    );
    // Empty entries: the seed keeps the preview window from going blank.
    let h = hook(Vec::new());
    assert!(
        h.build_preview_world()
            .expect("empty world seeds")
            .0
            .renders(),
        "an empty world is seeded so it still renders"
    );
}

// End to end over a really cooked world: an edit to a live type is written into
// the world the build produced -- through the real expansion, name interner and
// entity index -- and no rebuild is asked for. This is the whole point of the
// live path, so it is proved against a real world rather than a stubbed one.
#[test]
fn a_live_edit_reaches_a_really_cooked_world() {
    let _guard = crate::test_support::lock();
    isolate_state_dir();
    let mut h = hook(vec![
        serde_json::json!({"type":"Camera3D","args":{"$id":"cam"}}),
        serde_json::json!({"type":"Room","args":{"$id":"room"}}),
        serde_json::json!({"type":"TextLabel","args":{"$id":"hint","content":"before"}}),
    ]);
    let (mut world, shadows) = h.build_preview_world().expect("the world builds");
    h.world_shadows = Some(shadows);
    assert_eq!(
        world
            .query::<TextLabel>()
            .next()
            .expect("the label is in the world")
            .content,
        "before"
    );

    h.entries[2]["args"]["content"] = serde_json::json!("after");
    h.mark_changed();
    assert!(
        !h.refresh_preview(&mut world),
        "a TextLabel field is written into the running world"
    );
    assert_eq!(
        world.query::<TextLabel>().next().unwrap().content,
        "after",
        "and the running component carries the edit"
    );

    // Adding a line changes what the expansion produces, so it still rebuilds.
    h.entries.push(serde_json::json!({
        "type":"TextLabel","args":{"$id":"hint2","content":"new"}
    }));
    h.mark_changed();
    assert!(h.refresh_preview(&mut world), "a new line rebuilds");
}

#[test]
fn write_jsonl_persists_entries_atomically() {
    let tree = concinnity_testing::TempTree::new();
    let path = tree.join("world.jsonl");
    let path_str = path.to_str().unwrap().to_string();

    let mut h = hook(vec![serde_json::json!({
        "type": "GraphicsConfig", "args": {"$id": "scene"}
    })]);
    h.world_path = path_str.clone();
    let mut world = world_with_fields();
    h.form.selected_type = Some("PointLight".to_string());
    set_field(&mut world, form_panel::NAME_INPUT, "lamp");
    h.apply_form(FormAction::Confirm, &mut world);
    h.write_jsonl().unwrap();

    let content = std::fs::read_to_string(&path).unwrap();
    let parsed = parse_world_jsonl(&content).unwrap();
    assert_eq!(parsed.len(), 2, "both entries written, one line each");
    assert_eq!(parsed[1]["args"]["$id"], "lamp");
    assert!(!std::path::Path::new(&format!("{path_str}.tmp")).exists());

    let _ = std::fs::remove_file(&path);
}

// A fresh project has no `worlds/` until its first save, so the write creates
// the directory rather than failing on the rename.
#[test]
fn write_jsonl_creates_the_worlds_directory() {
    let tree = concinnity_testing::TempTree::new();
    let path = tree.join("worlds").join("world.jsonl");

    let mut h = hook(vec![serde_json::json!({
        "type": "GraphicsConfig", "args": {"$id": "scene"}
    })]);
    h.world_path = path.to_str().unwrap().to_string();
    h.write_jsonl().expect("the write creates its directory");

    let content = std::fs::read_to_string(&path).unwrap();
    assert_eq!(parse_world_jsonl(&content).unwrap().len(), 1);
}

// A world that does not cook reports why instead of showing an empty tree.
#[test]
fn a_broken_world_reports_its_error_in_the_status_line() {
    let _guard = crate::test_support::lock();
    isolate_state_dir();
    let mut h = hook(vec![serde_json::json!({
        "type": "NotARealAssetType", "args": {"$id": "oops"}
    })]);
    h.open[PanelKey::Assets] = true;
    h.refresh_tree_if_needed();
    assert!(h.assets.groups.is_empty());
    let status = h.assets.status.as_deref().expect("the failure surfaces");
    assert!(status.contains("NotARealAssetType"), "{status}");
}

// A committed edit becomes one undo step: undo restores the pre-edit list (and
// clears dirty when that list matches the on-disk state), redo replays it.
#[test]
fn undo_reverts_a_committed_edit_and_redo_replays_it() {
    let mut world = World::new();
    let mut h = hook(vec![entry("a", "Sprite")]);
    assert!(!h.hud_state().undo && !h.hud_state().redo);

    h.entries.push(entry("b", "Sprite"));
    h.mark_changed();
    assert!(h.dirty && h.hud_state().undo);

    h.undo(&mut world);
    assert_eq!(*h.entries, [entry("a", "Sprite")]);
    assert!(!h.dirty, "back at the on-disk list: Save chip clears");
    assert!(
        h.rebuild_preview,
        "the restored list drives the live preview"
    );
    assert!(h.hud_state().redo);

    h.redo(&mut world);
    assert_eq!(*h.entries, [entry("a", "Sprite"), entry("b", "Sprite")]);
    assert!(h.dirty, "the replayed edit is unsaved again");
}

// Editing from an undone state forks the timeline: the redo branch is gone.
#[test]
fn an_edit_after_undo_drops_the_redo_branch() {
    let mut world = World::new();
    let mut h = hook(Vec::new());
    h.entries.push(entry("b", "Sprite"));
    h.mark_changed();
    h.undo(&mut world);
    assert!(h.hud_state().redo);

    h.entries.push(entry("c", "Sprite"));
    h.mark_changed();
    assert!(!h.hud_state().redo, "the new edit invalidates redo");
    h.undo(&mut world);
    assert!(h.entries.is_empty());
}

// A mark_changed that changed nothing (e.g. an Apply that staged no edits)
// records no phantom undo step.
#[test]
fn a_no_change_mark_records_no_undo_step() {
    let mut h = hook(vec![entry("a", "Sprite")]);
    h.mark_changed();
    assert!(!h.hud_state().undo, "nothing changed, nothing to undo");
}

// The open form and row menu index into `entries`; a history jump drops them so
// they can never point at a removed or shifted row.
#[test]
fn undo_drops_entry_indexed_ui_state() {
    let mut world = World::new();
    let mut h = hook(vec![entry("a", "Sprite")]);
    h.entries.push(entry("b", "Sprite"));
    h.mark_changed();
    h.form.selected_type = Some("Sprite".to_string());
    let target = entry_target(&h, 1);
    h.form.target = target;
    h.assets.row_menu = Some(h.handle_for("b"));

    h.undo(&mut world);
    assert_eq!(
        h.form.target,
        FormTarget::New,
        "the form no longer targets a live row"
    );
    assert_eq!(h.form.selected_type, None);
    assert_eq!(h.assets.row_menu, None);
}

// Ctrl+Z / Ctrl+Y drive the history from the tick, but stand down while a text
// field owns the keyboard or the world holds the cursor (play mode).
#[test]
fn ctrl_z_y_step_history_unless_typing_or_playing() {
    use concinnity_core::components::InputKey;
    let step = |h: &mut EditorHook, key: InputKey| {
        let mut world = world_with_input(FrameInput {
            viewport: [1280.0, 720.0],
            ctrl: true,
            key_events: vec![KeyEvent::Press(KeyPress::new(key, KeyMods::CTRL))],
            ..Default::default()
        });
        h.tick(&mut world);
    };
    let mut h = hook(Vec::new());
    h.entries.push(entry("b", "Sprite"));
    h.mark_changed();

    // Typing in the Story panel: the shortcut must not fire.
    crate::editor::hook::tests::fixtures::focus_story(&mut h);
    step(&mut h, InputKey::Z);
    assert_eq!(
        h.entries.len(),
        1,
        "suppressed while a text field is focused"
    );
    h.story.focus = false;

    // Play mode: the world owns the keyboard.
    h.sim.state = sim::SimState::Playing;
    step(&mut h, InputKey::Z);
    assert_eq!(h.entries.len(), 1, "suppressed in play mode");
    h.sim.state = sim::SimState::Stopped;

    step(&mut h, InputKey::Z);
    assert!(h.entries.is_empty(), "Ctrl+Z undoes the edit");
    step(&mut h, InputKey::Y);
    assert_eq!(h.entries.len(), 1, "Ctrl+Y redoes it");
}

// SAVE writes world.jsonl and nothing else: the compiled blobs belong to an
// explicit build, so a save leaves the build root as it found it.
#[test]
fn save_writes_the_world_file_and_no_build_output() {
    let _guard = crate::test_support::lock();
    let dir = concinnity_testing::TempTree::new();
    let build_root = dir.path().join(".concinnity");
    crate::project::open(
        concinnity_host::store::paths::StateTree::at(dir.path()).with_build(&build_root),
    );

    let world_path = dir.path().join("worlds").join("world.jsonl");
    let entries = vec![serde_json::json!({"type":"PhysicsConfig","args":{"$id":"phys"}})];
    let mut h = EditorHook::new(world_path.to_string_lossy().into_owned(), entries.clone());
    h.entries
        .push(serde_json::json!({"type":"TextLabel","args":{"$id":"hint"}}));
    h.mark_changed();
    h.save();

    let written = std::fs::read_to_string(&world_path).expect("the world file was created");
    assert_eq!(
        parse_world_jsonl(&written).unwrap(),
        *h.entries,
        "the working entries are what landed on disk"
    );
    assert!(!h.dirty, "a written save clears the unsaved chip");
    assert_eq!(h.saved, h.entries, "and re-baselines dirty tracking");
    assert!(
        !build_root.exists(),
        "no blobs, no lock, no build root at all"
    );

    crate::test_support::isolate_state_dir();
}

// A save that cannot write leaves the world dirty for the next attempt.
#[test]
fn a_failed_save_leaves_the_world_dirty() {
    let dir = concinnity_testing::TempTree::new();
    // A path under a file, so `create_dir_all` of the parent fails.
    let blocker = dir.path().join("blocker");
    std::fs::write(&blocker, b"not a directory").unwrap();
    let world_path = blocker.join("worlds").join("world.jsonl");

    let mut h = EditorHook::new(world_path.to_string_lossy().into_owned(), Vec::new());
    h.entries
        .push(serde_json::json!({"type":"TextLabel","args":{"$id":"hint"}}));
    h.mark_changed();
    h.save();

    assert!(h.dirty, "the world stays unsaved");
    assert!(h.saved.is_empty(), "and the saved baseline does not move");
}

// A successful SAVE re-baselines dirty tracking: undoing past it re-dirties,
// redoing back to the saved list cleans the chip again.
#[test]
fn dirty_tracks_the_saved_list_across_history_jumps() {
    let mut world = World::new();
    let mut h = hook(Vec::new());
    h.entries.push(entry("b", "Sprite"));
    h.mark_changed();
    // Stand in for a successful SAVE (which would hit disk).
    h.dirty = false;
    h.saved = h.entries.clone();

    h.undo(&mut world);
    assert!(h.dirty, "behind the saved list is an unsaved state");
    h.redo(&mut world);
    assert!(!h.dirty, "redo back to the saved list clears the chip");
}

// A world whose middle line includes `lights.jsonl` beside it, opened the way
// the editor opens any world: the included entries sit in place of the line
// that brings them in, after it.
fn hook_with_an_include(tree: &concinnity_testing::TempTree) -> EditorHook {
    tree.write(
        "lights.jsonl",
        "[\"PointLight\",{\"$id\":\"lamp\"}]\n[\"PointLight\"]\n",
    );
    let world = tree.write(
        "world.jsonl",
        "[\"PointLight\"]\n[\"Include\",{\"path\":\"lights.jsonl\"}]\n[\"Camera3D\",{\"$id\":\"mine\"}]\n",
    );
    let entries = crate::editor::worlds::files::read_entries(&world).expect("the world reads");
    EditorHook::new(world.to_str().unwrap().to_string(), entries)
}

// Labels count the included entries where they land, as the build does.
#[test]
fn included_entries_take_the_labels_the_build_gives_them() {
    let tree = concinnity_testing::TempTree::new();
    let h = hook_with_an_include(&tree);
    let handles = concinnity_cook::authoring::world::entry_handles(&h.entries);
    assert_eq!(
        handles.into_iter().flatten().collect::<Vec<_>>(),
        ["PointLight#0", "Include#0", "lamp", "PointLight#1", "mine"]
    );
}

// An included entry belongs to its own file, and an `Include` line decides what
// that file brings in, so an edit that reaches either is undone and the world
// stays clean.
#[test]
fn an_edit_to_an_included_entry_or_include_line_is_refused() {
    let tree = concinnity_testing::TempTree::new();
    let mut h = hook_with_an_include(&tree);
    let before = h.entries.clone();

    h.entries[2]["args"]["intensity"] = serde_json::json!(5.0);
    h.mark_changed();
    assert_eq!(h.entries, before, "the included light is put back");
    assert!(!h.dirty && !h.can_undo(), "nothing was recorded");
    assert!(
        h.rebuild_required,
        "the preview is rebuilt from the kept list"
    );

    h.remove_entry_at(1);
    assert_eq!(h.entries, before, "the Include line stays");
    assert!(!h.dirty);
}

// A save writes the world file's own lines, the `Include` line among them, and
// never what the include brought in.
#[test]
fn a_save_writes_only_the_world_file_and_keeps_the_include_line() {
    let tree = concinnity_testing::TempTree::new();
    let mut h = hook_with_an_include(&tree);
    h.entries[4]["args"]["fov_y_degrees"] = serde_json::json!(60.0);
    h.mark_changed();
    assert!(h.dirty);
    h.write_jsonl().unwrap();

    assert_eq!(
        std::fs::read_to_string(tree.join("world.jsonl")).unwrap(),
        "[\"PointLight\",{}]\n[\"Include\",{\"path\":\"lights.jsonl\"}]\n[\"Camera3D\",{\"$id\":\"mine\",\"fov_y_degrees\":60.0}]\n"
    );
    assert_eq!(
        std::fs::read_to_string(tree.join("lights.jsonl")).unwrap(),
        "[\"PointLight\",{\"$id\":\"lamp\"}]\n[\"PointLight\"]\n",
        "the included file is not the editor's to write"
    );
}

// The preview compiles the included entries from the list, without reading the
// included file again.
#[test]
fn the_preview_builds_the_included_entries() {
    let _guard = crate::test_support::lock();
    isolate_state_dir();
    let tree = concinnity_testing::TempTree::new();
    let h = hook_with_an_include(&tree);
    std::fs::remove_file(tree.join("lights.jsonl")).unwrap();
    let (world, _) = h.build_preview_world().expect("the world builds");
    assert_eq!(
        world
            .query::<concinnity_core::components::PointLight>()
            .count(),
        3
    );
}