apostrophe
Version:
The Apostrophe Content Management System.
1,412 lines (1,356 loc) • 40.2 kB
JavaScript
const assert = require('node:assert/strict');
const getLib = async () => import(
'../modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs'
);
describe('Layout Widget', function () {
describe('Move (grid-state)', function () {
it('[shouldComputeMoveSnap] changes memo when coarse col/row bucket changes', async function () {
const lib = await getLib();
const items = [ buildItem('a', 1, 2, 0) ];
const state = makeState(lib, items, 12, 2);
const stepX = 100;
const stepY = 100;
const item = items[0];
const a1 = lib.shouldComputeMoveSnap({
left: 0,
top: 0,
state,
item,
columns: 12,
rows: 2,
stepX,
stepY,
threshold: 0.6
});
const a2 = lib.shouldComputeMoveSnap({
left: 120,
top: 0,
state,
item,
columns: 12,
rows: 2,
stepX,
stepY,
threshold: 0.6,
prevMemo: a1.memo
});
assert.equal(a1.compute, true);
assert.equal(a2.compute, true, 'moving one track should change memo');
});
it('[shouldComputeMoveSnap] stable pointer within same coarse state returns compute=false', async function () {
const lib = await getLib();
const items = [ buildItem('a', 1, 2, 0) ];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const stepY = 100;
const item = items[0];
const a1 = lib.shouldComputeMoveSnap({
left: 10,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY,
threshold: 0.6
});
const a2 = lib.shouldComputeMoveSnap({
left: 15,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY,
threshold: 0.6,
prevMemo: a1.memo
});
assert.equal(a2.compute, false, 'small move in same bucket should not recompute');
});
it('[shouldComputeMoveSnap] flips compute when crossing hovered-neighbor flip boundary (east)', async function () {
const lib = await getLib();
// a:1..6 (6), b:7..2 (2), c:9..12 (4)
const items = [
buildItem('a', 1, 6, 0),
buildItem('b', 7, 2, 1),
buildItem('c', 9, 4, 2)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const stepY = 100;
const item = items[1];
// For c width=4, threshold 0.7 ->
// flip at neighborStartPx + 0.7*width = 800 + 280 = 1080
// Using right edge (b width=2 => widthPx=200): left threshold = 880
const a1 = lib.shouldComputeMoveSnap({
left: 879,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY,
threshold: 0.7
});
const a2 = lib.shouldComputeMoveSnap({
left: 880,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY,
threshold: 0.7,
prevMemo: a1.memo
});
assert.equal(a2.compute, true, 'crossing swap flip must recompute');
});
it('[shouldComputeMoveSnap] flips compute when crossing hovered-neighbor flip boundary (west)', async function () {
const lib = await getLib();
// a:1..2 (2), b:3..8 (6), c:9..12 (4)
const items = [
buildItem('a', 1, 2, 0),
buildItem('b', 3, 6, 1),
buildItem('c', 9, 4, 2)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const stepY = 100;
const item = items[1];
// West neighbor a width=2 -> flip at end - 0.7*width -> 200 - 140 = 60
const a1 = lib.shouldComputeMoveSnap({
left: 59,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY,
threshold: 0.7
});
const a2 = lib.shouldComputeMoveSnap({
left: 61,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY,
threshold: 0.7,
prevMemo: a1.memo
});
assert.equal(a2.compute, true, 'crossing west swap flip must recompute');
});
it('[computeGhostMoveSnap] basic column-based snapping (no collisions)', async function () {
const lib = await getLib();
const items = [
buildItem('a', 1, 2, 0)
];
const state = makeState(lib, items, 12, 1);
const columns = 12;
const rows = 1;
// Assume track width 100px, no gap -> stepX = 100
const stepX = 100;
const stepY = 100; // unused for row=1
const item = items[0];
// Threshold default ~0.6 -> shift = (1 - 0.6) * 100 = 40
// Positions near 0 should snap to colstart 1
let snap = lib.computeGhostMoveSnap({
left: 0,
top: 0,
state,
item,
columns,
rows,
stepX,
stepY
});
assert.equal(snap.colstart, 1);
// At left=61, (61+40)/100=1.01 -> floor=1 -> +1 => col 2
snap = lib.computeGhostMoveSnap({
left: 61,
top: 0,
state,
item,
columns,
rows,
stepX,
stepY
});
assert.equal(snap.colstart, 2);
});
it('[computeGhostMoveSnap] respects custom threshold', async function () {
const lib = await getLib();
const items = [ buildItem('a', 1, 1, 0) ];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const item = items[0];
// With threshold=0.9 -> shift = 10px
// - at left=89: (89+10)/100=0.99 floor=0 -> col 1
// - at left=90: (90+10)/100=1.0 floor=1 -> +1 => col 2
let snap = lib.computeGhostMoveSnap({
left: 89,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.9
});
assert.equal(snap.colstart, 1);
snap = lib.computeGhostMoveSnap({
left: 90,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.9
});
assert.equal(snap.colstart, 2);
});
it('[computeGhostMoveSnap] 6|2|4: moving middle (2) into right (4) stays before when no space', async function () {
const lib = await getLib();
// a:1..6 (6), b:7..8 (2), c:9..12 (4)
const items = [
buildItem('a', 1, 6, 0),
buildItem('b', 7, 2, 1),
buildItem('c', 9, 4, 2)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const item = items[1]; // move b
// Start entering c area slightly but after-snap is impossible (edge packed)
const leftIntoC = (9 - 1) * stepX + 10; // 10px into c
const snap = lib.computeGhostMoveSnap({
left: leftIntoC,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100
});
// Should remain before c => colstart 7
assert.equal(snap.colstart, 7);
});
it('[computeGhostMoveSnap] 6|2|4: moving middle (2) into left (6) stays after when no space', async function () {
const lib = await getLib();
// a:1..6 (6), b:7..8 (2), c:9..12 (4)
const items = [
buildItem('a', 1, 6, 0),
buildItem('b', 7, 2, 1),
buildItem('c', 9, 4, 2)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const item = items[1]; // move b
// Enter a area slightly but before-snap is impossible (packed)
const leftIntoA = (6 - 1) * stepX - 10; // just 10px before a's end
const snap = lib.computeGhostMoveSnap({
left: leftIntoA,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100
});
// Should remain after a => colstart 7
assert.equal(snap.colstart, 7);
});
it('[computeGhostMoveSnap] 6|2|4 swap right flips at hovered-neighbor threshold (t=0.7)', async function () {
const lib = await getLib();
const items = [
buildItem('a', 1, 6, 0),
buildItem('b', 7, 2, 1),
buildItem('c', 9, 4, 2)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100; // px per column
const item = items[1];
// neighbor: c width=4 -> flip at 0.7*4 cols from c start
// stepX=100 => flipPx = 800 + 0.7*400 = 1080
// Using leading edge (east: right edge). Item width=2 => widthPx=200
// Swap when left + 200 >= 1080 -> left >= 880
let snap = lib.computeGhostMoveSnap({
left: 879,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.7
});
// 879 -> right edge 1079 < 1080 => stay before c
assert.equal(snap.colstart, 7);
snap = lib.computeGhostMoveSnap({
left: 880,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.7
});
// 880 -> right edge 1080 => swap-right to equal-edge at 11
assert.equal(snap.colstart, 11);
});
it('[computeGhostMoveSnap] 6|2|4 swap left flips at hovered-neighbor threshold (t=0.7)', async function () {
const lib = await getLib();
const items = [
buildItem('a', 1, 6, 0),
buildItem('b', 7, 2, 1),
buildItem('c', 9, 4, 2)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const item = items[1];
// neighbor: a width=6 -> west flip at end - 0.7*width = 600 - 420 = 180
// stepX=100 => flipPx = 180 (west uses left-edge)
let snap = lib.computeGhostMoveSnap({
left: 179,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.7
});
// 179 -> left-edge <= 180 => swap-left to 1
assert.equal(snap.colstart, 1);
snap = lib.computeGhostMoveSnap({
left: 181,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.7
});
// 181 -> above neighbor flip => stay after a at colstart 7
assert.equal(snap.colstart, 7);
});
it('[computeGhostMoveSnap] 2|6|4 swap right flips at hovered-neighbor threshold (t=0.7)', async function () {
const lib = await getLib();
// a:1..2 (2), b:3..8 (6), c:9..12 (4)
const items = [
buildItem('a', 1, 2, 0),
buildItem('b', 3, 6, 1),
buildItem('c', 9, 4, 2)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const item = items[1]; // move b (6 wide)
// neighbor: c width=4, flipPx = 800 + 0.7*400 = 1080
// Using leading edge (east: right edge)
// b width=6 => widthPx=600; left threshold=480
let snap = lib.computeGhostMoveSnap({
left: 479,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.7
});
// 479 -> right edge 1079 < 1080 => stay before c at colstart 3
assert.equal(snap.colstart, 3);
snap = lib.computeGhostMoveSnap({
left: 480,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.7
});
// 480 -> right edge 1080 => swap-right to equal-edge at colstart 7
assert.equal(snap.colstart, 7);
});
it('[computeGhostMoveSnap] 2|6|4 west swap flips at hovered-neighbor threshold (t=0.7)', async function () {
const lib = await getLib();
// a:1..2 (2), b:3..8 (6), c:9..12 (4)
const items = [
buildItem('a', 1, 2, 0),
buildItem('b', 3, 6, 1),
buildItem('c', 9, 4, 2)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const item = items[1];
let snap = lib.computeGhostMoveSnap({
left: 69,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.7
});
assert.equal(snap.colstart, 1);
snap = lib.computeGhostMoveSnap({
left: 71,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100,
threshold: 0.7
});
assert.equal(snap.colstart, 3);
});
it('[computeGhostMoveSnap] 6|4: nudges neighbor before swap (two steps east)', async function () {
const lib = await getLib();
// a:1..6 (6), b:7..10 (4) with 2 cols free at right
const items = [
buildItem('a', 1, 6, 0),
buildItem('b', 7, 4, 1)
];
const state = makeState(lib, items, 12, 1);
const stepX = 100;
const item = items[0]; // move the 6-wide left item
// Step 1: move so colstart becomes 2 (left ~60)
let snap = lib.computeGhostMoveSnap({
left: 60,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100
});
assert.equal(snap.colstart, 2);
// Dropping here should nudge the 4 from 7 -> 8
let patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 2,
rowstart: 1
},
state,
item
});
const pm1 = patchMap(patches);
assert.equal(pm1.get('a')?.colstart, 2);
assert.equal(pm1.get('b')?.colstart, 8);
// Step 2: move so colstart becomes 3 (left ~160)
snap = lib.computeGhostMoveSnap({
left: 160,
top: 0,
state,
item,
columns: 12,
rows: 1,
stepX,
stepY: 100
});
assert.equal(snap.colstart, 3);
patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 3,
rowstart: 1
},
state,
item
});
const pm2 = patchMap(patches);
assert.equal(pm2.get('a')?.colstart, 3);
assert.equal(pm2.get('b')?.colstart, 9);
});
it('[getMoveChanges] should return no patches for no changes', async function () {
const { getMoveChanges } = await getLib();
const state = {
lookup: new Map(),
grid: {
columns: 12,
rows: 6
}
};
const data = {
id: 'test-item',
colstart: 1,
colspan: 2,
rowspan: 1,
rowstart: 1
};
{
const patches = getMoveChanges({
data,
state,
item: {
id: 'test-item',
colstart: 1,
colspan: 2,
rowspan: 1,
rowstart: 1
}
});
assert.deepEqual(patches, []);
}
{
const patches = getMoveChanges({
data,
state,
item: {
id: 'test-item',
colstart: 1,
rowstart: 1
}
});
assert.deepEqual(patches, []);
}
});
it('[getMoveChanges] place if destination free (no nudge)', async function () {
const lib = await getLib();
const items = [
buildItem('a', 1, 3, 0),
buildItem('b', 6, 2, 1)
];
const state = makeState(lib, items, 12, 1);
const data = {
id: 'b',
colstart: 4, // free gap between a (1..3) and b (6..7)
rowstart: 1
};
const patches = lib.getMoveChanges({
data,
state,
item: items[1]
});
const pm = patchMap(patches);
// b moves to start 4
assert.equal(pm.get('b')?.colstart, 4);
// a unchanged
assert.equal(pm.get('a')?.colstart, undefined);
});
it('[getMoveChanges] v1: reject until equal-edge swap allowed', async function () {
// 12 cols, 1 row
// 1) a: 1..5, 2) b: 9..12
const lib = await getLib();
const items = [
buildItem('a', 1, 5, 0),
buildItem('b', 9, 4, 1)
];
const state = makeState(lib, items, 12, 1);
// Move b to colstart 5 -> reject
let patches = lib.getMoveChanges({
data: {
id: 'b',
colstart: 5,
rowstart: 1
},
state,
item: items[1]
});
assert.deepEqual(patches, []);
// 4 -> reject
patches = lib.getMoveChanges({
data: {
id: 'b',
colstart: 4,
rowstart: 1
},
state,
item: items[1]
});
assert.deepEqual(patches, []);
// 3 -> reject
patches = lib.getMoveChanges({
data: {
id: 'b',
colstart: 3,
rowstart: 1
},
state,
item: items[1]
});
assert.deepEqual(patches, []);
// 2 -> reject
patches = lib.getMoveChanges({
data: {
id: 'b',
colstart: 2,
rowstart: 1
},
state,
item: items[1]
});
assert.deepEqual(patches, []);
// 1 -> allowed via opposite-direction nudge (swap)
patches = lib.getMoveChanges({
data: {
id: 'b',
colstart: 1,
rowstart: 1
},
state,
item: items[1]
});
const pm = patchMap(patches);
assert.equal(pm.get('b')?.colstart, 1);
assert.equal(pm.get('a')?.colstart, 5); // a nudged east to start at 5
});
it('[getMoveChanges] v2: reject overlaps for b; allow free move for a', async function () {
// 1) a: 1..4, 2) b: 8..12
const lib = await getLib();
const items = [
buildItem('a', 1, 4, 0),
buildItem('b', 8, 5, 1)
];
const state = makeState(lib, items, 12, 1);
// Moving b into overlaps should be rejected
for (const start of [ 4, 3, 2 ]) {
const patches = lib.getMoveChanges({
data: {
id: 'b',
colstart: start,
rowstart: 1
},
state,
item: items[1]
});
assert.deepEqual(patches, []);
}
// Moving a to colstart 2 is free (no overlap) -> allowed
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 2,
rowstart: 1
},
state,
item: items[0]
});
const pm = patchMap(patches);
assert.equal(pm.get('a')?.colstart, 2);
assert.equal(pm.get('b')?.colstart, undefined);
});
it('[getMoveChanges] v3: reject overlaps for a and b (east)', async function () {
// 1) a: 1..4, 2) b: 9..12
const lib = await getLib();
const items = [
buildItem('a', 1, 5, 0),
buildItem('b', 9, 4, 1)
];
const state = makeState(lib, items, 12, 1);
// Moving a into overlaps should be rejected
for (const start of [ 5, 6, 7 ]) {
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: start,
rowstart: 1
},
state,
item: items[1]
});
assert.deepEqual(patches, [], `Failed to reject move with ${start - 4} step(s) east`);
}
});
it('[getMoveChanges] equal-edge triggers opposite-direction nudging first, then fallback', async function () {
// a: 1..4, b: 7..8. Move a -> colstart 5 (a new end 8 equals b end 8).
const lib = await getLib();
const items = [
buildItem('a', 1, 4, 0),
buildItem('b', 7, 2, 1)
];
const state = makeState(lib, items, 12, 1);
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 5,
rowstart: 1
},
state,
item: items[0]
});
const pm = patchMap(patches);
// a placed at 5..8
assert.equal(pm.get('a')?.colstart, 5);
// Opposite-direction first -> b nudged west to 3..4
assert.equal(pm.get('b')?.colstart, 3);
});
it('[getMoveChanges] overlapping may triggers opposite-direction nudging', async function () {
// a: 1..4, b: 5..9. Move a -> colstart 5 (a new end 8 equals b end 8).
const lib = await getLib();
const items = [
buildItem('a', 1, 4, 0),
buildItem('b', 5, 5, 1)
];
const state = makeState(lib, items, 12, 1);
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 7,
rowstart: 1
},
state,
item: items[0]
});
const pm = patchMap(patches);
// a placed at 7..10
assert.equal(pm.get('a')?.colstart, 7);
// Opposite-direction fallback -> b nudged west to 2..6
assert.equal(pm.get('b')?.colstart, 2);
});
it('[getMoveChanges] same-direction east nudging any allowed tile', async function () {
// Same setup as above: a: 1..4, b: 5..9.
// Move a -> colstart 2 (a new end 5 overlaps b start 5 by one cell).
// Expect b to nudge east by one to start at 6.
const lib = await getLib();
const items = [
buildItem('a', 1, 4, 0),
buildItem('b', 5, 5, 1)
];
const state = makeState(lib, items, 12, 1);
{
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 2,
rowstart: 1
},
state,
item: items[0]
});
const pm = patchMap(patches);
// a placed at 2..5
assert.equal(pm.get('a')?.colstart, 2, 'unable to move one tile east');
// Same-direction nudging east by one -> b to 6..10
assert.equal(pm.get('b')?.colstart, 6, 'unable to nudge b one tile east');
}
{
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 3,
rowstart: 1
},
state,
item: items[0]
});
const pm = patchMap(patches);
// a placed at 3..6
assert.equal(pm.get('a')?.colstart, 3, 'unable to move two tiles east');
// Same-direction nudging east by one -> b to 6..10
assert.equal(pm.get('b')?.colstart, 7, 'unable to nudge b two tiles east');
}
{
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 4,
rowstart: 1
},
state,
item: items[0]
});
const pm = patchMap(patches);
// a placed at 4..7
assert.equal(pm.get('a')?.colstart, 4, 'unable to move three tiles east');
// Same-direction nudging east by one -> b to 6..10
assert.equal(pm.get('b')?.colstart, 8, 'unable to nudge b three tiles east');
}
{
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 5,
rowstart: 1
},
state,
item: items[0]
});
const pm = patchMap(patches);
// a placed at 5..8 would push b past grid (to 9..13) -> reject
assert.equal(pm.get('a')?.colstart, undefined, 'failed to deny the move four tiles east');
// b remains unchanged when move is denied
assert.equal(pm.get('b')?.colstart, undefined, 'failed to deny the nudge b four tiles east');
}
});
it('[getMoveChanges] reject when nudging would exceed grid bounds', async function () {
const lib = await getLib();
// a: 1..7 (7 cols), b: 8..12 (5 cols).
// Move b -> 7..11 would require a to move west outside grid.
const items = [
buildItem('a', 1, 7, 0),
buildItem('b', 8, 5, 1)
];
const state = makeState(lib, items, 12, 1);
const patches = lib.getMoveChanges({
data: {
id: 'b',
colstart: 7,
rowstart: 1
},
state,
item: items[1]
});
assert.deepEqual(patches, []);
});
it('[getMoveChanges] vertical-only move, free destination accepted', async function () {
// rows: 2. a at row1 1..3. Move a to row2 same columns.
const lib = await getLib();
const items = [
buildItem('a', 1, 3, 0, 1, 1)
];
const state = makeState(lib, items, 12, 2);
// Destination row 2 is empty
const patches = lib.getMoveChanges({
data: {
id: 'a',
colstart: 1,
rowstart: 2
},
state,
item: items[0]
});
const pm = patchMap(patches);
assert.equal(pm.get('a')?.rowstart, 2);
assert.equal(pm.get('a')?.colstart, 1);
});
it('[getMoveChanges] honors precomputed index during nudging', async function () {
// Use Test 1 swap scenario with precomp
const lib = await getLib();
const items = [
buildItem('a', 1, 5, 0),
buildItem('b', 9, 4, 1)
];
const state = makeState(lib, items, 12, 1);
const precomp = lib.prepareMoveIndex({
state,
item: items[1]
});
const patches = lib.getMoveChanges({
data: {
id: 'b',
colstart: 1,
rowstart: 1
},
state,
item: items[1],
precomp
});
const pm = patchMap(patches);
assert.equal(pm.get('b')?.colstart, 1);
assert.equal(pm.get('a')?.colstart, 5);
});
it('[previewMoveChanges] mirrors getMoveChanges moving item placement (free move)', async function () {
const lib = await getLib();
const items = [
buildItem('a', 1, 3, 0),
buildItem('b', 6, 2, 1)
];
const state = makeState(lib, items, 12, 1);
const target = {
id: 'b',
colstart: 4,
rowstart: 1
};
const preview = lib.previewMoveChanges({
data: target,
state,
item: items[1]
});
assert.equal(preview?.colstart, 4);
const full = lib.getMoveChanges({
data: target,
state,
item: items[1]
});
const pm = patchMap(full);
assert.equal(pm.get('b')?.colstart, preview.colstart);
});
it('[previewMoveChanges] mirrors nudged placement on overlap scenario', async function () {
const lib = await getLib();
// a: 1..4, b: 5..9. Move a to colstart 2 (nudges b east)
const items = [
buildItem('a', 1, 4, 0),
buildItem('b', 5, 5, 1)
];
const state = makeState(lib, items, 12, 1);
const target = {
id: 'a',
colstart: 2,
rowstart: 1
};
const preview = lib.previewMoveChanges({
data: target,
state,
item: items[0]
});
// preview should allow the move and report new colstart 2
assert.equal(preview?.colstart, 2);
const full = lib.getMoveChanges({
data: target,
state,
item: items[0]
});
const pm = patchMap(full);
assert.equal(pm.get('a')?.colstart, preview.colstart);
});
});
describe('Resize (grid-state)', function () {
it('[validateResizeX] shrinking from east keeps colstart', async function () {
const lib = await getLib();
// spans 2..7
const item = buildItem('a', 2, 6, 0);
const state = makeState(lib, [ item ], 12, 1);
const validated = lib.validateResizeX({
item,
side: 'east',
direction: 'east',
// shrink by 2
newColspan: 4,
positionsIndex: state.positions,
maxColumns: state.columns,
minSpan: 1
});
// colstart unchanged, colspan reduced
assert.equal(validated.colstart, 2);
assert.equal(validated.colspan, 4);
});
it('[validateResizeX] shrinking from west shifts colstart right', async function () {
const lib = await getLib();
// 5..8
const item = buildItem('a', 5, 4, 0);
const state = makeState(lib, [ item ], 12, 1);
const validated = lib.validateResizeX({
item,
side: 'west',
direction: 'west',
// shrink by 2 keeping east edge fixed
newColspan: 2,
positionsIndex: state.positions,
maxColumns: state.columns,
minSpan: 1
});
// East edge was 8, new span 2 => colstart 7
assert.equal(validated.colstart, 7);
assert.equal(validated.colspan, 2);
});
it('[validateResizeX] shrinking below minSpan clamps and adjusts start (west)', async function () {
const lib = await getLib();
// 5..9
const item = buildItem('a', 5, 5, 0);
const state = makeState(lib, [ item ], 12, 1);
const validated = lib.validateResizeX({
item,
side: 'west',
direction: 'west',
// below minSpan 3
newColspan: 1,
positionsIndex: state.positions,
maxColumns: state.columns,
minSpan: 3
});
// Clamp to 3 -> shrink by 2; east edge 9 => new start 7
assert.equal(validated.colspan, 3);
assert.equal(validated.colstart, 7);
});
it('[validateResizeX] expand east within free space', async function () {
const lib = await getLib();
// 1..3
const item = buildItem('a', 1, 3, 0);
const state = makeState(lib, [ item ], 12, 1);
const validated = lib.validateResizeX({
item,
side: 'east',
direction: 'east',
newColspan: 8,
positionsIndex: state.positions,
maxColumns: state.columns,
minSpan: 1
});
assert.equal(validated.colstart, 1);
assert.equal(validated.colspan, 8);
});
it('[validateResizeX] expand east clamped at grid edge', async function () {
const lib = await getLib();
// 1..5
const item = buildItem('a', 1, 5, 0);
const state = makeState(lib, [ item ], 12, 1);
const validated = lib.validateResizeX({
item,
side: 'east',
direction: 'east',
// request beyond grid edge
newColspan: 15,
positionsIndex: state.positions,
maxColumns: state.columns,
minSpan: 1
});
// can fill entire row
assert.equal(validated.colspan, 12);
assert.equal(validated.colstart, 1);
});
it('[validateResizeX] expand west limited by neighbor and boundary', async function () {
const lib = await getLib();
// 5..7 target
const a = buildItem('a', 5, 3, 0);
// occupies 1..2 leaves 3..4 free
const blocker = buildItem('b', 1, 2, 1);
const state = makeState(lib, [ a, blocker ], 12, 1);
const validated = lib.validateResizeX({
item: a,
side: 'west',
direction: 'west',
// request more than available (only cols 3..7 usable)
newColspan: 10,
positionsIndex: state.positions,
maxColumns: state.columns,
minSpan: 1
});
// Free cells west of a: 3 & 4 -> 2 extra => final span 5, start 3
assert.equal(validated.colspan, 5);
assert.equal(validated.colstart, 3);
});
it('[getResizeChanges] shrink returns only item patch', async function () {
const lib = await getLib();
const a = buildItem('a', 2, 6, 0);
const state = makeState(lib, [ a ], 12, 1);
const patches = lib.getResizeChanges({
data: {
id: 'a',
side: 'east',
direction: 'east',
colspan: 4,
colstart: 2,
rowstart: 1,
rowspan: 1,
order: 0
},
state,
item: a
});
assert.equal(patches.length, 1);
const p = patches[0];
assert.equal(p._id, 'a');
assert.equal(p.colspan, 4);
assert.equal(p.colstart, 2);
});
it('[getResizeChanges] east expansion nudges single neighbor', async function () {
const lib = await getLib();
// 1..4
const a = buildItem('a', 1, 4, 0);
// 5..8
const b = buildItem('b', 5, 4, 1);
const state = makeState(lib, [ a, b ], 12, 1);
const patches = lib.getResizeChanges({
data: {
id: 'a',
side: 'east',
direction: 'east',
// expand by 1
colspan: 5,
colstart: 1,
rowstart: 1,
rowspan: 1,
order: 0
},
state,
item: a
});
const pm = patchMap(patches);
assert.equal(pm.get('a')?.colspan, 5);
assert.equal(pm.get('a')?.colstart, 1);
assert.equal(pm.get('b')?.colstart, 6, 'neighbor not nudged east by 1');
});
it('[getResizeChanges] east expansion cascades two neighbors (clamping at edge)', async function () {
const lib = await getLib();
const a = buildItem('a', 1, 4, 0); // 1..4
const b = buildItem('b', 5, 4, 1); // 5..8
const c = buildItem('c', 9, 4, 2); // 9..12 (edge)
const state = makeState(lib, [ a, b, c ], 12, 1);
const patches = lib.getResizeChanges({
data: {
id: 'a',
side: 'east',
direction: 'east',
// expand 1
colspan: 5,
colstart: 1,
rowstart: 1,
rowspan: 1,
order: 0
},
state,
item: a
});
const pm = patchMap(patches);
assert.equal(pm.get('a')?.colspan, 5);
assert.equal(pm.get('b')?.colstart, 6, 'b should shift east by 1');
// c cannot move east (already at edge), algorithm clamps -> stays 9
assert.equal(pm.get('c')?.colstart, 9);
});
it('[getResizeChanges] west expansion nudges neighbor west', async function () {
const lib = await getLib();
const a = buildItem('a', 5, 3, 0); // 5..7
const left = buildItem('b', 1, 3, 1); // 1..3
const state = makeState(lib, [ a, left ], 12, 1);
const patches = lib.getResizeChanges({
data: {
id: 'a',
side: 'west',
direction: 'west',
colspan: 5, // expand by 2 into cols 3-4
colstart: 3,
rowstart: 1,
rowspan: 1,
order: 0
},
state,
item: a
});
const pm = patchMap(patches);
assert.equal(pm.get('a')?.colspan, 5);
assert.equal(pm.get('a')?.colstart, 3);
// left neighbor shifts west by 0? Only 1 free cell (col 4) so needs shift
// It has original start 1 so cannot move further west -> clamped to 1
assert.equal(pm.get('b')?.colstart, 1);
});
});
describe('Provisioning (grid-state)', function () {
it('[provisionRow] smoke and symmetry cases', async function () {
const { provisionRow } = await getLib();
const cases = [
{
C: 12,
min: 2,
ideal: 3,
expect: [ 3, 3, 3, 3 ]
},
{
C: 10,
min: 2,
ideal: 3,
expect: [ 3, 4, 3 ]
},
{
C: 11,
min: 2,
ideal: 3,
expect: [ 3, 3, 3, 2 ]
},
{
C: 7,
min: 2,
ideal: 3,
expect: [ 4, 3 ]
},
{
C: 5,
min: 2,
ideal: 3,
expect: [ 3, 2 ]
},
{
C: 4,
min: 2,
ideal: 3,
expect: [ 4 ]
},
{
C: 3,
min: 2,
ideal: 3,
expect: [ 3 ]
},
{
C: 2,
min: 2,
ideal: 3,
expect: [ 2 ]
},
{
C: 1,
min: 2,
ideal: 3,
expect: [ 1 ]
}
];
for (const t of cases) {
const items = provisionRow(t.C, {
minColspan: t.min,
defaultColspan: t.ideal,
row: 1
});
const spans = items.map(i => i.colspan);
const sum = spans.reduce((a, b) => a + b, 0);
// Fills entire row
assert.equal(sum, t.C, `sum != columns for C=${t.C}`);
// Expected distribution
assert.deepEqual(
spans,
t.expect,
`unexpected spans for C=${t.C} -> ${JSON.stringify(spans)}`
);
// Sequential colstart and order
let col = 1;
items.forEach((it, idx) => {
assert.equal(it.rowstart, 1);
assert.equal(it.rowspan, 1);
assert.equal(it.order, idx);
assert.equal(it.colstart, col);
col += it.colspan;
});
}
});
it('[provisionRow] default < min coerces toward min-sized tiles', async function () {
const { provisionRow } = await getLib();
const C = 12;
const items = provisionRow(C, {
minColspan: 3,
defaultColspan: 2,
row: 2
});
const spans = items.map(i => i.colspan);
const sum = spans.reduce((a, b) => a + b, 0);
assert.equal(sum, C);
spans.forEach(s => assert.ok(s >= 3));
assert.ok(spans.every(s => s >= 3 && s <= 4));
items.forEach((it, idx) => {
assert.equal(it.rowstart, 2);
assert.equal(it.order, idx);
});
});
it('[provisionRow] min = 1 and ideal = 1 yields 1-wide tiles', async function () {
const { provisionRow } = await getLib();
const C = 5;
const items = provisionRow(C, {
minColspan: 1,
defaultColspan: 1,
row: 3
});
const spans = items.map(i => i.colspan);
assert.deepEqual(spans, [ 1, 1, 1, 1, 1 ]);
items.forEach((it, idx) => {
assert.equal(it.rowstart, 3);
assert.equal(it.rowspan, 1);
assert.equal(it.order, idx);
});
});
it('[provisionRow] columns less than min -> single full-width item', async function () {
const { provisionRow } = await getLib();
const C = 2;
const items = provisionRow(C, {
minColspan: 3,
defaultColspan: 4,
row: 4
});
assert.equal(items.length, 1);
assert.equal(items[0].colspan, 2);
assert.equal(items[0].rowstart, 4);
assert.equal(items[0].rowspan, 1);
assert.equal(items[0].colstart, 1);
assert.equal(items[0].order, 0);
});
it('[provisionRow] large columns with ideal 3 produce repeated 3s', async function () {
const { provisionRow } = await getLib();
const C = 24;
const items = provisionRow(C, {
minColspan: 2,
defaultColspan: 3,
row: 5
});
const spans = items.map(i => i.colspan);
const sum = spans.reduce((a, b) => a + b, 0);
assert.equal(sum, C);
assert.ok(spans.every(s => s === 3));
let c = 1;
items.forEach((it) => {
assert.equal(it.colstart, c);
c += it.colspan;
});
});
it('[provisionRow] odd columns with min 3 ideal 4 have limited spread', async function () {
const { provisionRow } = await getLib();
const C = 13;
const items = provisionRow(C, {
minColspan: 3,
defaultColspan: 4,
row: 6
});
const spans = items.map(i => i.colspan);
const sum = spans.reduce((a, b) => a + b, 0);
assert.equal(sum, C);
spans.forEach(s => assert.ok(s >= 3));
const minS = Math.min(...spans);
const maxS = Math.max(...spans);
assert.ok(maxS - minS <= 2);
});
it('[provisionRow] supports string columns input', async function () {
const { provisionRow } = await getLib();
const items = provisionRow('12', {
minColspan: 2,
defaultColspan: 3
});
const spans = items.map(i => i.colspan);
assert.equal(spans.reduce((a, b) => a + b, 0), 12);
});
});
});
function buildItem(id, colstart, colspan, order, rowstart = 1, rowspan = 1) {
return {
_id: id,
type: 'test',
order,
rowstart,
colstart,
colspan,
rowspan,
align: 'stretch',
justify: 'stretch'
};
}
function makeState(lib, items, columns = 12, rows = 1) {
const current = {
items,
rows
};
return {
columns,
current,
options: {},
lookup: new Map(items.map(i => [ i._id, i ])),
positions: lib.createPositionIndex(items, rows)
};
}
function patchMap(patches) {
const m = new Map();
for (const p of patches) {
m.set(p._id, p);
}
return m;
}