* feat(annotations): add image annotation schema with restore validation
Install the image annotation group beside image_favorites and saved_views:
media_annotation_metadata (one row, schema version 1), media_annotation_files
(one private locator per physical file), media_annotation_bytes (one record
per observed SHA-256 with the verified size and mtime_ns) and media_image_tags
(the ordered tag set of a byte record, joined into the global tags registry).
The group migrates in a SAVEPOINT that validates before and after creating
it, with no backfill from prompt or pair tags and no filesystem probe.
annotation_schema_error reads only: it compares the catalog objects after
whitespace compaction, the columns, foreign keys and indexes, and the
persisted values (UUIDs, hex digests, bounds, the 4096-character locator cap
measured past embedded NULs, exactly one metadata row). verify_database_file
checks it with allow_missing=not require_library_state, so old backups
restore and a migrated restore stage must carry it.
Tests pin the exact DDL, a fresh install and idempotent reopen, a current
database without the group gaining it with legacy rows untouched, every
partial or malformed shape rejected without repair, install failure rolling
back and retrying, a WAL backup/restore round trip, staged-failure
immutability of source, live and safety files with every handle closed, and
the require_library_state gate.
* feat(annotations): persist per-image tag sets in the shared vocabulary
Add database/annotations.py (AnnotationOperations, mixed into
PromptDatabase): read_image_tags, replace_image_tags and add_image_tags
run in one _pair_transaction fenced by the storage epoch, own the byte
record by exact locator and digest, always supply UUID ids, rewrite the
ordered junction and bump tag_revision plus image_index_revision.
Revision mismatches raise AnnotationCommandError("tag-conflict") with
the current set; the identical set at the current or previous revision
is replayed without writes; a union over 100 names is "tag-limit".
normalize_image_tags validates each name with validate_vocabulary_tag_name
and folds case-insensitive repeats keeping the first spelling.
refresh_image_fingerprint, current_image_tags (chunked, fingerprint
matched, newest verification wins) and iter_image_tag_records serve the
S1 cheap reads.
Vocabulary participation: _move_tag_links also carries media_image_tags,
legacy merge_tags moves image-only sources, tag rows gain image_count
(files with any byte record carrying the tag), impact gains images and
rename/delete receipts gain images_changed. vocabulary-state.mjs accepts
the optional counts and names images in the delete text.
Browser fixtures clear the three annotation data tables on reset (never
the metadata row); TESTING.md documents the schema beside favorites.
* feat(annotations): add bounded private media capture store
Add utils/media_capture.py: CaptureStore copies one rooted media file
into an owner-only prompt-manager-captures-* temp directory, streaming
it in 1 MiB chunks while computing the whole-file SHA-256, and compares
the opened descriptor with the path (dev, ino where meaningful, size,
mtime_ns, ctime_ns) before and after the copy; any disagreement raises
CaptureChanged and removes the copy. Symlinked files or directories,
root escapes, non-media names and directories raise CaptureDenied; the
locator is normcase(join(realpath(root), relative)), the listing's
identity, so a symlinked root matches it.
Limits are the plan's: 256 MiB per file on stat and stream
(CaptureTooLarge), 8 completed captures, 512 MiB reserved and 2
capture/verify workers with nonblocking admission (CaptureCapacity),
900 s idle and 1800 s hard lifetimes on an injected clock. acquire and
release_use keep an in-use capture past expiry, release and
invalidate_all until its last use ends; close removes only owned files
and the owned directory. A header-only probe of the copy marks Generate
unsupported above 4096 px per side or 16,777,216 px, for unreadable
headers and for video; verify re-hashes the current file without a
copy. Every handle closes before unlink.
tests/test_media_capture.py covers exact bytes and owner-only modes,
alias and escape denial, the symlinked-root locator, change detection
during the copy (size, same-size rewrite with restored mtime through
the POSIX change time, inode replacement), both size caps, count, byte
and worker admission, idle and hard expiry with in-use retention,
cleanup scope, handle release at unlink, dimension and video
readiness, and the allowlist equality with the inspection service.
TESTING.md documents the store and the Windows change-time limit.
* test(annotations): wait on the POSIX change time only where it exists
The same-size rewrite case waits for the filesystem clock to pass the
source's ctime so the detection is deterministic on Linux's coarse
timestamps; Windows reports the creation time as st_ctime, where the
probe could never advance, so the wait runs only on POSIX.
* fix(annotations): compare the change time only between fstat reads
Python 3.12+ on Windows reports the change time from stat but the
creation time from fstat, so a freshly written file whose creation and
write straddle a clock tick failed the pre-read identity check with
CaptureChanged. The path-versus-descriptor checks now compare size,
mtime_ns and dev/ino; ctime_ns is compared between the fstat before
and after the copy, where it still detects a same-size rewrite with a
restored mtime on POSIX.
The symlinked-root test asserted the locator against the unresolved
temp path, which macOS (/var -> /private/var) and Windows (short names)
resolve differently; it now compares against the real path.
* test(browser): give the inspector evidence matrix the concept comparisons' budget
The touch run of 'full inspector attaches matched theme and mobile
evidence with reachable content' captures 32 screenshots across four
viewports and took 23.5 s, then 31.6 s, in the sharded coverage job,
hitting the 30 s default. Like the concept comparison specs it now sets
an explicit 120 s timeout; the CI-like pinned run (4 CPUs, 3 workers,
repeat-each 3) passes 198/198 with the matrix at 18-20 s on touch.
* fix(annotations): compare capture identity like with like and keep paths out of errors
Native Windows CI (Python 3.12/3.13) rejected untouched files with
CaptureChanged because the path stat and the descriptor fstat disagree
there (change time versus creation time, possibly dev/ino encodings).
The descriptor is now checked against the path only on the API-stable
facts (size, mtime_ns); the full identity (size, mtime_ns, ctime_ns,
dev/ino where meaningful) is compared between the fstat before and
after the copy and between the path stats before and after. A test
injects shifted fstat results to simulate the Windows discrepancy, and
a fresh file is captured twice with no platform guard.
Error messages carry strerror only, never the path (the path goes to
the debug log); a test asserts no CaptureError names the root. Files
are unlinked outside the store lock; close() during an in-flight
capture lets that worker remove its temp file and then the directory.
get() documents that readers of capture.path must acquire/release_use.
The test fixture resolves its temp root (macOS /private/var, Windows
8.3 names). TESTING.md states the real identity contract.
* fix(annotations): move image revisions when a fingerprint or the vocabulary changes
A replayed save that observes a new verified (size, mtime_ns) now bumps
image_index_revision, like refresh_image_fingerprint, so caches keyed
on it see the record become current again; an identical replay still
moves nothing.
Vocabulary rename, merge and delete (native and legacy paths) bump the
tag_revision of every image set carrying the source tag, in the same
transaction and before the links move or cascade, plus one
image_index_revision bump when any set was affected. A Tag dialog
opened before the change therefore gets tag-conflict with the renamed
set as current instead of recreating the old name.
* test(annotations): accept change detection wherever fstat exposes a change time
Native Windows CI on Python 3.13 detected the same-size, mtime-restored
rewrite through the descriptor's change time (3.10-3.12 report the
creation time there and complete the torn copy). The test asserted the
platform, not the signal: it now accepts CaptureChanged plus temp
removal wherever it happens, requires detection on POSIX and on
Windows 3.13+, and otherwise requires the digest to describe the
copied bytes. A new test injects a drifting fstat change time so the
detection path runs on every platform; TESTING.md records the limit as
"Windows before Python 3.13".
* test(annotations): assert the rewrite outcome, not the platform
Native Windows CI detected the same-size, mtime-restored rewrite on
3.12 and missed it on 3.13 in the same run: the path stat's change time
(Python 3.12+ fast path) only moves when the clock ticks between the
pre-stat and the rewrite, and the tick wait was skipped on Windows. The
wait now runs everywhere with fresh probe names (Windows tunnels the
creation time of a reused name), and the test asserts the outcome: a
detected change leaves no temp file; an undetected one is accepted on
Windows only and must produce a digest of the copied bytes; POSIX must
detect it. The injected-fstat test keeps the detection path covered on
every platform.
* fix(images): keep the Filters label node across a listing reload
Every listing reload reports the active refinement count, and
setActiveFilters rewrote the button's textContent even when the label
was unchanged, replacing its text node. WebKit drops a click whose
mousedown hit a node that was detached before mouseup, so a Safari
user who types a search and clicks Filters inside the 250 ms debounce
lost the click, and the webkit page workflows timed out in the
source-change recovery tests (1 in ~30 runs of the pinned image; an
instrumented probe showed pointerdown/mousedown, a DOM mutation,
pointerup/mouseup and no click event).
The label is rewritten only when it changes. The toolbar unit test
counts textContent writes (1 for an unchanged count before, 0 after),
and a workspace-images test pins the text node identity across a
search reload: 0/6 before the fix on chromium and webkit, 6/6 after,
with the recovery and Refine tests 36/36 in the pinned CI-like run.
789 lines
40 KiB
JavaScript
789 lines
40 KiB
JavaScript
// The shared concept toolbar and dialogs (#249 C1): concept app.js toolbar() a347–360, "Refine this view"
|
||
// a1348–1364, "Import & inspect" a1483–1491 (D15) and the "Copy prompt" fallback a1223–1241.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const toolbarModule = import('../../web/js/workspace/toolbar.mjs');
|
||
const dialogsModule = import('../../web/js/workspace/workspace-dialogs.mjs');
|
||
|
||
function optionsCollection(list) {
|
||
const collection = { length: list.length, item: index => list[index] ?? null, [Symbol.iterator]: () => list[Symbol.iterator]() };
|
||
list.forEach((option, index) => { collection[index] = option; });
|
||
return Object.freeze(collection);
|
||
}
|
||
|
||
/** A minimal DOM: enough element behavior for markup, events, focus and dialogs. */
|
||
function fakeDOM() {
|
||
const previous = { document: global.document };
|
||
let active = null;
|
||
class Element extends EventTarget {
|
||
constructor(tag) {
|
||
super();
|
||
this.tagName = tag.toUpperCase();
|
||
this.children = [];
|
||
this.attributes = {};
|
||
this.parentNode = null;
|
||
this.className = '';
|
||
this.id = '';
|
||
this.value = '';
|
||
this.disabled = false;
|
||
this.open = false;
|
||
this.selected = false;
|
||
this.selectionStart = 0;
|
||
this.selectionEnd = 0;
|
||
this._text = '';
|
||
}
|
||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||
set textContent(text) { this.children = []; this._text = String(text); }
|
||
get isConnected() { return this === body || Boolean(this.parentNode?.isConnected); }
|
||
// Like a browser's HTMLOptionsCollection: indexed and iterable, but not an Array (no .some/.map).
|
||
get options() { return optionsCollection(this.children.filter(child => child.tagName === 'OPTION')); }
|
||
append(...nodes) {
|
||
for (const node of nodes) {
|
||
node.parentNode?.children.splice(node.parentNode.children.indexOf(node), 1);
|
||
node.parentNode = this;
|
||
this.children.push(node);
|
||
}
|
||
}
|
||
replaceChildren(...nodes) {
|
||
for (const child of this.children) child.parentNode = null;
|
||
this.children = [];
|
||
this._text = '';
|
||
this.append(...nodes);
|
||
}
|
||
remove() {
|
||
if (!this.parentNode) return;
|
||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
||
this.parentNode = null;
|
||
}
|
||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||
removeAttribute(name) { delete this.attributes[name]; }
|
||
focus() { active = this; }
|
||
select() { this.selectionStart = 0; this.selectionEnd = this.value.length; }
|
||
showModal() { this.open = true; }
|
||
close() {
|
||
if (!this.open) return;
|
||
this.open = false;
|
||
// Browsers queue the close event as a later task.
|
||
setTimeout(() => this.dispatchEvent(new Event('close')), 0);
|
||
}
|
||
click() { this.dispatchEvent(new Event('click', { cancelable: true })); }
|
||
}
|
||
const body = new Element('body');
|
||
global.document = {
|
||
body,
|
||
get activeElement() { return active ?? body; },
|
||
createElement: tag => new Element(tag),
|
||
createElementNS: (namespace, tag) => new Element(tag),
|
||
};
|
||
const all = (root, predicate, found = []) => {
|
||
if (predicate(root)) found.push(root);
|
||
for (const child of root.children) all(child, predicate, found);
|
||
return found;
|
||
};
|
||
return {
|
||
body, Element,
|
||
all: predicate => all(body, predicate),
|
||
find: (root, predicate) => all(root, predicate)[0],
|
||
restore() { Object.assign(global, previous); },
|
||
};
|
||
}
|
||
|
||
const byId = (dom, id) => dom.all(node => node.id === id)[0];
|
||
const tags = node => node.children.map(child => child.tagName.toLowerCase());
|
||
const optionPairs = select => [...select.options].map(option => [option.value, option.textContent]);
|
||
const modal = dom => byId(dom, 'modal');
|
||
const tick = () => new Promise(resolve => setTimeout(resolve, 5));
|
||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||
const button = (root, text) => {
|
||
const found = [];
|
||
const walk = node => {
|
||
if (node.tagName === 'BUTTON' && node.textContent === text) found.push(node);
|
||
node.children.forEach(walk);
|
||
};
|
||
walk(root);
|
||
return found[0];
|
||
};
|
||
|
||
test('toolbar emits concept markup and order', async () => {
|
||
const { mountToolbar } = await toolbarModule;
|
||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
const container = new dom.Element('div');
|
||
dom.body.append(container);
|
||
const changes = [];
|
||
const filters = [];
|
||
const saves = [];
|
||
const toolbar = mountToolbar(container, {
|
||
scope, categories: ['Portraits', 'Landscapes'],
|
||
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
|
||
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
|
||
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
|
||
onSaveView: trigger => saves.push(trigger),
|
||
});
|
||
const root = container.children[0];
|
||
assert.equal(toolbar.element, root);
|
||
assert.equal(root.tagName, 'SEARCH');
|
||
assert.equal(root.className, 'toolbar');
|
||
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button', 'button']);
|
||
|
||
const [label, category, sort, saveButton, filtersButton] = root.children;
|
||
assert.equal(label.className, 'search-field');
|
||
assert.equal(label.htmlFor, 'search');
|
||
assert.deepEqual(tags(label), ['svg', 'input']);
|
||
assert.equal(label.children[0].getAttribute('class'), 'icon');
|
||
assert.equal(label.children[0].getAttribute('aria-hidden'), 'true');
|
||
const input = label.children[1];
|
||
assert.equal(input.id, 'search');
|
||
assert.equal(input.type, 'search');
|
||
assert.equal(input.placeholder, 'Search prompts, tags, filenames…');
|
||
// The listing API rejects search text over 500 characters, as the legacy search capped it.
|
||
assert.equal(input.maxLength, 500);
|
||
assert.equal(input.getAttribute('aria-label'), 'Search workspace');
|
||
assert.equal(input.value, 'castle');
|
||
|
||
assert.equal(category.id, 'category-filter');
|
||
assert.equal(category.getAttribute('aria-label'), 'Filter by category');
|
||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Landscapes', 'Landscapes']]);
|
||
assert.equal(category.value, 'Landscapes');
|
||
assert.equal(sort.id, 'sort');
|
||
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
|
||
assert.equal(sort.value, 'name');
|
||
|
||
// The concept's Save view, between sort and Filters (a359), reports its own button as the trigger.
|
||
assert.equal(button(root, 'Save view'), saveButton);
|
||
assert.equal(saveButton.type, 'button');
|
||
saveButton.click();
|
||
assert.deepEqual(saves, [saveButton]);
|
||
assert.deepEqual(filters, []);
|
||
assert.equal(filtersButton.textContent, 'Filters');
|
||
assert.equal(filtersButton.type, 'button');
|
||
filtersButton.click();
|
||
assert.deepEqual(filters, [filtersButton]);
|
||
|
||
category.value = '';
|
||
category.dispatchEvent(new Event('change'));
|
||
sort.value = 'rating';
|
||
sort.dispatchEvent(new Event('change'));
|
||
assert.deepEqual(changes, [
|
||
{ q: 'castle', category: '', sort: 'name' },
|
||
{ q: 'castle', category: '', sort: 'rating' },
|
||
]);
|
||
|
||
// Categories refresh without dropping a still-listed selection.
|
||
toolbar.setCategories(['Archive']);
|
||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Archive', 'Archive']]);
|
||
toolbar.setValue({ q: 'moon', category: 'Archive', sort: 'newest' });
|
||
assert.deepEqual([input.value, category.value, sort.value], ['moon', 'Archive', 'newest']);
|
||
assert.deepEqual(toolbar.value(), { q: 'moon', category: 'Archive', sort: 'newest' });
|
||
// A still-listed selection survives a category refresh.
|
||
toolbar.setCategories(['Portraits', 'Archive']);
|
||
assert.equal(category.value, 'Archive');
|
||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Archive', 'Archive']]);
|
||
// A selection that is no longer listed drops to All categories.
|
||
toolbar.setCategories(['Portraits']);
|
||
assert.equal(category.value, '');
|
||
// An unknown sort falls back to the first option instead of an empty select.
|
||
toolbar.setValue({ sort: 'bogus' });
|
||
assert.equal(sort.value, 'newest');
|
||
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
|
||
onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||
// Every toolbar page offers Save view, so the callback is required.
|
||
assert.throws(() => mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||
onChange() {}, onFilters() {} }), TypeError);
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('toolbar search uses a debounced onChange that stops with its scope', async () => {
|
||
const { mountToolbar, SEARCH_DELAY } = await toolbarModule;
|
||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
const container = new dom.Element('div');
|
||
const changes = [];
|
||
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {}, onSaveView() {} });
|
||
const input = container.children[0].children[0].children[1];
|
||
assert.equal(input.id, 'search');
|
||
for (const text of ['c', 'ca', 'cat']) {
|
||
input.value = text;
|
||
input.dispatchEvent(new Event('input'));
|
||
}
|
||
assert.deepEqual(changes, []);
|
||
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
|
||
assert.deepEqual(changes, [{ q: 'cat', category: '', sort: 'newest' }]);
|
||
input.value = 'dog';
|
||
input.dispatchEvent(new Event('input'));
|
||
scope.dispose();
|
||
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
|
||
assert.equal(changes.length, 1);
|
||
assert.equal(container.children.length, 0);
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('sort lists concept options first', async () => {
|
||
const { mountToolbar, orderSorts } = await toolbarModule;
|
||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||
// D14: the concept's three, in the concept's order, then the page's appended options in their own order.
|
||
const given = [['newest', 'Recently added'], ['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A'],
|
||
['rating', 'Highest rated'], ['name', 'Name A–Z']];
|
||
assert.deepEqual(orderSorts(given), [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
|
||
['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A']]);
|
||
// A page without a concept option keeps the ones it has, concept first.
|
||
assert.deepEqual(orderSorts([['oldest', 'Oldest first'], ['newest', 'Recently added']]),
|
||
[['newest', 'Recently added'], ['oldest', 'Oldest first']]);
|
||
// Matching is by the concept's exact labels (en dash): a variant label is an appended option.
|
||
assert.deepEqual(orderSorts([['name', 'Name A-Z'], ['newest', 'Recently added']]),
|
||
[['newest', 'Recently added'], ['name', 'Name A-Z']]);
|
||
assert.throws(() => orderSorts([]), TypeError);
|
||
assert.throws(() => orderSorts([['newest']]), TypeError);
|
||
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
const container = new dom.Element('div');
|
||
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
|
||
onChange() {}, onFilters() {}, onSaveView() {} });
|
||
const sort = container.children[0].children[2];
|
||
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
|
||
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
|
||
assert.equal(sort.value, 'oldest');
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
const REFINE_FIELDS = [
|
||
{ key: 'source', options: [['all', 'All sources'], ['0', 'output'], ['1', 'archive']] },
|
||
{ key: 'folder', options: [['', 'All output folders'], ['portraits', 'portraits']] },
|
||
{ key: 'media', options: [['all', 'All media'], ['image', 'Images'], ['video', 'Videos']] },
|
||
{ key: 'tags' },
|
||
];
|
||
|
||
test('refine dialog round-trips values and clear resets them', async () => {
|
||
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const trigger = new dom.Element('button');
|
||
dom.body.append(trigger);
|
||
const applied = [];
|
||
const cleared = [];
|
||
const values = { source: '1', folder: 'portraits', media: 'video', tags: 'cinematic' };
|
||
openRefineDialog({ scope, fields: REFINE_FIELDS, values, trigger,
|
||
onApply: value => applied.push(value), onClear: value => cleared.push(value) });
|
||
const dialog = modal(dom);
|
||
assert.equal(dialog.open, true);
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'Refine this view');
|
||
const body = byId(dom, 'modal-body');
|
||
const fields = body.children.filter(child => child.className === 'field');
|
||
assert.deepEqual(fields.map(field => field.children[0].textContent), ['Source', 'Folder', 'Media', 'Tags']);
|
||
assert.deepEqual(fields.map(field => field.children[0].htmlFor), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
|
||
assert.deepEqual(fields.map(field => field.children[1].id), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
|
||
const [source, folder, media, tagsInput] = fields.map(field => field.children[1]);
|
||
assert.deepEqual(optionPairs(source), REFINE_FIELDS[0].options);
|
||
assert.deepEqual([source.value, folder.value, media.value, tagsInput.value], ['1', 'portraits', 'video', 'cinematic']);
|
||
assert.equal(tagsInput.placeholder, 'e.g. cinematic');
|
||
// D14: no mock-status note, because every field is a real filter.
|
||
assert.deepEqual(tags(body), ['div', 'div', 'div', 'div', 'div']);
|
||
assert.equal(body.children.some(child => child.tagName === 'P'), false);
|
||
const actions = body.children[4];
|
||
assert.equal(actions.className, 'dialog-actions');
|
||
assert.deepEqual(actions.children.map(child => [child.textContent, child.className]),
|
||
[['Clear filters', ''], ['Apply filters', 'ca-primary']]);
|
||
|
||
// Apply returns the values as edited and leaves the caller's object alone.
|
||
media.value = 'image';
|
||
tagsInput.value = ' moody ';
|
||
button(body, 'Apply filters').click();
|
||
assert.deepEqual(applied, [{ source: '1', folder: 'portraits', media: 'image', tags: 'moody' }]);
|
||
assert.equal(values.media, 'video');
|
||
assert.equal(dialog.open, false);
|
||
assert.equal(trigger.isConnected && document.activeElement, trigger);
|
||
|
||
// Reopening with the applied values shows them again (a round trip).
|
||
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value), onClear() {} });
|
||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||
assert.deepEqual(applied[1], applied[0]);
|
||
|
||
// Clear resets every field to its first option (Tags to empty) and reports that.
|
||
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value),
|
||
onClear: value => cleared.push(value) });
|
||
const reopened = byId(dom, 'modal-body');
|
||
button(reopened, 'Clear filters').click();
|
||
// The concept's Clear also clears the toolbar category; the page applies `category`.
|
||
assert.deepEqual(cleared, [{ source: 'all', folder: '', media: 'all', tags: '', category: '' }]);
|
||
assert.equal(applied.length, 2);
|
||
assert.equal(modal(dom).open, false);
|
||
|
||
// A page offers only its own fields; the modal is the one shared shell.
|
||
openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, onApply: value => applied.push(value), onClear() {} });
|
||
const only = byId(dom, 'modal-body').children.filter(child => child.className === 'field');
|
||
assert.deepEqual(only.map(field => field.children[0].textContent), ['Tags']);
|
||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||
assert.deepEqual(applied[2], { tags: '' });
|
||
assert.equal(dom.all(node => node.id === 'modal').length, 1);
|
||
assert.throws(() => openRefineDialog({ scope, fields: [{ key: 'evidence' }], values: {}, onApply() {}, onClear() {} }), TypeError);
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('Refine lists options discovered after it opened and keeps the wanted values', async () => {
|
||
const { openRefineDialog, openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
let bySource = {};
|
||
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
|
||
const fields = [{ key: 'source', options: [['all', 'All sources']] }, { key: 'folder', options: optionsFor('all'), optionsFor },
|
||
REFINE_FIELDS[2]];
|
||
const applied = [];
|
||
const refine = openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'video' },
|
||
onApply: value => applied.push(value), onClear() {} });
|
||
const [source, folder, media] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
|
||
.map(field => field.children[1]);
|
||
// Opened before discovery: only the cleared values.
|
||
assert.deepEqual(optionPairs(source), [['all', 'All sources']]);
|
||
assert.equal(source.value, 'all');
|
||
assert.equal(folder.value, '');
|
||
bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
|
||
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
|
||
// The values it opened with, now offered, and the chosen Source's folders.
|
||
assert.deepEqual(optionPairs(source).map(([value]) => value), ['all', '0', '1']);
|
||
assert.equal(source.value, '1');
|
||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
|
||
assert.equal(folder.value, 'temp-only');
|
||
// A choice made since wins over the opening value; tags and unknown keys are ignored.
|
||
source.value = '0';
|
||
source.dispatchEvent(new Event('change'));
|
||
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
|
||
refine.setOptions('tags', [['x', 'x']]);
|
||
assert.equal(source.value, '0');
|
||
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits']);
|
||
assert.equal(media.value, 'video');
|
||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||
assert.deepEqual(applied, [{ source: '0', folder: '', media: 'video' }]);
|
||
// Closed: a late discovery leaves the shell alone.
|
||
refine.setOptions('source', [['all', 'All sources']]);
|
||
assert.equal(source.options.length, 3);
|
||
// Replaced by another dialog: the same.
|
||
const again = openRefineDialog({ scope, fields, values: {}, onApply() {}, onClear() {} });
|
||
openImportDialog({ scope, onInspect() {}, onRescan() {} });
|
||
again.setOptions('source', [['all', 'All sources'], ['9', 'late']]);
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('the Folder list follows the chosen Source', async () => {
|
||
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
|
||
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
|
||
const fields = [REFINE_FIELDS[0], { key: 'folder', options: optionsFor('all'), optionsFor }, REFINE_FIELDS[2]];
|
||
const applied = [];
|
||
openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'all' },
|
||
onApply: value => applied.push(value), onClear() {} });
|
||
const [source, folder] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
|
||
.map(field => field.children[1]);
|
||
// Opened on a chosen Source: only its folders.
|
||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
|
||
assert.equal(folder.value, 'temp-only');
|
||
// Another Source drops a folder it does not have.
|
||
source.value = '0';
|
||
source.dispatchEvent(new Event('change'));
|
||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['portraits', 'portraits']]);
|
||
assert.equal(folder.value, '');
|
||
folder.value = 'portraits';
|
||
// A folder the next Source also has stays chosen.
|
||
source.value = 'all';
|
||
source.dispatchEvent(new Event('change'));
|
||
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits', 'temp-only']);
|
||
assert.equal(folder.value, 'portraits');
|
||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||
assert.deepEqual(applied, [{ source: 'all', folder: 'portraits', media: 'all' }]);
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('a running rescan marks Rescan busy and ignores a second press', async () => {
|
||
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const calls = [];
|
||
const handle = openImportDialog({ scope, rescanBusy: true, onInspect: () => calls.push('inspect'),
|
||
onRescan: () => calls.push('rescan') });
|
||
const body = byId(dom, 'modal-body');
|
||
const busy = button(body, 'Rescanning…');
|
||
assert.ok(busy, 'the busy Rescan says it is running');
|
||
assert.equal(busy.getAttribute('aria-disabled'), 'true');
|
||
busy.click();
|
||
assert.deepEqual(calls, []);
|
||
assert.equal(modal(dom).open, true, 'an ignored press keeps the dialog open');
|
||
handle.setRescanBusy(false);
|
||
assert.equal(busy.textContent, 'Rescan');
|
||
assert.equal(busy.getAttribute('aria-disabled'), null);
|
||
busy.click();
|
||
assert.deepEqual(calls, ['rescan']);
|
||
assert.equal(modal(dom).open, false);
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('import dialog lists only real operations', async () => {
|
||
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const calls = [];
|
||
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||
const body = byId(dom, 'modal-body');
|
||
assert.deepEqual(tags(body), ['p', 'div']);
|
||
assert.equal(body.children[0].className, 'small');
|
||
const list = body.children[1];
|
||
assert.equal(list.className, 'operation-list');
|
||
const rows = list.children.map(row => {
|
||
assert.equal(row.className, 'operation');
|
||
const [text, actions] = row.children;
|
||
assert.deepEqual(tags(text), ['h3', 'p']);
|
||
assert.equal(actions.className, 'row-actions');
|
||
return [text.children[0].textContent, text.children[1].textContent, actions.children[0].textContent];
|
||
});
|
||
// D15: no "Import portable recipes" until import exists; Rescan finds added or removed files.
|
||
assert.deepEqual(rows, [
|
||
['Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector'],
|
||
['Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan'],
|
||
]);
|
||
assert.doesNotMatch(body.textContent, /recipe|portable|sample/i);
|
||
|
||
button(body, 'Rescan').click();
|
||
assert.deepEqual(calls, ['rescan']);
|
||
assert.equal(modal(dom).open, false);
|
||
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
|
||
button(byId(dom, 'modal-body'), 'Open inspector').click();
|
||
assert.deepEqual(calls, ['rescan', 'inspect']);
|
||
assert.equal(modal(dom).open, false);
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('copy fallback selects the text', async () => {
|
||
const { openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const text = '<b>a castle</b>, "moonlight" & fog';
|
||
openCopyFallback(text, { scope });
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||
const body = byId(dom, 'modal-body');
|
||
assert.deepEqual(tags(body), ['div', 'p']);
|
||
const [field, note] = body.children;
|
||
assert.equal(field.className, 'field');
|
||
const [label, area] = field.children;
|
||
assert.equal(label.textContent, 'Select and copy this text');
|
||
assert.equal(label.htmlFor, 'copy-text');
|
||
assert.equal(area.tagName, 'TEXTAREA');
|
||
assert.equal(area.id, 'copy-text');
|
||
assert.equal(area.readOnly, true);
|
||
assert.equal(area.value, text);
|
||
assert.deepEqual([area.selectionStart, area.selectionEnd], [0, text.length]);
|
||
assert.equal(document.activeElement, area);
|
||
assert.equal(note.className, 'small');
|
||
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
|
||
assert.throws(() => openCopyFallback(42, { scope }), TypeError);
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
// The concept's Save view dialog (a1330–1346 via simpleInput a1138): an empty name falls back to "Saved view".
|
||
test('an input dialog with a fallback applies it for an empty value', async () => {
|
||
const { openInputDialog, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const applied = [];
|
||
const open = value => openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save',
|
||
value, maxLength: 100, fallback: 'Saved view', onApply: name => applied.push(name) });
|
||
open('My workspace view');
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'Save this view');
|
||
const body = byId(dom, 'modal-body');
|
||
const [field, actions] = body.children;
|
||
const [label, input] = field.children;
|
||
assert.equal(label.textContent, 'View name');
|
||
assert.equal(input.id, 'action-input');
|
||
assert.equal(input.value, 'My workspace view');
|
||
assert.equal(input.maxLength, 100);
|
||
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Save']);
|
||
input.value = ' ';
|
||
actions.children[1].click();
|
||
assert.deepEqual(applied, ['Saved view']);
|
||
open('');
|
||
byId(dom, 'action-input').value = ' Castles ';
|
||
byId(dom, 'modal-body').children[1].children[1].click();
|
||
assert.deepEqual(applied, ['Saved view', 'Castles']);
|
||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', fallback: '', onApply() {} }), TypeError);
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('dialogs restore focus to the trigger on Escape', async () => {
|
||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const opens = [
|
||
trigger => openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, trigger, onApply() {}, onClear() {} }),
|
||
trigger => openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} }),
|
||
trigger => openCopyFallback('text', { trigger, scope }),
|
||
];
|
||
for (const open of opens) {
|
||
const trigger = new dom.Element('button');
|
||
dom.body.append(trigger);
|
||
open(trigger);
|
||
const dialog = modal(dom);
|
||
assert.equal(dialog.open, true);
|
||
assert.equal(dialog.getAttribute('aria-labelledby'), 'modal-title');
|
||
const header = dialog.children[0];
|
||
assert.equal(header.className, 'dialog-header');
|
||
assert.deepEqual(tags(header), ['h2', 'button']);
|
||
const close = header.children[1];
|
||
assert.equal(close.id, 'modal-close');
|
||
assert.equal(close.textContent, '×');
|
||
assert.equal(close.getAttribute('aria-label'), 'Close dialog');
|
||
assert.notEqual(document.activeElement, trigger);
|
||
// Escape fires `cancel` on a modal dialog.
|
||
const escape = new Event('cancel', { cancelable: true });
|
||
dialog.dispatchEvent(escape);
|
||
assert.equal(dialog.open, false);
|
||
assert.equal(document.activeElement, trigger);
|
||
}
|
||
|
||
// × closes and restores focus too; a removed trigger leaves focus alone.
|
||
const trigger = new dom.Element('button');
|
||
dom.body.append(trigger);
|
||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||
byId(dom, 'modal-close').click();
|
||
assert.equal(modal(dom).open, false);
|
||
assert.equal(document.activeElement, trigger);
|
||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||
trigger.remove();
|
||
const close = byId(dom, 'modal-close');
|
||
close.click();
|
||
assert.notEqual(document.activeElement, trigger);
|
||
|
||
// Opening over an open dialog swaps its content and keeps the first trigger; dispose removes the shell.
|
||
const first = new dom.Element('button');
|
||
dom.body.append(first);
|
||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||
openCopyFallback('swap', { scope });
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||
assert.equal(document.activeElement, first);
|
||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||
closeWorkspaceDialog();
|
||
assert.equal(modal(dom), undefined);
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('an action may open the next dialog without the late close event closing it', async () => {
|
||
const { openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const first = new dom.Element('button');
|
||
const second = new dom.Element('button');
|
||
dom.body.append(first, second);
|
||
openImportDialog({ scope, trigger: first, onInspect() {},
|
||
onRescan: () => openCopyFallback('next', { scope, trigger: second }) });
|
||
button(byId(dom, 'modal-body'), 'Rescan').click();
|
||
await tick();
|
||
assert.equal(modal(dom).open, true);
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||
assert.equal(document.activeElement, byId(dom, 'copy-text'));
|
||
// Its own Escape still restores focus to its own trigger.
|
||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||
await tick();
|
||
assert.equal(document.activeElement, second);
|
||
// Replacing the shell ignores the old shell's late close event too.
|
||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||
closeWorkspaceDialog();
|
||
openImportDialog({ scope, trigger: second, onInspect() {}, onRescan() {} });
|
||
await tick();
|
||
assert.equal(modal(dom).open, true);
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('a content dialog shows page nodes and releases them once on close or replacement', async () => {
|
||
const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||
const dom = fakeDOM();
|
||
const scope = (await coreModule).createScope();
|
||
try {
|
||
const trigger = new dom.Element('button');
|
||
dom.body.append(trigger);
|
||
let closed = 0;
|
||
const grid = new dom.Element('div');
|
||
openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ });
|
||
assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive');
|
||
assert.deepEqual(byId(dom, 'modal-body').children, [grid]);
|
||
assert.equal(modal(dom).open, true);
|
||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||
assert.equal(closed, 1);
|
||
assert.equal(document.activeElement, trigger);
|
||
await tick();
|
||
assert.equal(closed, 1);
|
||
// Another dialog replacing the body releases it too, exactly once.
|
||
openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ });
|
||
openCopyFallback('swap', { scope });
|
||
assert.equal(closed, 2);
|
||
closeWorkspaceDialog();
|
||
assert.equal(closed, 2);
|
||
// Disposing the page scope closes it.
|
||
openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ });
|
||
scope.dispose();
|
||
assert.equal(closed, 3);
|
||
assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/);
|
||
const other = (await coreModule).createScope();
|
||
assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError);
|
||
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError);
|
||
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError);
|
||
other.dispose();
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('dialogs need the page scope and close with it', async () => {
|
||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
try {
|
||
assert.throws(() => openImportDialog({ onInspect() {}, onRescan() {} }), /page scope/);
|
||
assert.throws(() => openRefineDialog({ fields: [], onApply() {}, onClear() {} }), /page scope/);
|
||
assert.throws(() => openCopyFallback('text'), /page scope/);
|
||
const scope = createScope();
|
||
assert.throws(() => openImportDialog({ scope, onInspect() {} }), TypeError);
|
||
assert.throws(() => openRefineDialog({ scope, fields: [], onApply() {} }), TypeError);
|
||
assert.equal(modal(dom), undefined);
|
||
|
||
// Disposing the page scope closes and removes the shell, however often the page opened it.
|
||
const trigger = new dom.Element('button');
|
||
dom.body.append(trigger);
|
||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||
openCopyFallback('again', { scope });
|
||
scope.dispose();
|
||
assert.equal(modal(dom), undefined);
|
||
assert.equal(document.activeElement, trigger);
|
||
|
||
// An old page's scope never closes the next page's dialog.
|
||
const older = createScope();
|
||
const newer = createScope();
|
||
openImportDialog({ scope: older, onInspect() {}, onRescan() {} });
|
||
openCopyFallback('next page', { scope: newer });
|
||
older.dispose();
|
||
assert.equal(modal(dom).open, true);
|
||
newer.dispose();
|
||
assert.equal(modal(dom), undefined);
|
||
} finally {
|
||
closeWorkspaceDialog();
|
||
dom.restore();
|
||
}
|
||
});
|
||
|
||
test('every route change closes the shared workspace dialog', async () => {
|
||
const { readFile } = require('node:fs/promises');
|
||
const path = require('node:path');
|
||
const source = await readFile(path.join(__dirname, '../../web/js/workspace/index.mjs'), 'utf8');
|
||
assert.match(source, /import \{[^}]*\bcloseWorkspaceDialog\b[^}]*\} from '\.\/workspace-dialogs\.mjs';/);
|
||
const render = source.slice(source.indexOf('function render(route) {'));
|
||
const body = render.slice(0, render.indexOf('viewScope = createScope();'));
|
||
assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts');
|
||
});
|
||
|
||
// D44: the concept has no indicator, so a filter set in "Refine this view" (or by Browse) was invisible on the page.
|
||
test('the Filters button shows the active refinement count', async () => {
|
||
const { mountToolbar } = await toolbarModule;
|
||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||
const dom = fakeDOM();
|
||
const scope = createScope();
|
||
try {
|
||
const container = new dom.Element('div');
|
||
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||
activeFilters: 2, onChange() {}, onFilters() {}, onSaveView() {} });
|
||
const filtersButton = container.children[0].children[4];
|
||
// The visible text is the accessible name, so the count is spoken too.
|
||
assert.equal(filtersButton.textContent, 'Filters · 2');
|
||
toolbar.setActiveFilters(1);
|
||
assert.equal(filtersButton.textContent, 'Filters · 1');
|
||
toolbar.setActiveFilters(0);
|
||
assert.equal(filtersButton.textContent, 'Filters');
|
||
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
|
||
assert.equal(filtersButton.textContent, 'Filters');
|
||
// An unchanged count leaves the label's text node alone: every listing reload reports the count, and WebKit
|
||
// drops a click whose mousedown hit a text node that was replaced before mouseup.
|
||
let writes = 0;
|
||
const proto = Object.getPrototypeOf(filtersButton);
|
||
const descriptor = Object.getOwnPropertyDescriptor(proto, 'textContent');
|
||
Object.defineProperty(filtersButton, 'textContent', {
|
||
get() { return descriptor.get.call(this); },
|
||
set(text) { writes += 1; descriptor.set.call(this, text); },
|
||
configurable: true,
|
||
});
|
||
toolbar.setActiveFilters(0);
|
||
assert.equal(writes, 0);
|
||
toolbar.setActiveFilters(3);
|
||
assert.deepEqual([writes, filtersButton.textContent], [1, 'Filters · 3']);
|
||
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||
} finally {
|
||
scope.dispose();
|
||
dom.restore();
|
||
}
|
||
});
|