@yuneta/gobj-ui 7.25.4 → 7.25.6
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.
- package/README.md +86 -1
- package/dist/gobj-ui.cjs.js +287 -56
- package/dist/gobj-ui.es.js +288 -57
- package/package.json +1 -1
- package/src/c_yui_schema_editor.js +165 -32
- package/src/c_yui_shell.js +61 -8
- package/src/c_yui_treedb_topic_with_form.js +12 -1
- package/src/c_yui_treedb_topics.js +41 -10
- package/src/form_time_value.js +23 -0
- package/src/form_writes_in_flight.js +32 -13
- package/src/form_writes_in_flight.test.js +18 -3
- package/src/host_drafts.js +28 -2
- package/src/host_drafts.test.js +24 -1
- package/src/schema_editor_drafts.wiring.test.js +176 -0
- package/src/schema_editor_transport.wiring.test.js +284 -0
- package/src/schema_version_bump.test.js +10 -5
- package/src/treedb_topics_transport.wiring.test.js +228 -0
- package/src/treedb_write_plan.js +32 -1
- package/src/treedb_write_plan.test.js +22 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* schema_editor_transport.wiring.test.js
|
|
3
|
+
*
|
|
4
|
+
* C_YUI_SCHEMA_EDITOR when the transport drops (M-1 of the
|
|
5
|
+
* independent review of 7.25.4). A drop used to leave the editor
|
|
6
|
+
* where it was: in ST_SAVING with its body busy, or in ST_LOADING
|
|
7
|
+
* -- and the reconnect skipped the reload BECAUSE the state was
|
|
8
|
+
* ST_LOADING. Only a reload of the page got it out.
|
|
9
|
+
*
|
|
10
|
+
* Driven through the FSM on a document double, with a fake
|
|
11
|
+
* transport whose state is the one the library reads
|
|
12
|
+
* (`ST_SESSION` or not).
|
|
13
|
+
*
|
|
14
|
+
* Copyright (c) 2026, ArtGins.
|
|
15
|
+
* All Rights Reserved.
|
|
16
|
+
***********************************************************************/
|
|
17
|
+
import {describe, test, expect, beforeAll, beforeEach, vi} from "vitest";
|
|
18
|
+
import {install_dom_double} from "../test/dom_double.js";
|
|
19
|
+
|
|
20
|
+
install_dom_double();
|
|
21
|
+
|
|
22
|
+
/* What the editor SAYS is asserted on the call. */
|
|
23
|
+
const shown = [];
|
|
24
|
+
vi.mock("./shell_modals.js", () => ({
|
|
25
|
+
yui_shell_show_error: (shell, message) => {
|
|
26
|
+
shown.push(message);
|
|
27
|
+
return {close() {}};
|
|
28
|
+
},
|
|
29
|
+
yui_shell_show_info: () => ({close() {}}),
|
|
30
|
+
yui_shell_show_modal: () => ({close() {}}),
|
|
31
|
+
yui_shell_confirm_danger: () => ({close() {}}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
SDATA_END,
|
|
36
|
+
gclass_create,
|
|
37
|
+
gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_service,
|
|
38
|
+
gobj_start, gobj_send_event, gobj_read_attr,
|
|
39
|
+
gobj_change_state, gobj_current_state,
|
|
40
|
+
set_log_callback,
|
|
41
|
+
} = await import("@yuneta/gobj-js");
|
|
42
|
+
const {register_c_yui_schema_editor} = await import("./c_yui_schema_editor.js");
|
|
43
|
+
|
|
44
|
+
const logged = [];
|
|
45
|
+
const commands = [];
|
|
46
|
+
|
|
47
|
+
const RECORDS = {
|
|
48
|
+
treedbs: [{id: "db", schema_version: 4}],
|
|
49
|
+
topics: [
|
|
50
|
+
{id: "db.users", value: "users", order: 1, pkey: "id", topic_version: 2,
|
|
51
|
+
treedbs: ["treedbs^db^topics"]},
|
|
52
|
+
],
|
|
53
|
+
cols: [
|
|
54
|
+
{id: "db.users.id", value: "id", order: 1, type: "string",
|
|
55
|
+
flag: ["persistent", "required"], topics: ["topics^db.users^cols"]},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function remote_command_parser(gobj, command, kw, src)
|
|
60
|
+
{
|
|
61
|
+
commands.push({command, kw, src});
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let yuno = null;
|
|
66
|
+
|
|
67
|
+
beforeAll(() => {
|
|
68
|
+
gobj_start_up(null, null, null, null, null, null, null);
|
|
69
|
+
set_log_callback((level, msg) => {
|
|
70
|
+
logged.push({level: String(level), msg: String(msg)});
|
|
71
|
+
});
|
|
72
|
+
gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
|
|
73
|
+
gclass_create(
|
|
74
|
+
"C_TEST_EDITOR_HOST",
|
|
75
|
+
[["EV_POSITION_CHANGED", 0], ["EV_RECORD_WRITTEN", 0], ["EV_SCHEMA_CHECKED", 0]],
|
|
76
|
+
[["ST_IDLE", [
|
|
77
|
+
["EV_POSITION_CHANGED", () => 0, null],
|
|
78
|
+
["EV_RECORD_WRITTEN", () => 0, null],
|
|
79
|
+
["EV_SCHEMA_CHECKED", () => 0, null]
|
|
80
|
+
]]],
|
|
81
|
+
{}, 0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
82
|
+
);
|
|
83
|
+
/* The two states the library reads off a transport. */
|
|
84
|
+
gclass_create(
|
|
85
|
+
"C_TEST_REMOTE",
|
|
86
|
+
[],
|
|
87
|
+
[["ST_DISCONNECTED", []], ["ST_SESSION", []]],
|
|
88
|
+
{mt_command_parser: remote_command_parser},
|
|
89
|
+
0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
90
|
+
);
|
|
91
|
+
register_c_yui_schema_editor();
|
|
92
|
+
yuno = gobj_create_yuno("transport_yuno", "C_TEST_HOST", {});
|
|
93
|
+
gobj_start(yuno);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
logged.length = 0;
|
|
98
|
+
commands.length = 0;
|
|
99
|
+
shown.length = 0;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
function take(command)
|
|
103
|
+
{
|
|
104
|
+
const out = commands.filter((c) => c.command === command);
|
|
105
|
+
for(const c of out) {
|
|
106
|
+
commands.splice(commands.indexOf(c), 1);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* The answer, the way a transport gives it: the request's frame on top. */
|
|
112
|
+
function answer(editor, remote, request, result, data, comment)
|
|
113
|
+
{
|
|
114
|
+
gobj_send_event(editor, "EV_MT_COMMAND_ANSWER", {
|
|
115
|
+
result: result,
|
|
116
|
+
comment: comment || "",
|
|
117
|
+
data: data,
|
|
118
|
+
__md_iev__: {command_stack: [{command: request.command, kw: request.kw}]}
|
|
119
|
+
}, remote);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function answer_the_load(editor, remote)
|
|
123
|
+
{
|
|
124
|
+
const asked = take("nodes");
|
|
125
|
+
for(const c of asked) {
|
|
126
|
+
answer(editor, remote, c, 0, RECORDS[c.kw.topic_name]);
|
|
127
|
+
}
|
|
128
|
+
return asked.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function drop(editor, remote, host)
|
|
132
|
+
{
|
|
133
|
+
gobj_change_state(remote, "ST_DISCONNECTED");
|
|
134
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function reconnect(editor, remote, host)
|
|
138
|
+
{
|
|
139
|
+
gobj_change_state(remote, "ST_SESSION");
|
|
140
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: true}, host);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function build(name, loaded)
|
|
144
|
+
{
|
|
145
|
+
const remote = gobj_create_service(`${name}_remote`, "C_TEST_REMOTE", {}, yuno);
|
|
146
|
+
gobj_change_state(remote, "ST_SESSION");
|
|
147
|
+
const host = gobj_create(`${name}_host`, "C_TEST_EDITOR_HOST", {}, yuno);
|
|
148
|
+
const editor = gobj_create(`${name}_editor`, "C_YUI_SCHEMA_EDITOR", {
|
|
149
|
+
gobj_remote_yuno: remote,
|
|
150
|
+
treedb_name: "treedb_system_schema",
|
|
151
|
+
base_route: "/schemas"
|
|
152
|
+
}, host);
|
|
153
|
+
gobj_start(editor);
|
|
154
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: true}, host);
|
|
155
|
+
if(loaded) {
|
|
156
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
157
|
+
gobj_send_event(editor, "EV_SHOW", {subpath: "db/users"}, host);
|
|
158
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
159
|
+
}
|
|
160
|
+
return {editor, remote, host};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* A write in flight: a new column, saved from its form. */
|
|
164
|
+
function start_a_write(editor, host)
|
|
165
|
+
{
|
|
166
|
+
gobj_send_event(editor, "EV_SAVE_COLUMN", {
|
|
167
|
+
creating: true, topic: "users", values: {value: "name", type: "string"}
|
|
168
|
+
}, host);
|
|
169
|
+
expect(gobj_current_state(editor)).toBe("ST_SAVING");
|
|
170
|
+
const [write] = take("update-node");
|
|
171
|
+
expect(write).toBeTruthy();
|
|
172
|
+
return write;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function is_busy(editor)
|
|
176
|
+
{
|
|
177
|
+
const $c = gobj_read_attr(editor, "$container");
|
|
178
|
+
return $c.querySelector(".SCHEMA_BODY").classList.contains("SCHEMA_BUSY");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function errors()
|
|
182
|
+
{
|
|
183
|
+
return logged.filter((l) => l.level === "error").map((l) => l.msg);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
describe("a drop while LOADING", () => {
|
|
187
|
+
|
|
188
|
+
test("the editor leaves ST_LOADING, and the reconnect loads again", () => {
|
|
189
|
+
const {editor, remote, host} = build("l1", false);
|
|
190
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
191
|
+
expect(take("nodes").length).toBe(3); /* never answered */
|
|
192
|
+
|
|
193
|
+
drop(editor, remote, host);
|
|
194
|
+
expect(gobj_current_state(editor)).toBe("ST_IDLE");
|
|
195
|
+
|
|
196
|
+
reconnect(editor, remote, host);
|
|
197
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
198
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
199
|
+
expect(gobj_current_state(editor)).toBe("ST_TREEDBS");
|
|
200
|
+
expect(errors()).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("an answer of the load the drop abandoned is not counted in the next one", () => {
|
|
204
|
+
const {editor, remote, host} = build("l2", false);
|
|
205
|
+
const old = take("nodes");
|
|
206
|
+
drop(editor, remote, host);
|
|
207
|
+
reconnect(editor, remote, host);
|
|
208
|
+
const fresh = take("nodes");
|
|
209
|
+
expect(fresh.length).toBe(3);
|
|
210
|
+
|
|
211
|
+
/* The three OLD answers arrive late: were they counted, the new
|
|
212
|
+
* round would end here with the old data and 3 answers owed. */
|
|
213
|
+
for(const c of old) {
|
|
214
|
+
answer(editor, remote, c, 0, []);
|
|
215
|
+
}
|
|
216
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
217
|
+
for(const c of fresh) {
|
|
218
|
+
answer(editor, remote, c, 0, RECORDS[c.kw.topic_name]);
|
|
219
|
+
}
|
|
220
|
+
expect(gobj_current_state(editor)).toBe("ST_TREEDBS");
|
|
221
|
+
expect(editor.priv.model.treedbs.length).toBe(1);
|
|
222
|
+
expect(errors()).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("a drop while SAVING", () => {
|
|
227
|
+
|
|
228
|
+
test("the write ends, said, the body answers again, and the reconnect reloads", () => {
|
|
229
|
+
const {editor, remote, host} = build("s1", true);
|
|
230
|
+
start_a_write(editor, host);
|
|
231
|
+
expect(is_busy(editor)).toBe(true);
|
|
232
|
+
|
|
233
|
+
drop(editor, remote, host);
|
|
234
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
235
|
+
expect(is_busy(editor)).toBe(false);
|
|
236
|
+
expect(shown).toEqual(["the connection dropped during the write"]);
|
|
237
|
+
|
|
238
|
+
/* Whether the write landed is unknown: the model is asked again. */
|
|
239
|
+
reconnect(editor, remote, host);
|
|
240
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
241
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
242
|
+
expect(errors()).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("the transport's own failure arriving FIRST is read as the drop it is", () => {
|
|
246
|
+
/* A routing adapter settles what it had in flight when its session
|
|
247
|
+
* closes, and that answer can reach the editor before the host's
|
|
248
|
+
* EV_TRANSPORT_STATE. Read as a refusal, it ended the write and the
|
|
249
|
+
* reconnect did not reload a model the write may have changed. */
|
|
250
|
+
const {editor, remote, host} = build("s2", true);
|
|
251
|
+
const write = start_a_write(editor, host);
|
|
252
|
+
|
|
253
|
+
gobj_change_state(remote, "ST_DISCONNECTED");
|
|
254
|
+
answer(editor, remote, write, -1, null, "the connection dropped");
|
|
255
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
256
|
+
expect(shown).toEqual(["the connection dropped during the write"]);
|
|
257
|
+
|
|
258
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
|
|
259
|
+
expect(shown.length).toBe(1); /* said once */
|
|
260
|
+
|
|
261
|
+
reconnect(editor, remote, host);
|
|
262
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
263
|
+
expect(errors()).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("the answer of the write the drop ended is not taken for the next one", () => {
|
|
267
|
+
const {editor, remote, host} = build("s3", true);
|
|
268
|
+
const write = start_a_write(editor, host);
|
|
269
|
+
drop(editor, remote, host);
|
|
270
|
+
reconnect(editor, remote, host);
|
|
271
|
+
answer_the_load(editor, remote);
|
|
272
|
+
|
|
273
|
+
const second = start_a_write(editor, host);
|
|
274
|
+
/* The first write's answer, late: it must not end the second. */
|
|
275
|
+
answer(editor, remote, write, 0, {id: "db.users.name", value: "name",
|
|
276
|
+
topics: ["topics^db.users^cols"]});
|
|
277
|
+
expect(gobj_current_state(editor)).toBe("ST_SAVING");
|
|
278
|
+
|
|
279
|
+
answer(editor, remote, second, 0, {id: "db.users.name", value: "name", order: 2,
|
|
280
|
+
type: "string", topics: ["topics^db.users^cols"]});
|
|
281
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
282
|
+
expect(errors()).toEqual([]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -19,17 +19,22 @@ const TOPIC = {id: "device_groups", topic_version: 5};
|
|
|
19
19
|
|
|
20
20
|
describe("topic_is_draft", () => {
|
|
21
21
|
it("says nothing about a topic this session never wrote", () => {
|
|
22
|
-
expect(topic_is_draft({}, TOPIC)).toBe(false);
|
|
22
|
+
expect(topic_is_draft({written: {}, host_ids: {}}, TOPIC)).toBe(false);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
it("a written topic is a draft, whatever its version says", () => {
|
|
26
|
-
expect(topic_is_draft({device_groups: true}, TOPIC)).toBe(true);
|
|
27
|
-
expect(topic_is_draft({
|
|
28
|
-
.toBe(true);
|
|
26
|
+
expect(topic_is_draft({written: {device_groups: true}}, TOPIC)).toBe(true);
|
|
27
|
+
expect(topic_is_draft({written: {device_groups: true}},
|
|
28
|
+
{id: "device_groups", topic_version: 6})).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("a topic the host names is a draft too (EV_DRAFTS)", () => {
|
|
32
|
+
expect(topic_is_draft({written: {}, host_ids: {device_groups: true}}, TOPIC)).toBe(true);
|
|
29
33
|
});
|
|
30
34
|
|
|
31
35
|
it("answers false rather than throwing on a missing half", () => {
|
|
32
36
|
expect(topic_is_draft(null, TOPIC)).toBe(false);
|
|
33
|
-
expect(topic_is_draft({device_groups: true}, null)).toBe(false);
|
|
37
|
+
expect(topic_is_draft({written: {device_groups: true}}, null)).toBe(false);
|
|
38
|
+
expect(topic_is_draft({}, TOPIC)).toBe(false);
|
|
34
39
|
});
|
|
35
40
|
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* treedb_topics_transport.wiring.test.js
|
|
3
|
+
*
|
|
4
|
+
* The WIRING of a form's write and the backend going away, driven
|
|
5
|
+
* through the real gclasses (C_YUI_SHELL, C_YUI_TREEDB_TOPICS) on a
|
|
6
|
+
* document double. The helper (form_writes_in_flight.js) was tested
|
|
7
|
+
* and right; what failed was that two hosts never called the action
|
|
8
|
+
* that uses it (M8 of the 2026-09-23 review), which no helper test
|
|
9
|
+
* can see.
|
|
10
|
+
*
|
|
11
|
+
* Copyright (c) 2026, ArtGins.
|
|
12
|
+
* All Rights Reserved.
|
|
13
|
+
***********************************************************************/
|
|
14
|
+
import {describe, test, expect, beforeAll, beforeEach} from "vitest";
|
|
15
|
+
import {install_dom_double} from "../test/dom_double.js";
|
|
16
|
+
|
|
17
|
+
install_dom_double();
|
|
18
|
+
|
|
19
|
+
const gobj_js = await import("@yuneta/gobj-js");
|
|
20
|
+
const {
|
|
21
|
+
SDATA, SDATA_END, data_type_t, event_flag_t,
|
|
22
|
+
gclass_create,
|
|
23
|
+
gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_service,
|
|
24
|
+
gobj_start, gobj_send_event,
|
|
25
|
+
gobj_change_state,
|
|
26
|
+
set_log_callback,
|
|
27
|
+
} = gobj_js;
|
|
28
|
+
const {register_c_yui_shell, yui_shell_set_connection_state} = await import("./c_yui_shell.js");
|
|
29
|
+
const {register_c_yui_treedb_topics} = await import("./c_yui_treedb_topics.js");
|
|
30
|
+
|
|
31
|
+
const logged = [];
|
|
32
|
+
const answers = []; /* what the fake forms were told */
|
|
33
|
+
const commands = []; /* what the fake transport was asked */
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* The transport: answers nothing, like a C_IEVENT_CLI whose socket is
|
|
37
|
+
* about to drop. Its state is what `is_connected()` reads.
|
|
38
|
+
*/
|
|
39
|
+
function remote_command_parser(gobj, command, kw, src)
|
|
40
|
+
{
|
|
41
|
+
commands.push({command, kw});
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* A topic form, as C_YUI_TREEDB_TOPICS finds it (by gclass name and
|
|
47
|
+
* `topic_name`) to answer its write.
|
|
48
|
+
*/
|
|
49
|
+
function ac_form_answer(gobj, event, kw, src)
|
|
50
|
+
{
|
|
51
|
+
answers.push({topic: gobj.priv_topic, event, form_write: kw.form_write});
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let yuno = null;
|
|
56
|
+
let remote = null;
|
|
57
|
+
|
|
58
|
+
beforeAll(() => {
|
|
59
|
+
gobj_start_up(null, null, null, null, null, null, null);
|
|
60
|
+
set_log_callback((level, msg) => {
|
|
61
|
+
logged.push({level: String(level), msg: String(msg)});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
|
|
65
|
+
gclass_create(
|
|
66
|
+
"C_TEST_REMOTE",
|
|
67
|
+
[],
|
|
68
|
+
[["ST_SESSION", []], ["ST_DISCONNECTED", []]],
|
|
69
|
+
{mt_command_parser: remote_command_parser},
|
|
70
|
+
0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
71
|
+
);
|
|
72
|
+
gclass_create(
|
|
73
|
+
"C_YUI_TREEDB_TOPIC_WITH_FORM",
|
|
74
|
+
[["EV_WRITE_DONE", 0], ["EV_WRITE_REFUSED", 0]],
|
|
75
|
+
[["ST_IDLE", [
|
|
76
|
+
["EV_WRITE_DONE", ac_form_answer, null],
|
|
77
|
+
["EV_WRITE_REFUSED", ac_form_answer, null]
|
|
78
|
+
]]],
|
|
79
|
+
{},
|
|
80
|
+
0,
|
|
81
|
+
[SDATA(data_type_t.DTP_STRING, "topic_name", 0, "", "topic"), SDATA_END()],
|
|
82
|
+
{}, 0, 0, 0, 0
|
|
83
|
+
);
|
|
84
|
+
/* The host of the view: it hears what a CHILD view publishes. */
|
|
85
|
+
gclass_create(
|
|
86
|
+
"C_TEST_VIEW_HOST",
|
|
87
|
+
[["EV_RECORD_WRITTEN", 0], ["EV_TOPIC_SELECTED", 0]],
|
|
88
|
+
[["ST_IDLE", [
|
|
89
|
+
["EV_RECORD_WRITTEN", () => 0, null],
|
|
90
|
+
["EV_TOPIC_SELECTED", () => 0, null]
|
|
91
|
+
]]],
|
|
92
|
+
{}, 0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
93
|
+
);
|
|
94
|
+
/* An app that subscribes to EVERY event of its shell and declares
|
|
95
|
+
* none of the new ones: wattyzer's and yunovatios' shape. */
|
|
96
|
+
gclass_create("C_TEST_APP", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
|
|
97
|
+
|
|
98
|
+
register_c_yui_shell();
|
|
99
|
+
register_c_yui_treedb_topics();
|
|
100
|
+
|
|
101
|
+
yuno = gobj_create_yuno("wiring_yuno", "C_TEST_HOST", {});
|
|
102
|
+
gobj_start(yuno);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
logged.length = 0;
|
|
107
|
+
answers.length = 0;
|
|
108
|
+
commands.length = 0;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function build(name)
|
|
112
|
+
{
|
|
113
|
+
const app = gobj_create(`${name}_app`, "C_TEST_APP", {}, yuno);
|
|
114
|
+
gobj_start(app);
|
|
115
|
+
const shell = gobj_create(`${name}_shell`, "C_YUI_SHELL", {config: {shell: {}}, default_route: "/home", subscriber: app}, app);
|
|
116
|
+
gobj_start(shell);
|
|
117
|
+
|
|
118
|
+
remote = gobj_create_service(`${name}_remote`, "C_TEST_REMOTE", {}, yuno);
|
|
119
|
+
gobj_change_state(remote, "ST_SESSION");
|
|
120
|
+
|
|
121
|
+
const host = gobj_create(`${name}_host`, "C_TEST_VIEW_HOST", {}, yuno);
|
|
122
|
+
const topics = gobj_create_service(`${name}_topics`, "C_YUI_TREEDB_TOPICS", {
|
|
123
|
+
treedb_name: "treedb_test",
|
|
124
|
+
gobj_remote_yuno: remote
|
|
125
|
+
}, host);
|
|
126
|
+
gobj_start(topics);
|
|
127
|
+
|
|
128
|
+
const forms = {};
|
|
129
|
+
for(const topic of ["users", "roles"]) {
|
|
130
|
+
forms[topic] = gobj_create(`${name}_form_${topic}`, "C_YUI_TREEDB_TOPIC_WITH_FORM",
|
|
131
|
+
{topic_name: topic}, topics);
|
|
132
|
+
forms[topic].priv_topic = topic;
|
|
133
|
+
}
|
|
134
|
+
/* What the start of a shell with no routes says is the fixture's,
|
|
135
|
+
* not the behaviour under test: the check starts here. */
|
|
136
|
+
logged.length = 0;
|
|
137
|
+
return {app, shell, topics, forms};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function save(topics, form, form_write)
|
|
141
|
+
{
|
|
142
|
+
gobj_send_event(topics, "EV_UPDATE_RECORD",
|
|
143
|
+
{record: {id: "x"}, form_write: form_write}, form);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* EVERY error the run logged: the check a deployed page gets too. */
|
|
147
|
+
function errors()
|
|
148
|
+
{
|
|
149
|
+
return logged.filter((l) => l.level === "error").map((l) => l.msg);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe("a Save in flight when the backend drops (M8)", () => {
|
|
153
|
+
|
|
154
|
+
test("the shell's connection edge answers every form, with no host forwarding", () => {
|
|
155
|
+
const {shell, topics, forms} = build("t1");
|
|
156
|
+
yui_shell_set_connection_state(shell, true);
|
|
157
|
+
|
|
158
|
+
/* Two forms, each at ITS serial 1: two writes, not one. */
|
|
159
|
+
save(topics, forms.users, 1);
|
|
160
|
+
save(topics, forms.roles, 1);
|
|
161
|
+
expect(commands.filter((c) => c.command === "update-node").length).toBe(2);
|
|
162
|
+
expect(answers).toEqual([]);
|
|
163
|
+
|
|
164
|
+
yui_shell_set_connection_state(shell, false);
|
|
165
|
+
|
|
166
|
+
expect(answers).toEqual([
|
|
167
|
+
{topic: "users", event: "EV_WRITE_REFUSED", form_write: 1},
|
|
168
|
+
{topic: "roles", event: "EV_WRITE_REFUSED", form_write: 1},
|
|
169
|
+
]);
|
|
170
|
+
expect(errors()).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a host that also forwards EV_TRANSPORT_STATE: each write answered once", () => {
|
|
174
|
+
const {shell, topics, forms} = build("t2");
|
|
175
|
+
yui_shell_set_connection_state(shell, true);
|
|
176
|
+
save(topics, forms.users, 1);
|
|
177
|
+
|
|
178
|
+
gobj_send_event(topics, "EV_TRANSPORT_STATE", {connected: false}, topics);
|
|
179
|
+
yui_shell_set_connection_state(shell, false);
|
|
180
|
+
|
|
181
|
+
expect(answers).toEqual([
|
|
182
|
+
{topic: "users", event: "EV_WRITE_REFUSED", form_write: 1},
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("only an EDGE is published: a repeated 'down' answers nothing twice", () => {
|
|
187
|
+
const {shell, topics, forms} = build("t3");
|
|
188
|
+
yui_shell_set_connection_state(shell, true);
|
|
189
|
+
save(topics, forms.users, 1);
|
|
190
|
+
yui_shell_set_connection_state(shell, false);
|
|
191
|
+
yui_shell_set_connection_state(shell, false);
|
|
192
|
+
expect(answers.length).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("an app subscribed to every shell event is not sent the new one", () => {
|
|
196
|
+
/* C_TEST_APP declares no event at all: were EV_CONNECTION_STATE
|
|
197
|
+
* published to it, the FSM would log "Event NOT DEFINED". */
|
|
198
|
+
const {shell} = build("t4");
|
|
199
|
+
yui_shell_set_connection_state(shell, true);
|
|
200
|
+
yui_shell_set_connection_state(shell, false);
|
|
201
|
+
expect(errors()).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("an answer from the backend settles its own form only", () => {
|
|
205
|
+
const {shell, topics, forms} = build("t5");
|
|
206
|
+
yui_shell_set_connection_state(shell, true);
|
|
207
|
+
save(topics, forms.users, 1);
|
|
208
|
+
save(topics, forms.roles, 1);
|
|
209
|
+
|
|
210
|
+
/* The answer of the users write, the way C_IEVENT_CLI delivers it:
|
|
211
|
+
* the request's frame on top of the command_stack. */
|
|
212
|
+
const ok = {result: 0, comment: "", data: [{id: "x"}], __md_iev__: {
|
|
213
|
+
command_stack: [{command: "update-node", kw: {
|
|
214
|
+
topic_name: "users", treedb_name: "treedb_test", form_write: 1, record: {id: "x"}
|
|
215
|
+
}}]
|
|
216
|
+
}};
|
|
217
|
+
gobj_send_event(topics, "EV_MT_COMMAND_ANSWER", ok, remote);
|
|
218
|
+
expect(answers).toEqual([
|
|
219
|
+
{topic: "users", event: "EV_WRITE_DONE", form_write: 1},
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
yui_shell_set_connection_state(shell, false);
|
|
223
|
+
expect(answers).toEqual([
|
|
224
|
+
{topic: "users", event: "EV_WRITE_DONE", form_write: 1},
|
|
225
|
+
{topic: "roles", event: "EV_WRITE_REFUSED", form_write: 1},
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
});
|
package/src/treedb_write_plan.js
CHANGED
|
@@ -116,4 +116,35 @@ function col_goes_back_to_treedb(desc, col)
|
|
|
116
116
|
return topic_pkey2s(d).includes(col.id);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
/************************************************************
|
|
120
|
+
* The INSTANCE an update of `record` is for: its pkey2 values, as the
|
|
121
|
+
* record held them when the form was opened, {pkey2: value}.
|
|
122
|
+
*
|
|
123
|
+
* They go back as they WERE, never as the form's widget hands them: a
|
|
124
|
+
* `time` pkey2 is an epoch rendered in a `datetime-local`, and a value
|
|
125
|
+
* that crossed the widget (seconds dropped, a local wall time read
|
|
126
|
+
* back, an hour the autumn change names twice) names ANOTHER instance
|
|
127
|
+
* -- or none, and C_NODE falls back to the primary. The pkey2 is an
|
|
128
|
+
* address, not a field of the edit.
|
|
129
|
+
************************************************************/
|
|
130
|
+
function instance_keys_of(record, desc)
|
|
131
|
+
{
|
|
132
|
+
const keys = {};
|
|
133
|
+
if(!record || typeof record !== "object") {
|
|
134
|
+
return keys;
|
|
135
|
+
}
|
|
136
|
+
for(const k of topic_pkey2s(desc || {})) {
|
|
137
|
+
if(Object.prototype.hasOwnProperty.call(record, k) &&
|
|
138
|
+
record[k] !== undefined && record[k] !== null) {
|
|
139
|
+
keys[k] = record[k];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return keys;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export {
|
|
146
|
+
plan_treedb_writes,
|
|
147
|
+
READONLY_FORM_TOOLBAR,
|
|
148
|
+
col_goes_back_to_treedb,
|
|
149
|
+
instance_keys_of,
|
|
150
|
+
};
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
***********************************************************************/
|
|
10
10
|
import { describe, test, expect } from "vitest";
|
|
11
11
|
import {
|
|
12
|
-
plan_treedb_writes, READONLY_FORM_TOOLBAR, col_goes_back_to_treedb
|
|
12
|
+
plan_treedb_writes, READONLY_FORM_TOOLBAR, col_goes_back_to_treedb,
|
|
13
|
+
instance_keys_of
|
|
13
14
|
} from "./treedb_write_plan.js";
|
|
14
15
|
|
|
15
16
|
const WRITES = ["edition_mode", "new_button", "delete_button", "paste_button", "in_row_icons"];
|
|
@@ -133,3 +134,23 @@ describe("what goes back to treedb from a form", () => {
|
|
|
133
134
|
expect(col_goes_back_to_treedb(null, COL.yuno_release)).toBe(false);
|
|
134
135
|
});
|
|
135
136
|
});
|
|
137
|
+
|
|
138
|
+
describe("the instance an update is for", () => {
|
|
139
|
+
test("the pkey2 values as the record held them", () => {
|
|
140
|
+
expect(instance_keys_of({id: "a", yuno_release: "1.2.3", yuno_name: "x"}, YUNOS_DESC))
|
|
141
|
+
.toEqual({yuno_release: "1.2.3"});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a time pkey2 keeps its seconds: the ORIGINAL epoch, not the widget's", () => {
|
|
145
|
+
/* A datetime-local that dropped the seconds, or read a local wall
|
|
146
|
+
* time back, named another instance of the node. */
|
|
147
|
+
const desc = {pkey: "id", pkey2s: ["tm"]};
|
|
148
|
+
expect(instance_keys_of({id: "a", tm: 1790000059}, desc)).toEqual({tm: 1790000059});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a record without the pkey2, or no pkey2s at all: nothing", () => {
|
|
152
|
+
expect(instance_keys_of({id: "a"}, YUNOS_DESC)).toEqual({});
|
|
153
|
+
expect(instance_keys_of({id: "a", yuno_release: "1"}, {pkey: "id"})).toEqual({});
|
|
154
|
+
expect(instance_keys_of(null, YUNOS_DESC)).toEqual({});
|
|
155
|
+
});
|
|
156
|
+
});
|