@yuneta/gobj-ui 7.25.14 → 7.25.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +100 -4
  2. package/dist/gobj-ui.cjs.js +406 -94
  3. package/dist/gobj-ui.es.js +406 -94
  4. package/package.json +2 -2
  5. package/src/c_g6_nodes_tree.js +79 -15
  6. package/src/c_yui_schema_editor.js +343 -66
  7. package/src/c_yui_treedb_graph.js +53 -6
  8. package/src/c_yui_treedb_topic_with_form.js +10 -10
  9. package/src/c_yui_treedb_topics.js +7 -7
  10. package/src/form_busy.js +2 -3
  11. package/src/form_busy.test.js +1 -1
  12. package/src/form_time_value.js +1 -1
  13. package/src/form_time_value.test.js +1 -1
  14. package/src/form_writes_in_flight.js +1 -2
  15. package/src/form_writes_in_flight.test.js +1 -1
  16. package/src/g6_graphs_write_owed.wiring.test.js +200 -0
  17. package/src/g6_nodes_tree_fsm.test.js +33 -0
  18. package/src/graph_save_plan.js +28 -0
  19. package/src/graph_save_plan.test.js +36 -2
  20. package/src/host_drafts.js +2 -3
  21. package/src/host_drafts.test.js +2 -2
  22. package/src/schema_editor_drafts.wiring.test.js +2 -2
  23. package/src/schema_editor_reload.wiring.test.js +329 -49
  24. package/src/schema_editor_transport.wiring.test.js +2 -2
  25. package/src/schema_version_bump.test.js +1 -1
  26. package/src/shell_modals.js +31 -11
  27. package/src/shell_toast_once.test.js +73 -10
  28. package/src/table_cell_text.js +1 -1
  29. package/src/table_cell_text.test.js +1 -1
  30. package/src/treedb_graph_writes.wiring.test.js +208 -0
  31. package/src/treedb_topics_transport.wiring.test.js +2 -2
  32. package/src/yui_install.js +14 -3
  33. package/src/yui_install.test.js +23 -0
  34. package/src/yui_row_search.test.js +1 -1
  35. package/src/yui_table_select.test.js +1 -1
@@ -1504,7 +1504,7 @@ function treedb_update_node(gobj, treedb_name, topic_name, record, options)
1504
1504
 
1505
1505
  if(!priv.gobj_remote_yuno) {
1506
1506
  log_error(`${gobj_short_name(gobj)}: No gobj_remote_yuno defined`);
1507
- return;
1507
+ return -1;
1508
1508
  }
1509
1509
 
1510
1510
  let command = "update-node";
@@ -1528,7 +1528,9 @@ function treedb_update_node(gobj, treedb_name, topic_name, record, options)
1528
1528
  );
1529
1529
  if(ret) {
1530
1530
  log_error(ret);
1531
+ return -1;
1531
1532
  }
1533
+ return 0;
1532
1534
  }
1533
1535
 
1534
1536
  /************************************************************
@@ -2113,6 +2115,11 @@ function ac_mt_command_answer(gobj, event, kw, src)
2113
2115
  * would tell a schema editor that the schema changed because
2114
2116
  * somebody dragged a node.
2115
2117
  */
