@yuneta/gobj-ui 7.23.58 → 7.23.60
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/dist/gobj-ui.cjs.js +609 -130
- package/dist/gobj-ui.es.js +602 -131
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/c_yui_form.js +144 -9
- package/src/c_yui_treedb_topic_with_form.css +15 -0
- package/src/c_yui_treedb_topic_with_form.js +141 -10
- package/src/yui_file_field.css +67 -0
- package/src/yui_file_field.js +361 -0
- package/src/yui_file_field.test.js +119 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_file_field.js
|
|
3
|
+
*
|
|
4
|
+
* The control that PICKS a file for a treedb `file` column, and the
|
|
5
|
+
* manifest that carries its bytes beside the record.
|
|
6
|
+
*
|
|
7
|
+
* A column flagged ['fkey','file'] holds an fkey into the treedb's
|
|
8
|
+
* system topic `__assets__`: the bytes live on disk under the treedb,
|
|
9
|
+
* the index in memory, and the record names the asset by its sha256.
|
|
10
|
+
* So this control does NOT put a file in the record. It keeps the
|
|
11
|
+
* `File` the person picked and hands up an IDENTITY, and the bytes
|
|
12
|
+
* travel beside the record in a `__files__` manifest keyed by column.
|
|
13
|
+
*
|
|
14
|
+
* Three things about that are not obvious and are the whole reason
|
|
15
|
+
* this module is separate:
|
|
16
|
+
*
|
|
17
|
+
* - **The file is read at SAVE, not at pick.** An `<input type=file>`
|
|
18
|
+
* hands over a `File`, which is a REFERENCE and not the bytes.
|
|
19
|
+
* Picking shows a name and a size and reads nothing, so cancelling
|
|
20
|
+
* a form does not mean a 40 MB video was read for nothing.
|
|
21
|
+
* - **A `File` cannot travel in a kw.** A kw is plain json -- the
|
|
22
|
+
* machine trace serialises it -- and a `File` is a host object. The
|
|
23
|
+
* form KEEPS it and the host asks for it through a local method.
|
|
24
|
+
* - **Reading is a promise, so saving stops being synchronous**, and
|
|
25
|
+
* a resolved promise is an OS notification that must enter the
|
|
26
|
+
* machine as an EVENT, never as a chain of callbacks. That is the
|
|
27
|
+
* HOST's business; what lives here is the promise it awaits.
|
|
28
|
+
*
|
|
29
|
+
* The pure half (manifest, size, sha256, the name of a picked file)
|
|
30
|
+
* is tested without a DOM; the DOM half builds the control.
|
|
31
|
+
*
|
|
32
|
+
* Using Bulma Framework (https://bulma.io)
|
|
33
|
+
*
|
|
34
|
+
* Copyright (c) 2026, ArtGins.
|
|
35
|
+
* All Rights Reserved.
|
|
36
|
+
***********************************************************************/
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
createElement2, log_error
|
|
40
|
+
} from "@yuneta/gobj-js";
|
|
41
|
+
|
|
42
|
+
import "./yui_file_field.css";
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* What the treedb's default ceiling accepts, as an `accept` attribute:
|
|
46
|
+
* a hint to the file dialog, never a check. The check is treedb's, on
|
|
47
|
+
* the BYTES, at the door -- a browser filter is a convenience and a
|
|
48
|
+
* client that means to lie walks past it.
|
|
49
|
+
*/
|
|
50
|
+
const YUI_FILE_ACCEPT = [
|
|
51
|
+
"image/jpeg", "image/png", "image/webp", "image/gif",
|
|
52
|
+
"application/pdf",
|
|
53
|
+
"video/mp4", "video/webm", "video/quicktime", "video/ogg", "video/x-matroska",
|
|
54
|
+
"audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm", "audio/flac"
|
|
55
|
+
].join(",");
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/***************************
|
|
59
|
+
* The pure half
|
|
60
|
+
***************************/
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
/***************************************************************************
|
|
66
|
+
* A size a person reads. Bytes up to 1 KB, then one decimal.
|
|
67
|
+
***************************************************************************/
|
|
68
|
+
function yui_file_size_label(bytes)
|
|
69
|
+
{
|
|
70
|
+
let n = Number(bytes);
|
|
71
|
+
if(!isFinite(n) || n < 0) {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
if(n < 1024) {
|
|
75
|
+
return `${n} B`;
|
|
76
|
+
}
|
|
77
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
78
|
+
let i = -1;
|
|
79
|
+
do {
|
|
80
|
+
n = n / 1024;
|
|
81
|
+
i++;
|
|
82
|
+
} while(n >= 1024 && i < units.length - 1);
|
|
83
|
+
return `${n.toFixed(1)} ${units[i]}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/***************************************************************************
|
|
87
|
+
* The sha256 of an ArrayBuffer, lowercase hex, or null.
|
|
88
|
+
*
|
|
89
|
+
* NULL is a legal answer and not a failure: `crypto.subtle` exists only
|
|
90
|
+
* in a SECURE CONTEXT, so a dev server on plain http has none. Then the
|
|
91
|
+
* record carries no id and treedb fills it -- the write path hashes what
|
|
92
|
+
* arrives anyway, because it never trusts the client's id. What the hash
|
|
93
|
+
* buys here is an integrity check the backend can make (a wrong id with
|
|
94
|
+
* good bytes is refused), not the identity itself.
|
|
95
|
+
***************************************************************************/
|
|
96
|
+
async function yui_file_sha256(buffer)
|
|
97
|
+
{
|
|
98
|
+
const subtle = (typeof crypto !== "undefined") && crypto.subtle;
|
|
99
|
+
if(!subtle) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const digest = await subtle.digest("SHA-256", buffer);
|
|
104
|
+
return Array.from(new Uint8Array(digest))
|
|
105
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
106
|
+
.join("");
|
|
107
|
+
} catch (e) {
|
|
108
|
+
/*
|
|
109
|
+
* Said, not swallowed: without this the record simply carries no
|
|
110
|
+
* id, which is indistinguishable from an insecure context, and
|
|
111
|
+
* the reason never reaches anybody.
|
|
112
|
+
*/
|
|
113
|
+
log_error(`cannot hash the picked file: ${e && e.message? e.message: e}`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/***************************************************************************
|
|
119
|
+
* base64 of an ArrayBuffer.
|
|
120
|
+
*
|
|
121
|
+
* In chunks because `String.fromCharCode(...bytes)` spreads the whole
|
|
122
|
+
* array onto the call stack, and a few hundred KB is already a
|
|
123
|
+
* RangeError -- which for a file picker is every file that matters.
|
|
124
|
+
***************************************************************************/
|
|
125
|
+
function yui_array_buffer_to_base64(buffer)
|
|
126
|
+
{
|
|
127
|
+
const bytes = new Uint8Array(buffer);
|
|
128
|
+
const CHUNK = 0x8000;
|
|
129
|
+
let binary = "";
|
|
130
|
+
for(let i = 0; i < bytes.length; i += CHUNK) {
|
|
131
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
|
132
|
+
}
|
|
133
|
+
return btoa(binary);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/***************************************************************************
|
|
137
|
+
* Read ONE picked file into what the manifest carries.
|
|
138
|
+
*
|
|
139
|
+
* -> {content64, content_type, original_name, size, id}
|
|
140
|
+
*
|
|
141
|
+
* `id` is the sha256 when it could be computed, and "" when it could
|
|
142
|
+
* not. Nothing else in here is optional: treedb checks the size on the
|
|
143
|
+
* base64 before decoding it, and the type against the BYTES.
|
|
144
|
+
***************************************************************************/
|
|
145
|
+
async function yui_file_read(file)
|
|
146
|
+
{
|
|
147
|
+
const buffer = await file.arrayBuffer();
|
|
148
|
+
const id = await yui_file_sha256(buffer);
|
|
149
|
+
return {
|
|
150
|
+
content64: yui_array_buffer_to_base64(buffer),
|
|
151
|
+
content_type: file.type || "",
|
|
152
|
+
original_name: file.name || "",
|
|
153
|
+
size: file.size,
|
|
154
|
+
id: id || ""
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/***************************************************************************
|
|
159
|
+
* The write a record with picked files becomes.
|
|
160
|
+
*
|
|
161
|
+
* picks {col: read} what yui_file_read() answered, per column
|
|
162
|
+
* record the record the form collected (MODIFIED: each picked
|
|
163
|
+
* column takes the id, or "" when there is none)
|
|
164
|
+
*
|
|
165
|
+
* -> {record, __files__}
|
|
166
|
+
*
|
|
167
|
+
* `__files__` is not a column: it is an instruction to the write path,
|
|
168
|
+
* consumed and dropped at the door. The bytes never go INSIDE the
|
|
169
|
+
* column -- a field that CARRIES the bytes and a field that KEEPS them
|
|
170
|
+
* are one word apart and 460 MB of RAM apart.
|
|
171
|
+
***************************************************************************/
|
|
172
|
+
function yui_files_manifest(picks, record)
|
|
173
|
+
{
|
|
174
|
+
const out = record || {};
|
|
175
|
+
const manifest = {};
|
|
176
|
+
|
|
177
|
+
Object.keys(picks || {}).forEach((col) => {
|
|
178
|
+
const read = picks[col];
|
|
179
|
+
if(!read) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
manifest[col] = {
|
|
183
|
+
content64: read.content64,
|
|
184
|
+
content_type: read.content_type,
|
|
185
|
+
original_name: read.original_name
|
|
186
|
+
};
|
|
187
|
+
/*
|
|
188
|
+
* The column takes the id, never the bytes. Empty when the
|
|
189
|
+
* browser could not hash: treedb then fills it from what arrives.
|
|
190
|
+
*/
|
|
191
|
+
out[col] = read.id || "";
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return {record: out, __files__: manifest};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
/***************************
|
|
201
|
+
* The DOM half
|
|
202
|
+
***************************/
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
/***************************************************************************
|
|
208
|
+
* What the control shows for what the column HOLDS today: the asset id,
|
|
209
|
+
* shortened. A sha256 in full is 64 characters of noise in a form.
|
|
210
|
+
***************************************************************************/
|
|
211
|
+
function yui_file_id_label(id)
|
|
212
|
+
{
|
|
213
|
+
if(!id) {
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
return (id.length > 16)? `${id.slice(0, 8)}…${id.slice(-4)}`: id;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/***************************************************************************
|
|
220
|
+
* The control for a `file` column.
|
|
221
|
+
*
|
|
222
|
+
* name the column
|
|
223
|
+
* value the asset id the record holds today ("" when none)
|
|
224
|
+
* readonly draw it, do not let it change
|
|
225
|
+
* on_pick (file|null) -> void. NULL is "the person cleared it".
|
|
226
|
+
*
|
|
227
|
+
* Returns the `<div class="control">` content, and hangs the state the
|
|
228
|
+
* form reads off the returned element:
|
|
229
|
+
*
|
|
230
|
+
* $control.yui_file the picked File, or null
|
|
231
|
+
* $control.yui_file_value the id the column keeps ("" = cleared)
|
|
232
|
+
*
|
|
233
|
+
* The `<input type="file">` is hidden and driven by a button, because a
|
|
234
|
+
* bare file input cannot be styled and says "Sin archivos seleccionados"
|
|
235
|
+
* in the browser's own language, not the app's.
|
|
236
|
+
***************************************************************************/
|
|
237
|
+
function yui_file_control(gobj, {name, value, readonly, accept, on_pick})
|
|
238
|
+
{
|
|
239
|
+
const has_asset = !!value;
|
|
240
|
+
|
|
241
|
+
const $input = createElement2(["input", {
|
|
242
|
+
type: "file",
|
|
243
|
+
class: "FILE_INPUT",
|
|
244
|
+
name: `${name}__file`,
|
|
245
|
+
accept: accept || YUI_FILE_ACCEPT,
|
|
246
|
+
hidden: ""
|
|
247
|
+
}]);
|
|
248
|
+
|
|
249
|
+
const $pick = createElement2(["button", {
|
|
250
|
+
type: "button",
|
|
251
|
+
class: "FILE_PICK button is-small",
|
|
252
|
+
"data-i18n-title": "choose a file",
|
|
253
|
+
"aria-label": "choose a file"
|
|
254
|
+
}, [
|
|
255
|
+
["span", {class: "FILE_PICK_ICON yi-upload"}],
|
|
256
|
+
/* The label stays: this control is alone in its row, so nothing
|
|
257
|
+
* competes for the width, and two bare icons side by side read as
|
|
258
|
+
* the same control. */
|
|
259
|
+
["span", {class: "FILE_PICK_LABEL", i18n: "choose a file"}, "choose a file"]
|
|
260
|
+
]]);
|
|
261
|
+
|
|
262
|
+
const $clear = createElement2(["button", {
|
|
263
|
+
type: "button",
|
|
264
|
+
class: "FILE_CLEAR button is-small",
|
|
265
|
+
"data-i18n-title": "remove the file",
|
|
266
|
+
"aria-label": "remove the file"
|
|
267
|
+
}, [
|
|
268
|
+
["span", {class: "FILE_CLEAR_ICON yi-trash"}]
|
|
269
|
+
]]);
|
|
270
|
+
|
|
271
|
+
const $name = createElement2(["span", {class: "FILE_NAME"}]);
|
|
272
|
+
const $size = createElement2(["span", {class: "FILE_SIZE"}]);
|
|
273
|
+
const $state = createElement2(["span", {class: "FILE_STATE"}]);
|
|
274
|
+
|
|
275
|
+
const $control = createElement2(["div", {class: "FILE_FIELD"}, [
|
|
276
|
+
$input, $pick, $name, $size, $state, $clear
|
|
277
|
+
]]);
|
|
278
|
+
|
|
279
|
+
$control.yui_file = null;
|
|
280
|
+
$control.yui_file_value = value || "";
|
|
281
|
+
|
|
282
|
+
/*
|
|
283
|
+
* Three things it can be showing, and they are not the same thing:
|
|
284
|
+
* the asset the record already names, a file picked and not yet
|
|
285
|
+
* saved, and nothing at all.
|
|
286
|
+
*/
|
|
287
|
+
const render = () => {
|
|
288
|
+
const picked = $control.yui_file;
|
|
289
|
+
const kept = $control.yui_file_value;
|
|
290
|
+
|
|
291
|
+
$name.textContent = picked? (picked.name || ""): yui_file_id_label(kept);
|
|
292
|
+
$size.textContent = picked? yui_file_size_label(picked.size): "";
|
|
293
|
+
|
|
294
|
+
$state.className = "FILE_STATE";
|
|
295
|
+
$state.removeAttribute("data-i18n");
|
|
296
|
+
$state.textContent = "";
|
|
297
|
+
if(picked) {
|
|
298
|
+
$state.classList.add("is-picked");
|
|
299
|
+
$state.setAttribute("data-i18n", "not saved yet");
|
|
300
|
+
$state.textContent = "not saved yet";
|
|
301
|
+
} else if(!kept) {
|
|
302
|
+
$state.classList.add("is-empty");
|
|
303
|
+
$state.setAttribute("data-i18n", "no file");
|
|
304
|
+
$state.textContent = "no file";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
$clear.hidden = readonly || (!picked && !kept);
|
|
308
|
+
$pick.hidden = !!readonly;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
if(!readonly) {
|
|
312
|
+
$pick.addEventListener("click", () => {
|
|
313
|
+
$input.click();
|
|
314
|
+
});
|
|
315
|
+
$input.addEventListener("change", () => {
|
|
316
|
+
/*
|
|
317
|
+
* Picking READS NOTHING. It takes the reference, shows the
|
|
318
|
+
* name and the size the `File` already carries, and stops.
|
|
319
|
+
*/
|
|
320
|
+
const file = ($input.files && $input.files.length)? $input.files[0]: null;
|
|
321
|
+
$control.yui_file = file;
|
|
322
|
+
render();
|
|
323
|
+
if(on_pick) {
|
|
324
|
+
on_pick(file);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
$clear.addEventListener("click", () => {
|
|
328
|
+
$control.yui_file = null;
|
|
329
|
+
$control.yui_file_value = "";
|
|
330
|
+
$input.value = "";
|
|
331
|
+
render();
|
|
332
|
+
if(on_pick) {
|
|
333
|
+
on_pick(null);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/*
|
|
339
|
+
* The form writes `yui_file` / `yui_file_value` straight onto the
|
|
340
|
+
* element -- when it loads a record, when it clears the form -- and
|
|
341
|
+
* writing a property fires nothing, so it has to be able to say
|
|
342
|
+
* "now draw what you are holding".
|
|
343
|
+
*/
|
|
344
|
+
$control.yui_file_render = render;
|
|
345
|
+
|
|
346
|
+
render();
|
|
347
|
+
|
|
348
|
+
return {$control, $input, has_asset};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
export {
|
|
353
|
+
YUI_FILE_ACCEPT,
|
|
354
|
+
yui_file_size_label,
|
|
355
|
+
yui_file_sha256,
|
|
356
|
+
yui_array_buffer_to_base64,
|
|
357
|
+
yui_file_read,
|
|
358
|
+
yui_files_manifest,
|
|
359
|
+
yui_file_id_label,
|
|
360
|
+
yui_file_control
|
|
361
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_file_field.test.js
|
|
3
|
+
*
|
|
4
|
+
* The pure half of the `file` column control: the manifest that
|
|
5
|
+
* carries the bytes BESIDE the record, the base64 that must survive
|
|
6
|
+
* a file worth picking, and the hash that is allowed to be absent.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) 2026, ArtGins.
|
|
9
|
+
* All Rights Reserved.
|
|
10
|
+
***********************************************************************/
|
|
11
|
+
import { test, expect } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
yui_file_size_label,
|
|
14
|
+
yui_array_buffer_to_base64,
|
|
15
|
+
yui_files_manifest,
|
|
16
|
+
yui_file_id_label
|
|
17
|
+
} from "./yui_file_field.js";
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/***************************************************************
|
|
21
|
+
* The manifest
|
|
22
|
+
***************************************************************/
|
|
23
|
+
test("the bytes go BESIDE the record, and the column takes the id", () => {
|
|
24
|
+
const picks = {
|
|
25
|
+
foto: {
|
|
26
|
+
content64: "AAA=",
|
|
27
|
+
content_type: "image/png",
|
|
28
|
+
original_name: "dev-1.png",
|
|
29
|
+
size: 2,
|
|
30
|
+
id: "a".repeat(64)
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const {record, __files__} = yui_files_manifest(picks, {id: "dev-1", name: "one"});
|
|
34
|
+
|
|
35
|
+
expect(record.foto).toBe("a".repeat(64));
|
|
36
|
+
expect(record.content64).toBeUndefined();
|
|
37
|
+
expect(record.__files__).toBeUndefined();
|
|
38
|
+
expect(__files__.foto.content64).toBe("AAA=");
|
|
39
|
+
expect(__files__.foto.original_name).toBe("dev-1.png");
|
|
40
|
+
/* size is treedb's to measure, never the client's to declare */
|
|
41
|
+
expect(__files__.foto.size).toBeUndefined();
|
|
42
|
+
expect(record.id).toBe("dev-1");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("no hash: the column goes empty and treedb fills the id", () => {
|
|
46
|
+
const picks = {foto: {content64: "AAA=", content_type: "", original_name: "x", size: 2, id: ""}};
|
|
47
|
+
const {record, __files__} = yui_files_manifest(picks, {id: "dev-1"});
|
|
48
|
+
expect(record.foto).toBe("");
|
|
49
|
+
expect(__files__.foto.content64).toBe("AAA=");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("two columns in one record make two entries", () => {
|
|
53
|
+
const picks = {
|
|
54
|
+
foto: {content64: "AA==", content_type: "image/png", original_name: "a", size: 1, id: "1"},
|
|
55
|
+
qr: {content64: "BB==", content_type: "image/jpeg", original_name: "b", size: 1, id: "2"}
|
|
56
|
+
};
|
|
57
|
+
const {record, __files__} = yui_files_manifest(picks, {id: "dev-2"});
|
|
58
|
+
expect(Object.keys(__files__).sort()).toEqual(["foto", "qr"]);
|
|
59
|
+
expect(record.foto).toBe("1");
|
|
60
|
+
expect(record.qr).toBe("2");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("nothing picked leaves the record alone and the manifest empty", () => {
|
|
64
|
+
const {record, __files__} = yui_files_manifest({}, {id: "dev-1", foto: "kept"});
|
|
65
|
+
expect(record.foto).toBe("kept");
|
|
66
|
+
expect(Object.keys(__files__)).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a null pick is not an entry", () => {
|
|
70
|
+
const {__files__} = yui_files_manifest({foto: null}, {id: "dev-1"});
|
|
71
|
+
expect(Object.keys(__files__)).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
/***************************************************************
|
|
76
|
+
* base64
|
|
77
|
+
***************************************************************/
|
|
78
|
+
test("base64 survives a buffer that would blow the call stack spread", () => {
|
|
79
|
+
/*
|
|
80
|
+
* String.fromCharCode(...bytes) is a RangeError well under this, and
|
|
81
|
+
* a few hundred KB is every file a picker is for.
|
|
82
|
+
*/
|
|
83
|
+
const buffer = new Uint8Array(300 * 1024);
|
|
84
|
+
for(let i = 0; i < buffer.length; i++) {
|
|
85
|
+
buffer[i] = i % 256;
|
|
86
|
+
}
|
|
87
|
+
const b64 = yui_array_buffer_to_base64(buffer.buffer);
|
|
88
|
+
expect(b64.length).toBe(Math.ceil(buffer.length / 3) * 4);
|
|
89
|
+
/* and it round-trips */
|
|
90
|
+
const back = atob(b64);
|
|
91
|
+
expect(back.length).toBe(buffer.length);
|
|
92
|
+
expect(back.charCodeAt(0)).toBe(0);
|
|
93
|
+
expect(back.charCodeAt(257)).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an empty buffer is an empty string, not a crash", () => {
|
|
97
|
+
expect(yui_array_buffer_to_base64(new Uint8Array(0).buffer)).toBe("");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
/***************************************************************
|
|
102
|
+
* Labels
|
|
103
|
+
***************************************************************/
|
|
104
|
+
test("a size a person reads", () => {
|
|
105
|
+
expect(yui_file_size_label(0)).toBe("0 B");
|
|
106
|
+
expect(yui_file_size_label(512)).toBe("512 B");
|
|
107
|
+
expect(yui_file_size_label(1024)).toBe("1.0 KB");
|
|
108
|
+
expect(yui_file_size_label(1536)).toBe("1.5 KB");
|
|
109
|
+
expect(yui_file_size_label(5 * 1024 * 1024)).toBe("5.0 MB");
|
|
110
|
+
expect(yui_file_size_label(-1)).toBe("");
|
|
111
|
+
expect(yui_file_size_label("nope")).toBe("");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a sha256 is shortened, a short id is not", () => {
|
|
115
|
+
const sha = "afbcdae99c628ddb7b12813d4ffced63cb6a1c6f52dcdbcef4e641dbe96debb6";
|
|
116
|
+
expect(yui_file_id_label(sha)).toBe("afbcdae9…ebb6");
|
|
117
|
+
expect(yui_file_id_label("short")).toBe("short");
|
|
118
|
+
expect(yui_file_id_label("")).toBe("");
|
|
119
|
+
});
|