cursedbelt 3.0.2 โ 4.0.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.
- package/dist/react/components/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +111 -31
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- package/src/verifyGraph.spec.ts +6 -0
|
@@ -29,7 +29,7 @@ for (const prop of ['clientHeight', 'offsetHeight'] as const) {
|
|
|
29
29
|
}
|
|
30
30
|
HTMLElement.prototype.getBoundingClientRect = () => RECT;
|
|
31
31
|
|
|
32
|
-
import { MediaGallery, stagePlayableVideo } from './MediaGallery';
|
|
32
|
+
import { MediaGallery, galleryTileLine, stagePlayableVideo } from './MediaGallery';
|
|
33
33
|
import type { MediaGalleryItem } from './types';
|
|
34
34
|
|
|
35
35
|
afterEach(cleanup);
|
|
@@ -294,7 +294,7 @@ describe('MediaGallery multi-select (opt-in)', () => {
|
|
|
294
294
|
fireEvent.click(boxes(container)[0] as HTMLInputElement);
|
|
295
295
|
expect(quiet(container)).toHaveLength(0);
|
|
296
296
|
// โฆand Clear puts the grid back to quiet rather than leaving it armed forever.
|
|
297
|
-
fireEvent.click(getByRole('button', { name: 'Clear' }));
|
|
297
|
+
fireEvent.click(getByRole('button', { name: 'Clear the selection' }));
|
|
298
298
|
expect(quiet(container)).toHaveLength(ITEMS.length);
|
|
299
299
|
});
|
|
300
300
|
});
|
|
@@ -307,9 +307,11 @@ describe('MediaGallery detail view', () => {
|
|
|
307
307
|
// The stage shows the full image (url, not thumbnail).
|
|
308
308
|
const stage = container.querySelector('.cbgd-stage-img img');
|
|
309
309
|
expect(stage?.getAttribute('src')).toBe('/files/img1');
|
|
310
|
-
//
|
|
311
|
-
|
|
310
|
+
// The strip below, holding every displayed file โ the open one included, which is why the
|
|
311
|
+
// heading is no longer "More files". See the suite at the foot of this file.
|
|
312
|
+
expect(getByText('Files')).toBeDefined();
|
|
312
313
|
expect(getByRole('button', { name: 'Open clip.mp4' })).toBeDefined();
|
|
314
|
+
expect(getByRole('button', { name: 'Open beach.jpg' })).toBeDefined();
|
|
313
315
|
// Download + fullscreen affordances.
|
|
314
316
|
expect(getByRole('link', { name: /download/i }).getAttribute('href')).toBe('/files/img1');
|
|
315
317
|
expect(getByRole('button', { name: /fullscreen/i })).toBeDefined();
|
|
@@ -477,6 +479,37 @@ describe('MediaGallery โ turning a video', () => {
|
|
|
477
479
|
expect(frame?.getAttribute('data-turned')).toBe('true');
|
|
478
480
|
});
|
|
479
481
|
|
|
482
|
+
/**
|
|
483
|
+
* ๐ด A quarter turn has to REFIT the panel, not just spin the picture inside it.
|
|
484
|
+
*
|
|
485
|
+
* Owner, `ideas/collections-09-14.md`: *"If I rotate a portait style video 90 degrees it
|
|
486
|
+
* should be changed to fit the wide screen panel if I save it so I can watch it like it was
|
|
487
|
+
* recorded with wide screen."* The rotation above shipped without the fit, and the assertion
|
|
488
|
+
* above is exactly what let it through: it reads the transform and stops there.
|
|
489
|
+
*
|
|
490
|
+
* The fit is a pair of declarations rather than a scale factor on that transform, and the
|
|
491
|
+
* stylesheet says why at length โ a scale does not know the film's shape, so it can only be
|
|
492
|
+
* right for footage whose aspect is the panel's inverse and crops everything else. What a
|
|
493
|
+
* happy-dom test CAN prove is that the axis swap is declared where the turn is applied; that
|
|
494
|
+
* the film then covers the panel is a rectangle, measured in `e2e/video-turn.spec.ts`.
|
|
495
|
+
*/
|
|
496
|
+
test('๐ด a quarter turn also carries the axis-swapped FIT, or it is a film in a letterbox', () => {
|
|
497
|
+
const css = readFileSync(join(import.meta.dir, 'mediaGallery.css'), 'utf8');
|
|
498
|
+
const block = css
|
|
499
|
+
.slice(css.indexOf(".cbmg-stage-turn[data-turned='true'] video"))
|
|
500
|
+
.slice(0, css.slice(css.indexOf(".cbmg-stage-turn[data-turned='true'] video")).indexOf('}') + 1);
|
|
501
|
+
// Width read from the container's BLOCK size and height from its INLINE size โ the swap.
|
|
502
|
+
expect(block).toContain('width: 100cqb');
|
|
503
|
+
expect(block).toContain('height: 100cqi');
|
|
504
|
+
// `max-*` alone is what shipped, and it can only shrink: a clip the player had already
|
|
505
|
+
// sized stayed exactly as it was, which is the pillar in the middle of a black panel.
|
|
506
|
+
expect(block).not.toContain('max-block-size: 100cqi');
|
|
507
|
+
expect(block).not.toContain('max-inline-size: 100cqb');
|
|
508
|
+
// A rotation turns about the element's own centre, so the element has to BE at the panel's
|
|
509
|
+
// centre โ off-centre by a quarter of the panel is what the first browser run measured.
|
|
510
|
+
expect(css).toContain(".cbmg-stage-turn[data-turned='true'] [data-media-provider]");
|
|
511
|
+
});
|
|
512
|
+
|
|
480
513
|
test('an UNturned clip adds no box, no layer and no transform', () => {
|
|
481
514
|
const plain: MediaGalleryItem[] = [{ ...TURNED[0], orientation: undefined } as MediaGalleryItem];
|
|
482
515
|
const { container, getByRole } = render(<MediaGallery items={plain} nativeVideoStage />);
|
|
@@ -489,26 +522,61 @@ describe('MediaGallery โ turning a video', () => {
|
|
|
489
522
|
});
|
|
490
523
|
|
|
491
524
|
test('the Turn control is offered on a film, and only when the app can store the answer', () => {
|
|
525
|
+
// By accessible NAME rather than by text: the pill is an icon now, so `queryByText`
|
|
526
|
+
// would find nothing in either arm and the test would pass without proving anything.
|
|
492
527
|
const withoutHandler = render(<MediaGallery items={TURNED} />);
|
|
493
528
|
fireEvent.click(withoutHandler.getByRole('button', { name: 'Open clip.mp4' }));
|
|
494
|
-
expect(withoutHandler.
|
|
529
|
+
expect(withoutHandler.queryByRole('button', { name: 'Turn video' })).toBeNull();
|
|
495
530
|
withoutHandler.unmount();
|
|
496
531
|
|
|
497
532
|
const withHandler = render(<MediaGallery items={TURNED} onSaveOrientation={() => {}} />);
|
|
498
533
|
fireEvent.click(withHandler.getByRole('button', { name: 'Open clip.mp4' }));
|
|
499
|
-
expect(withHandler.
|
|
534
|
+
expect(withHandler.queryByRole('button', { name: 'Turn video' })).not.toBeNull();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* ๐ด The dialog is handed the RESOLVED source โ the owner's *"When I click the turn the video
|
|
539
|
+
* button I see a black screen."*
|
|
540
|
+
*
|
|
541
|
+
* It read `item.streamUrl ?? item.url` straight off the row, and an item is allowed to carry
|
|
542
|
+
* neither: `resolveMedia` is the documented path for a signed URL, and it is what the app that
|
|
543
|
+
* reported this uses. Both fields `undefined` meant `src=''`, which React renders as a
|
|
544
|
+
* `<video>` with no source at all โ an element that never loads, never errors, and paints
|
|
545
|
+
* black forever. Every other surface in the gallery reads an item through `useItemMedia`;
|
|
546
|
+
* this is the one that did not.
|
|
547
|
+
*/
|
|
548
|
+
test('๐ด the turn dialog is handed the RESOLVED source, not the raw row', async () => {
|
|
549
|
+
const signed: MediaGalleryItem[] = [
|
|
550
|
+
{
|
|
551
|
+
id: 'vid2',
|
|
552
|
+
name: 'signed.mp4',
|
|
553
|
+
mime: 'video/mp4',
|
|
554
|
+
createdAt: 10,
|
|
555
|
+
// No `url` and no `streamUrl`: the app mints them when something needs them.
|
|
556
|
+
resolveMedia: async () => ({ streamUrl: '/signed/vid2', posterUrl: '/signed/poster2' }),
|
|
557
|
+
},
|
|
558
|
+
];
|
|
559
|
+
const view = render(<MediaGallery items={signed} onSaveOrientation={() => {}} />);
|
|
560
|
+
await waitFor(() => expect(view.queryByRole('button', { name: 'Open signed.mp4' })).not.toBeNull());
|
|
561
|
+
fireEvent.click(view.getByRole('button', { name: 'Open signed.mp4' }));
|
|
562
|
+
fireEvent.click(view.getByRole('button', { name: 'Turn video' }));
|
|
563
|
+
// The dialog is a lazy chunk and a portal โ wait for it, then read it off `baseElement`.
|
|
564
|
+
await waitFor(() => expect(view.queryByText('Turn this video')).not.toBeNull());
|
|
565
|
+
const preview = view.baseElement.querySelector<HTMLVideoElement>('.cbpe-frame-film video');
|
|
566
|
+
expect(preview?.getAttribute('src')).toBe('/signed/vid2');
|
|
567
|
+
expect(preview?.getAttribute('poster')).toBe('/signed/poster2');
|
|
500
568
|
});
|
|
501
569
|
|
|
502
570
|
test('๐ด a PICTURE never gets the video control โ it already has the fuller editor', () => {
|
|
503
571
|
const picture: MediaGalleryItem[] = [
|
|
504
572
|
{ id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 1 },
|
|
505
573
|
];
|
|
506
|
-
const { getByRole,
|
|
574
|
+
const { getByRole, queryByRole } = render(
|
|
507
575
|
<MediaGallery items={picture} onSaveOrientation={() => {}} />,
|
|
508
576
|
);
|
|
509
577
|
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
510
578
|
// Two controls that both say "turn" on the same item is the confusion the split avoids.
|
|
511
|
-
expect(
|
|
579
|
+
expect(queryByRole('button', { name: 'Turn video' })).toBeNull();
|
|
512
580
|
});
|
|
513
581
|
});
|
|
514
582
|
|
|
@@ -523,11 +591,20 @@ describe('MediaGallery โ turning a video', () => {
|
|
|
523
591
|
* kind of thing a refactor drops silently.
|
|
524
592
|
*/
|
|
525
593
|
describe('MediaGallery โ the playback run', () => {
|
|
526
|
-
/**
|
|
594
|
+
/**
|
|
595
|
+
* Press the cycling button until it reads `label`, and return the button.
|
|
596
|
+
*
|
|
597
|
+
* ๐ด Read off the ACCESSIBLE NAME, not `textContent`. The pill is a bare glyph since
|
|
598
|
+
* 2026-09-16 (owner: *"icons only, instead of words in the strip of tools"*), so its text
|
|
599
|
+
* content is the empty string for all five settings โ a `textContent` probe would loop six
|
|
600
|
+
* times and throw on every call, which is how a cosmetic change takes a behavioural suite
|
|
601
|
+
* with it. The name is where the value lives now, and asserting through it is also the
|
|
602
|
+
* point: it is the thing a screen reader gets.
|
|
603
|
+
*/
|
|
527
604
|
function cycleTo(getByRole: ReturnType<typeof render>['getByRole'], label: RegExp) {
|
|
528
605
|
for (let i = 0; i < 6; i++) {
|
|
529
606
|
const button = getByRole('button', { name: /^When an item ends:/ });
|
|
530
|
-
if (label.test(button.
|
|
607
|
+
if (label.test(button.getAttribute('aria-label') ?? '')) return button;
|
|
531
608
|
fireEvent.click(button);
|
|
532
609
|
}
|
|
533
610
|
throw new Error(`the playback button never reached ${label}`);
|
|
@@ -792,6 +869,162 @@ describe('MediaGallery table view', () => {
|
|
|
792
869
|
});
|
|
793
870
|
});
|
|
794
871
|
|
|
872
|
+
/**
|
|
873
|
+
* SIZE and LENGTH, reported where they are useful โ the `meta` prop.
|
|
874
|
+
*
|
|
875
|
+
* Three asks from two drafts, one surface. `ideas/collections-09-14.md`: *"In collections I
|
|
876
|
+
* want to see the exact file size in bytes as a column option so I can easily tell if two
|
|
877
|
+
* files are the exact same byte size to help me know if they are duplicates or not"* and
|
|
878
|
+
* *"in the grid view show the video length instead of the size."* `ideas/binary-server-09-15.md`
|
|
879
|
+
* asked for both facts on the same tiles and guessed the right home: *"maybe controlled
|
|
880
|
+
* through param on media gallery in cb."*
|
|
881
|
+
*
|
|
882
|
+
* ๐ด Every one of these is OFF unless asked for, which is why the last test in the block is
|
|
883
|
+
* the one that would catch a regression in four apps rather than in this feature.
|
|
884
|
+
*/
|
|
885
|
+
describe('MediaGallery โ size and length', () => {
|
|
886
|
+
/** Two near-duplicates one byte apart, and a document. `formatBytes` calls both films `1.3 GB`. */
|
|
887
|
+
const MEASURED: MediaGalleryItem[] = [
|
|
888
|
+
{
|
|
889
|
+
id: 'v1',
|
|
890
|
+
name: 'holiday.mp4',
|
|
891
|
+
category: 'video',
|
|
892
|
+
streamUrl: '/files/v1',
|
|
893
|
+
posterUrl: '/thumbs/v1',
|
|
894
|
+
durationSeconds: 187,
|
|
895
|
+
sizeBytes: 1_402_653_184,
|
|
896
|
+
createdAt: 30,
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
id: 'v2',
|
|
900
|
+
name: 'holiday-copy.mp4',
|
|
901
|
+
category: 'video',
|
|
902
|
+
streamUrl: '/files/v2',
|
|
903
|
+
posterUrl: '/thumbs/v2',
|
|
904
|
+
durationSeconds: 187,
|
|
905
|
+
sizeBytes: 1_402_653_185,
|
|
906
|
+
createdAt: 20,
|
|
907
|
+
},
|
|
908
|
+
{ id: 'd1', name: 'notes.pdf', url: '/files/d1', sizeBytes: 5_242_880, createdAt: 10 },
|
|
909
|
+
];
|
|
910
|
+
|
|
911
|
+
/** The caption's second line for the tile whose title is `name` โ see `GalleryMeta`. */
|
|
912
|
+
const tileLine = (container: HTMLElement, name: string): string | null => {
|
|
913
|
+
const block = Array.from(container.querySelectorAll('.cbgd-meta')).find(
|
|
914
|
+
(meta) => meta.querySelector('.cbgd-meta-title')?.textContent === name,
|
|
915
|
+
);
|
|
916
|
+
return block?.querySelector('.cbgd-meta-line')?.textContent ?? null;
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const showTable = (getByRole: ReturnType<typeof render>['getByRole']): void => {
|
|
920
|
+
fireEvent.click(getByRole('button', { name: /Table/ }));
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
test('a video tile leads with its running time, and a document keeps its size', () => {
|
|
924
|
+
const { container } = render(
|
|
925
|
+
<MediaGallery items={MEASURED} meta={{ tileMeasure: true }} />,
|
|
926
|
+
);
|
|
927
|
+
expect(tileLine(container, 'holiday.mp4')).toBe('3:07');
|
|
928
|
+
// Everything that is not time-based is unchanged by the same switch: a PDF has no
|
|
929
|
+
// running time, and its size is the measurement that means something for it.
|
|
930
|
+
expect(tileLine(container, 'notes.pdf')).toBe('5.0 MB');
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test('tags keep their line and the running time joins them on it', () => {
|
|
934
|
+
// ๐ด Never a fourth line: the caption block is a fixed 65px (`CBGD_META_HEIGHT`), and a
|
|
935
|
+
// tile taller than its neighbour is what made rows lap each other across the fleet.
|
|
936
|
+
const tagged: MediaGalleryItem[] = [
|
|
937
|
+
{ ...(MEASURED[0] as MediaGalleryItem), tags: ['holiday', 'beach'] },
|
|
938
|
+
];
|
|
939
|
+
const { container } = render(<MediaGallery items={tagged} meta={{ tileMeasure: true }} />);
|
|
940
|
+
expect(tileLine(container, 'holiday.mp4')).toBe('holiday ยท beach ยท 3:07');
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test('a film nobody measured falls back to its size, never to an empty caption', () => {
|
|
944
|
+
const unmeasured: MediaGalleryItem[] = [
|
|
945
|
+
{ id: 'v9', name: 'unknown.mp4', category: 'video', streamUrl: '/files/v9', sizeBytes: 2048 },
|
|
946
|
+
{ id: 'v8', name: 'nothing.mp4', category: 'video', streamUrl: '/files/v8' },
|
|
947
|
+
];
|
|
948
|
+
const { container } = render(<MediaGallery items={unmeasured} meta={{ tileMeasure: true }} />);
|
|
949
|
+
expect(tileLine(container, 'unknown.mp4')).toBe('2.0 KB');
|
|
950
|
+
// Neither fact known: the same em dash the table's columns show, not a blank line.
|
|
951
|
+
expect(tileLine(container, 'nothing.mp4')).toBe('โ');
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
test('๐ด the exact-bytes column separates two files a rounded size cannot', () => {
|
|
955
|
+
const { getByRole, container } = render(
|
|
956
|
+
<MediaGallery items={MEASURED} meta={{ exactBytes: true }} />,
|
|
957
|
+
);
|
|
958
|
+
showTable(getByRole);
|
|
959
|
+
expect(container.querySelector('th.cbgt-cell--bytes')?.textContent).toBe('Bytes');
|
|
960
|
+
const bytesFor = (name: string): string | undefined =>
|
|
961
|
+
Array.from(container.querySelectorAll('.cbgt-row'))
|
|
962
|
+
.find((row) => row.textContent?.includes(name))
|
|
963
|
+
?.querySelector('.cbgt-cell--bytes')?.textContent ?? undefined;
|
|
964
|
+
// The whole ask: one byte apart, and the column says so.
|
|
965
|
+
expect(bytesFor('holiday.mp4')).toMatch(/^1\D?402\D?653\D?184$/);
|
|
966
|
+
expect(bytesFor('holiday-copy.mp4')).toMatch(/^1\D?402\D?653\D?185$/);
|
|
967
|
+
expect(bytesFor('holiday.mp4')).not.toBe(bytesFor('holiday-copy.mp4'));
|
|
968
|
+
// โฆwhile the rounded column beside it calls them the same file, which is the defect.
|
|
969
|
+
const sizeFor = (name: string): string | undefined =>
|
|
970
|
+
Array.from(container.querySelectorAll('.cbgt-row'))
|
|
971
|
+
.find((row) => row.textContent?.includes(name))
|
|
972
|
+
?.querySelectorAll('.cbgt-cell--num')[0]?.textContent ?? undefined;
|
|
973
|
+
expect(sizeFor('holiday.mp4')).toBe(sizeFor('holiday-copy.mp4'));
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test('the column is an ADDITION โ Size, Length and the sort are where they were', () => {
|
|
977
|
+
const { getByRole, container } = render(
|
|
978
|
+
<MediaGallery items={MEASURED} meta={{ exactBytes: true }} />,
|
|
979
|
+
);
|
|
980
|
+
showTable(getByRole);
|
|
981
|
+
// Without the direction glyph a sorted header carries โ the active sort is Date's, and
|
|
982
|
+
// this test is about which columns exist and in what order.
|
|
983
|
+
const headers = Array.from(container.querySelectorAll('thead th')).map((th) =>
|
|
984
|
+
th.textContent?.replace(/[โฒโผ]/g, '').trim(),
|
|
985
|
+
);
|
|
986
|
+
expect(headers).toEqual(['Preview', 'Name', 'Kind', 'Size', 'Bytes', 'Length', 'Date']);
|
|
987
|
+
// The size SORT is still the rounded column's, and it still sorts on the number: the
|
|
988
|
+
// exact column carries no sort of its own, so there is one ordering, not two.
|
|
989
|
+
fireEvent.click(container.querySelector('.cbgt-cell--num .cbgt-sort') as HTMLElement);
|
|
990
|
+
expect(container.querySelector('.cbgt-row')?.textContent).toContain('holiday-copy.mp4');
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test('๐ด a consumer that passes nothing renders exactly what it rendered before', () => {
|
|
994
|
+
const { getByRole, container } = render(<MediaGallery items={MEASURED} />);
|
|
995
|
+
// The grid: a film shows its rounded SIZE, the way it always has.
|
|
996
|
+
expect(tileLine(container, 'holiday.mp4')).toBe('1.3 GB');
|
|
997
|
+
showTable(getByRole);
|
|
998
|
+
// The table: six columns, no exact-bytes cell anywhere.
|
|
999
|
+
expect(container.querySelectorAll('thead th')).toHaveLength(6);
|
|
1000
|
+
expect(container.querySelector('.cbgt-cell--bytes')).toBeNull();
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* The line itself, called directly โ the branch table is wider than any fixture, and two of
|
|
1005
|
+
* these combinations never appear in a rendered tile in this file.
|
|
1006
|
+
*/
|
|
1007
|
+
test('the caption line, branch by branch', () => {
|
|
1008
|
+
const film = MEASURED[0] as MediaGalleryItem;
|
|
1009
|
+
expect(galleryTileLine(film)).toBe('1.3 GB');
|
|
1010
|
+
expect(galleryTileLine(film, { measure: true })).toBe('3:07');
|
|
1011
|
+
// Only the first three tags, as the line has always done.
|
|
1012
|
+
expect(
|
|
1013
|
+
galleryTileLine({ ...film, tags: ['a', 'b', 'c', 'd'] }, { measure: true }),
|
|
1014
|
+
).toBe('a ยท b ยท c ยท 3:07');
|
|
1015
|
+
// An AUDIO track is time-based too โ the same argument as a film, and the same fix.
|
|
1016
|
+
expect(
|
|
1017
|
+
galleryTileLine(
|
|
1018
|
+
{ id: 'a1', name: 'memo.m4a', category: 'audio', durationSeconds: 65, sizeBytes: 900 },
|
|
1019
|
+
{ measure: true },
|
|
1020
|
+
),
|
|
1021
|
+
).toBe('1:05');
|
|
1022
|
+
// Off, nothing about the line moves โ tags win, then the size, then nothing at all.
|
|
1023
|
+
expect(galleryTileLine({ ...film, tags: ['holiday'] })).toBe('holiday');
|
|
1024
|
+
expect(galleryTileLine({ id: 'x', name: 'bare.bin' })).toBeNull();
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
795
1028
|
/**
|
|
796
1029
|
* ๐ด The render loop that took `apps/family`'s `/#/p/<id>/media` off the air.
|
|
797
1030
|
*
|
|
@@ -1006,6 +1239,90 @@ describe('๐ด the stage hands the player every transport the app resolved', ()
|
|
|
1006
1239
|
expect(video.processingStatus).toBe('processing');
|
|
1007
1240
|
expect(video.durationSeconds).toBe(12);
|
|
1008
1241
|
});
|
|
1242
|
+
|
|
1243
|
+
/*
|
|
1244
|
+
* ๐ด The named sections are the FOURTH field to come down this road, and the only assertion
|
|
1245
|
+
* that can catch it being forgotten. The header above this function names the other three:
|
|
1246
|
+
* each was resolved by the app, carried on the item, and then simply not listed in the
|
|
1247
|
+
* mapping โ so the feature was invisible with nothing anywhere reporting a fault. A chapter
|
|
1248
|
+
* list would fail exactly that way, and more quietly than the rest: the player would draw a
|
|
1249
|
+
* bare scrub bar, which is precisely what a video with no chapters is supposed to look like.
|
|
1250
|
+
*/
|
|
1251
|
+
test('๐ด the named sections reach the player', () => {
|
|
1252
|
+
const chaptered: MediaGalleryItem = {
|
|
1253
|
+
...item,
|
|
1254
|
+
chapters: [
|
|
1255
|
+
{ startSeconds: 0, title: 'Cold open' },
|
|
1256
|
+
{ startSeconds: 6, title: 'The walk home' },
|
|
1257
|
+
],
|
|
1258
|
+
};
|
|
1259
|
+
const video = stagePlayableVideo(chaptered, {}, '/files/v1.web.mp4');
|
|
1260
|
+
expect(video.chapters).toEqual([
|
|
1261
|
+
{ startSeconds: 0, title: 'Cold open' },
|
|
1262
|
+
{ startSeconds: 6, title: 'The walk home' },
|
|
1263
|
+
]);
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
test('a video with no sections leaves the field ABSENT, not empty', () => {
|
|
1267
|
+
// An empty chapters track is not neutral: the layout draws a chapter-title area and a menu
|
|
1268
|
+
// entry for a film that has no chapters.
|
|
1269
|
+
expect('chapters' in stagePlayableVideo(item, {}, '/files/v1.web.mp4')).toBe(false);
|
|
1270
|
+
expect('chapters' in stagePlayableVideo({ ...item, chapters: [] }, {}, '/files/v1.web.mp4')).toBe(
|
|
1271
|
+
false,
|
|
1272
|
+
);
|
|
1273
|
+
});
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Naming the parts of a film โ the owner's `media-gallery-cb.md` ask, at the component edge.
|
|
1278
|
+
*
|
|
1279
|
+
* ๐ด What the chapter MODEL does is asserted in `react/media/videoChapters.spec.ts`, where it
|
|
1280
|
+
* is pure. What can only be checked here is the capability rule: the control exists exactly
|
|
1281
|
+
* when the app passed somewhere to put the answer, and never otherwise. That rule is the whole
|
|
1282
|
+
* of how a feature gets turned on in this component โ there is no flag and no config โ so it
|
|
1283
|
+
* is the thing a refactor can silently invert.
|
|
1284
|
+
*/
|
|
1285
|
+
describe('MediaGallery โ naming the sections of a video', () => {
|
|
1286
|
+
const FILM: MediaGalleryItem[] = [
|
|
1287
|
+
{
|
|
1288
|
+
id: 'vid1',
|
|
1289
|
+
name: 'clip.mp4',
|
|
1290
|
+
streamUrl: '/files/vid1',
|
|
1291
|
+
mime: 'video/mp4',
|
|
1292
|
+
createdAt: 10,
|
|
1293
|
+
durationSeconds: 60,
|
|
1294
|
+
},
|
|
1295
|
+
];
|
|
1296
|
+
|
|
1297
|
+
test('๐ด the Sections control appears only when the app can STORE the answer', () => {
|
|
1298
|
+
const withoutHandler = render(<MediaGallery items={FILM} />);
|
|
1299
|
+
fireEvent.click(withoutHandler.getByRole('button', { name: 'Open clip.mp4' }));
|
|
1300
|
+
expect(withoutHandler.queryByRole('button', { name: 'Sections' })).toBeNull();
|
|
1301
|
+
withoutHandler.unmount();
|
|
1302
|
+
|
|
1303
|
+
const withHandler = render(<MediaGallery items={FILM} onSaveChapters={() => {}} />);
|
|
1304
|
+
fireEvent.click(withHandler.getByRole('button', { name: 'Open clip.mp4' }));
|
|
1305
|
+
expect(withHandler.queryByRole('button', { name: 'Sections' })).not.toBeNull();
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test('๐ด a PICTURE never gets it โ there is no timeline to put a marker on', () => {
|
|
1309
|
+
const picture: MediaGalleryItem[] = [
|
|
1310
|
+
{ id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 1 },
|
|
1311
|
+
];
|
|
1312
|
+
const { getByRole, queryByRole } = render(
|
|
1313
|
+
<MediaGallery items={picture} onSaveChapters={() => {}} />,
|
|
1314
|
+
);
|
|
1315
|
+
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
1316
|
+
expect(queryByRole('button', { name: 'Sections' })).toBeNull();
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
test('๐ด READING sections needs no handler โ only writing them is gated', () => {
|
|
1320
|
+
// An app that merely lists someone else's films still shows their chapters; what it cannot
|
|
1321
|
+
// do is save new ones. Asserted on the mapping, because the rendered player is a Vidstack
|
|
1322
|
+
// custom element happy-dom never brings to life.
|
|
1323
|
+
const item = { ...(FILM[0] as MediaGalleryItem), chapters: [{ startSeconds: 3, title: 'Two' }] };
|
|
1324
|
+
expect(stagePlayableVideo(item, {}, '/files/vid1')?.chapters).toHaveLength(1);
|
|
1325
|
+
});
|
|
1009
1326
|
});
|
|
1010
1327
|
|
|
1011
1328
|
/**
|
|
@@ -1300,3 +1617,832 @@ describe('๐ด Preview all reaches the TABLE, not only the grid', () => {
|
|
|
1300
1617
|
expect(view.container.querySelectorAll('.cbgt-play')).toHaveLength(1);
|
|
1301
1618
|
});
|
|
1302
1619
|
});
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* ๐ด A CAPABILITY DIALOG MAY NOT OUTLIVE THE FILE IT WAS OPENED FOR.
|
|
1623
|
+
*
|
|
1624
|
+
* The owner, `ideas/collections-09-14.md`, 2026-09-14, on the thumbnail chooser:
|
|
1625
|
+
*
|
|
1626
|
+
* *"In collections app I chose a thumbnail for a video but then I could not get back to the
|
|
1627
|
+
* normal viewing area โฆ Clicking on other folders also did not move to view them and the
|
|
1628
|
+
* thumbnail selection screen for the first video stayed on the screen. Selecting other
|
|
1629
|
+
* sidebar files and icons should also leave that screen."*
|
|
1630
|
+
*
|
|
1631
|
+
* The reset effect keyed on `selectedId` cleared the rename field and the picture editor and
|
|
1632
|
+
* nothing else, so four of the five dialogs survived the selection that opened them โ and a
|
|
1633
|
+
* surviving dialog is not merely stale, it is WRONG: its title, its `src`, its carried playhead
|
|
1634
|
+
* and the item every save handler is closed over all still name the file you navigated away
|
|
1635
|
+
* from. The worst shape that has is the chooser's, because its Save writes a thumbnail onto
|
|
1636
|
+
* whichever film it is still holding.
|
|
1637
|
+
*
|
|
1638
|
+
* Driven through the CONTROLLED `selectedId`, because that is the wire the sidebar is on: an
|
|
1639
|
+
* app moves the selection and the gallery has to follow.
|
|
1640
|
+
*/
|
|
1641
|
+
describe('๐ด selecting another file leaves the thumbnail chooser', () => {
|
|
1642
|
+
const VIDEOS: MediaGalleryItem[] = [
|
|
1643
|
+
{
|
|
1644
|
+
id: 'vid1',
|
|
1645
|
+
name: 'first.mp4',
|
|
1646
|
+
streamUrl: '/files/vid1',
|
|
1647
|
+
mime: 'video/mp4',
|
|
1648
|
+
durationSeconds: 12,
|
|
1649
|
+
createdAt: 20,
|
|
1650
|
+
},
|
|
1651
|
+
{
|
|
1652
|
+
id: 'vid2',
|
|
1653
|
+
name: 'second.mp4',
|
|
1654
|
+
streamUrl: '/files/vid2',
|
|
1655
|
+
mime: 'video/mp4',
|
|
1656
|
+
durationSeconds: 9,
|
|
1657
|
+
createdAt: 10,
|
|
1658
|
+
},
|
|
1659
|
+
];
|
|
1660
|
+
|
|
1661
|
+
const gallery = (selectedId: string) => (
|
|
1662
|
+
<MediaGallery
|
|
1663
|
+
items={VIDEOS}
|
|
1664
|
+
selectedId={selectedId}
|
|
1665
|
+
nativeVideoStage
|
|
1666
|
+
onSaveThumbnail={() => {}}
|
|
1667
|
+
onSaveSnapshot={() => {}}
|
|
1668
|
+
/>
|
|
1669
|
+
);
|
|
1670
|
+
|
|
1671
|
+
/** Press `Choose thumbnail` and wait for the lazily-imported dialog to arrive. */
|
|
1672
|
+
const openChooser = async (view: ReturnType<typeof render>, name: string) => {
|
|
1673
|
+
fireEvent.click(view.getByRole('button', { name: /thumbnail/ }));
|
|
1674
|
+
await waitFor(() =>
|
|
1675
|
+
expect(document.body.textContent).toContain(`Pick a moment in ${name}`),
|
|
1676
|
+
);
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1679
|
+
test('the chooser closes when the selection moves to another film', async () => {
|
|
1680
|
+
const view = render(gallery('vid1'));
|
|
1681
|
+
await openChooser(view, 'first.mp4');
|
|
1682
|
+
view.rerender(gallery('vid2'));
|
|
1683
|
+
await waitFor(() =>
|
|
1684
|
+
expect(document.body.textContent, 'the chooser stayed on screen').not.toContain(
|
|
1685
|
+
'Pick a moment in',
|
|
1686
|
+
),
|
|
1687
|
+
);
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
test('๐ด and it never comes back describing the wrong film', async () => {
|
|
1691
|
+
// The half a "did it disappear" check cannot see. If the dialog were merely hidden and
|
|
1692
|
+
// re-shown, or if `grabbing` held the OLD item, this is where that shows up โ the title is
|
|
1693
|
+
// read straight off the item every save handler is closed over.
|
|
1694
|
+
const view = render(gallery('vid1'));
|
|
1695
|
+
await openChooser(view, 'first.mp4');
|
|
1696
|
+
view.rerender(gallery('vid2'));
|
|
1697
|
+
await waitFor(() => expect(document.body.textContent).not.toContain('Pick a moment in'));
|
|
1698
|
+
await openChooser(view, 'second.mp4');
|
|
1699
|
+
// Scoped to the DIALOG: `first.mp4` is legitimately on the page, in the "Files" strip
|
|
1700
|
+
// beside the stage. What must not name it is the chooser.
|
|
1701
|
+
const dialog = document.body.querySelector('[role="dialog"]');
|
|
1702
|
+
expect(dialog?.textContent, 'the chooser is still holding the old film').not.toContain(
|
|
1703
|
+
'first.mp4',
|
|
1704
|
+
);
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
test('closing the file entirely leaves it too', async () => {
|
|
1708
|
+
const view = render(gallery('vid1'));
|
|
1709
|
+
await openChooser(view, 'first.mp4');
|
|
1710
|
+
// Escape / the back arrow โ the app clears the selection.
|
|
1711
|
+
view.rerender(
|
|
1712
|
+
<MediaGallery
|
|
1713
|
+
items={VIDEOS}
|
|
1714
|
+
selectedId={null}
|
|
1715
|
+
nativeVideoStage
|
|
1716
|
+
onSaveThumbnail={() => {}}
|
|
1717
|
+
onSaveSnapshot={() => {}}
|
|
1718
|
+
/>,
|
|
1719
|
+
);
|
|
1720
|
+
await waitFor(() => expect(document.body.textContent).not.toContain('Pick a moment in'));
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
test('๐ด while the chooser is open, Space does not toggle the film BEHIND it', async () => {
|
|
1724
|
+
/*
|
|
1725
|
+
* The owner, same report: *"the space bar should toggle playing the video no matter what is
|
|
1726
|
+
* focused but isn't working on the thumbnail selection page."*
|
|
1727
|
+
*
|
|
1728
|
+
* It was working โ on the wrong film. This component binds `isPlayPauseSpace` at CAPTURE on
|
|
1729
|
+
* `window` whenever a file is open, so a press inside the dialog reached the STAGE player
|
|
1730
|
+
* first. `stopPropagation` is no help: it does not stop other listeners on the same target,
|
|
1731
|
+
* so once `FrameGrabber` bound the same rule BOTH films would have toggled on one press.
|
|
1732
|
+
* The gallery yields instead โ whoever is on top owns the key.
|
|
1733
|
+
*/
|
|
1734
|
+
const view = render(gallery('vid1'));
|
|
1735
|
+
const stage = view.container.querySelector('.cbmg-stage-video') as HTMLVideoElement;
|
|
1736
|
+
let paused = true;
|
|
1737
|
+
const calls: string[] = [];
|
|
1738
|
+
Object.defineProperty(stage, 'paused', { configurable: true, get: () => paused });
|
|
1739
|
+
stage.play = (() => {
|
|
1740
|
+
calls.push('play');
|
|
1741
|
+
paused = false;
|
|
1742
|
+
return Promise.resolve();
|
|
1743
|
+
}) as HTMLVideoElement['play'];
|
|
1744
|
+
stage.pause = () => {
|
|
1745
|
+
calls.push('pause');
|
|
1746
|
+
paused = true;
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
// With no dialog up the gallery owns the key โ the behaviour this must not cost us.
|
|
1750
|
+
const press = () => {
|
|
1751
|
+
const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
|
|
1752
|
+
Object.defineProperty(event, 'target', { value: document.body });
|
|
1753
|
+
window.dispatchEvent(event);
|
|
1754
|
+
return event.defaultPrevented;
|
|
1755
|
+
};
|
|
1756
|
+
expect(press()).toBe(true);
|
|
1757
|
+
expect(calls).toEqual(['play']);
|
|
1758
|
+
|
|
1759
|
+
await openChooser(view, 'first.mp4');
|
|
1760
|
+
// The dialog's own `<video>` is what a press must reach now; happy-dom implements neither
|
|
1761
|
+
// method on it, so it is stubbed to keep the press from throwing rather than to be asserted
|
|
1762
|
+
// on โ `FrameGrabber.spec.tsx` owns that half.
|
|
1763
|
+
const inDialog = document.body.querySelector(
|
|
1764
|
+
'[role="dialog"] video',
|
|
1765
|
+
) as HTMLVideoElement | null;
|
|
1766
|
+
expect(inDialog, 'the chooser rendered no player').not.toBeNull();
|
|
1767
|
+
Object.defineProperty(inDialog, 'paused', { configurable: true, get: () => true });
|
|
1768
|
+
if (inDialog) {
|
|
1769
|
+
inDialog.play = (() => Promise.resolve()) as HTMLVideoElement['play'];
|
|
1770
|
+
inDialog.pause = () => {};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
press();
|
|
1774
|
+
expect(calls, 'the film behind the dialog moved').toEqual(['play']);
|
|
1775
|
+
});
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* ๐ด The strip under the stage keeps the file that is PLAYING, and keeps its place.
|
|
1780
|
+
*
|
|
1781
|
+
* Owner, `ideas/collections-09-14.md`: *"In collections sometimes I click on a video and while
|
|
1782
|
+
* it's playing I click on a different video in the thumbnails under the playing video and the
|
|
1783
|
+
* video I click on gets taken out of the list. I want to change this cb media gallery behavior
|
|
1784
|
+
* so that I can still see the playing video in the list and nothing about the list changes as I
|
|
1785
|
+
* pick other videos."*
|
|
1786
|
+
*
|
|
1787
|
+
* The cause was one line โ the strip was `displayItems.filter(i => i.id !== selected.id)` โ and
|
|
1788
|
+
* the consequence is the one he describes: each pick removed the tile he had just clicked and
|
|
1789
|
+
* put the previous one back, so every tile between the two moved by one. *"Nothing about the
|
|
1790
|
+
* list changes"* is a claim about the ids AND their ORDER, which is why the first test below
|
|
1791
|
+
* compares the whole sequence rather than a count; a count cannot tell a re-sort from a rest.
|
|
1792
|
+
*/
|
|
1793
|
+
describe('MediaGallery โ the strip keeps the file that is playing', () => {
|
|
1794
|
+
/** Five clips, `createdAt` descending so the default Newest sort is the order written here. */
|
|
1795
|
+
const CLIPS: MediaGalleryItem[] = ['one', 'two', 'three', 'four', 'five'].map((word, i) => ({
|
|
1796
|
+
id: `c${i}`,
|
|
1797
|
+
name: `${word}.mp4`,
|
|
1798
|
+
streamUrl: `/files/c${i}`,
|
|
1799
|
+
posterUrl: `/thumbs/c${i}`,
|
|
1800
|
+
mime: 'video/mp4',
|
|
1801
|
+
durationSeconds: 10 + i,
|
|
1802
|
+
createdAt: 500 - i * 10,
|
|
1803
|
+
}));
|
|
1804
|
+
|
|
1805
|
+
/** Every tile caption the gallery is drawing, in the order it draws them. */
|
|
1806
|
+
const captions = (c: HTMLElement): (string | null)[] =>
|
|
1807
|
+
[...c.querySelectorAll('.cbgd-meta-title')].map((el) => el.textContent);
|
|
1808
|
+
|
|
1809
|
+
const title = (c: HTMLElement) => c.querySelector('.cbgd-watch-title')?.textContent;
|
|
1810
|
+
|
|
1811
|
+
const open = (view: ReturnType<typeof render>, name: string) =>
|
|
1812
|
+
fireEvent.click(view.getByRole('button', { name: `Open ${name}` }));
|
|
1813
|
+
|
|
1814
|
+
test('๐ด the ids AND their order are identical before and after every pick', () => {
|
|
1815
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
1816
|
+
const browse = captions(view.container);
|
|
1817
|
+
expect(browse).toEqual(['one.mp4', 'two.mp4', 'three.mp4', 'four.mp4', 'five.mp4']);
|
|
1818
|
+
|
|
1819
|
+
// The first pick. Before the fix this came back four long, missing `two.mp4` โ the tile he
|
|
1820
|
+
// had just clicked, gone from under his pointer.
|
|
1821
|
+
open(view, 'two.mp4');
|
|
1822
|
+
expect(captions(view.container), 'the strip dropped the file it opened').toEqual(browse);
|
|
1823
|
+
|
|
1824
|
+
// โฆand it stays that way however many he picks, including picking the one already playing.
|
|
1825
|
+
for (const name of ['four.mp4', 'one.mp4', 'five.mp4', 'five.mp4', 'three.mp4']) {
|
|
1826
|
+
open(view, name);
|
|
1827
|
+
expect(captions(view.container), `the strip moved when ${name} was picked`).toEqual(browse);
|
|
1828
|
+
expect(title(view.container)).toBe(name);
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
test('๐ด the one that is playing is IN the strip, and is marked', () => {
|
|
1833
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
1834
|
+
open(view, 'three.mp4');
|
|
1835
|
+
|
|
1836
|
+
const marked = view.container.querySelectorAll('[data-playing]');
|
|
1837
|
+
expect(marked.length, 'exactly one tile is the one on the stage').toBe(1);
|
|
1838
|
+
const tile = marked[0] as HTMLElement;
|
|
1839
|
+
expect(tile.textContent).toContain('three.mp4');
|
|
1840
|
+
expect(tile.className).toContain('is-playing');
|
|
1841
|
+
// A ring drawn by a stylesheet says nothing to a screen reader; these two do.
|
|
1842
|
+
expect(tile.getAttribute('aria-current')).toBe('true');
|
|
1843
|
+
expect(tile.textContent).toContain('Now playing');
|
|
1844
|
+
|
|
1845
|
+
// The mark MOVES rather than accumulating, and the tile it leaves goes back to plain.
|
|
1846
|
+
open(view, 'five.mp4');
|
|
1847
|
+
const after = view.container.querySelectorAll('[data-playing]');
|
|
1848
|
+
expect(after.length).toBe(1);
|
|
1849
|
+
expect((after[0] as HTMLElement).textContent).toContain('five.mp4');
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
test('a still is "On screen", not "Now playing" โ the album holds any kind of file', () => {
|
|
1853
|
+
const view = render(<MediaGallery items={ITEMS} />);
|
|
1854
|
+
open(view, 'beach.jpg');
|
|
1855
|
+
const tile = view.container.querySelector('[data-playing]');
|
|
1856
|
+
expect(tile?.textContent).toContain('beach.jpg');
|
|
1857
|
+
expect(tile?.textContent).toContain('On screen');
|
|
1858
|
+
expect(tile?.textContent).not.toContain('Now playing');
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
test('๐ด the heading no longer says "More files" โ the open one is one of them', () => {
|
|
1862
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
1863
|
+
open(view, 'two.mp4');
|
|
1864
|
+
const head = view.container.querySelector('.cbgd-section-head');
|
|
1865
|
+
expect(head?.textContent).toBe('Files');
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
test('the TABLE view of the same strip marks the same row, and keeps it', () => {
|
|
1869
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
1870
|
+
fireEvent.click(view.getByRole('button', { name: /Table/ }));
|
|
1871
|
+
open(view, 'two.mp4');
|
|
1872
|
+
|
|
1873
|
+
const rows = view.container.querySelectorAll('.cbgt-row');
|
|
1874
|
+
expect(rows.length, 'the open file left the table').toBe(CLIPS.length);
|
|
1875
|
+
const playing = view.container.querySelectorAll('.cbgt-row.is-playing');
|
|
1876
|
+
expect(playing.length).toBe(1);
|
|
1877
|
+
expect((playing[0] as HTMLElement).textContent).toContain('two.mp4');
|
|
1878
|
+
expect((playing[0] as HTMLElement).getAttribute('aria-current')).toBe('true');
|
|
1879
|
+
expect((playing[0] as HTMLElement).getAttribute('data-playing')).toBe('true');
|
|
1880
|
+
// The name a screen reader reads, since the row's mark is a tint and a bar.
|
|
1881
|
+
expect((playing[0] as HTMLElement).textContent).toContain('Now playing');
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
/*
|
|
1885
|
+
* ๐ด The trap the strip change walks straight into, if `advance` ever reads the strip.
|
|
1886
|
+
*
|
|
1887
|
+
* `nextInRun` is handed `displayIds` โ the whole displayed order, which has ALWAYS included
|
|
1888
|
+
* the open item โ and it excludes the current one itself (`playbackRun.ts`, asserted there
|
|
1889
|
+
* over hundreds of steps with an injected generator). What that pure test cannot see is the
|
|
1890
|
+
* WIRING: an `advance` that started counting the strip instead would now be offered the item
|
|
1891
|
+
* already on the stage, and `shuffle` would sit on one clip replaying it forever โ the exact
|
|
1892
|
+
* failure `playbackPreferences.ts` describes for `loop` + `ended`.
|
|
1893
|
+
*/
|
|
1894
|
+
const cycleTo = (view: ReturnType<typeof render>, label: RegExp) => {
|
|
1895
|
+
for (let i = 0; i < 6; i++) {
|
|
1896
|
+
const button = view.getByRole('button', { name: /^When an item ends:/ });
|
|
1897
|
+
// The pill is a bare glyph โ see the note on the other `cycleTo` in this file.
|
|
1898
|
+
if (label.test(button.getAttribute('aria-label') ?? '')) return;
|
|
1899
|
+
fireEvent.click(button);
|
|
1900
|
+
}
|
|
1901
|
+
throw new Error(`the playback button never reached ${label}`);
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
/** End the clip on the stage the way the element does. */
|
|
1905
|
+
const endStage = (view: ReturnType<typeof render>) => {
|
|
1906
|
+
const video = view.container.querySelector('.cbmg-stage-video');
|
|
1907
|
+
expect(video, 'the native stage did not render a player').not.toBeNull();
|
|
1908
|
+
if (video) fireEvent.ended(video);
|
|
1909
|
+
};
|
|
1910
|
+
|
|
1911
|
+
test('๐ด shuffle never offers the item already playing as its next', () => {
|
|
1912
|
+
const view = render(<MediaGallery items={CLIPS} nativeVideoStage />);
|
|
1913
|
+
open(view, 'one.mp4');
|
|
1914
|
+
cycleTo(view, /Shuffle/);
|
|
1915
|
+
// Ten hand-offs over five clips: two full passes plus the refill between them, which is
|
|
1916
|
+
// where a shuffle that counts the current item as a candidate repeats.
|
|
1917
|
+
for (let i = 0; i < 10; i++) {
|
|
1918
|
+
const before = title(view.container);
|
|
1919
|
+
endStage(view);
|
|
1920
|
+
expect(title(view.container), `shuffle replayed ${before}`).not.toBe(before);
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
test('๐ด play all steps to the NEXT file, never back to the one that just ended', () => {
|
|
1925
|
+
const view = render(<MediaGallery items={CLIPS} nativeVideoStage />);
|
|
1926
|
+
open(view, 'one.mp4');
|
|
1927
|
+
cycleTo(view, /Play all/);
|
|
1928
|
+
// The displayed order, wrapping past the last โ and never the item that just played.
|
|
1929
|
+
for (const next of ['two.mp4', 'three.mp4', 'four.mp4', 'five.mp4', 'one.mp4']) {
|
|
1930
|
+
endStage(view);
|
|
1931
|
+
expect(title(view.container)).toBe(next);
|
|
1932
|
+
}
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
test('โง-arrow stepping still walks the displayed order, which now matches the strip', () => {
|
|
1936
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
1937
|
+
open(view, 'two.mp4');
|
|
1938
|
+
// Dispatched at the BODY rather than at `window`: the gallery's handler is a capture
|
|
1939
|
+
// listener on the window, so a press anywhere reaches it โ and `fireEvent` is what wraps
|
|
1940
|
+
// the state change it causes in `act`, which a raw `dispatchEvent` does not.
|
|
1941
|
+
const press = (key: string) => fireEvent.keyDown(document.body, { key, shiftKey: true });
|
|
1942
|
+
press('ArrowRight');
|
|
1943
|
+
expect(title(view.container)).toBe('three.mp4');
|
|
1944
|
+
press('ArrowLeft');
|
|
1945
|
+
press('ArrowLeft');
|
|
1946
|
+
expect(title(view.container)).toBe('one.mp4');
|
|
1947
|
+
// The strip did not move while the stepping did.
|
|
1948
|
+
expect(captions(view.container)).toEqual([
|
|
1949
|
+
'one.mp4',
|
|
1950
|
+
'two.mp4',
|
|
1951
|
+
'three.mp4',
|
|
1952
|
+
'four.mp4',
|
|
1953
|
+
'five.mp4',
|
|
1954
|
+
]);
|
|
1955
|
+
});
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* The REVEAL โ bringing the marked tile back into view.
|
|
1960
|
+
*
|
|
1961
|
+
* Owner, same report: *"I can still see the playing video in the list."* A tile that stays in
|
|
1962
|
+
* the list but forty rows above the fold is not one he can see, so the strip scrolls to it โ
|
|
1963
|
+
* the same thing `FileTree`'s reveal does, with the same two limits (only when it is off
|
|
1964
|
+
* screen, never mid-drag) and one more: only while the stage is PINNED, because the watch view
|
|
1965
|
+
* is one scroller and scrolling the strip in the unpinned mode would scroll the playing film
|
|
1966
|
+
* off the top, which is the 2026-08-14 report ("navigate automatically back to the top to see
|
|
1967
|
+
* the movie I am playing") re-opened.
|
|
1968
|
+
*
|
|
1969
|
+
* happy-dom lays nothing out, so every rect in this file is the same 800ร600 stub โ which
|
|
1970
|
+
* reads as "everything is on screen" and would make the reveal untestable. These tests give
|
|
1971
|
+
* the marked tile a rect of its own, which is the only part of the geometry that matters.
|
|
1972
|
+
*/
|
|
1973
|
+
describe('MediaGallery โ the playing tile is scrolled back into view', () => {
|
|
1974
|
+
const CLIPS: MediaGalleryItem[] = ['one', 'two', 'three', 'four', 'five'].map((word, i) => ({
|
|
1975
|
+
id: `c${i}`,
|
|
1976
|
+
name: `${word}.mp4`,
|
|
1977
|
+
streamUrl: `/files/c${i}`,
|
|
1978
|
+
posterUrl: `/thumbs/c${i}`,
|
|
1979
|
+
mime: 'video/mp4',
|
|
1980
|
+
createdAt: 500 - i * 10,
|
|
1981
|
+
}));
|
|
1982
|
+
|
|
1983
|
+
const rect = (top: number, bottom: number): DOMRect =>
|
|
1984
|
+
({
|
|
1985
|
+
top,
|
|
1986
|
+
bottom,
|
|
1987
|
+
height: bottom - top,
|
|
1988
|
+
left: 0,
|
|
1989
|
+
right: 800,
|
|
1990
|
+
width: 800,
|
|
1991
|
+
x: 0,
|
|
1992
|
+
y: top,
|
|
1993
|
+
toJSON: () => ({}),
|
|
1994
|
+
}) as DOMRect;
|
|
1995
|
+
|
|
1996
|
+
/** The file-wide stub, restored after each test here. */
|
|
1997
|
+
const FLAT = HTMLElement.prototype.getBoundingClientRect;
|
|
1998
|
+
afterEach(() => {
|
|
1999
|
+
HTMLElement.prototype.getBoundingClientRect = FLAT;
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
/**
|
|
2003
|
+
* Lay the view out: the scroller and the pinned stage as written, the MARKED tile wherever
|
|
2004
|
+
* the test puts it. Everything else keeps the file's flat 0โ600 stub.
|
|
2005
|
+
*/
|
|
2006
|
+
const layOut = (playing: DOMRect) => {
|
|
2007
|
+
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement): DOMRect {
|
|
2008
|
+
if (this.getAttribute('data-playing') === 'true') return playing;
|
|
2009
|
+
// The pinned top region covers the first half of the scroller, so a tile revealed to
|
|
2010
|
+
// the frame's own top edge would land behind the film.
|
|
2011
|
+
if (this.classList.contains('cbgd-watch-top')) return rect(0, 300);
|
|
2012
|
+
return rect(0, 600);
|
|
2013
|
+
};
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
/** Open a file with the stage pinned, and hand back the watch scroller. */
|
|
2017
|
+
const pinned = (view: ReturnType<typeof render>, name: string): HTMLElement => {
|
|
2018
|
+
fireEvent.click(view.getByRole('button', { name: `Open ${name}` }));
|
|
2019
|
+
fireEvent.click(view.getByRole('button', { name: /Pin/ }));
|
|
2020
|
+
return view.container.querySelector('.cbgd-watch') as HTMLElement;
|
|
2021
|
+
};
|
|
2022
|
+
|
|
2023
|
+
test('๐ด a tile BELOW the fold is brought up to the bottom edge', () => {
|
|
2024
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
2025
|
+
const scroller = pinned(view, 'one.mp4');
|
|
2026
|
+
scroller.scrollTop = 0;
|
|
2027
|
+
// The next pick's tile is 300px below the strip's bottom edge.
|
|
2028
|
+
layOut(rect(900, 1100));
|
|
2029
|
+
fireEvent.click(view.getByRole('button', { name: 'Open three.mp4' }));
|
|
2030
|
+
// Exactly enough to bring its bottom edge into the frame, and not one pixel more.
|
|
2031
|
+
expect(scroller.scrollTop).toBe(500);
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
test('๐ด a tile hidden BEHIND the pinned stage is brought below it, not to the top', () => {
|
|
2035
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
2036
|
+
const scroller = pinned(view, 'one.mp4');
|
|
2037
|
+
scroller.scrollTop = 400;
|
|
2038
|
+
// 100โ250: inside the scroller, but under the pinned region that ends at 300. Revealing
|
|
2039
|
+
// to the frame's top (`scrollIntoView`'s `nearest`) would leave it there, behind the film.
|
|
2040
|
+
layOut(rect(100, 250));
|
|
2041
|
+
fireEvent.click(view.getByRole('button', { name: 'Open three.mp4' }));
|
|
2042
|
+
expect(scroller.scrollTop).toBe(200);
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
test('a tile already in view does not move the strip โ limit 2', () => {
|
|
2046
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
2047
|
+
const scroller = pinned(view, 'one.mp4');
|
|
2048
|
+
scroller.scrollTop = 120;
|
|
2049
|
+
layOut(rect(320, 460));
|
|
2050
|
+
fireEvent.click(view.getByRole('button', { name: 'Open three.mp4' }));
|
|
2051
|
+
expect(scroller.scrollTop).toBe(120);
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
test('๐ด UNPINNED, the strip does not move โ going back to the stage wins', () => {
|
|
2055
|
+
const view = render(<MediaGallery items={CLIPS} />);
|
|
2056
|
+
fireEvent.click(view.getByRole('button', { name: 'Open one.mp4' }));
|
|
2057
|
+
const scroller = view.container.querySelector('.cbgd-watch') as HTMLElement;
|
|
2058
|
+
scroller.scrollTop = 0;
|
|
2059
|
+
layOut(rect(900, 1100));
|
|
2060
|
+
fireEvent.click(view.getByRole('button', { name: 'Open three.mp4' }));
|
|
2061
|
+
// The effect above this one scrolls the STAGE back into view; a reveal here would undo it
|
|
2062
|
+
// every single time and play the film off the top of the screen.
|
|
2063
|
+
expect(scroller.scrollTop).toBe(0);
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
/*
|
|
2067
|
+
* Limit 3 โ *never mid-drag* โ is vacuous in this strip rather than skipped, and this is what
|
|
2068
|
+
* keeps it that way: the detail view hands its tiles no `drag` prop, so there is no drag of
|
|
2069
|
+
* ours to scroll out from under. A strip that becomes a drag source fails here, which is the
|
|
2070
|
+
* moment to write the guard `FileTree` has.
|
|
2071
|
+
*/
|
|
2072
|
+
test('the strip is not a drag source, which is what makes the reveal safe mid-drag', () => {
|
|
2073
|
+
const view = render(<MediaGallery items={CLIPS} onItemDragStart={() => {}} />);
|
|
2074
|
+
// The BROWSE grid is one โ that is the feature, and it is what makes this assertion mean
|
|
2075
|
+
// something rather than pass by accident.
|
|
2076
|
+
expect(view.container.querySelector('.cbmg-selectable[draggable="true"]')).not.toBeNull();
|
|
2077
|
+
fireEvent.click(view.getByRole('button', { name: 'Open one.mp4' }));
|
|
2078
|
+
expect(view.container.querySelector('.cbmg-selectable[draggable="true"]')).toBeNull();
|
|
2079
|
+
});
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
/**
|
|
2083
|
+
* โโ ๐ด The icon-only toolbar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
2084
|
+
*
|
|
2085
|
+
* Owner, `ideas/collections-09-14.md`, 2026-09-14: *"In the cb media galleries like collections
|
|
2086
|
+
* uses I want to make the toolbar look more professional. Make it have icons only, instead of
|
|
2087
|
+
* words in the strip of tools where have things like pin, stop at the end, and turn video.
|
|
2088
|
+
* Hovering over the tooltip can show the words."*
|
|
2089
|
+
*
|
|
2090
|
+
* A visual change, so what is worth asserting is exactly what a visual change silently drops:
|
|
2091
|
+
*
|
|
2092
|
+
* 1. **The name.** A pill whose only content is an `<svg>` has no accessible name unless
|
|
2093
|
+
* somebody put one there. Deleting the words deletes the name unless an `aria-label`
|
|
2094
|
+
* takes over, and nothing about the page LOOKS broken when it happens โ the toolbar is
|
|
2095
|
+
* simply unusable with a screen reader from that commit on. Every control the owner
|
|
2096
|
+
* named is listed here, plus the ones in the same strip he meant by *"things like"*.
|
|
2097
|
+
* 2. **The state that was a word.** `๐ Pin` vs `๐ Pinned` and the five end-behaviour
|
|
2098
|
+
* labels were readouts, not names. One glyph in two states removes information he was
|
|
2099
|
+
* reading off the strip, so the value has to be somewhere a test can see it.
|
|
2100
|
+
* 3. **That the words are actually GONE.** Otherwise "icons only" is a change that half
|
|
2101
|
+
* happened โ a pill still printing its label passes (1) and (2) perfectly.
|
|
2102
|
+
*
|
|
2103
|
+
* The 44px floor is the fourth thing this change could drop and is deliberately NOT here:
|
|
2104
|
+
* happy-dom computes no layout, so every rectangle in this file is the 800ร600 stub at the
|
|
2105
|
+
* top. `e2e/touch-targets.spec.ts` measures it in a real engine on a coarse pointer, which is
|
|
2106
|
+
* this repo's standing lesson (task 69, and the 36ร36 false green of 2026-09-14).
|
|
2107
|
+
*/
|
|
2108
|
+
describe('MediaGallery โ the toolbar is icons, and the words are its names', () => {
|
|
2109
|
+
/** Every capability wired, so the whole strip renders on one item. */
|
|
2110
|
+
const wired = (items: MediaGalleryItem[]) =>
|
|
2111
|
+
render(
|
|
2112
|
+
<MediaGallery
|
|
2113
|
+
items={items}
|
|
2114
|
+
onRename={() => {}}
|
|
2115
|
+
onSaveThumbnail={() => {}}
|
|
2116
|
+
onSaveEdit={() => {}}
|
|
2117
|
+
onSaveOrientation={() => {}}
|
|
2118
|
+
onTrimVideo={() => {}}
|
|
2119
|
+
onSaveChapters={() => {}}
|
|
2120
|
+
onDelete={() => {}}
|
|
2121
|
+
/>,
|
|
2122
|
+
);
|
|
2123
|
+
|
|
2124
|
+
test('๐ด every converted control still exposes its accessible name', () => {
|
|
2125
|
+
const view = wired(ITEMS);
|
|
2126
|
+
fireEvent.click(view.getByRole('button', { name: 'Open clip.mp4' }));
|
|
2127
|
+
for (const name of [
|
|
2128
|
+
// The three the owner named by handโฆ
|
|
2129
|
+
'Pin the stage',
|
|
2130
|
+
'When an item ends: Stop at the end',
|
|
2131
|
+
'Turn video',
|
|
2132
|
+
// โฆand the rest of the same strip, which "things like" is his phrasing for.
|
|
2133
|
+
'Rename',
|
|
2134
|
+
// `clip.mp4` already carries a `posterUrl`, so it is CHANGE rather than choose โ which
|
|
2135
|
+
// is the distinction the next test is about.
|
|
2136
|
+
'Change thumbnail',
|
|
2137
|
+
'Trim',
|
|
2138
|
+
'Sections',
|
|
2139
|
+
'Picture',
|
|
2140
|
+
'Delete this item',
|
|
2141
|
+
'Back to grid',
|
|
2142
|
+
'Fullscreen',
|
|
2143
|
+
]) {
|
|
2144
|
+
expect(view.getByRole('button', { name }), `no control named "${name}"`).toBeDefined();
|
|
2145
|
+
}
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
test('๐ด โฆand so does the download, which is a LINK โ same rule, different role', () => {
|
|
2149
|
+
// On the photograph, because `clip.mp4` in this file's fixture has a `streamUrl` and no
|
|
2150
|
+
// downloadable `url` โ so the film renders no link at all and the assertion would be
|
|
2151
|
+
// measuring an absence.
|
|
2152
|
+
const view = wired(ITEMS);
|
|
2153
|
+
fireEvent.click(view.getByRole('button', { name: 'Open beach.jpg' }));
|
|
2154
|
+
const link = view.getByRole('link', { name: 'Download beach.jpg' });
|
|
2155
|
+
expect(link.getAttribute('href')).toBe('/files/img1');
|
|
2156
|
+
expect(link.textContent?.trim()).toBe('');
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
test('๐ด the strip prints no words at all โ the half of "icons only" a name check cannot see', () => {
|
|
2160
|
+
const view = wired(ITEMS);
|
|
2161
|
+
fireEvent.click(view.getByRole('button', { name: 'Open clip.mp4' }));
|
|
2162
|
+
const row = view.container.querySelector('.cbgd-watch-actions') as HTMLElement;
|
|
2163
|
+
expect(row).not.toBeNull();
|
|
2164
|
+
const wordy = [...row.querySelectorAll<HTMLElement>('.cbgd-order-btn')]
|
|
2165
|
+
.filter((el) => (el.textContent ?? '').trim().length > 0)
|
|
2166
|
+
.map((el) => `${el.getAttribute('aria-label')}: "${el.textContent?.trim()}"`);
|
|
2167
|
+
expect(wordy, `pills still printing a label:\n${wordy.join('\n')}`).toEqual([]);
|
|
2168
|
+
// โฆand it is a real strip that was measured, not an empty one the filter walked past.
|
|
2169
|
+
expect(row.querySelectorAll('.cbgd-order-btn').length).toBeGreaterThan(8);
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
test('๐ด Pin says WHICH WAY it goes โ the state that used to be the word', () => {
|
|
2173
|
+
const view = wired(ITEMS);
|
|
2174
|
+
fireEvent.click(view.getByRole('button', { name: 'Open clip.mp4' }));
|
|
2175
|
+
const pin = () => view.getByRole('button', { name: /[Pp]in/ });
|
|
2176
|
+
// Unpinned: not pressed, not lit, and the name is the verb.
|
|
2177
|
+
expect(pin().getAttribute('aria-pressed')).toBe('false');
|
|
2178
|
+
expect(pin().className).not.toContain('is-active');
|
|
2179
|
+
expect(pin().getAttribute('aria-label')).toBe('Pin the stage');
|
|
2180
|
+
|
|
2181
|
+
fireEvent.click(pin());
|
|
2182
|
+
// Pinned: all three flip together. The lit pill is what a sighted reader sees instead of
|
|
2183
|
+
// the word "Pinned" โ this control had no `is-active` at all before the conversion,
|
|
2184
|
+
// because the word was carrying the whole state on its own.
|
|
2185
|
+
expect(pin().getAttribute('aria-pressed')).toBe('true');
|
|
2186
|
+
expect(pin().className).toContain('is-active');
|
|
2187
|
+
expect(pin().getAttribute('aria-label')).toBe('Pinned โ unpin the stage');
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
test('๐ด the end-behaviour control names its CURRENT setting, all five of them', () => {
|
|
2191
|
+
const view = wired(ITEMS);
|
|
2192
|
+
fireEvent.click(view.getByRole('button', { name: 'Open beach.jpg' }));
|
|
2193
|
+
const button = () => view.getByRole('button', { name: /^When an item ends:/ });
|
|
2194
|
+
const seen: string[] = [];
|
|
2195
|
+
for (let i = 0; i < 5; i++) {
|
|
2196
|
+
seen.push(button().getAttribute('aria-label') ?? '');
|
|
2197
|
+
fireEvent.click(button());
|
|
2198
|
+
}
|
|
2199
|
+
expect(seen).toEqual([
|
|
2200
|
+
'When an item ends: Stop at the end',
|
|
2201
|
+
'When an item ends: Repeat one',
|
|
2202
|
+
'When an item ends: Play next',
|
|
2203
|
+
'When an item ends: Play all',
|
|
2204
|
+
'When an item ends: Shuffle',
|
|
2205
|
+
]);
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
test('๐ด โฆand it draws a DIFFERENT glyph for each one, so the value is legible unhovered', () => {
|
|
2209
|
+
const view = wired(ITEMS);
|
|
2210
|
+
fireEvent.click(view.getByRole('button', { name: 'Open beach.jpg' }));
|
|
2211
|
+
const button = () => view.getByRole('button', { name: /^When an item ends:/ });
|
|
2212
|
+
// lucide stamps the icon's name onto the rendered `<svg>` as a class โ `lucide-repeat-1`,
|
|
2213
|
+
// `lucide-shuffle` โ which is the one handle a DOM test has on which glyph is drawn. Five
|
|
2214
|
+
// settings sharing one icon would collapse this set to a single entry.
|
|
2215
|
+
const glyphs = new Set<string>();
|
|
2216
|
+
for (let i = 0; i < 5; i++) {
|
|
2217
|
+
glyphs.add(button().querySelector('svg')?.getAttribute('class') ?? '');
|
|
2218
|
+
fireEvent.click(button());
|
|
2219
|
+
}
|
|
2220
|
+
expect(glyphs.size).toBe(5);
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
test('the Picture pill says so when the light has been moved, not only when it is open', () => {
|
|
2224
|
+
const view = wired(ITEMS);
|
|
2225
|
+
fireEvent.click(view.getByRole('button', { name: 'Open beach.jpg' }));
|
|
2226
|
+
// The `ยท` this pill used to print meant "you have already moved these sliders" โ it is a
|
|
2227
|
+
// word in the name now, because a bare glyph has nowhere to put a dot.
|
|
2228
|
+
expect(view.getByRole('button', { name: 'Picture' })).toBeDefined();
|
|
2229
|
+
fireEvent.click(view.getByRole('button', { name: 'Picture' }));
|
|
2230
|
+
const slider = view.container.querySelector<HTMLInputElement>(
|
|
2231
|
+
'.cbmg-picture-sliders input[type="range"]',
|
|
2232
|
+
) as HTMLInputElement;
|
|
2233
|
+
// `LIGHT_RANGE` is 0.2โ2 around a neutral 1, not a percentage.
|
|
2234
|
+
fireEvent.change(slider, { target: { value: '1.3' } });
|
|
2235
|
+
expect(view.getByRole('button', { name: 'Picture โ adjusted' })).toBeDefined();
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
test('the thumbnail control still says whether a poster exists โ three different names', () => {
|
|
2239
|
+
const noPoster: MediaGalleryItem[] = [
|
|
2240
|
+
{ id: 'p1', name: 'clip.mp4', streamUrl: '/f/p1', createdAt: 1 },
|
|
2241
|
+
];
|
|
2242
|
+
const a = wired(noPoster);
|
|
2243
|
+
fireEvent.click(a.getByRole('button', { name: 'Open clip.mp4' }));
|
|
2244
|
+
expect(a.getByRole('button', { name: 'Choose thumbnail' })).toBeDefined();
|
|
2245
|
+
a.unmount();
|
|
2246
|
+
|
|
2247
|
+
// `onSaveSnapshot` without `onSaveThumbnail` is a different capability, not a different
|
|
2248
|
+
// wording โ hence a camera rather than a picture frame.
|
|
2249
|
+
const b = render(<MediaGallery items={noPoster} onSaveSnapshot={() => {}} />);
|
|
2250
|
+
fireEvent.click(b.getByRole('button', { name: 'Open clip.mp4' }));
|
|
2251
|
+
expect(b.getByRole('button', { name: 'Take a picture' })).toBeDefined();
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
test('the header pills and the selection bar went with it, rather than half converting', () => {
|
|
2255
|
+
const view = render(<MediaGallery items={ITEMS} selectable selectionReveal='on-demand' />);
|
|
2256
|
+
for (const name of ['Grid', 'Table', 'Select']) {
|
|
2257
|
+
expect(view.getByRole('button', { name }), `no control named "${name}"`).toBeDefined();
|
|
2258
|
+
}
|
|
2259
|
+
expect(view.getByRole('button', { name: 'Grid' }).textContent?.trim()).toBe('');
|
|
2260
|
+
expect(view.getByRole('button', { name: 'Table' }).textContent?.trim()).toBe('');
|
|
2261
|
+
// Which one is live is still readable without a word, because they are a PAIR.
|
|
2262
|
+
expect(view.getByRole('button', { name: 'Grid' }).getAttribute('aria-pressed')).toBe('true');
|
|
2263
|
+
expect(view.getByRole('button', { name: 'Table' }).getAttribute('aria-pressed')).toBe('false');
|
|
2264
|
+
// The count stays a WORD: it is a value you read, and the bar is the only place it is said.
|
|
2265
|
+
expect(view.getByText('Select all')).toBeDefined();
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* โโ ๐ด Tagging and describing โ the capability, not a flag โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
2271
|
+
*
|
|
2272
|
+
* Owner, `ideas/collections-09-14.md`, 2026-09-14: *"In media galleries have an option for apps
|
|
2273
|
+
* to tag media and aadd descriptions to media. In collections allow these features so I can tag
|
|
2274
|
+
* and add descriptions to its media."*
|
|
2275
|
+
*
|
|
2276
|
+
* The gallery could already READ `tags` and had no way to write one and no `description` field
|
|
2277
|
+
* at all. These assert the three claims that make the new half real: the control is absent
|
|
2278
|
+
* without a handler and present with one (the `onTrimVideo` rule, held here too), a bulk tag
|
|
2279
|
+
* reports every ticked id in ONE call, and a description renders on the stage without touching
|
|
2280
|
+
* the tile caption โ which is two lines by design and already contested.
|
|
2281
|
+
*/
|
|
2282
|
+
describe('MediaGallery โ tags and descriptions', () => {
|
|
2283
|
+
const TAGGABLE: MediaGalleryItem[] = [
|
|
2284
|
+
{ id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 30, tags: ['holiday'] },
|
|
2285
|
+
{ id: 'img2', name: 'dog.jpg', url: '/files/img2', createdAt: 20 },
|
|
2286
|
+
{ id: 'img3', name: 'receipt.pdf', url: '/files/pdf1', createdAt: 10 },
|
|
2287
|
+
];
|
|
2288
|
+
const boxes = (c: HTMLElement) =>
|
|
2289
|
+
[...c.querySelectorAll('.cbmg-select-box input')] as HTMLInputElement[];
|
|
2290
|
+
|
|
2291
|
+
test('๐ด the control appears only when the app can STORE the answer', () => {
|
|
2292
|
+
const withoutHandler = render(<MediaGallery items={TAGGABLE} />);
|
|
2293
|
+
fireEvent.click(withoutHandler.getByRole('button', { name: 'Open beach.jpg' }));
|
|
2294
|
+
expect(withoutHandler.queryByRole('button', { name: 'Tags and description' })).toBeNull();
|
|
2295
|
+
withoutHandler.unmount();
|
|
2296
|
+
|
|
2297
|
+
const withHandler = render(<MediaGallery items={TAGGABLE} onSaveMeta={() => {}} />);
|
|
2298
|
+
fireEvent.click(withHandler.getByRole('button', { name: 'Open beach.jpg' }));
|
|
2299
|
+
expect(withHandler.queryByRole('button', { name: 'Tags and description' })).not.toBeNull();
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
test('๐ด the bulk verb is gated by the same handler, and by nothing else', () => {
|
|
2303
|
+
const { container, queryByRole, rerender } = render(
|
|
2304
|
+
<MediaGallery items={TAGGABLE} selectable />,
|
|
2305
|
+
);
|
|
2306
|
+
fireEvent.click(boxes(container)[0] as HTMLInputElement);
|
|
2307
|
+
expect(queryByRole('button', { name: /^Tag the 1 selected item$/ })).toBeNull();
|
|
2308
|
+
|
|
2309
|
+
rerender(<MediaGallery items={TAGGABLE} selectable onSaveMeta={() => {}} />);
|
|
2310
|
+
expect(queryByRole('button', { name: /^Tag the 1 selected item$/ })).not.toBeNull();
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
test('๐ด a bulk tag reports EVERY selected id in one call, not one call per item', async () => {
|
|
2314
|
+
// The whole reason the handler takes a list. A gallery that looped the singular would call
|
|
2315
|
+
// this three times with one item, and an app re-deriving its rows between the writes is how
|
|
2316
|
+
// a batch tag half-lands โ the failure `tasks/Completed/240-*.md` records for the tree's
|
|
2317
|
+
// batch move, in the same words.
|
|
2318
|
+
const calls: { ids: string[]; add: string[]; remove: string[] }[] = [];
|
|
2319
|
+
const { container, getByRole, getByLabelText, findByRole } = render(
|
|
2320
|
+
<MediaGallery
|
|
2321
|
+
items={TAGGABLE}
|
|
2322
|
+
selectable
|
|
2323
|
+
onSaveMeta={(items, change) => {
|
|
2324
|
+
calls.push({
|
|
2325
|
+
ids: items.map((i) => i.id),
|
|
2326
|
+
add: change.addTags,
|
|
2327
|
+
remove: change.removeTags,
|
|
2328
|
+
});
|
|
2329
|
+
}}
|
|
2330
|
+
/>,
|
|
2331
|
+
);
|
|
2332
|
+
fireEvent.click(container.querySelector('.cbmg-bulkbar-all input') as HTMLInputElement);
|
|
2333
|
+
fireEvent.click(getByRole('button', { name: 'Tag the 3 selected items' }));
|
|
2334
|
+
|
|
2335
|
+
const add = (await findByRole('dialog')) && getByLabelText('Tags to add to all 3 selected items');
|
|
2336
|
+
fireEvent.change(add, { target: { value: 'greece' } });
|
|
2337
|
+
fireEvent.keyDown(add, { key: 'Enter' });
|
|
2338
|
+
fireEvent.click(getByRole('button', { name: /^Save$/ }));
|
|
2339
|
+
|
|
2340
|
+
await waitFor(() => expect(calls).toHaveLength(1));
|
|
2341
|
+
expect(calls[0]).toEqual({
|
|
2342
|
+
ids: ['img1', 'img2', 'img3'],
|
|
2343
|
+
add: ['greece'],
|
|
2344
|
+
remove: [],
|
|
2345
|
+
});
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
test('๐ด the single-item save arrives as the SAME add/remove pair a bulk one sends', async () => {
|
|
2349
|
+
// An array rather than a `let โฆ = null`: TypeScript narrows a local that is only ever
|
|
2350
|
+
// written inside a callback back to `null`, so the assertion below would not compile.
|
|
2351
|
+
const calls: { ids: string[]; add: string[]; remove: string[]; description?: string }[] = [];
|
|
2352
|
+
const { getByRole, getByLabelText, findByRole } = render(
|
|
2353
|
+
<MediaGallery
|
|
2354
|
+
items={TAGGABLE}
|
|
2355
|
+
onSaveMeta={(items, change) => {
|
|
2356
|
+
calls.push({
|
|
2357
|
+
ids: items.map((i) => i.id),
|
|
2358
|
+
add: change.addTags,
|
|
2359
|
+
remove: change.removeTags,
|
|
2360
|
+
...(change.description === undefined ? {} : { description: change.description }),
|
|
2361
|
+
});
|
|
2362
|
+
}}
|
|
2363
|
+
/>,
|
|
2364
|
+
);
|
|
2365
|
+
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
2366
|
+
fireEvent.click(getByRole('button', { name: 'Tags and description' }));
|
|
2367
|
+
await findByRole('dialog');
|
|
2368
|
+
|
|
2369
|
+
const tags = getByLabelText('Tags for beach.jpg');
|
|
2370
|
+
fireEvent.change(tags, { target: { value: 'greece' } });
|
|
2371
|
+
fireEvent.keyDown(tags, { key: 'Enter' });
|
|
2372
|
+
fireEvent.change(getByLabelText('Description of beach.jpg'), {
|
|
2373
|
+
target: { value: 'The last morning in Naxos.' },
|
|
2374
|
+
});
|
|
2375
|
+
fireEvent.click(getByRole('button', { name: /^Save$/ }));
|
|
2376
|
+
|
|
2377
|
+
await waitFor(() => expect(calls).toHaveLength(1));
|
|
2378
|
+
expect(calls[0]).toEqual({
|
|
2379
|
+
ids: ['img1'],
|
|
2380
|
+
add: ['greece'],
|
|
2381
|
+
remove: [],
|
|
2382
|
+
description: 'The last morning in Naxos.',
|
|
2383
|
+
});
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
test('๐ด the bulk form offers no description โ one sentence cannot describe three files', async () => {
|
|
2387
|
+
const { container, getByRole, queryByLabelText, findByRole } = render(
|
|
2388
|
+
<MediaGallery items={TAGGABLE} selectable onSaveMeta={() => {}} />,
|
|
2389
|
+
);
|
|
2390
|
+
fireEvent.click(container.querySelector('.cbmg-bulkbar-all input') as HTMLInputElement);
|
|
2391
|
+
fireEvent.click(getByRole('button', { name: 'Tag the 3 selected items' }));
|
|
2392
|
+
await findByRole('dialog');
|
|
2393
|
+
expect(queryByLabelText(/^Description of/)).toBeNull();
|
|
2394
|
+
expect(queryByLabelText('Tags to remove from all 3 selected items')).not.toBeNull();
|
|
2395
|
+
});
|
|
2396
|
+
|
|
2397
|
+
test('a description RENDERS on the stage, under the title', () => {
|
|
2398
|
+
const described: MediaGalleryItem[] = [
|
|
2399
|
+
{
|
|
2400
|
+
id: 'img1',
|
|
2401
|
+
name: 'beach.jpg',
|
|
2402
|
+
url: '/files/img1',
|
|
2403
|
+
createdAt: 30,
|
|
2404
|
+
description: 'The last morning in Naxos.',
|
|
2405
|
+
},
|
|
2406
|
+
];
|
|
2407
|
+
const { container, getByRole, getByText } = render(<MediaGallery items={described} />);
|
|
2408
|
+
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
2409
|
+
expect(getByText('The last morning in Naxos.')).toBeDefined();
|
|
2410
|
+
// Its own element rather than appended to the ` ยท ` facts line, which is nowrap with an
|
|
2411
|
+
// ellipsis because the same class captions every tile in the grid.
|
|
2412
|
+
expect(container.querySelector('.cbmg-stage-description')?.textContent).toBe(
|
|
2413
|
+
'The last morning in Naxos.',
|
|
2414
|
+
);
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
test('๐ด reading a description needs NO handler โ only writing is gated', () => {
|
|
2418
|
+
const described: MediaGalleryItem[] = [
|
|
2419
|
+
{ id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 30, description: 'Naxos.' },
|
|
2420
|
+
];
|
|
2421
|
+
const { container, getByRole } = render(<MediaGallery items={described} />);
|
|
2422
|
+
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
2423
|
+
expect(container.querySelector('.cbmg-stage-description')).not.toBeNull();
|
|
2424
|
+
expect(container.querySelector('button[aria-label="Tags and description"]')).toBeNull();
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
test('๐ด the TILE caption is unchanged by a description', () => {
|
|
2428
|
+
// The caption is two lines by design and already contested between the tags and the
|
|
2429
|
+
// measurement; a sentence there would be an ellipsis or a third line on every tile.
|
|
2430
|
+
const bare: MediaGalleryItem = { id: 'i', name: 'beach.jpg', sizeBytes: 2_048, createdAt: 1 };
|
|
2431
|
+
const described: MediaGalleryItem = { ...bare, description: 'A very long sentence indeed.' };
|
|
2432
|
+
expect(galleryTileLine(described)).toBe(galleryTileLine(bare));
|
|
2433
|
+
expect(galleryTileLine(described, { measure: true })).toBe(
|
|
2434
|
+
galleryTileLine(bare, { measure: true }),
|
|
2435
|
+
);
|
|
2436
|
+
|
|
2437
|
+
const { container } = render(<MediaGallery items={[described]} />);
|
|
2438
|
+
expect(container.querySelector('.cbgd-meta-line')?.textContent).toBe('2.0 KB');
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
test('a gallery with no description renders exactly the meta line it rendered before', () => {
|
|
2442
|
+
const { container, getByRole } = render(<MediaGallery items={TAGGABLE} />);
|
|
2443
|
+
fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
|
|
2444
|
+
const line = container.querySelector('.cbgd-watch-titlebar .cbgd-meta-line');
|
|
2445
|
+
expect(line?.querySelector('.cbmg-stage-description')).toBeNull();
|
|
2446
|
+
expect(line?.textContent).toContain('holiday');
|
|
2447
|
+
});
|
|
2448
|
+
});
|