2118
+ if(result < 0 && command === "update-node") {
2119
+ graphs_write_refused(gobj,
2120
+ kw_get_str(gobj, kw_command, "topic_name", "", 0),
2121
+ kw_get_dict(gobj, kw_command, "record", {}, 0));
2122
+ }
2116
2123
  if(result >= 0 &&
2117
2124
  kw_get_str(gobj, kw_command, "topic_name", "", 0) !== "__graphs__") {
2118
2125
  gobj_publish_event(gobj, "EV_RECORD_WRITTEN", {
@@ -2473,22 +2480,62 @@ function ac_update_node(gobj, event, kw, src)
2473
2480
  {
2474
2481
  let priv = gobj.priv;
2475
2482
 
2476
- if(refuse_if_readonly(gobj, event)) {
2477
- return -1;
2478
- }
2479
-
2480
2483
  let treedb_name = priv.treedb_name;
2481
2484
  let topic_name = kw.topic_name;
2482
2485
  let record = kw.record;
2483
2486
  let options = kw.options || {};
2484
2487
 
2485
- return treedb_update_node(
2488
+ if(refuse_if_readonly(gobj, event)) {
2489
+ graphs_write_refused(gobj, topic_name, record);
2490
+ return -1;
2491
+ }
2492
+
2493
+ /* The engine's arrangement is sent with no way back when it lands,
2494
+ * so one that cannot leave must be said here: out of session the
2495
+ * transport may answer nothing at all. */
2496
+ if(topic_name === "__graphs__" && !is_connected(gobj)) {
2497
+ log_warning(`${gobj_short_name(gobj)}: no session, the arrangement ` +
2498
+ `was not sent`);
2499
+ graphs_write_refused(gobj, topic_name, record);
2500
+ return -1;
2501
+ }
2502
+
2503
+ let ret = treedb_update_node(
2486
2504
  gobj,
2487
2505
  treedb_name,
2488
2506
  topic_name,
2489
2507
  record,
2490
2508
  options
2491
2509
  );
2510
+ if(ret < 0) {
2511
+ graphs_write_refused(gobj, topic_name, record);
2512
+ }
2513
+ return ret;
2514
+ }
2515
+
2516
+ /************************************************************
2517
+ * A write of `__graphs__` did not land. The engine
2518
+ * (C_G6_NODES_TREE) took it for granted when it left -- nothing
2519
+ * answers it when it lands -- so it is told, and its next Save
2520
+ * writes the topic again. Other topics are the operator's data:
2521
+ * the answer's error says what happened to them.
2522
+ ************************************************************/
2523
+ function graphs_write_refused(gobj, topic_name, record)
2524
+ {
2525
+ let priv = gobj.priv;
2526
+
2527
+ if(topic_name !== "__graphs__") {
2528
+ return;
2529
+ }
2530
+ let topic = (record && typeof record === "object") ? (record.topic || record.id) : "";
2531
+ if(!topic) {
2532
+ log_error(`${gobj_short_name(gobj)}: a refused __graphs__ write names no topic`);
2533
+ return;
2534
+ }
2535
+ if(!priv.gobj_nodes_tree) {
2536
+ return; /* no engine: nothing believes it was saved */
2537
+ }
2538
+ gobj_send_event(priv.gobj_nodes_tree, "EV_GRAPHS_WRITE_REFUSED", {topic: topic}, gobj);
2492
2539
  }
2493
2540
 
2494
2541
  /********************************************
@@ -1500,8 +1500,8 @@ function create_tabulator(gobj)
1500
1500
  * what the ceiling costs is having to widen the column, or open
1501
1501
  * the record, to read the end of it.
1502
1502
  * `maxInitialWidth`, not `maxWidth`: the latter is a HARD ceiling
1503
- * that neither the drag handle nor the double click could pass
1504
- * (M29 of the 2026-09-21 review); this one caps only the width the
1503
+ * that neither the drag handle nor the double click could pass;
1504
+ * this one caps only the width the
1505
1505
  * layout gives, and leaves the column to the reader. */
1506
1506
  if(max_col_width > 0) {
1507
1507
  colDef.maxInitialWidth = max_col_width;
@@ -2153,8 +2153,8 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field)
2153
2153
  let items = treedb_hook_data_size(value);
2154
2154
 
2155
2155
  if(items > 0) {
2156
- /* DOM, not an HTML string: the row id is record data (M31
2157
- * of the 2026-09-21 review). A language change re-runs
2156
+ /* DOM, not an HTML string: the row id is record data. A
2157
+ * language change re-runs
2158
2158
  * setColumns(), which re-runs this. */
2159
2159
  value = createElement2(hook_cell_spec(
2160
2160
  row.id, col.id, items, t("show linked records"), "show linked records"
@@ -2250,7 +2250,7 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field)
2250
2250
  /*
2251
2251
  * A string is record data, and Tabulator would parse it as markup
2252
2252
  * (innerHTML): `a<b` lost its text, a field holding an `<img onerror>`
2253
- * ran it (M31 of the 2026-09-21 review). Shown as a text node.
2253
+ * ran it. Shown as a text node.
2254
2254
  */
2255
2255
  if(typeof value === "string" && value !== "") {
2256
2256
  value = cell_text(value);
@@ -4035,7 +4035,7 @@ function ac_new_row(gobj, event, kw, src)
4035
4035
  * What crosses the confirmation is the rows' IDENTITY, never their
4036
4036
  * position: the view applies EV_TREEDB_NODE_* of every writer while the
4037
4037
  * dialog is open, and a position names whatever row sits there when the
4038
- * person answers -- with force:true (A6 of the 2026-09-21 review).
4038
+ * person answers -- with force:true.
4039
4039
  ************************************************************/
4040
4040
  function ac_delete_rows(gobj, event, kw, src)
4041
4041
  {
@@ -4103,8 +4103,8 @@ function ac_confirmed(gobj, event, kw, src)
4103
4103
  * them): never replaced by the row that took their place. */
4104
4104
  log_error(`${gobj_short_name(gobj)}: ${event}: rows gone while ` +
4105
4105
  `confirming the delete, not deleted: ${found.missing.join(", ")}`);
4106
- /* The person who answered "yes" is told, not only the log
4107
- * (a low of the 2026-09-22 review). */
4106
+ /* The person who answered "yes" is told, not only the
4107
+ * log. */
4108
4108
  yui_shell_show_error(yui_shell_of(gobj), "some records were gone before the delete", {t: t});
4109
4109
  }
4110
4110
  if(!found.rows.length) {
@@ -4374,8 +4374,8 @@ function ac_files_read(gobj, event, kw, src)
4374
4374
  * open and BUSY, and the serial it waits for is taken here.
4375
4375
  *
4376
4376
  * A refused write used to close the form all the same and throw
4377
- * away what was typed, although the README said it stayed open
4378
- * (M25 of the 2026-09-21 review): the close was posted with the
4377
+ * away what was typed, although the README said it stayed open:
4378
+ * the close was posted with the
4379
4379
  * publish, before the backend had answered anything.
4380
4380
  ************************************************************/
4381
4381
  function wait_for_write_answer(gobj)
@@ -188,7 +188,7 @@ function mt_start(gobj)
188
188
  * (yui_shell_set_connection_state). A write in flight when it drops
189
189
  * is never answered, and its form waited for ever in every host
190
190
  * that did not forward EV_TRANSPORT_STATE -- wattyzer and yunovatios
191
- * among them (M8 of the 2026-09-23 review). The transport itself is
191
+ * among them. The transport itself is
192
192
  * not subscribed to: see ac_transport_state(). */
193
193
  let shell = yui_shell_of(gobj);
194
194
  if(shell) {
@@ -2476,8 +2476,8 @@ function ac_transport_state(gobj, event, kw, src)
2476
2476
 
2477
2477
  /*
2478
2478
  * A write in flight when the transport closes is never answered by
2479
- * the backend: its form stayed open and busy for ever (N8 of the
2480
- * 2026-09-22 review). Answered refused: the form stays open on what
2479
+ * the backend: its form stayed open and busy for ever. Answered
2480
+ * refused: the form stays open on what
2481
2481
  * was typed, and the person saves again when the session is back.
2482
2482
  */
2483
2483
  if(!connected) {
@@ -2648,8 +2648,8 @@ function ac_create_record(gobj, event, kw, src)
2648
2648
 
2649
2649
  /* `create_only`: +New makes a NEW record. With `create` alone a taken
2650
2650
  * id was an update -- the record overwritten and, through autolink
2651
- * with the selects empty, unlinked, answered "Node update!" (M28 of
2652
- * the 2026-09-21 review). A backend older than the option ignores it
2651
+ * with the selects empty, unlinked, answered "Node update!". A
2652
+ * backend older than the option ignores it
2653
2653
  * and keeps the old upsert. */
2654
2654
  let options = {
2655
2655
  list_dict: true,
@@ -2895,8 +2895,8 @@ function ac_update_record(gobj, event, kw, src)
2895
2895
  /************************************************************
2896
2896
  * A write sent with no session never leaves: C_IEVENT_CLI logs
2897
2897
  * "Not in session" and answers null, as it does when the command
2898
- * went -- so a form waiting for the answer waited for ever (N8 of
2899
- * the 2026-09-22 review). Refused before it is sent, aloud.
2898
+ * went -- so a form waiting for the answer waited for ever. Refused
2899
+ * before it is sent, aloud.
2900
2900
  ************************************************************/
2901
2901
  function refuse_without_session(gobj, topic_name)
2902
2902
  {
package/src/form_busy.js CHANGED
@@ -10,9 +10,8 @@
10
10
  * that back. So a second busy(true) while already busy must not
11
11
  * run at all -- it read the buttons it had just disabled as
12
12
  * "disabled on their own", and the busy(false) of a refused write
13
- * left Save and Cancel dead (N9 of the 2026-09-22 review: a record
14
- * with a picked file went busy for the read, then busy again for
15
- * the write). The caller owns `state` ({busy}) for the form's life.
13
+ * left Save and Cancel dead (a record with a picked file went
14
+ * busy for the read, then busy again for the write). The caller owns `state` ({busy}) for the form's life.
16
15
  *
17
16
  * Copyright (c) 2026, ArtGins.
18
17
  * All Rights Reserved.
@@ -53,7 +53,7 @@ describe("set_toolbar_busy", () => {
53
53
  expect(tb.save.classList.contains("is-loading")).toBe(false);
54
54
  });
55
55
 
56
- it("busy twice then free: Save and Cancel come back (N9)", () => {
56
+ it("busy twice then free: Save and Cancel come back", () => {
57
57
  /* A record with a picked file went busy for the read and busy
58
58
  * again for the write; the second pass recorded the buttons the
59
59
  * first had disabled as disabled on their own, and the free of a
@@ -37,7 +37,7 @@
37
37
  * A Date -> the value of a `datetime-local` input, in local time,
38
38
  * WITH seconds: the input is built with `step: 1`, which is what
39
39
  * lets it hold them. Written as "YYYY-MM-DDTHH:mm" it lost them on
40
- * every save of any field (M26 of the 2026-09-21 review).
40
+ * every save of any field.
41
41
  ************************************************************/
42
42
  function date_to_datetime_local(value)
43
43
  {
@@ -4,7 +4,7 @@ import {date_to_datetime_local, datetime_local_to_epoch} from "./form_time_value
4
4
  describe("form_time_value", () => {
5
5
 
6
6
  it("an epoch with seconds survives a round trip through the input", () => {
7
- /* M26 of the 2026-09-21 review: the input was written without
7
+ /* The input was written without
8
8
  * seconds, so every save of ANY field moved a writable time
9
9
  * column back up to 59 s. */
10
10
  const epoch = 1790000037; // :37 seconds
@@ -7,8 +7,7 @@
7
7
  * answers, so the host has to answer EVERY way a write ends --
8
8
  * and the transport closing under a write in flight, or a
9
9
  * command that never left because there was no session, are two
10
- * of them that nobody answered: the form stayed busy for ever
11
- * (N8 of the 2026-09-22 review).
10
+ * of them that nobody answered: the form stayed busy for ever.
12
11
  *
13
12
  * A write is known by its TOPIC and its `form_write` serial,
14
13
  * never by the serial alone: each topic has its own form, and
@@ -30,7 +30,7 @@ describe("form writes in flight", () => {
30
30
  ]);
31
31
  });
32
32
 
33
- it("the transport closes: every write in flight is abandoned, once (N8)", () => {
33
+ it("the transport closes: every write in flight is abandoned, once", () => {
34
34
  /* The form of each one is answered EV_WRITE_REFUSED by the host
35
35
  * and stays open on what was typed; before, it stayed busy for
36
36
  * ever, because nobody answered a write whose answer was lost. */
@@ -0,0 +1,200 @@
1
+ /***********************************************************************
2
+ * g6_graphs_write_owed.wiring.test.js
3
+ *
4
+ * A `__graphs__` write that did not land is OWED, and the Save
5
+ * button says so until a Save writes it again.
6
+ *
7
+ * EV_GRAPHS_WRITE_REFUSED lit the Save (in edition only), and
8
+ * the next repaint of the history buttons put it out again:
9
+ * update_history_buttons() decides the Save from
10
+ * `history.canUndo()` alone, and it runs on every history
11
+ * change, every change of mode and every redraw of the theme.
12
+ * A refusal heard in reading was never shown at all.
13
+ *
14
+ * The real C_G6_NODES_TREE, on a fake G6 graph: only the history
15
+ * plugin and the Save button are what is looked at.
16
+ *
17
+ * Copyright (c) 2026, ArtGins.
18
+ * All Rights Reserved.
19
+ ***********************************************************************/
20
+ import {describe, test, expect, beforeAll, beforeEach} from "vitest";
21
+ import {install_dom_double} from "../test/dom_double.js";
22
+
23
+ install_dom_double();
24
+
25
+ const {
26
+ SDATA, SDATA_END, data_type_t,
27
+ gclass_create, gobj_start_up, gobj_create_yuno, gobj_create_pure_child,
28
+ gobj_send_event, register_c_timer, set_log_callback,
29
+ } = await import("@yuneta/gobj-js");
30
+ const {register_c_g6_nodes_tree} = await import("./c_g6_nodes_tree.js");
31
+
32
+ const logged = [];
33
+ const published = [];
34
+
35
+ /* G6, as far as these paths ask it: plugins by key, and nothing on
36
+ * screen. Any other method answers undefined. */
37
+ function fake_graph(history)
38
+ {
39
+ let plugins = [];
40
+ const known = {
41
+ rendered: true,
42
+ getPlugins: () => plugins,
43
+ setPlugins: (fn) => {
44
+ plugins = (typeof fn === "function") ? fn(plugins) : fn;
45
+ },
46
+ getPluginInstance: (key) => (key === "history") ? history : {on() {}, off() {}},
47
+ updatePlugin: () => {},
48
+ getBehaviors: () => [],
49
+ setBehaviors: () => {},
50
+ getData: () => ({nodes: [], edges: [], combos: []}),
51
+ getZoom: () => 1,
52
+ };
53
+ return new Proxy(known, {
54
+ get: (t, k) => (k in t) ? t[k] : (() => undefined)
55
+ });
56
+ }
57
+
58
+ let yuno = null;
59
+
60
+ beforeAll(() => {
61
+ gobj_start_up(null, null, null, null, null, null, null);
62
+ set_log_callback((level, msg) => {
63
+ logged.push({level: String(level), msg: String(msg)});
64
+ });
65
+ register_c_timer();
66
+ register_c_g6_nodes_tree();
67
+ /* The host: what the tree publishes lands here. */
68
+ const host_events = ["EV_UPDATE_NODE"];
69
+ gclass_create(
70
+ "C_TEST_GRAPH_HOST",
71
+ host_events.map((e) => [e, 0]),
72
+ [["ST_IDLE", host_events.map((e) => [e, (gobj, ev, kw) => {
73
+ published.push({event: ev, kw: kw});
74
+ return 0;
75
+ }, null])]],
76
+ {}, 0,
77
+ [SDATA(data_type_t.DTP_STRING, "node_uuid", 0, "test-node-uuid", ""), SDATA_END()],
78
+ {}, 0, 0, 0, 0
79
+ );
80
+ yuno = gobj_create_yuno("owed_yuno", "C_TEST_GRAPH_HOST", {});
81
+ });
82
+
83
+ beforeEach(() => {
84
+ logged.length = 0;
85
+ published.length = 0;
86
+ });
87
+
88
+ let seq = 0;
89
+
90
+ /* A tree whose `users` arrangement the backend holds, in `mode`,
91
+ * with a Save button in its container and a history that has
92
+ * nothing to undo. */
93
+ function build(mode)
94
+ {
95
+ const $container = document.createElement("div");
96
+ const tree = gobj_create_pure_child(`tree_${++seq}`, "C_G6_NODES_TREE",
97
+ {$container: $container}, yuno);
98
+ const listeners = [];
99
+ const history = {
100
+ undo: false,
101
+ canUndo() {
102
+ return this.undo;
103
+ },
104
+ canRedo: () => false,
105
+ on: (ev, fn) => {
106
+ listeners.push(fn);
107
+ },
108
+ clear() {
109
+ this.undo = false;
110
+ }
111
+ };
112
+ tree.priv.graph = fake_graph(history);
113
+ tree.priv.graph_rendered = true;
114
+ tree.priv.treedb_name = "treedb_x";
115
+ tree.priv._graph_properties = {users: {nodes: {u1: {x: 10, y: 20}}}};
116
+ tree.priv._saved_graph_properties = {users: {nodes: {u1: {x: 10, y: 20}}}};
117
+
118
+ const $save = document.createElement("div");
119
+ $save.className = "EV_SAVE_GRAPH";
120
+ $save.setAttribute("disabled", "");
121
+ $container.appendChild($save);
122
+
123
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: mode}, tree);
124
+ return {
125
+ tree,
126
+ lit: () => !$save.hasAttribute("disabled"),
127
+ history_changed: () => {
128
+ for(const fn of listeners) {
129
+ fn();
130
+ }
131
+ }
132
+ };
133
+ }
134
+
135
+ function errors()
136
+ {
137
+ return logged.filter((l) => l.level === "error").map((l) => l.msg);
138
+ }
139
+
140
+ describe("a refused __graphs__ write keeps the Save lit", () => {
141
+
142
+ test("in edition: a history change does not put it out", () => {
143
+ const g = build("edition");
144
+ expect(g.lit()).toBe(false);
145
+
146
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
147
+ expect(g.lit()).toBe(true);
148
+
149
+ g.history_changed(); /* nothing to undo */
150
+ expect(g.lit()).toBe(true);
151
+
152
+ gobj_send_event(g.tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, g.tree);
153
+ expect(g.lit()).toBe(true);
154
+ expect(errors()).toEqual([]);
155
+ });
156
+
157
+ test("heard in reading, it is shown on entering edition", () => {
158
+ const g = build("reading");
159
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
160
+
161
+ gobj_send_event(g.tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, g.tree);
162
+ expect(g.lit()).toBe(true);
163
+ expect(errors()).toEqual([]);
164
+ });
165
+
166
+ test("the Save writes it again, and then the Save goes dark", () => {
167
+ const g = build("edition");
168
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
169
+
170
+ gobj_send_event(g.tree, "EV_SAVE_GRAPH", {}, g.tree);
171
+ const writes = published.filter((p) => p.event === "EV_UPDATE_NODE");
172
+ expect(writes.map((w) => w.kw.record.id)).toEqual(["users"]);
173
+ expect(g.lit()).toBe(false);
174
+
175
+ g.history_changed();
176
+ expect(g.lit()).toBe(false);
177
+ expect(errors()).toEqual([]);
178
+ });
179
+
180
+ test("refused again after that Save: lit again", () => {
181
+ const g = build("edition");
182
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
183
+ gobj_send_event(g.tree, "EV_SAVE_GRAPH", {}, g.tree);
184
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
185
+
186
+ g.history_changed();
187
+ expect(g.lit()).toBe(true);
188
+ expect(errors()).toEqual([]);
189
+ });
190
+
191
+ test("a reload forgets it: the view holds what the backend holds", () => {
192
+ const g = build("edition");
193
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
194
+ gobj_send_event(g.tree, "EV_CLEAR_DATA", {}, g.tree);
195
+ /* What the load does once the data is in again. */
196
+ gobj_send_event(g.tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, g.tree);
197
+ expect(g.lit()).toBe(false);
198
+ expect(errors()).toEqual([]);
199
+ });
200
+ });
@@ -0,0 +1,33 @@
1
+ /***********************************************************************
2
+ * g6_nodes_tree_fsm.test.js
3
+ *
4
+ * The machine of the real C_G6_NODES_TREE: the events its host
5
+ * sends it are declared, and each has an action. The host's own
6
+ * tests drive a fake engine, which cannot see this.
7
+ *
8
+ * Copyright (c) 2026, ArtGins.
9
+ * All Rights Reserved.
10
+ ***********************************************************************/
11
+ import {describe, test, expect} from "vitest";
12
+ import {install_dom_double} from "../test/dom_double.js";
13
+
14
+ install_dom_double();
15
+
16
+ const {
17
+ gclass_find_by_name, gclass_event_type, gclass_check_fsm,
18
+ } = await import("@yuneta/gobj-js");
19
+ const {register_c_g6_nodes_tree} = await import("./c_g6_nodes_tree.js");
20
+
21
+ describe("C_G6_NODES_TREE", () => {
22
+
23
+ test("hears EV_GRAPHS_WRITE_REFUSED, the host's word on a __graphs__ write", () => {
24
+ register_c_g6_nodes_tree();
25
+ const gclass = gclass_find_by_name("C_G6_NODES_TREE", false);
26
+ expect(gclass).toBeTruthy();
27
+ expect(gclass_event_type(gclass, "EV_GRAPHS_WRITE_REFUSED")).toBeTruthy();
28
+ const idle = gclass.dl_states.find((st) => st.state_name === "ST_IDLE");
29
+ expect(idle.dl_actions.some((a) => a.event_name === "EV_GRAPHS_WRITE_REFUSED" &&
30
+ typeof a.action === "function")).toBe(true);
31
+ expect(gclass_check_fsm(gclass)).toBe(0);
32
+ });
33
+ });
@@ -140,7 +140,35 @@ function apply_graphs_echo(live, saved, records, rec)
140
140
  }
141
141
  }
142
142
 
143
+ /***************************************************************
144
+ * A write of one topic's arrangement did NOT land: the backend
145
+ * refused it, the transport refused it on the way out, or there
146
+ * was no session to send it on.
147
+ *
148
+ * saved {topic_name: properties} what the backend has
149
+ * topic_name the topic of the refused record
150
+ *
151
+ * The view records a write as saved BEFORE it leaves, because
152
+ * nothing answers it when it lands. So a refused one left the view
153
+ * believing the backend held an arrangement it does not, and the
154
+ * next Save found nothing to write. What the backend holds for the
155
+ * topic is not known any more, so it is forgotten: the next Save
156
+ * writes the topic again (plan_graph_saves()).
157
+ *
158
+ * Returns TRUE if there was something to forget.
159
+ ***************************************************************/
160
+ function forget_refused_graphs_write(saved, topic_name)
161
+ {
162
+ if(!is_object(saved) || !topic_name ||
163
+ !Object.prototype.hasOwnProperty.call(saved, topic_name)) {
164
+ return false;
165
+ }
166
+ delete saved[topic_name];
167
+ return true;
168
+ }
169
+
143
170
  export {
171
+ forget_refused_graphs_write,
144
172
  apply_graphs_echo,
145
173
  plan_graph_saves,
146
174
  topic_arrangement_changed,
@@ -18,7 +18,8 @@
18
18
  ***********************************************************************/
19
19
  import {describe, test, expect} from "vitest";
20
20
  import {
21
- plan_graph_saves, topic_arrangement_changed, arrangement_of, apply_graphs_echo
21
+ plan_graph_saves, topic_arrangement_changed, arrangement_of, apply_graphs_echo,
22
+ forget_refused_graphs_write
22
23
  } from "./graph_save_plan.js";
23
24
 
24
25
  const ORIGIN = "f3163a9f-1f37-412a-b8c9-87a4341f14de";
@@ -150,7 +151,7 @@ describe("arrangement_of", () => {
150
151
  * save by another browser, sends back. It used to rebuild the saved
151
152
  * snapshot of EVERY topic, taking the copies from the live objects the
152
153
  * view had already rearranged: what was unsaved in OTHER topics counted
153
- * as saved, and the next Save skipped it (M32 of the 2026-09-21 review).
154
+ * as saved, and the next Save skipped it.
154
155
  */
155
156
  describe("an echo of one __graphs__ record", () => {
156
157
  function state()
@@ -200,3 +201,36 @@ describe("an echo of one __graphs__ record", () => {
200
201
  expect(live.yunos).toBeDefined();
201
202
  });
202
203
  });
204
+
205
+ /*
206
+ * A write of `__graphs__` that the backend REFUSED. The view takes the
207
+ * write for granted before it leaves (nothing answers it when it lands),
208
+ * so a refused one was recorded as saved: the next Save found nothing to
209
+ * write, and the arrangement was never stored.
210
+ */
211
+ describe("a refused __graphs__ write", () => {
212
+
213
+ test("is planned again by the next Save", () => {
214
+ let live = five_topics();
215
+ let saved = five_topics();
216
+ live.yunos.nodes["1"].x = 500;
217
+ expect(plan_graph_saves(live, saved)).toEqual(["yunos"]);
218
+
219
+ /* What save_topic_graph_properties() does before the publish. */
220
+ saved.yunos = JSON.parse(JSON.stringify(live.yunos));
221
+ expect(plan_graph_saves(live, saved)).toEqual([]);
222
+
223
+ expect(forget_refused_graphs_write(saved, "yunos")).toBe(true);
224
+ expect(plan_graph_saves(live, saved)).toEqual(["yunos"]);
225
+ expect(Object.keys(saved)).not.toContain("yunos");
226
+ expect(Object.keys(saved).length).toBe(4);
227
+ });
228
+
229
+ test("a topic the view never believed saved: nothing to forget", () => {
230
+ let saved = five_topics();
231
+ expect(forget_refused_graphs_write(saved, "nothing")).toBe(false);
232
+ expect(forget_refused_graphs_write(saved, "")).toBe(false);
233
+ expect(forget_refused_graphs_write(null, "yunos")).toBe(false);
234
+ expect(Object.keys(saved).length).toBe(5);
235
+ });
236
+ });
@@ -7,9 +7,8 @@
7
7
  * it, and that mark lived in the memory of the session only:
8
8
  * a reload of the page, a reconnect or a refresh of the model
9
9
  * lost the chip, the banner and the export warning while
10
- * __system__ still differed from the file in use (N13 of the
11
- * 2026-09-22 review). The host reads that difference from
12
- * C_TREEDB's `saved-schema` (`draft_changed`, by treedb) and
10
+ * __system__ still differed from the file in use. The host
11
+ * reads that difference from C_TREEDB's `saved-schema` (`draft_changed`, by treedb) and
13
12
  * hands it here; the editor applies it to its model, in place
14
13
  * of what the host said before.
15
14
  *
@@ -15,7 +15,7 @@ const model = {
15
15
 
16
16
  describe("mark_host_drafts", () => {
17
17
 
18
- it("marks the topics the host names, by treedb (N13)", () => {
18
+ it("marks the topics the host names, by treedb", () => {
19
19
  /* The mark lived in the memory of one session: after a reload the
20
20
  * editor showed no draft while __system__ still differed from the
21
21
  * file in use. Rebuilt from the host's data. */
@@ -68,7 +68,7 @@ describe("host_draft_ids", () => {
68
68
  .toEqual({"treedb_a^users": true, "treedb_b^users": true});
69
69
  });
70
70
 
71
- it("is a fresh map every time: what the host stopped naming is gone (M1)", () => {
71
+ it("is a fresh map every time: what the host stopped naming is gone", () => {
72
72
  /* mark_host_drafts() only ADDS, and the editor fed it the same map
73
73
  * every time: a topic the host named before the Save stayed a
74
74
  * draft after it. */
@@ -4,7 +4,7 @@
4
4
  * EV_DRAFTS driven through a real C_YUI_SCHEMA_EDITOR on a document
5
5
  * double. The helper was right and tested; the ACTION folded the
6
6
  * host's marks into the session's, so a topic the host named before
7
- * a Save stayed a draft after it (M1 of the 2026-09-23 review).
7
+ * a Save stayed a draft after it.
8
8
  *
9
9
  * Copyright (c) 2026, ArtGins.
10
10
  * All Rights Reserved.
@@ -138,7 +138,7 @@ describe("EV_DRAFTS: the host is the truth", () => {
138
138
  expect(drafts_on_screen(editor)).toBe(1);
139
139
  });
140
140
 
141
- test("the host names none any more (a Save): the mark goes (M1)", () => {
141
+ test("the host names none any more (a Save): the mark goes", () => {
142
142
  /* It only ever ADDED: after the Save the editor still said
143
143
  * "unsaved schema changes". */
144
144
  const {editor, host} = build("d2");