Every Images and Library-embedded gallery card shows the concept's Tag after Info. Tag opens the concept's Tag image dialog (image-tagging.mjs over the C1 controller) in the shared modal shell with the sticky tagging-modal footer: capture preview, manual tags, the four legacy engines with real defaults, explicit Generate, removable suggestion chips (D83), legacy model labels (D84), a Suggestions count (D91) and Retry where the controller allows it. The capture is released on close, route change and pagehide. The Tags page gains the concept's Auto-tag images for the image selected in Images (aria-disabled without one, D115) and real Images counts that follow every save. Retires D9, #245 D2 and #245 D3. The toast now uses the mock's copy verbatim ("tags" even for one), C1's D60/D61/D68 markers are renumbered to D83/D84/D91, and at extreme zoom a card's info row stacks (and wraps Info and Tag) when its row is narrower than the narrowest bounded card, list rows included (D31).
164 lines
8.8 KiB
JavaScript
164 lines
8.8 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const source = import('../../web/js/workspace/image-layout.mjs');
|
|
|
|
test('layouts preserve data order, remain within width and never overlap', async () => {
|
|
const {layoutImages} = await source;
|
|
const images=Array.from({length:200},(_,i)=>({width:i%2?1024:640,height:i%2?640:1024}));
|
|
for(const width of [280,760,1400]) for(const mode of ['grid','list','masonry']) {
|
|
const {positions,height}=layoutImages(images,width,mode);
|
|
assert.equal(positions.length,200);
|
|
for(const [i,a] of positions.entries()) {
|
|
assert.ok(a.x>=0 && a.y>=0 && a.width>0 && a.height>0);
|
|
assert.ok(a.x+a.width<=width+.01);
|
|
assert.ok(a.y+a.height<=height+.01);
|
|
for(const b of positions.slice(i+1)) assert.ok(a.x+a.width<=b.x+.01 || b.x+b.width<=a.x+.01 || a.y+a.height<=b.y+.01 || b.y+b.height<=a.y+.01);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{
|
|
const {layoutImages}=await source;
|
|
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],columns:1,height:0,maxHeight:0});
|
|
for(const mode of ['grid','list','masonry']) {
|
|
const result=layoutImages([{}, {width:0,height:null}],1,mode);
|
|
assert.ok(Number.isFinite(result.height));
|
|
}
|
|
});
|
|
|
|
test('virtual window retains visible rows and focused card while bounding rendered media',async()=>{
|
|
const {layoutImages,visibleImages}=await source;
|
|
const {positions}=layoutImages(Array(5000).fill({}),900,'masonry');
|
|
const indexes=visibleImages(positions,10000,900,0);
|
|
assert.ok(indexes.length<100);
|
|
assert.ok(indexes.includes(0));
|
|
assert.ok(indexes.some(i=>positions[i].y>=10000 && positions[i].y<10900));
|
|
assert.deepEqual(indexes,[...indexes].sort((a,b)=>a-b));
|
|
assert.equal(new Set(indexes).size,indexes.length);
|
|
assert.ok(visibleImages(positions,0,800).includes(0));
|
|
});
|
|
|
|
test('large virtual collections inspect a bounded range rather than every loaded position', async () => {
|
|
const {layoutImages,visibleImages}=await source;
|
|
for(const mode of ['grid','list','masonry']) {
|
|
const geometry=layoutImages(Array.from({length:50000},(_,index)=>({width:640,height:index%2?320:1024})),900,mode);
|
|
for(let index=1;index<geometry.positions.length;index++) assert.ok(geometry.positions[index].y>=geometry.positions[index-1].y);
|
|
let reads=0;
|
|
const positions=new Proxy(geometry.positions,{get(target,key,receiver){
|
|
if(typeof key==='string' && /^\d+$/.test(key)) reads++;
|
|
return Reflect.get(target,key,receiver);
|
|
}});
|
|
const top=geometry.positions[25000].y;
|
|
const indexes=visibleImages(positions,top,900,49999,geometry.maxHeight);
|
|
assert.ok(reads<500,`${mode} read ${reads} positions`);
|
|
const expected=geometry.positions.flatMap((position,index)=>(index===49999 ||
|
|
(position.y+position.height>=top-900 && position.y<=top+1800))?[index]:[]);
|
|
assert.deepEqual(indexes,expected);
|
|
}
|
|
});
|
|
|
|
test('binary-searched windows handle boundaries, empty collections and focus without duplicates',async()=>{
|
|
const {layoutImages,visibleImages}=await source;
|
|
assert.deepEqual(visibleImages([],0,800,0),[]);
|
|
const {positions}=layoutImages(Array(1000).fill({}),900,'grid');
|
|
for(const focused of [-1,0,999,1000]) {
|
|
for(const top of [0,10000,1000000]) {
|
|
const actual=visibleImages(positions,top,600,focused);
|
|
const expected=positions.flatMap((position,index)=>(index===focused ||
|
|
(position.y+position.height>=top-600 && position.y<=top+1200))?[index]:[]);
|
|
assert.deepEqual(actual,expected);
|
|
}
|
|
}
|
|
assert.deepEqual(visibleImages(positions,1000000,0),[]);
|
|
});
|
|
|
|
test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{
|
|
const {layoutImages,visibleImages}=await source;
|
|
const geometry=layoutImages([{}, {}, {}],10000,'grid',{columns:1});
|
|
assert.equal(geometry.maxHeight,9998/1.33+56);
|
|
assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]);
|
|
});
|
|
|
|
// The concept's mountMasonry reads the same two values (app.js 530-534); its breakpoints live in concept.css only.
|
|
function computedGrid(columns, gap) {
|
|
globalThis.getComputedStyle = node => {
|
|
assert.equal(node, grid);
|
|
return {gridTemplateColumns:columns, columnGap:gap};
|
|
};
|
|
const grid = {};
|
|
return grid;
|
|
}
|
|
|
|
test('layout uses columns and gap from computed style', async () => {
|
|
const {gridMetrics, layoutImages} = await source;
|
|
const original = globalThis.getComputedStyle;
|
|
try {
|
|
for (const [columns, gap, expected] of [
|
|
['329.333px 329.333px 329.333px', '13px', {columns:3, gap:13}],
|
|
['200px 200px 200px 200px 200px', '20px', {columns:5, gap:20}],
|
|
['990px', '13px', {columns:1, gap:13}],
|
|
['none', 'normal', {columns:1, gap:0}],
|
|
['', '', {columns:1, gap:0}],
|
|
]) assert.deepEqual(gridMetrics(computedGrid(columns, gap)), expected, `${columns} / ${gap}`);
|
|
} finally { globalThis.getComputedStyle = original; }
|
|
const {positions, columns} = layoutImages(Array(6).fill({}), 990, 'grid', {columns:3, gap:13});
|
|
assert.equal(columns, 3);
|
|
assert.equal(positions[1].x, positions[0].width + 13);
|
|
assert.equal(positions[3].x, 0);
|
|
assert.equal(positions[3].y, positions[0].height + 13);
|
|
// The concept media is `aspect-ratio: 1.33` inside a 1px card border, under the measured `.media-info`.
|
|
assert.equal(positions[0].height, (positions[0].width - 2) / 1.33 + 54 + 2);
|
|
const wide = layoutImages(Array(6).fill({}), 990, 'grid', {columns:5, gap:20, footerHeight:60});
|
|
assert.equal(wide.positions[0].width, (990 - 80) / 5);
|
|
assert.equal(wide.positions[0].height, (wide.positions[0].width - 2) / 1.33 + 62);
|
|
assert.equal(layoutImages([{}, {}], 990, 'list', {columns:1, gap:13}).positions[1].x, 0);
|
|
assert.equal(layoutImages([{}], 990, 'list', {columns:1, gap:13}).height, 97);
|
|
assert.equal(layoutImages([{}], 990, 'list', {columns:1, listHeight:100}).height, 100);
|
|
});
|
|
|
|
test('masonry uses real finite proportions and falls back for invalid dimensions', async () => {
|
|
const {layoutImages}=await source;
|
|
const images=[{width:100,height:1000},{width:1000,height:100},{width:Infinity,height:10},{width:10,height:NaN}];
|
|
const {positions}=layoutImages(images,990,'masonry',{columns:3});
|
|
assert.equal(positions[0].height,(positions[0].width-2)*10+56);
|
|
assert.equal(positions[1].height,(positions[1].width-2)/10+56);
|
|
assert.equal(positions[2].height,(positions[2].width-2)*.75+56);
|
|
for (const position of positions) assert.ok(Number.isFinite(position.height));
|
|
});
|
|
|
|
test('masonry falls back to the concept ratio when a finite size underflows the proportion', async () => {
|
|
const {layoutImages}=await source;
|
|
// Both sides are finite and positive, but their ratio underflows to 0, so the proportional height is infinite.
|
|
const {positions,height}=layoutImages([{width:Number.MIN_VALUE,height:1e300}],300,'masonry',{columns:1});
|
|
assert.equal(positions[0].height,(300-2)/1.33+56);
|
|
assert.ok(Number.isFinite(height));
|
|
});
|
|
|
|
test('a narrow gallery caps the concept columns at the smallest readable card', async () => {
|
|
const {layoutImages, MINIMUM_CARD_WIDTH} = await source;
|
|
// One shared minimum: the page marks `.narrow-cards` below the same width the layout caps columns at.
|
|
assert.equal(MINIMUM_CARD_WIDTH, 120);
|
|
// At 200 percent zoom the concept's 390px breakpoint still asks for two columns of a half-width grid (D3, D31).
|
|
for (const [width, asked, columns] of [
|
|
[990,3,3], [990,4,4], [428,3,3], [362,2,2], [190,3,1], [190,4,1], [167,2,1], [69.5,2,1], [0,2,1],
|
|
]) {
|
|
const geometry = layoutImages(Array(8).fill({}), width, 'grid', {columns:asked, gap:13});
|
|
assert.equal(geometry.columns, columns, `${width}/${asked}`);
|
|
assert.equal(geometry.positions[columns].x, 0);
|
|
assert.ok(geometry.positions[columns].y > 0);
|
|
}
|
|
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
|
|
});
|
|
|
|
test('the card info stacks below the narrowest bounded card, a list row beside its art too (D31)', async () => {
|
|
const {stacksCardInfo} = await source;
|
|
assert.equal(typeof stacksCardInfo, 'function', 'stacksCardInfo is not implemented');
|
|
assert.equal(stacksCardInfo(119, 'grid'), true);
|
|
assert.equal(stacksCardInfo(120, 'grid'), false);
|
|
assert.equal(stacksCardInfo(119, 'masonry'), true);
|
|
// A list row's info is what its art (160px, at most 45%) leaves: 195px at 200% zoom on a phone leaves 107px.
|
|
assert.equal(stacksCardInfo(195, 'list'), true);
|
|
assert.equal(stacksCardInfo(280, 'list'), false);
|
|
assert.equal(stacksCardInfo(358, 'list'), false);
|
|
});
|