Files
gods-eye-view/src/ui/sceneSharingView.test.mjs
T
Sameh Khamis 9d9eb267c5 feat(ui): render the scene sharing dialogs and bar from a store
createSceneDialog keeps its builder but publishes the dialog's lines,
fields, buttons and status to a scene sharing store; SceneDialog.vue,
teleported to the body from the scene panels view, draws the same modal
with the same styles and opens it with Cancel focused. The EDIT DETAILS /
SHARE SCENE bar is drawn by that view while sharing offers it. The status
directive is shared with Provider Settings, and the two QA scripts that
drove the old DOM read the new dialog.
2026-10-31 18:37:14 -07:00

255 lines
9.3 KiB
JavaScript

// src/ui/sceneSharingView.test.mjs — scene sharing's authoring bar and its
// dialog render from the scene sharing store, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useSceneSharingStore } from './stores/sceneSharing.js';
import { createSceneDialog, mountSceneSharing } from './sceneSharing.js';
import LayerPanelsView from './views/LayerPanelsView.vue';
// happy-dom may lack the modal methods; record what the view asks for.
const opened = [];
HTMLDialogElement.prototype.showModal ??= function () {
this.open = true;
};
const showModal = HTMLDialogElement.prototype.showModal;
HTMLDialogElement.prototype.showModal = function () {
opened.push(this.getAttribute('aria-label'));
return showModal.call(this);
};
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(LayerPanelsView).use(pinia);
app.mount(host);
t.after(() => app.unmount());
return { store: useSceneSharingStore(pinia) };
}
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
const labels = (nodes) => nodes.map((node) => node.textContent);
test('the authoring bar sits under SAVE & SHARE, after the file row, while offered', async (t) => {
const { store } = fixture(t);
assert.equal($('[data-director-authoring]'), null);
const calls = [];
const withdraw = mountSceneSharing(
{ edit: () => calls.push('edit'), share: () => calls.push('share') },
store,
);
await nextTick();
const bar = $('[data-director-authoring]');
assert.equal(
bar.previousElementSibling.classList.contains('scene-file-controls'),
true,
);
assert.equal(bar.className, 'scene-controls');
const buttons = $$('[data-director-authoring] button');
assert.deepEqual(labels(buttons), ['EDIT DETAILS', 'SHARE SCENE']);
assert.ok(buttons.every((button) => button.title));
assert.ok(
buttons.every((button) => button.classList.contains('gev-action-button')),
);
buttons[0].click();
buttons[1].click();
assert.deepEqual(calls, ['edit', 'share']);
// A newer offer survives the older one's withdrawal.
const newer = mountSceneSharing({ edit() {}, share() {} }, store);
withdraw();
await nextTick();
assert.ok($('[data-director-authoring]'));
newer();
await nextTick();
assert.equal($('[data-director-authoring]'), null);
});
test('a dialog draws its lines, fields and buttons in order and opens modal', async (t) => {
const { store } = fixture(t);
opened.length = 0;
const clicks = [];
const dialog = createSceneDialog('Edit scene details', () => {}, store);
dialog.text('Drafts are validated.');
const details = dialog.input('Anchors', '{"a":1}', { multiline: true });
const anchor = dialog.input('New anchor ID', 'anchor-1');
dialog.button('Capture', () => clicks.push('capture'), { place: 'body' });
const apply = dialog.button('Apply details', () => clicks.push('apply'), {
hook: 'data-director-apply-details',
});
await nextTick();
const root = $('dialog[data-director-dialog]');
assert.equal(root.parentElement, document.body);
assert.equal(root.getAttribute('aria-label'), 'Edit scene details');
assert.equal(root.classList.contains('director-sharing-dialog'), true);
assert.deepEqual(opened, ['Edit scene details']);
assert.equal($('dialog h2').textContent, 'Edit scene details');
const [body, status, footer] = [...root.children].slice(1);
assert.equal(status.getAttribute('role'), 'status');
assert.deepEqual(
[...body.children].map((node) => node.tagName),
['P', 'LABEL', 'LABEL', 'BUTTON'],
);
// Cancel comes first in the footer and takes the opening focus.
assert.deepEqual(labels([...footer.children]), ['Cancel', 'Apply details']);
assert.equal(footer.children[0].hasAttribute('autofocus'), true);
assert.equal(footer.children[1].hasAttribute('autofocus'), false);
assert.equal(
footer.children[1].hasAttribute('data-director-apply-details'),
true,
);
const textarea = $('dialog textarea');
assert.equal(textarea.value, '{"a":1}');
assert.equal(textarea.getAttribute('aria-label'), 'Anchors');
assert.equal($('dialog input').value, 'anchor-1');
textarea.value = '{"b":2}';
textarea.dispatchEvent(new Event('input', { bubbles: true }));
assert.equal(details.value, '{"b":2}');
anchor.value = 'anchor-2';
await nextTick();
assert.equal($('dialog input').value, 'anchor-2');
body.children[3].click();
footer.children[1].click();
assert.deepEqual(clicks, ['capture', 'apply']);
apply.disabled = true;
await nextTick();
assert.equal(footer.children[1].disabled, true);
assert.equal(apply.disabled, true);
// A disabled action does not run, even when asked directly.
store.controls.click(
store.dialog.items.find((item) => item.label === 'Apply details').id,
);
footer.children[1].click();
assert.deepEqual(clicks, ['capture', 'apply']);
// The owner's write reaches a focused field; typing is kept on republish.
textarea.focus();
details.value = '{"c":3}';
await nextTick();
assert.equal(textarea.value, '{"c":3}');
textarea.value = '{"d":4}';
textarea.dispatchEvent(new Event('input', { bubbles: true }));
dialog.setStatus('Anchor added to draft');
await nextTick();
assert.equal(textarea.value, '{"d":4}');
assert.equal(details.value, '{"d":4}');
});
test('the status line re-announces a repeated message', async (t) => {
const { store } = fixture(t);
const dialog = createSceneDialog('Review scene import', () => {}, store);
await nextTick();
const status = $('dialog [role=status]');
assert.equal(status.textContent, '');
const writes = [];
const text = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
Object.defineProperty(status, 'textContent', {
configurable: true,
get: () => text.get.call(status),
set(value) {
writes.push(value);
text.set.call(status, value);
},
});
dialog.setStatus('Ready to import');
await nextTick();
dialog.setStatus('Ready to import');
await nextTick();
assert.deepEqual(writes, ['Ready to import', 'Ready to import']);
});
test('Cancel and Escape close through the owner; dispose removes the dialog', async (t) => {
const { store } = fixture(t);
let closes = 0;
const dialog = createSceneDialog(
'Share selected scene',
() => closes++,
store,
);
await nextTick();
$('dialog div:last-of-type button').click();
const cancel = new Event('cancel', { cancelable: true });
$('dialog').dispatchEvent(cancel);
assert.equal(cancel.defaultPrevented, true);
assert.equal(closes, 2);
dialog.dispose();
await nextTick();
assert.equal($('dialog'), null);
assert.equal(store.controls, null);
});
test('a replaced dialog opens fresh and its late dispose leaves the new one', async (t) => {
const { store } = fixture(t);
opened.length = 0;
const first = createSceneDialog('Review scene import', () => {}, store);
await nextTick();
const firstRoot = $('dialog');
const second = createSceneDialog('Edit scene details', () => {}, store);
first.dispose();
first.text('stale');
await nextTick();
assert.equal($$('dialog').length, 1);
assert.equal($('dialog') === firstRoot, false);
assert.equal($('dialog').getAttribute('aria-label'), 'Edit scene details');
assert.deepEqual(opened, ['Review scene import', 'Edit scene details']);
assert.equal(
$$('dialog p').some((p) => p.textContent === 'stale'),
false,
);
second.dispose();
});
test('a file field draws translated chooser text over a native input', async (t) => {
const { store } = fixture(t);
const dialog = createSceneDialog('Share selected scene', () => {}, store);
const files = dialog.input('Choose data-pack files', '', {
type: 'file',
multiple: true,
});
dialog.input('Or choose a data-pack folder', '', {
type: 'file',
multiple: true,
directory: true,
});
await nextTick();
const [pick, folder] = $$('dialog input[type=file]');
assert.equal(pick.multiple, true);
assert.equal(pick.hasAttribute('webkitdirectory'), false);
assert.equal(folder.hasAttribute('webkitdirectory'), true);
assert.equal(pick.style.opacity, '0');
const chosen = () =>
pick.parentElement.querySelector('[aria-hidden] span:last-child')
.textContent;
assert.equal(chosen(), 'No file chosen');
const one = new File(['x'], 'pack.json');
Object.defineProperty(pick, 'files', { configurable: true, value: [one] });
pick.dispatchEvent(new Event('change', { bubbles: true }));
await nextTick();
assert.equal(chosen(), 'pack.json');
assert.equal(files.files[0] === one, true);
Object.defineProperty(pick, 'files', {
configurable: true,
value: [one, new File(['y'], 'b.json')],
});
pick.dispatchEvent(new Event('change', { bubbles: true }));
await nextTick();
assert.equal(chosen(), '2 files selected');
assert.equal(files.files.length, 2);
const chooser = pick.parentElement.querySelector('[aria-hidden] span');
pick.dispatchEvent(new FocusEvent('focus'));
await nextTick();
assert.notEqual(chooser.style.outline, '');
pick.dispatchEvent(new FocusEvent('blur'));
await nextTick();
assert.equal(chooser.style.outline, '');
dialog.dispose();
});