@yuneta/gobj-ui 7.25.5 → 7.25.7
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 +35 -0
- package/dist/gobj-ui.cjs.js +243 -21
- package/dist/gobj-ui.es.js +243 -21
- package/package.json +1 -1
- package/src/c_yui_schema_editor.js +274 -23
- package/src/schema_editor_transport.wiring.test.js +457 -0
- package/src/shell_modals.js +32 -4
- package/src/shell_toast_once.test.js +64 -0
|
@@ -0,0 +1,457 @@
|
|
|
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
|
+
/* A transport out of session refuses in the RETURN, as C_IEVENT_CLI and
|
|
60
|
+
* the routing adapters do: a request that did not leave is not waited for. */
|
|
61
|
+
function remote_command_parser(gobj, command, kw, src)
|
|
62
|
+
{
|
|
63
|
+
if(gobj_current_state(gobj) !== "ST_SESSION") {
|
|
64
|
+
return `${command}: not in session`;
|
|
65
|
+
}
|
|
66
|
+
commands.push({command, kw, src});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let yuno = null;
|
|
71
|
+
|
|
72
|
+
beforeAll(() => {
|
|
73
|
+
gobj_start_up(null, null, null, null, null, null, null);
|
|
74
|
+
set_log_callback((level, msg) => {
|
|
75
|
+
logged.push({level: String(level), msg: String(msg)});
|
|
76
|
+
});
|
|
77
|
+
gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
|
|
78
|
+
gclass_create(
|
|
79
|
+
"C_TEST_EDITOR_HOST",
|
|
80
|
+
[["EV_POSITION_CHANGED", 0], ["EV_RECORD_WRITTEN", 0], ["EV_SCHEMA_CHECKED", 0]],
|
|
81
|
+
[["ST_IDLE", [
|
|
82
|
+
["EV_POSITION_CHANGED", () => 0, null],
|
|
83
|
+
["EV_RECORD_WRITTEN", () => 0, null],
|
|
84
|
+
["EV_SCHEMA_CHECKED", () => 0, null]
|
|
85
|
+
]]],
|
|
86
|
+
{}, 0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
87
|
+
);
|
|
88
|
+
/* The two states the library reads off a transport. */
|
|
89
|
+
gclass_create(
|
|
90
|
+
"C_TEST_REMOTE",
|
|
91
|
+
[],
|
|
92
|
+
[["ST_DISCONNECTED", []], ["ST_SESSION", []]],
|
|
93
|
+
{mt_command_parser: remote_command_parser},
|
|
94
|
+
0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
95
|
+
);
|
|
96
|
+
register_c_yui_schema_editor();
|
|
97
|
+
yuno = gobj_create_yuno("transport_yuno", "C_TEST_HOST", {});
|
|
98
|
+
gobj_start(yuno);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
logged.length = 0;
|
|
103
|
+
commands.length = 0;
|
|
104
|
+
shown.length = 0;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function take(command)
|
|
108
|
+
{
|
|
109
|
+
const out = commands.filter((c) => c.command === command);
|
|
110
|
+
for(const c of out) {
|
|
111
|
+
commands.splice(commands.indexOf(c), 1);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* The answer, the way a transport gives it: the request's frame on top. */
|
|
117
|
+
function answer(editor, remote, request, result, data, comment)
|
|
118
|
+
{
|
|
119
|
+
gobj_send_event(editor, "EV_MT_COMMAND_ANSWER", {
|
|
120
|
+
result: result,
|
|
121
|
+
comment: comment || "",
|
|
122
|
+
data: data,
|
|
123
|
+
__md_iev__: {command_stack: [{command: request.command, kw: request.kw}]}
|
|
124
|
+
}, remote);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function answer_the_load(editor, remote)
|
|
128
|
+
{
|
|
129
|
+
const asked = take("nodes");
|
|
130
|
+
for(const c of asked) {
|
|
131
|
+
/* A copy, as a transport hands it: the editor patches what it is
|
|
132
|
+
* given, and a shared fixture would carry one test's write into
|
|
133
|
+
* the next. */
|
|
134
|
+
answer(editor, remote, c, 0, JSON.parse(JSON.stringify(RECORDS[c.kw.topic_name])));
|
|
135
|
+
}
|
|
136
|
+
return asked.length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function drop(editor, remote, host)
|
|
140
|
+
{
|
|
141
|
+
gobj_change_state(remote, "ST_DISCONNECTED");
|
|
142
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function reconnect(editor, remote, host)
|
|
146
|
+
{
|
|
147
|
+
gobj_change_state(remote, "ST_SESSION");
|
|
148
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: true}, host);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function build(name, loaded)
|
|
152
|
+
{
|
|
153
|
+
const remote = gobj_create_service(`${name}_remote`, "C_TEST_REMOTE", {}, yuno);
|
|
154
|
+
gobj_change_state(remote, "ST_SESSION");
|
|
155
|
+
const host = gobj_create(`${name}_host`, "C_TEST_EDITOR_HOST", {}, yuno);
|
|
156
|
+
const editor = gobj_create(`${name}_editor`, "C_YUI_SCHEMA_EDITOR", {
|
|
157
|
+
gobj_remote_yuno: remote,
|
|
158
|
+
treedb_name: "treedb_system_schema",
|
|
159
|
+
base_route: "/schemas"
|
|
160
|
+
}, host);
|
|
161
|
+
gobj_start(editor);
|
|
162
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: true}, host);
|
|
163
|
+
if(loaded) {
|
|
164
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
165
|
+
gobj_send_event(editor, "EV_SHOW", {subpath: "db/users"}, host);
|
|
166
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
167
|
+
}
|
|
168
|
+
return {editor, remote, host};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* A write in flight: a new column, saved from its form. */
|
|
172
|
+
function start_a_write(editor, host)
|
|
173
|
+
{
|
|
174
|
+
gobj_send_event(editor, "EV_SAVE_COLUMN", {
|
|
175
|
+
creating: true, topic: "users", values: {value: "name", type: "string"}
|
|
176
|
+
}, host);
|
|
177
|
+
expect(gobj_current_state(editor)).toBe("ST_SAVING");
|
|
178
|
+
const [write] = take("update-node");
|
|
179
|
+
expect(write).toBeTruthy();
|
|
180
|
+
return write;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function is_busy(editor)
|
|
184
|
+
{
|
|
185
|
+
const $c = gobj_read_attr(editor, "$container");
|
|
186
|
+
return $c.querySelector(".SCHEMA_BODY").classList.contains("SCHEMA_BUSY");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function errors()
|
|
190
|
+
{
|
|
191
|
+
return logged.filter((l) => l.level === "error").map((l) => l.msg);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function warnings()
|
|
195
|
+
{
|
|
196
|
+
return logged.filter((l) => l.level === "warning").map((l) => l.msg);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
describe("a drop while LOADING", () => {
|
|
200
|
+
|
|
201
|
+
test("the editor leaves ST_LOADING, and the reconnect loads again", () => {
|
|
202
|
+
const {editor, remote, host} = build("l1", false);
|
|
203
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
204
|
+
expect(take("nodes").length).toBe(3); /* never answered */
|
|
205
|
+
|
|
206
|
+
drop(editor, remote, host);
|
|
207
|
+
expect(gobj_current_state(editor)).toBe("ST_IDLE");
|
|
208
|
+
|
|
209
|
+
reconnect(editor, remote, host);
|
|
210
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
211
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
212
|
+
expect(gobj_current_state(editor)).toBe("ST_TREEDBS");
|
|
213
|
+
expect(errors()).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("an answer of the load the drop abandoned is not counted in the next one", () => {
|
|
217
|
+
const {editor, remote, host} = build("l2", false);
|
|
218
|
+
const old = take("nodes");
|
|
219
|
+
drop(editor, remote, host);
|
|
220
|
+
reconnect(editor, remote, host);
|
|
221
|
+
const fresh = take("nodes");
|
|
222
|
+
expect(fresh.length).toBe(3);
|
|
223
|
+
|
|
224
|
+
/* The three OLD answers arrive late: were they counted, the new
|
|
225
|
+
* round would end here with the old data and 3 answers owed. */
|
|
226
|
+
for(const c of old) {
|
|
227
|
+
answer(editor, remote, c, 0, []);
|
|
228
|
+
}
|
|
229
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
230
|
+
for(const c of fresh) {
|
|
231
|
+
answer(editor, remote, c, 0, RECORDS[c.kw.topic_name]);
|
|
232
|
+
}
|
|
233
|
+
expect(gobj_current_state(editor)).toBe("ST_TREEDBS");
|
|
234
|
+
expect(editor.priv.model.treedbs.length).toBe(1);
|
|
235
|
+
expect(errors()).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("a drop while SAVING", () => {
|
|
240
|
+
|
|
241
|
+
test("the write ends, said, the body answers again, and the reconnect reloads", () => {
|
|
242
|
+
const {editor, remote, host} = build("s1", true);
|
|
243
|
+
start_a_write(editor, host);
|
|
244
|
+
expect(is_busy(editor)).toBe(true);
|
|
245
|
+
|
|
246
|
+
drop(editor, remote, host);
|
|
247
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
248
|
+
expect(is_busy(editor)).toBe(false);
|
|
249
|
+
expect(shown).toEqual(["the connection dropped during the write"]);
|
|
250
|
+
|
|
251
|
+
/* Whether the write landed is unknown: the model is asked again. */
|
|
252
|
+
reconnect(editor, remote, host);
|
|
253
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
254
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
255
|
+
expect(errors()).toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("the transport's own failure arriving FIRST is read as the drop it is", () => {
|
|
259
|
+
/* A routing adapter settles what it had in flight when its session
|
|
260
|
+
* closes, and that answer can reach the editor before the host's
|
|
261
|
+
* EV_TRANSPORT_STATE. Read as a refusal, it ended the write and the
|
|
262
|
+
* reconnect did not reload a model the write may have changed. */
|
|
263
|
+
const {editor, remote, host} = build("s2", true);
|
|
264
|
+
const write = start_a_write(editor, host);
|
|
265
|
+
|
|
266
|
+
gobj_change_state(remote, "ST_DISCONNECTED");
|
|
267
|
+
answer(editor, remote, write, -1, null, "the connection dropped");
|
|
268
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
269
|
+
expect(shown).toEqual(["the connection dropped during the write"]);
|
|
270
|
+
|
|
271
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
|
|
272
|
+
expect(shown.length).toBe(1); /* said once */
|
|
273
|
+
|
|
274
|
+
reconnect(editor, remote, host);
|
|
275
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
276
|
+
expect(errors()).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("the answer of the write the drop ended is not taken for the next one", () => {
|
|
280
|
+
const {editor, remote, host} = build("s3", true);
|
|
281
|
+
const write = start_a_write(editor, host);
|
|
282
|
+
drop(editor, remote, host);
|
|
283
|
+
reconnect(editor, remote, host);
|
|
284
|
+
answer_the_load(editor, remote);
|
|
285
|
+
|
|
286
|
+
const second = start_a_write(editor, host);
|
|
287
|
+
/* The first write's answer, late: it must not end the second. */
|
|
288
|
+
answer(editor, remote, write, 0, {id: "db.users.name", value: "name",
|
|
289
|
+
topics: ["topics^db.users^cols"]});
|
|
290
|
+
expect(gobj_current_state(editor)).toBe("ST_SAVING");
|
|
291
|
+
|
|
292
|
+
/* ...and it says the first one was DONE, so once the second ends
|
|
293
|
+
* the model is read again (late_write_done()). */
|
|
294
|
+
answer(editor, remote, second, 0, {id: "db.users.name", value: "name", order: 2,
|
|
295
|
+
type: "string", topics: ["topics^db.users^cols"]});
|
|
296
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
297
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
298
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
299
|
+
expect(errors()).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/*
|
|
304
|
+
* The regression of 7.25.6: a navigation DURING a reload. The load was
|
|
305
|
+
* matched by its round AND by the state being ST_LOADING, and EV_SHOW
|
|
306
|
+
* moved the editor out of ST_LOADING while its three `nodes` were in
|
|
307
|
+
* flight. Every answer was then "for a load that is over", the reload
|
|
308
|
+
* never landed, and request_model() had already emptied the records: the
|
|
309
|
+
* next write patched an empty store and the model lost every treedb.
|
|
310
|
+
*/
|
|
311
|
+
describe("a navigation DURING a reload", () => {
|
|
312
|
+
|
|
313
|
+
test("refresh, move, write: the model keeps every treedb", () => {
|
|
314
|
+
const {editor, remote, host} = build("n1", true);
|
|
315
|
+
gobj_send_event(editor, "EV_REFRESH", {}, host);
|
|
316
|
+
gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
|
|
317
|
+
gobj_send_event(editor, "EV_SHOW", {subpath: "db/users"}, host);
|
|
318
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
319
|
+
|
|
320
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
321
|
+
expect(editor.priv.pending).toBe(0);
|
|
322
|
+
|
|
323
|
+
const write = start_a_write(editor, host);
|
|
324
|
+
answer(editor, remote, write, 0, {id: "db.users.name", value: "name", order: 2,
|
|
325
|
+
type: "string", topics: ["topics^db.users^cols"]});
|
|
326
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
327
|
+
expect(editor.priv.model.treedbs.length).toBe(1);
|
|
328
|
+
expect(editor.priv.model.treedbs[0].topics[0].cols.length).toBe(2);
|
|
329
|
+
expect(warnings()).toEqual([]);
|
|
330
|
+
expect(errors()).toEqual([]);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("the editor stays in ST_LOADING, and lands where the host said last", () => {
|
|
334
|
+
const {editor, remote, host} = build("n2", true);
|
|
335
|
+
gobj_send_event(editor, "EV_REFRESH", {}, host);
|
|
336
|
+
gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
|
|
337
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
338
|
+
|
|
339
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
340
|
+
expect(gobj_current_state(editor)).toBe("ST_TOPICS");
|
|
341
|
+
expect(editor.priv.treedb_id).toBe("db");
|
|
342
|
+
expect(editor.priv.topic_name).toBe("");
|
|
343
|
+
expect(warnings()).toEqual([]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("the reload of a reconnect, with a move in the middle", () => {
|
|
347
|
+
const {editor, remote, host} = build("n3", true);
|
|
348
|
+
start_a_write(editor, host);
|
|
349
|
+
drop(editor, remote, host); /* the reconnect will reload */
|
|
350
|
+
shown.length = 0;
|
|
351
|
+
reconnect(editor, remote, host);
|
|
352
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
353
|
+
|
|
354
|
+
gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
|
|
355
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
356
|
+
expect(gobj_current_state(editor)).toBe("ST_TOPICS");
|
|
357
|
+
expect(editor.priv.pending).toBe(0);
|
|
358
|
+
expect(editor.priv.records.cols.length).toBe(1);
|
|
359
|
+
expect(editor.priv.model.treedbs.length).toBe(1);
|
|
360
|
+
expect(errors()).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("a drop after the move: the move is applied on the model kept, and the reconnect reloads", () => {
|
|
364
|
+
const {editor, remote, host} = build("n4", true);
|
|
365
|
+
gobj_send_event(editor, "EV_REFRESH", {}, host);
|
|
366
|
+
take("nodes"); /* never answered */
|
|
367
|
+
gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
|
|
368
|
+
|
|
369
|
+
drop(editor, remote, host);
|
|
370
|
+
expect(gobj_current_state(editor)).toBe("ST_TOPICS");
|
|
371
|
+
|
|
372
|
+
reconnect(editor, remote, host);
|
|
373
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
374
|
+
expect(gobj_current_state(editor)).toBe("ST_TOPICS");
|
|
375
|
+
expect(editor.priv.model.treedbs.length).toBe(1);
|
|
376
|
+
expect(errors()).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("a position with no model and no load", () => {
|
|
381
|
+
|
|
382
|
+
test("does not claim a load, and the session loads it", () => {
|
|
383
|
+
const remote = gobj_create_service("p1_remote", "C_TEST_REMOTE", {}, yuno);
|
|
384
|
+
gobj_change_state(remote, "ST_DISCONNECTED");
|
|
385
|
+
const host = gobj_create("p1_host", "C_TEST_EDITOR_HOST", {}, yuno);
|
|
386
|
+
const editor = gobj_create("p1_editor", "C_YUI_SCHEMA_EDITOR", {
|
|
387
|
+
gobj_remote_yuno: remote,
|
|
388
|
+
treedb_name: "treedb_system_schema",
|
|
389
|
+
base_route: "/schemas"
|
|
390
|
+
}, host);
|
|
391
|
+
gobj_start(editor); /* its load cannot leave */
|
|
392
|
+
logged.length = 0;
|
|
393
|
+
expect(gobj_current_state(editor)).toBe("ST_IDLE");
|
|
394
|
+
|
|
395
|
+
gobj_send_event(editor, "EV_SHOW", {subpath: "db/users"}, host);
|
|
396
|
+
expect(gobj_current_state(editor)).toBe("ST_IDLE");
|
|
397
|
+
|
|
398
|
+
reconnect(editor, remote, host);
|
|
399
|
+
expect(answer_the_load(editor, remote)).toBe(3);
|
|
400
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
401
|
+
expect(errors()).toEqual([]);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("the drop is settled ONCE", () => {
|
|
406
|
+
|
|
407
|
+
test("a load cut by the drop: the transport's three failures are one event", () => {
|
|
408
|
+
const {editor, remote, host} = build("o1", true);
|
|
409
|
+
gobj_send_event(editor, "EV_REFRESH", {}, host);
|
|
410
|
+
const cut = take("nodes");
|
|
411
|
+
expect(cut.length).toBe(3);
|
|
412
|
+
|
|
413
|
+
/* A routing adapter settles all three when its session closes,
|
|
414
|
+
* before (or after) the host's edge. */
|
|
415
|
+
gobj_change_state(remote, "ST_DISCONNECTED");
|
|
416
|
+
for(const c of cut) {
|
|
417
|
+
answer(editor, remote, c, -1, null, "the connection dropped");
|
|
418
|
+
}
|
|
419
|
+
gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
|
|
420
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
421
|
+
expect(warnings().length).toBe(1);
|
|
422
|
+
expect(warnings()[0]).toMatch(/dropped during the load/);
|
|
423
|
+
expect(errors()).toEqual([]);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("the host's edge first: the failures that follow are not said again", () => {
|
|
427
|
+
const {editor, remote, host} = build("o2", true);
|
|
428
|
+
gobj_send_event(editor, "EV_REFRESH", {}, host);
|
|
429
|
+
const cut = take("nodes");
|
|
430
|
+
|
|
431
|
+
drop(editor, remote, host);
|
|
432
|
+
for(const c of cut) {
|
|
433
|
+
answer(editor, remote, c, -1, null, "the connection dropped");
|
|
434
|
+
}
|
|
435
|
+
expect(warnings().length).toBe(1);
|
|
436
|
+
expect(errors()).toEqual([]);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe("a write answered after it was given up", () => {
|
|
441
|
+
|
|
442
|
+
test("it was DONE: the model is read again", () => {
|
|
443
|
+
const {editor, remote, host} = build("w1", true);
|
|
444
|
+
const write = start_a_write(editor, host);
|
|
445
|
+
drop(editor, remote, host);
|
|
446
|
+
reconnect(editor, remote, host);
|
|
447
|
+
answer_the_load(editor, remote);
|
|
448
|
+
expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
|
|
449
|
+
|
|
450
|
+
/* The treedb says it wrote it, after all. */
|
|
451
|
+
answer(editor, remote, write, 0, {id: "db.users.name", value: "name",
|
|
452
|
+
topics: ["topics^db.users^cols"]});
|
|
453
|
+
expect(gobj_current_state(editor)).toBe("ST_LOADING");
|
|
454
|
+
expect(take("nodes").length).toBe(3);
|
|
455
|
+
expect(errors()).toEqual([]);
|
|
456
|
+
});
|
|
457
|
+
});
|
package/src/shell_modals.js
CHANGED
|
@@ -111,6 +111,14 @@ function maybe_apply_translator($node, opts)
|
|
|
111
111
|
* Non-blocking, auto-dismiss after `opts.timeout` ms (default
|
|
112
112
|
* 5000). `opts.timeout = 0` disables auto-dismiss.
|
|
113
113
|
* Returns `{ close() }` so callers can dismiss programmatically.
|
|
114
|
+
*
|
|
115
|
+
* ONE toast per message on screen: a string message of the same
|
|
116
|
+
* kind as a toast still showing is not stacked under it -- that
|
|
117
|
+
* toast is kept, its time starts again, and its handle is
|
|
118
|
+
* returned. One close of a transport settles every request it
|
|
119
|
+
* cut, each view shows its failure, and the operator got a
|
|
120
|
+
* column of identical "the connection dropped". A message built
|
|
121
|
+
* of nodes is never merged: its text is not a key.
|
|
114
122
|
***************************************************************/
|
|
115
123
|
function show_notification(shell, kind, message, opts)
|
|
116
124
|
{
|
|
@@ -120,6 +128,16 @@ function show_notification(shell, kind, message, opts)
|
|
|
120
128
|
return { close: () => {} };
|
|
121
129
|
}
|
|
122
130
|
|
|
131
|
+
let key = (typeof message === "string") ? `${kind}\u0000${message}` : null;
|
|
132
|
+
if(key !== null) {
|
|
133
|
+
for(let $shown of Array.from($layer.children)) {
|
|
134
|
+
if($shown.__toast_key__ === key && $shown.__toast__) {
|
|
135
|
+
$shown.__toast__.restart();
|
|
136
|
+
return $shown.__toast__.handle;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
123
141
|
let p_attrs = (typeof message === "string")
|
|
124
142
|
? {i18n: message}
|
|
125
143
|
: {};
|
|
@@ -158,11 +176,21 @@ function show_notification(shell, kind, message, opts)
|
|
|
158
176
|
}
|
|
159
177
|
|
|
160
178
|
let timeout = (opts && opts.timeout != null) ? opts.timeout : 5000;
|
|
161
|
-
|
|
162
|
-
timeout_id
|
|
163
|
-
|
|
179
|
+
let restart = function() {
|
|
180
|
+
if(timeout_id) {
|
|
181
|
+
clearTimeout(timeout_id);
|
|
182
|
+
timeout_id = null;
|
|
183
|
+
}
|
|
184
|
+
if(timeout > 0) {
|
|
185
|
+
timeout_id = setTimeout(close, timeout);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
restart();
|
|
164
189
|
|
|
165
|
-
|
|
190
|
+
let handle = { close };
|
|
191
|
+
$note.__toast_key__ = key;
|
|
192
|
+
$note.__toast__ = {handle: handle, restart: restart};
|
|
193
|
+
return handle;
|
|
166
194
|
}
|
|
167
195
|
|
|
168
196
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* shell_toast_once.test.js
|
|
3
|
+
*
|
|
4
|
+
* A toast that says what one already on screen says is not
|
|
5
|
+
* stacked under it: one close of the transport settles every
|
|
6
|
+
* request it cut, each view that asked shows its failure, and
|
|
7
|
+
* the operator got a column of identical "the connection
|
|
8
|
+
* dropped" (low of the independent review of the 2nd round).
|
|
9
|
+
*
|
|
10
|
+
* Copyright (c) 2026, ArtGins.
|
|
11
|
+
* All Rights Reserved.
|
|
12
|
+
***********************************************************************/
|
|
13
|
+
import {describe, test, expect, beforeEach} from "vitest";
|
|
14
|
+
import {install_dom_double} from "../test/dom_double.js";
|
|
15
|
+
|
|
16
|
+
install_dom_double();
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
yui_shell_show_error,
|
|
20
|
+
yui_shell_show_info,
|
|
21
|
+
} = await import("./shell_modals.js");
|
|
22
|
+
|
|
23
|
+
let shell = null;
|
|
24
|
+
|
|
25
|
+
function toasts()
|
|
26
|
+
{
|
|
27
|
+
return shell.priv.layers.notification.children;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
shell = {priv: {layers: {notification: document.createElement("div")}}};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("one toast per message on screen", () => {
|
|
35
|
+
|
|
36
|
+
test("the same error twice is ONE toast", () => {
|
|
37
|
+
yui_shell_show_error(shell, "the connection dropped");
|
|
38
|
+
yui_shell_show_error(shell, "the connection dropped");
|
|
39
|
+
yui_shell_show_error(shell, "the connection dropped");
|
|
40
|
+
expect(toasts().length).toBe(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("different messages, or kinds, still stack", () => {
|
|
44
|
+
yui_shell_show_error(shell, "the connection dropped");
|
|
45
|
+
yui_shell_show_error(shell, "the connection dropped during the write");
|
|
46
|
+
yui_shell_show_info(shell, "the connection dropped");
|
|
47
|
+
expect(toasts().length).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("closing the one on screen: the next one is shown", () => {
|
|
51
|
+
const first = yui_shell_show_error(shell, "the connection dropped");
|
|
52
|
+
first.close();
|
|
53
|
+
expect(toasts().length).toBe(0);
|
|
54
|
+
yui_shell_show_error(shell, "the connection dropped");
|
|
55
|
+
expect(toasts().length).toBe(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the handle of the repeat closes the toast on screen", () => {
|
|
59
|
+
yui_shell_show_error(shell, "the connection dropped");
|
|
60
|
+
const again = yui_shell_show_error(shell, "the connection dropped");
|
|
61
|
+
again.close();
|
|
62
|
+
expect(toasts().length).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
});
|