@echovisionlab/geul-event 0.1.0

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.
@@ -0,0 +1,1164 @@
1
+ import { create, fromJson, toJson } from "@bufbuild/protobuf";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ canonicalStorageDocumentBytes,
6
+ contentBlockCatalogFingerprint,
7
+ extractContentStorageFileReferences,
8
+ flattenPageDocumentStorage,
9
+ flattenPageMutationBatchStorage,
10
+ flattenRichTextMutationBatchStorage,
11
+ flattenRichTextDocumentStorage,
12
+ isPageSectionCollaborativeTextPath,
13
+ isRichTextCollaborativeTextPath,
14
+ materializePageDocumentStorage,
15
+ materializeRichTextDocumentStorage,
16
+ normalizeContentStorageLocale,
17
+ normalizeContentStorageBlock,
18
+ normalizeContentStorageShared,
19
+ pageSectionKinds,
20
+ richTextBlockKinds,
21
+ richTextBlockCatalog,
22
+ richTextProfiles,
23
+ validatePageSectionMutationBatch,
24
+ validateRichTextBlockMutationBatch,
25
+ } from "../../proto/gen/api/content/v1/block_catalog.ts";
26
+ import {
27
+ PageDocumentSchema,
28
+ PageSectionDataSchema,
29
+ PageSectionLocaleDataSchema,
30
+ PageSectionMutationBatchSchema,
31
+ ContentValidationMode,
32
+ MissingAttachmentMediaKind,
33
+ RichTextBlockLocaleDataSchema,
34
+ RichTextBlockDataSchema,
35
+ RichTextBlockMutationBatchSchema,
36
+ RichTextDocumentSchema,
37
+ RichTextProfile,
38
+ ShaderProps_StagesItem_ChannelsItem_Kind,
39
+ ShaderProps_StagesItem_Kind,
40
+ } from "../../proto/gen/api/content/v1/block_content_pb.ts";
41
+
42
+ const BLOCK_ID = "00000000-0000-4000-8000-000000000001";
43
+ const SECOND_BLOCK_ID = "00000000-0000-4000-8000-000000000003";
44
+ const SECTION_ID = "00000000-0000-4000-8000-000000000002";
45
+
46
+ describe("generated collaborative text paths", () => {
47
+ it("keeps only authored rich text and code fields collaborative", () => {
48
+ expect(
49
+ isRichTextCollaborativeTextPath("paragraph", "content[0].text.text"),
50
+ ).toBe(true);
51
+ expect(
52
+ isRichTextCollaborativeTextPath(
53
+ "paragraph",
54
+ "content[0].link.content[0].text",
55
+ ),
56
+ ).toBe(true);
57
+ expect(
58
+ isRichTextCollaborativeTextPath("paragraph", "content[0].link.href"),
59
+ ).toBe(false);
60
+ expect(
61
+ isRichTextCollaborativeTextPath(
62
+ "paragraph",
63
+ "content[0].text.styles.textColor",
64
+ ),
65
+ ).toBe(false);
66
+ expect(
67
+ isRichTextCollaborativeTextPath("shader", "props.stages[4].source"),
68
+ ).toBe(true);
69
+ expect(
70
+ isRichTextCollaborativeTextPath(
71
+ "shader",
72
+ "props.stages[4].channels[0].file",
73
+ ),
74
+ ).toBe(false);
75
+ expect(isRichTextCollaborativeTextPath("file", "props.name")).toBe(true);
76
+ expect(
77
+ isRichTextCollaborativeTextPath("file", "props.attachment.activeFileId"),
78
+ ).toBe(false);
79
+ });
80
+
81
+ it("covers Page nested authored strings without treating selectors as text", () => {
82
+ expect(
83
+ isPageSectionCollaborativeTextPath("text-marquee", "props.items[2].text"),
84
+ ).toBe(true);
85
+ expect(
86
+ isPageSectionCollaborativeTextPath("text-marquee", "props.items[2].href"),
87
+ ).toBe(false);
88
+ expect(
89
+ isPageSectionCollaborativeTextPath(
90
+ "immersive-scene",
91
+ "units[1].props.name",
92
+ ),
93
+ ).toBe(true);
94
+ expect(
95
+ isPageSectionCollaborativeTextPath(
96
+ "immersive-scene",
97
+ "units[1].props.title",
98
+ ),
99
+ ).toBe(true);
100
+ expect(
101
+ isPageSectionCollaborativeTextPath(
102
+ "immersive-scene",
103
+ "units[1].props.meshFile",
104
+ ),
105
+ ).toBe(false);
106
+ });
107
+ });
108
+
109
+ describe("generated storage materializers", () => {
110
+ it("round-trips Rich Text rows with canonical kebab-case kinds", () => {
111
+ const document = create(RichTextDocumentSchema, {
112
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
113
+ profile: RichTextProfile.POST,
114
+ sourceLocale: "en",
115
+ base: {
116
+ nodes: [
117
+ {
118
+ block: {
119
+ id: BLOCK_ID,
120
+ value: { case: "codeBlock", value: { props: {} } },
121
+ },
122
+ placement: { index: 0 },
123
+ },
124
+ ],
125
+ },
126
+ localeOverlays: [
127
+ {
128
+ locale: "en",
129
+ blocks: [
130
+ {
131
+ blockId: BLOCK_ID,
132
+ value: {
133
+ case: "codeBlock",
134
+ value: { props: {}, content: "const answer = 42" },
135
+ },
136
+ },
137
+ ],
138
+ },
139
+ ],
140
+ });
141
+
142
+ const rows = flattenRichTextDocumentStorage(document);
143
+ expect(rows).toHaveLength(1);
144
+ expect(rows[0]?.kind).toBe("code-block");
145
+
146
+ const rebuilt = materializeRichTextDocumentStorage(
147
+ RichTextProfile.POST,
148
+ "en",
149
+ rows,
150
+ );
151
+ expect(rebuilt.localeOverlays[0]?.blocks[0]?.value).toMatchObject({
152
+ case: "codeBlock",
153
+ value: { content: "const answer = 42" },
154
+ });
155
+ expect(
156
+ canonicalStorageDocumentBytes(
157
+ "post",
158
+ flattenRichTextDocumentStorage(rebuilt),
159
+ ),
160
+ ).toEqual(canonicalStorageDocumentBytes("post", rows));
161
+ });
162
+
163
+ it("round-trips Page sections and their separately stored Rich Text descendants", () => {
164
+ const document = create(PageDocumentSchema, {
165
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
166
+ sourceLocale: "en",
167
+ base: {
168
+ nodes: [
169
+ {
170
+ section: {
171
+ id: SECTION_ID,
172
+ settings: {},
173
+ value: {
174
+ case: "richText",
175
+ value: {
176
+ props: {},
177
+ blocks: {
178
+ nodes: [
179
+ {
180
+ block: {
181
+ id: BLOCK_ID,
182
+ value: { case: "codeBlock", value: { props: {} } },
183
+ },
184
+ placement: { index: 0 },
185
+ },
186
+ {
187
+ block: {
188
+ id: SECOND_BLOCK_ID,
189
+ value: { case: "paragraph", value: { props: {} } },
190
+ },
191
+ placement: { index: 1 },
192
+ },
193
+ ],
194
+ },
195
+ },
196
+ },
197
+ },
198
+ placement: { index: 0 },
199
+ },
200
+ ],
201
+ },
202
+ localeOverlays: [
203
+ {
204
+ locale: "en",
205
+ sections: [
206
+ {
207
+ sectionId: SECTION_ID,
208
+ value: {
209
+ case: "richText",
210
+ value: {
211
+ props: {},
212
+ blocks: {
213
+ locale: "en",
214
+ blocks: [
215
+ {
216
+ blockId: BLOCK_ID,
217
+ value: {
218
+ case: "codeBlock",
219
+ value: { props: {}, content: "nested code" },
220
+ },
221
+ },
222
+ {
223
+ blockId: SECOND_BLOCK_ID,
224
+ value: {
225
+ case: "paragraph",
226
+ value: {
227
+ props: {},
228
+ content: [
229
+ {
230
+ value: {
231
+ case: "text",
232
+ value: { text: "second nested block" },
233
+ },
234
+ },
235
+ ],
236
+ },
237
+ },
238
+ },
239
+ ],
240
+ },
241
+ },
242
+ },
243
+ },
244
+ ],
245
+ },
246
+ ],
247
+ });
248
+
249
+ const rows = flattenPageDocumentStorage(document);
250
+ expect(rows.map((row) => row.kind)).toEqual([
251
+ "rich-text",
252
+ "code-block",
253
+ "paragraph",
254
+ ]);
255
+
256
+ const rebuilt = materializePageDocumentStorage("en", rows);
257
+ const nested = rebuilt.base?.nodes[0]?.section?.value;
258
+ expect(nested?.case).toBe("richText");
259
+ if (nested?.case !== "richText")
260
+ throw new Error("expected Rich Text section");
261
+ expect(nested.value.blocks?.nodes[0]?.block?.value.case).toBe("codeBlock");
262
+ expect(nested.value.blocks?.nodes[1]?.block?.value.case).toBe("paragraph");
263
+ expect(
264
+ canonicalStorageDocumentBytes(
265
+ "page",
266
+ flattenPageDocumentStorage(rebuilt),
267
+ ),
268
+ ).toEqual(canonicalStorageDocumentBytes("page", rows));
269
+ });
270
+
271
+ it("uses the protobuf JSON name show3dBuildings in catalog and storage", () => {
272
+ expect(richTextBlockCatalog.map.fields).toHaveProperty("show3dBuildings");
273
+ expect(richTextBlockCatalog.map.fields).not.toHaveProperty(
274
+ "show3DBuildings",
275
+ );
276
+
277
+ const document = create(RichTextDocumentSchema, {
278
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
279
+ profile: RichTextProfile.POST,
280
+ sourceLocale: "en",
281
+ base: {
282
+ nodes: [
283
+ {
284
+ block: {
285
+ id: BLOCK_ID,
286
+ value: {
287
+ case: "map",
288
+ value: { props: { show3dBuildings: true } },
289
+ },
290
+ },
291
+ placement: { index: 0 },
292
+ },
293
+ ],
294
+ },
295
+ localeOverlays: [
296
+ {
297
+ locale: "en",
298
+ blocks: [
299
+ {
300
+ blockId: BLOCK_ID,
301
+ value: { case: "map", value: { props: {} } },
302
+ },
303
+ ],
304
+ },
305
+ ],
306
+ });
307
+
308
+ const row = flattenRichTextDocumentStorage(document)[0];
309
+ expect(row).toBeDefined();
310
+ if (row?.sharedData.$typeName !== "api.content.v1.RichTextBlockData") {
311
+ throw new Error("expected Rich Text storage data");
312
+ }
313
+ const json = toJson(RichTextBlockDataSchema, row!.sharedData);
314
+ expect(json).toMatchObject({ map: { props: { show3dBuildings: true } } });
315
+ expect(JSON.stringify(json)).not.toContain("show3DBuildings");
316
+ });
317
+
318
+ it("keeps browser mutation validation on the attributed public surface", () => {
319
+ const rich = create(RichTextBlockMutationBatchSchema, {
320
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
321
+ profile: RichTextProfile.POST,
322
+ expectedRevision: SECTION_ID,
323
+ baseMutations: [
324
+ { operation: { case: "delete", value: { blockId: BLOCK_ID } } },
325
+ ],
326
+ });
327
+ expect(() => validateRichTextBlockMutationBatch(rich)).toThrow(
328
+ /contributor Member/,
329
+ );
330
+
331
+ const page = create(PageSectionMutationBatchSchema, {
332
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
333
+ expectedRevision: SECTION_ID,
334
+ baseMutations: [
335
+ { operation: { case: "delete", value: { sectionId: SECTION_ID } } },
336
+ ],
337
+ });
338
+ expect(() => validatePageSectionMutationBatch(page)).toThrow(
339
+ /contributor Member/,
340
+ );
341
+ });
342
+
343
+ it("carries the locale ExpectedKind and validates it independently", () => {
344
+ const batch = create(RichTextBlockMutationBatchSchema, {
345
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
346
+ profile: RichTextProfile.POST,
347
+ expectedRevision: SECTION_ID,
348
+ contributorMemberIds: [SECTION_ID],
349
+ localeMutationGroups: [
350
+ {
351
+ locale: "ko",
352
+ mutations: [
353
+ {
354
+ operation: {
355
+ case: "upsert",
356
+ value: {
357
+ block: {
358
+ blockId: BLOCK_ID,
359
+ value: {
360
+ case: "codeBlock",
361
+ value: { props: {}, content: "const answer = 42" },
362
+ },
363
+ },
364
+ },
365
+ },
366
+ },
367
+ ],
368
+ },
369
+ ],
370
+ });
371
+ const storage = flattenRichTextMutationBatchStorage(batch);
372
+ const upsert = storage.localeGroups[0]?.upserts[0];
373
+ expect(upsert?.expectedKind).toBe("code-block");
374
+ expect(() =>
375
+ normalizeContentStorageLocale(
376
+ "compact",
377
+ "code-block",
378
+ upsert!.localizedData,
379
+ ),
380
+ ).toThrow(/forbidden by profile/);
381
+ const normalized = normalizeContentStorageLocale(
382
+ "post",
383
+ "code-block",
384
+ upsert!.localizedData,
385
+ );
386
+ if (normalized.$typeName !== "api.content.v1.RichTextBlockLocaleData") {
387
+ throw new Error("expected Rich Text locale storage data");
388
+ }
389
+ expect(toJson(RichTextBlockLocaleDataSchema, normalized)).toMatchObject({
390
+ codeBlock: { content: "const answer = 42" },
391
+ });
392
+ });
393
+
394
+ it("preserves present empty locale defaults without materializing missing values", () => {
395
+ const pageBatch = (caption: string | undefined) =>
396
+ create(PageSectionMutationBatchSchema, {
397
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
398
+ expectedRevision: SECTION_ID,
399
+ contributorMemberIds: [SECTION_ID],
400
+ localeMutationGroups: [
401
+ {
402
+ locale: "ko",
403
+ mutations: [
404
+ {
405
+ operation: {
406
+ case: "upsert",
407
+ value: {
408
+ section: {
409
+ sectionId: SECTION_ID,
410
+ value: {
411
+ case: "externalVideo",
412
+ value: { props: { caption } },
413
+ },
414
+ },
415
+ },
416
+ },
417
+ },
418
+ ],
419
+ },
420
+ ],
421
+ });
422
+
423
+ const explicitPage = flattenPageMutationBatchStorage(pageBatch(""));
424
+ const missingPage = flattenPageMutationBatchStorage(pageBatch(undefined));
425
+ const explicitPageData =
426
+ explicitPage.localeGroups[0]!.upserts[0]!.localizedData;
427
+ const missingPageData =
428
+ missingPage.localeGroups[0]!.upserts[0]!.localizedData;
429
+ if (
430
+ explicitPageData.$typeName !== "api.content.v1.PageSectionLocaleData" ||
431
+ missingPageData.$typeName !== "api.content.v1.PageSectionLocaleData"
432
+ ) {
433
+ throw new Error("expected Page locale storage data");
434
+ }
435
+ const explicitPageJSON = toJson(
436
+ PageSectionLocaleDataSchema,
437
+ explicitPageData,
438
+ ) as { externalVideo?: { props?: { caption?: string } } };
439
+ const missingPageJSON = toJson(
440
+ PageSectionLocaleDataSchema,
441
+ missingPageData,
442
+ ) as { externalVideo?: { props?: { caption?: string } } };
443
+ expect(
444
+ Object.hasOwn(explicitPageJSON.externalVideo!.props!, "caption"),
445
+ ).toBe(true);
446
+ expect(explicitPageJSON.externalVideo!.props!.caption).toBe("");
447
+ expect(
448
+ Object.hasOwn(missingPageJSON.externalVideo!.props!, "caption"),
449
+ ).toBe(false);
450
+
451
+ const richBatch = (title: string | undefined) =>
452
+ create(RichTextBlockMutationBatchSchema, {
453
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
454
+ profile: RichTextProfile.POST,
455
+ expectedRevision: SECTION_ID,
456
+ contributorMemberIds: [SECTION_ID],
457
+ localeMutationGroups: [
458
+ {
459
+ locale: "ko",
460
+ mutations: [
461
+ {
462
+ operation: {
463
+ case: "upsert",
464
+ value: {
465
+ block: {
466
+ blockId: BLOCK_ID,
467
+ value: {
468
+ case: "codeBlock",
469
+ value: { props: { title }, content: "" },
470
+ },
471
+ },
472
+ },
473
+ },
474
+ },
475
+ ],
476
+ },
477
+ ],
478
+ });
479
+ const explicitRich = flattenRichTextMutationBatchStorage(richBatch(""));
480
+ const missingRich = flattenRichTextMutationBatchStorage(
481
+ richBatch(undefined),
482
+ );
483
+ const explicitRichData =
484
+ explicitRich.localeGroups[0]!.upserts[0]!.localizedData;
485
+ const missingRichData =
486
+ missingRich.localeGroups[0]!.upserts[0]!.localizedData;
487
+ if (
488
+ explicitRichData.$typeName !== "api.content.v1.RichTextBlockLocaleData" ||
489
+ missingRichData.$typeName !== "api.content.v1.RichTextBlockLocaleData"
490
+ ) {
491
+ throw new Error("expected Rich Text locale storage data");
492
+ }
493
+ const explicitRichJSON = toJson(
494
+ RichTextBlockLocaleDataSchema,
495
+ explicitRichData,
496
+ ) as { codeBlock?: { props?: { title?: string } } };
497
+ const missingRichJSON = toJson(
498
+ RichTextBlockLocaleDataSchema,
499
+ missingRichData,
500
+ ) as { codeBlock?: { props?: { title?: string } } };
501
+ expect(Object.hasOwn(explicitRichJSON.codeBlock!.props!, "title")).toBe(
502
+ true,
503
+ );
504
+ expect(explicitRichJSON.codeBlock!.props!.title).toBe("");
505
+ expect(Object.hasOwn(missingRichJSON.codeBlock!.props!, "title")).toBe(
506
+ false,
507
+ );
508
+ });
509
+
510
+ it("preserves nested Page units and empty Rich Text content shapes", () => {
511
+ const nestedPage = create(PageSectionLocaleDataSchema, {
512
+ value: {
513
+ case: "immersiveScene",
514
+ value: {
515
+ props: {},
516
+ units: [
517
+ { unitId: BLOCK_ID, props: { title: "" } },
518
+ { unitId: SECTION_ID, props: {} },
519
+ ],
520
+ },
521
+ },
522
+ });
523
+ const normalizedPage = normalizeContentStorageLocale(
524
+ "page",
525
+ "immersive-scene",
526
+ nestedPage,
527
+ );
528
+ if (
529
+ normalizedPage.$typeName !== "api.content.v1.PageSectionLocaleData" ||
530
+ normalizedPage.value.case !== "immersiveScene"
531
+ ) {
532
+ throw new Error("expected immersive Page locale storage data");
533
+ }
534
+ expect(
535
+ Object.hasOwn(normalizedPage.value.value.units[0]!.props!, "title"),
536
+ ).toBe(true);
537
+ expect(normalizedPage.value.value.units[0]!.props!.title).toBe("");
538
+ expect(
539
+ Object.hasOwn(normalizedPage.value.value.units[1]!.props!, "title"),
540
+ ).toBe(false);
541
+
542
+ const pageDocument = create(PageDocumentSchema, {
543
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
544
+ sourceLocale: "en",
545
+ base: {
546
+ nodes: [
547
+ {
548
+ section: {
549
+ id: SECTION_ID,
550
+ settings: {},
551
+ value: {
552
+ case: "immersiveScene",
553
+ value: {
554
+ props: {},
555
+ units: [
556
+ { id: BLOCK_ID, props: {} },
557
+ { id: SECOND_BLOCK_ID, props: {} },
558
+ ],
559
+ },
560
+ },
561
+ },
562
+ placement: { index: 0 },
563
+ },
564
+ ],
565
+ },
566
+ localeOverlays: [
567
+ {
568
+ locale: "en",
569
+ sections: [
570
+ {
571
+ sectionId: SECTION_ID,
572
+ value: {
573
+ case: "immersiveScene",
574
+ value: {
575
+ props: {},
576
+ units: [
577
+ { unitId: BLOCK_ID, props: { title: "" } },
578
+ { unitId: SECOND_BLOCK_ID, props: {} },
579
+ ],
580
+ },
581
+ },
582
+ },
583
+ ],
584
+ },
585
+ ],
586
+ });
587
+ const pageRow = flattenPageDocumentStorage(pageDocument)[0];
588
+ const pageLocaleData = pageRow?.locales[0]?.data;
589
+ if (
590
+ pageRow?.sharedData.$typeName !== "api.content.v1.PageSectionData" ||
591
+ pageLocaleData?.$typeName !== "api.content.v1.PageSectionLocaleData"
592
+ ) {
593
+ throw new Error("expected Page storage row");
594
+ }
595
+ const pageSharedJSON = toJson(
596
+ PageSectionDataSchema,
597
+ pageRow.sharedData,
598
+ ) as { immersiveScene?: { props?: { textureSize?: string } } };
599
+ expect(
600
+ Object.hasOwn(pageSharedJSON.immersiveScene!.props!, "textureSize"),
601
+ ).toBe(false);
602
+ const pageLocaleJSON = toJson(
603
+ PageSectionLocaleDataSchema,
604
+ pageLocaleData,
605
+ ) as {
606
+ immersiveScene?: {
607
+ units?: { props?: { title?: string } }[];
608
+ };
609
+ };
610
+ expect(
611
+ Object.hasOwn(pageLocaleJSON.immersiveScene!.units![0]!.props!, "title"),
612
+ ).toBe(true);
613
+ expect(
614
+ Object.hasOwn(pageLocaleJSON.immersiveScene!.units![1]!.props!, "title"),
615
+ ).toBe(false);
616
+
617
+ const table = create(RichTextBlockLocaleDataSchema, {
618
+ value: {
619
+ case: "table",
620
+ value: {
621
+ props: {},
622
+ content: {
623
+ rows: [
624
+ {
625
+ rowId: BLOCK_ID,
626
+ cells: [{ cellId: SECTION_ID, content: [] }],
627
+ },
628
+ ],
629
+ },
630
+ },
631
+ },
632
+ });
633
+ const normalizedTable = normalizeContentStorageLocale(
634
+ "post",
635
+ "table",
636
+ table,
637
+ );
638
+ if (
639
+ normalizedTable.$typeName !== "api.content.v1.RichTextBlockLocaleData" ||
640
+ normalizedTable.value.case !== "table"
641
+ ) {
642
+ throw new Error("expected Table locale storage data");
643
+ }
644
+ expect(normalizedTable.value.value.content?.rows).toHaveLength(1);
645
+ expect(
646
+ normalizedTable.value.value.content?.rows[0]?.cells[0]?.content,
647
+ ).toEqual([]);
648
+ });
649
+
650
+ it("validates descriptor-owned inline styles across locale storage and mutations", () => {
651
+ const malformed = create(RichTextBlockLocaleDataSchema, {
652
+ value: {
653
+ case: "paragraph",
654
+ value: {
655
+ props: {},
656
+ content: [
657
+ {
658
+ value: {
659
+ case: "link",
660
+ value: {
661
+ href: "https://geul.io",
662
+ content: [
663
+ {
664
+ text: "invalid",
665
+ styles: { backgroundColor: "RGB" },
666
+ },
667
+ ],
668
+ },
669
+ },
670
+ },
671
+ ],
672
+ },
673
+ },
674
+ });
675
+ expect(() =>
676
+ normalizeContentStorageLocale("post", "paragraph", malformed),
677
+ ).toThrow(/invalid editor color/);
678
+
679
+ const malformedTable = create(RichTextBlockLocaleDataSchema, {
680
+ value: {
681
+ case: "table",
682
+ value: {
683
+ props: {},
684
+ content: {
685
+ rows: [
686
+ {
687
+ rowId: BLOCK_ID,
688
+ cells: [
689
+ {
690
+ cellId: SECTION_ID,
691
+ content: [
692
+ {
693
+ value: {
694
+ case: "text",
695
+ value: {
696
+ text: "invalid",
697
+ styles: { textColor: "RGB" },
698
+ },
699
+ },
700
+ },
701
+ ],
702
+ },
703
+ ],
704
+ },
705
+ ],
706
+ },
707
+ },
708
+ },
709
+ });
710
+ expect(() =>
711
+ normalizeContentStorageLocale("post", "table", malformedTable),
712
+ ).toThrow(/invalid editor color/);
713
+
714
+ const valid = create(RichTextBlockLocaleDataSchema, {
715
+ value: {
716
+ case: "paragraph",
717
+ value: {
718
+ props: {},
719
+ content: [
720
+ {
721
+ value: {
722
+ case: "text",
723
+ value: {
724
+ text: "valid",
725
+ styles: { bold: true, textColor: "#A1B2C3" },
726
+ },
727
+ },
728
+ },
729
+ ],
730
+ },
731
+ },
732
+ });
733
+ const normalized = normalizeContentStorageLocale(
734
+ "post",
735
+ "paragraph",
736
+ valid,
737
+ );
738
+ if (normalized.$typeName !== "api.content.v1.RichTextBlockLocaleData") {
739
+ throw new Error("expected Rich Text locale storage data");
740
+ }
741
+ expect(toJson(RichTextBlockLocaleDataSchema, normalized)).toMatchObject({
742
+ paragraph: {
743
+ content: [{ text: { styles: { bold: true, textColor: "#a1b2c3" } } }],
744
+ },
745
+ });
746
+
747
+ const inlineMath = create(RichTextBlockLocaleDataSchema, {
748
+ value: {
749
+ case: "paragraph",
750
+ value: {
751
+ props: {},
752
+ content: [
753
+ { value: { case: "mathInline", value: { source: "x^2" } } },
754
+ ],
755
+ },
756
+ },
757
+ });
758
+ expect(() =>
759
+ normalizeContentStorageLocale("post", "paragraph", inlineMath),
760
+ ).not.toThrow();
761
+ expect(() =>
762
+ normalizeContentStorageLocale("page", "paragraph", inlineMath),
763
+ ).toThrow(/inline math is forbidden/);
764
+
765
+ const mutation = create(RichTextBlockMutationBatchSchema, {
766
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
767
+ profile: RichTextProfile.POST,
768
+ expectedRevision: SECTION_ID,
769
+ contributorMemberIds: [SECTION_ID],
770
+ localeMutationGroups: [
771
+ {
772
+ locale: "ko",
773
+ mutations: [
774
+ {
775
+ operation: {
776
+ case: "upsert",
777
+ value: {
778
+ block: {
779
+ blockId: BLOCK_ID,
780
+ value: malformed.value,
781
+ },
782
+ },
783
+ },
784
+ },
785
+ ],
786
+ },
787
+ ],
788
+ });
789
+ expect(() => validateRichTextBlockMutationBatch(mutation)).toThrow(
790
+ /invalid editor color/,
791
+ );
792
+ expect(() => flattenRichTextMutationBatchStorage(mutation)).toThrow(
793
+ /invalid editor color/,
794
+ );
795
+ });
796
+
797
+ it("uses WRITE versus RESTORE_SNAPSHOT while flattening storage", () => {
798
+ const document = create(RichTextDocumentSchema, {
799
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
800
+ profile: RichTextProfile.POST,
801
+ sourceLocale: "en",
802
+ base: {
803
+ nodes: [
804
+ {
805
+ block: {
806
+ id: BLOCK_ID,
807
+ value: {
808
+ case: "file",
809
+ value: {
810
+ props: {
811
+ attachment: {
812
+ state: {
813
+ case: "missingAttachment",
814
+ value: {
815
+ formerFileId: SECTION_ID,
816
+ mediaKind: MissingAttachmentMediaKind.FILE,
817
+ },
818
+ },
819
+ },
820
+ },
821
+ },
822
+ },
823
+ },
824
+ placement: { index: 0 },
825
+ },
826
+ ],
827
+ },
828
+ localeOverlays: [
829
+ {
830
+ locale: "en",
831
+ blocks: [
832
+ {
833
+ blockId: BLOCK_ID,
834
+ value: { case: "file", value: { props: {} } },
835
+ },
836
+ ],
837
+ },
838
+ ],
839
+ });
840
+ expect(() => flattenRichTextDocumentStorage(document)).toThrow(
841
+ /snapshot-restore only/,
842
+ );
843
+ expect(
844
+ flattenRichTextDocumentStorage(
845
+ document,
846
+ ContentValidationMode.RESTORE_SNAPSHOT,
847
+ ),
848
+ ).toHaveLength(1);
849
+ });
850
+
851
+ it("extracts exact active and missing storage attachment selectors", () => {
852
+ const active = create(RichTextBlockDataSchema, {
853
+ value: {
854
+ case: "file",
855
+ value: {
856
+ props: {
857
+ attachment: {
858
+ state: { case: "activeFileId", value: BLOCK_ID },
859
+ },
860
+ },
861
+ },
862
+ },
863
+ });
864
+ expect(extractContentStorageFileReferences("file", active)).toEqual([
865
+ {
866
+ referencePath: "file",
867
+ fileId: BLOCK_ID,
868
+ missing: false,
869
+ missingAttachmentMediaKind: MissingAttachmentMediaKind.UNSPECIFIED,
870
+ allowedMimeTypes: [],
871
+ allowedMimePrefixes: [],
872
+ },
873
+ ]);
874
+
875
+ const shader = create(RichTextBlockDataSchema, {
876
+ value: {
877
+ case: "shader",
878
+ value: {
879
+ props: {
880
+ stages: [
881
+ {
882
+ channels: [
883
+ {
884
+ file: {
885
+ state: {
886
+ case: "missingAttachment",
887
+ value: {
888
+ formerFileId: SECTION_ID,
889
+ mediaKind: MissingAttachmentMediaKind.VIDEO,
890
+ },
891
+ },
892
+ },
893
+ faces: [
894
+ {
895
+ state: { case: "activeFileId", value: BLOCK_ID },
896
+ },
897
+ ],
898
+ },
899
+ ],
900
+ },
901
+ ],
902
+ },
903
+ },
904
+ },
905
+ });
906
+ expect(extractContentStorageFileReferences("shader", shader)).toMatchObject(
907
+ [
908
+ {
909
+ referencePath: "shader.stages.0.channels.0.faces.0",
910
+ missing: false,
911
+ missingAttachmentMediaKind: MissingAttachmentMediaKind.UNSPECIFIED,
912
+ },
913
+ {
914
+ referencePath: "shader.stages.0.channels.0.file",
915
+ missing: true,
916
+ missingAttachmentMediaKind: MissingAttachmentMediaKind.VIDEO,
917
+ },
918
+ ],
919
+ );
920
+
921
+ const immersive = create(PageSectionDataSchema, {
922
+ value: {
923
+ case: "immersiveScene",
924
+ value: {
925
+ units: [
926
+ {
927
+ id: SECTION_ID,
928
+ props: {
929
+ textureFile: {
930
+ state: {
931
+ case: "missingAttachment",
932
+ value: {
933
+ formerFileId: BLOCK_ID,
934
+ mediaKind: MissingAttachmentMediaKind.IMAGE,
935
+ },
936
+ },
937
+ },
938
+ },
939
+ },
940
+ ],
941
+ },
942
+ },
943
+ });
944
+ expect(
945
+ extractContentStorageFileReferences("immersive-scene", immersive),
946
+ ).toMatchObject([
947
+ {
948
+ referencePath: `immersive_scene:${SECTION_ID}:texture`,
949
+ missing: true,
950
+ missingAttachmentMediaKind: MissingAttachmentMediaKind.IMAGE,
951
+ },
952
+ ]);
953
+
954
+ const invalid = create(RichTextBlockDataSchema, {
955
+ value: {
956
+ case: "file",
957
+ value: {
958
+ props: {
959
+ attachment: {
960
+ state: {
961
+ case: "missingAttachment",
962
+ value: { formerFileId: BLOCK_ID },
963
+ },
964
+ },
965
+ },
966
+ },
967
+ },
968
+ });
969
+ expect(() => extractContentStorageFileReferences("file", invalid)).toThrow(
970
+ /media kind is required/,
971
+ );
972
+ });
973
+
974
+ it("normalizes shared storage for every profile and catalog kind", () => {
975
+ const protoCase = (kind: string) =>
976
+ kind.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
977
+ const validRichShared = (kind: string) => {
978
+ if (kind === "file")
979
+ return create(RichTextBlockDataSchema, {
980
+ value: {
981
+ case: "file",
982
+ value: {
983
+ props: {
984
+ attachment: {
985
+ state: { case: "activeFileId", value: BLOCK_ID },
986
+ },
987
+ },
988
+ },
989
+ },
990
+ });
991
+ if (kind === "shader")
992
+ return create(RichTextBlockDataSchema, {
993
+ value: {
994
+ case: "shader",
995
+ value: {
996
+ props: {
997
+ stages: Array.from({ length: 9 }, () => ({
998
+ kind: ShaderProps_StagesItem_Kind.COMMON,
999
+ source: "",
1000
+ channels: Array.from({ length: 4 }, () => ({
1001
+ kind: ShaderProps_StagesItem_ChannelsItem_Kind.NONE,
1002
+ })),
1003
+ })),
1004
+ },
1005
+ },
1006
+ },
1007
+ });
1008
+ return fromJson(RichTextBlockDataSchema, {
1009
+ [protoCase(kind)]: { props: {} },
1010
+ });
1011
+ };
1012
+ const validPageShared = (kind: string) => {
1013
+ const props: Record<string, unknown> = {};
1014
+ if (kind === "external-video") props.uri = "https://example.com/video";
1015
+ if (kind === "form") props.formId = BLOCK_ID;
1016
+ if (kind === "columns")
1017
+ props.columns = [
1018
+ { id: BLOCK_ID, ratio: 1 },
1019
+ { id: SECTION_ID, ratio: 1 },
1020
+ ];
1021
+ return fromJson(PageSectionDataSchema, {
1022
+ [protoCase(kind)]: { props },
1023
+ } as never);
1024
+ };
1025
+
1026
+ for (const [profile, definition] of Object.entries(richTextProfiles)) {
1027
+ for (const kind of richTextBlockKinds) {
1028
+ const normalize = () =>
1029
+ normalizeContentStorageShared(
1030
+ profile as keyof typeof richTextProfiles,
1031
+ kind,
1032
+ validRichShared(kind),
1033
+ );
1034
+ if ((definition.blocks as readonly string[]).includes(kind))
1035
+ expect(normalize, `${profile}/${kind}`).not.toThrow();
1036
+ else expect(normalize, `${profile}/${kind}`).toThrow(/forbidden/);
1037
+ }
1038
+ }
1039
+ for (const kind of pageSectionKinds)
1040
+ expect(
1041
+ () =>
1042
+ normalizeContentStorageShared("page", kind, validPageShared(kind)),
1043
+ `page/${kind}`,
1044
+ ).not.toThrow();
1045
+ });
1046
+
1047
+ it("owns canonical shared validation, modes, refs, and flattener reuse", () => {
1048
+ const upperFileId = BLOCK_ID.toUpperCase();
1049
+ const active = create(RichTextBlockDataSchema, {
1050
+ value: {
1051
+ case: "file",
1052
+ value: {
1053
+ props: {
1054
+ attachment: {
1055
+ state: { case: "activeFileId", value: upperFileId },
1056
+ },
1057
+ previewWidth: 100,
1058
+ },
1059
+ },
1060
+ },
1061
+ });
1062
+ const shared = normalizeContentStorageShared("post", "file", active);
1063
+ if (shared.sharedData.$typeName !== "api.content.v1.RichTextBlockData")
1064
+ throw new Error("expected rich-text shared storage data");
1065
+ expect(toJson(RichTextBlockDataSchema, shared.sharedData)).toEqual({
1066
+ file: { props: { attachment: { activeFileId: BLOCK_ID } } },
1067
+ });
1068
+ const locale = create(RichTextBlockLocaleDataSchema, {
1069
+ value: { case: "file", value: { props: {} } },
1070
+ });
1071
+ const full = normalizeContentStorageBlock("post", "file", active, locale);
1072
+ expect(shared.fileReferences).toEqual(full.fileReferences);
1073
+ expect(shared.fileReferences[0]).toMatchObject({
1074
+ fileId: BLOCK_ID,
1075
+ missing: false,
1076
+ missingAttachmentMediaKind: MissingAttachmentMediaKind.UNSPECIFIED,
1077
+ });
1078
+
1079
+ const missing = create(RichTextBlockDataSchema, {
1080
+ value: {
1081
+ case: "file",
1082
+ value: {
1083
+ props: {
1084
+ attachment: {
1085
+ state: {
1086
+ case: "missingAttachment",
1087
+ value: {
1088
+ formerFileId: SECTION_ID,
1089
+ mediaKind: MissingAttachmentMediaKind.VIDEO,
1090
+ },
1091
+ },
1092
+ },
1093
+ },
1094
+ },
1095
+ },
1096
+ });
1097
+ expect(() =>
1098
+ normalizeContentStorageShared("post", "file", missing),
1099
+ ).toThrow(/restore.only/);
1100
+ const restored = normalizeContentStorageShared(
1101
+ "post",
1102
+ "file",
1103
+ missing,
1104
+ ContentValidationMode.RESTORE_SNAPSHOT,
1105
+ );
1106
+ expect(restored.fileReferences[0]).toMatchObject({
1107
+ fileId: SECTION_ID,
1108
+ missing: true,
1109
+ missingAttachmentMediaKind: MissingAttachmentMediaKind.VIDEO,
1110
+ });
1111
+
1112
+ const runtime = fromJson(RichTextBlockDataSchema, {
1113
+ paragraph: { props: {} },
1114
+ });
1115
+ Object.assign(runtime.value.value!.props!, {
1116
+ url: "https://runtime.invalid",
1117
+ });
1118
+ expect(() =>
1119
+ normalizeContentStorageShared("post", "paragraph", runtime),
1120
+ ).toThrow(/runtime projection field/);
1121
+ expect(() =>
1122
+ normalizeContentStorageShared(
1123
+ "post",
1124
+ "unknown",
1125
+ fromJson(RichTextBlockDataSchema, { paragraph: { props: {} } }),
1126
+ ),
1127
+ ).toThrow(/kind/);
1128
+ expect(() =>
1129
+ normalizeContentStorageShared(
1130
+ "post",
1131
+ "file",
1132
+ fromJson(RichTextBlockDataSchema, { file: { props: {} } }),
1133
+ ),
1134
+ ).toThrow(/required/);
1135
+
1136
+ const mutation = create(RichTextBlockMutationBatchSchema, {
1137
+ blockCatalogFingerprint: contentBlockCatalogFingerprint,
1138
+ profile: RichTextProfile.POST,
1139
+ expectedRevision: SECTION_ID,
1140
+ contributorMemberIds: [SECTION_ID],
1141
+ baseMutations: [
1142
+ {
1143
+ operation: {
1144
+ case: "upsert",
1145
+ value: {
1146
+ node: {
1147
+ block: { id: BLOCK_ID, value: missing.value },
1148
+ placement: { index: 0 },
1149
+ },
1150
+ },
1151
+ },
1152
+ },
1153
+ ],
1154
+ });
1155
+ expect(() => flattenRichTextMutationBatchStorage(mutation)).toThrow(
1156
+ /restore.only/,
1157
+ );
1158
+ const flattened = flattenRichTextMutationBatchStorage(
1159
+ mutation,
1160
+ ContentValidationMode.RESTORE_SNAPSHOT,
1161
+ );
1162
+ expect(flattened.baseUpserts[0]?.sharedData).toEqual(restored.sharedData);
1163
+ });
1164
+ });