Until now every account could do the same, and managing accounts meant running make-user.php on the server. Accounts now carry a role, and admins get a tab in the editor to create, rename, re-role, reset and delete accounts. Both issues share the same server files, so they land in one commit. #180 Roles: - Every account is admin or user. A missing or unknown role counts as user, on the server and in the client. - auth/session and auth/login return {user, role}. - Auth::requireAdmin() answers everyone else with 403. - make-user.php takes the role as a third argument. Without it an existing account keeps its role, so a password reset from the CLI never demotes an admin. - Admins see and save other users' projects like their own. Saving keeps the original owner. #179 User management: - The account menu gets "User management" (admins only) and "Plugins" (everyone). Both open the same closable tab, accounts on top and plugins below. The fixed Plugins tab is gone. - Per account: Rename, Role, Password (no old password needed) and Delete with a confirmation. "New user..." creates one. - Each account lists its projects with an Open button. - Delete is refused while the account still owns projects. - Renaming moves the projects along and ends every session of the account. transfer takes the same lock as save. - On their own row an admin can only set the password. The last admin cannot be demoted or deleted. The server enforces both. - The account rules live in AccountRules.php and accountRules.ts as pure functions, so they are testable without a server. Findings: - At 1280 px the Delete button reached past the column edge and was not clickable. The name column is flexible now, and module 49 checks the layout. - The server lets an admin delete other users' projects, but the editor has no button for it yet. Left open in the issue. Tests: - ./check.sh: 148 files, 2214 tests, exit 0 (was 147 / 2204). - New tests/php/accounts.test.php, 51 checks, plain PHP. CI runs it as its own step in the gate job, the host has no PHP. - New e2e module 49 with 18 checks. CI creates the e2e-admin account in serve.sh. Modules 14, 38, 46 and the scaffold follow the new tab. - e2e 1-16,37,38,45,46,49: all passed, 581 checks. - Counter-checks: owner on save, admin view, default role and self delete in PHP fail 7, 5, 5 and 1 checks. Unchecked requireAdmin, a wider column and the caller as owner each fail exactly their e2e check. - Check sum 1314 -> 1333 (+1 in 38, +18 in 49), computed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
/**
|
||
* Labels must be shortened to fit their control, not written past it. Runs last because it renames
|
||
* assets to absurd lengths to provoke the case.
|
||
*/
|
||
|
||
/** A name far wider than a tab may grow, so a tab that does not cut it must spill. */
|
||
const LONG_NAME = 'Player movement with jumping and coyote time';
|
||
|
||
export async function overflow(t) {
|
||
const { check, sleep, json, evaluate, shot, mutate, drag, clickText, showTab } = t;
|
||
|
||
await mutate(`
|
||
for (const asset of __gm.state.project.assets) {
|
||
if (asset.type === 'behavior' || asset.type === 'texture' || asset.type === 'container')
|
||
asset.name = ${JSON.stringify(LONG_NAME)};
|
||
}
|
||
__gm.state.select(null)`);
|
||
// 14a. A document's tab shows its name, but a long one is cut instead of taking the row (#171).
|
||
// The three do not fit side by side at this width, so each is measured while it shows.
|
||
const widths = [];
|
||
for (const tab of ['graph', 'texture', 'container']) {
|
||
await showTab(tab);
|
||
const { active } = await json('__gm.documents()');
|
||
const shown = (await json('__gm.tabRow()')).shown.find((b) => b.tab === active);
|
||
widths.push(shown ? Math.round(shown.right - shown.x) : null);
|
||
}
|
||
await sleep(600);
|
||
check(
|
||
'three documents with long names each keep a tab of bounded width',
|
||
(await json('__gm.documents()')).docs.length === 3 && widths.every((w) => w !== null && w < 300),
|
||
JSON.stringify(widths),
|
||
);
|
||
|
||
// 14a-ii. What a tab looks like (#176): the one showing is a light grey card with rounded upper
|
||
// corners standing on the row's line, its name in plain weight, and a press on it keeps
|
||
// that colour instead of flashing the pressed fill of a button.
|
||
await showTab('scene');
|
||
const ACTIVE = '#3f444e';
|
||
const BAR = '#1f2229';
|
||
const LINE = '#383c46';
|
||
const pixel = (x, y) => json(`__gm.pixelAt(${Math.round(x)}, ${Math.round(y)})`);
|
||
const styled = await json('__gm.tabRow()');
|
||
const card = styled.shown.find((b) => b.tab === 'scene');
|
||
const plain = styled.shown.find((b) => b.tab !== 'scene');
|
||
const cardFill = await pixel(card.x + 3, card.y + card.h - 3);
|
||
const plainFill = await pixel(plain.x + 3, plain.y + plain.h - 3);
|
||
check(
|
||
'the tab showing is a light grey card, the others stay on the bar',
|
||
cardFill === ACTIVE && plainFill === BAR,
|
||
JSON.stringify({ cardFill, plainFill, card: card.tab, plain: plain.tab }),
|
||
);
|
||
const corner = await pixel(card.x, card.y);
|
||
const top = await pixel(card.x + card.h / 2, card.y + 3);
|
||
const line = await pixel(plain.x + 4, plain.y + plain.h - 1);
|
||
check(
|
||
'its upper corners are rounded and it covers the line the other tabs stand on',
|
||
corner === BAR && top === ACTIVE && line === LINE,
|
||
JSON.stringify({ corner, top, line }),
|
||
);
|
||
check(
|
||
'neither the tab showing nor the others write their name bold',
|
||
styled.shown.every((b) => b.bold === false),
|
||
JSON.stringify(styled.shown.map((b) => ({ tab: b.tab, bold: b.bold }))),
|
||
);
|
||
const pressX = Math.round(card.x + 3);
|
||
const pressY = Math.round(card.y + card.h - 3);
|
||
await t.mouse('mouseMoved', pressX, pressY);
|
||
await t.mouse('mousePressed', pressX, pressY);
|
||
const held = await pixel(pressX, pressY);
|
||
await t.mouse('mouseReleased', pressX, pressY);
|
||
check(
|
||
'holding the mouse down on the tab showing does not change its colour',
|
||
held === ACTIVE,
|
||
JSON.stringify({ held, expected: ACTIVE }),
|
||
);
|
||
// The fixed tabs carry an icon as well (#176); the Scene card shows it in front of its name.
|
||
const sceneInk = await json(`(() => {
|
||
const x0 = ${card.x} + 6, y = ${card.y + card.h / 2};
|
||
let n = 0;
|
||
for (let dy = -7; dy <= 7; dy++)
|
||
for (let dx = 0; dx < 16; dx++) if (__gm.pixelAt(x0 + dx, y + dy) !== '${ACTIVE}') n++;
|
||
return n;
|
||
})()`);
|
||
const fixedIcons = styled.shown
|
||
.filter((b) => ['scene', 'game'].includes(b.tab))
|
||
.map((b) => [b.tab, b.icon]);
|
||
check(
|
||
'Scene and Game show an icon before their name',
|
||
JSON.stringify(fixedIcons) ===
|
||
JSON.stringify([
|
||
['scene', true],
|
||
['game', true],
|
||
]) && sceneInk >= 20,
|
||
JSON.stringify({ fixedIcons, sceneInk }),
|
||
);
|
||
|
||
// 14b. The tab row itself: a button past the centre column's edge sits under the inspector, and
|
||
// a click on it lands there (nine tabs on a 90 px floor did exactly that at 1280 px wide).
|
||
const columnRight = async () => {
|
||
const column = await json('__gm.viewportRect()');
|
||
return column.x + column.w;
|
||
};
|
||
const spillOf = (row, right) => row.shown.filter((b) => b.right > right);
|
||
const wide = await json('__gm.tabRow()');
|
||
check(
|
||
'every tab button ends inside the centre column',
|
||
spillOf(wide, await columnRight()).length === 0,
|
||
JSON.stringify({ spilled: spillOf(wide, await columnRight()), hidden: wide.hidden }),
|
||
);
|
||
// Three open documents on top of the fixed tabs no longer fit a 1280 px window.
|
||
check(
|
||
'at the test window’s width the row already needs its overflow, and still shows what is edited',
|
||
wide.hidden.length > 0 && wide.shown.some((b) => b.tab === 'scene'),
|
||
JSON.stringify({ hidden: wide.hidden, shown: wide.shown.map((b) => b.tab) }),
|
||
);
|
||
|
||
// 14b-ii. Narrowed until the row cannot hold nine tabs, the leftovers go behind the overflow
|
||
// button instead of under the inspector – the guard against the tenth tab (#84).
|
||
const widenPanels = async (by) => {
|
||
const bars = await json('__gm.panelSplitters()');
|
||
await drag(bars.left.x, bars.left.y, bars.left.x + by, bars.left.y, { steps: 8 });
|
||
const after = await json('__gm.panelSplitters()');
|
||
await drag(after.right.x, after.right.y, after.right.x - by, after.right.y, { steps: 8 });
|
||
};
|
||
await widenPanels(250);
|
||
const narrow = await json('__gm.tabRow()');
|
||
const narrowRight = await columnRight();
|
||
check(
|
||
'a row too narrow for its tabs puts the leftovers away instead of past the edge',
|
||
narrow.hidden.length > 0 && spillOf(narrow, narrowRight).length === 0,
|
||
JSON.stringify({ hidden: narrow.hidden, spilled: spillOf(narrow, narrowRight) }),
|
||
);
|
||
check(
|
||
'the tab being edited stays in the row',
|
||
narrow.shown.some((b) => b.tab === 'scene'),
|
||
JSON.stringify(narrow.shown.map((b) => b.tab)),
|
||
);
|
||
await shot('35-tab-overflow');
|
||
// The overflow button names them, and picking one brings it into the row.
|
||
await clickText('»');
|
||
const offered = (await json('__gm.menuRows()')).filter(Boolean);
|
||
const offeredIcons = (await json('__gm.menuIcons()')).filter((icon) => icon !== null);
|
||
// Every hidden tab brings its icon along; only a plugin's own tab has none (#176, #119).
|
||
const withIcon = (tab) => !tab.startsWith('plugin:');
|
||
check(
|
||
'the overflow button offers every tab that had to go, each with the icon its tab shows',
|
||
offered.length === narrow.hidden.length &&
|
||
offeredIcons.filter(Boolean).length === narrow.hidden.filter(withIcon).length,
|
||
JSON.stringify({ offered, offeredIcons, hidden: narrow.hidden }),
|
||
);
|
||
const wanted = narrow.hidden[narrow.hidden.length - 1];
|
||
const item = await t.locate(offered[offered.length - 1], true);
|
||
await t.click(item.x, item.y);
|
||
const picked = await json('__gm.tabRow()');
|
||
check(
|
||
'picking a document from the overflow menu opens it and puts it in the row',
|
||
(await json('__gm.documents()')).active === wanted && picked.shown.some((b) => b.tab === wanted),
|
||
JSON.stringify({ wanted, docs: await json('__gm.documents()'), shown: picked.shown.map((b) => b.tab) }),
|
||
);
|
||
await widenPanels(-250);
|
||
await showTab('scene');
|
||
const back = await json('__gm.tabRow()');
|
||
check(
|
||
'giving the room back brings tabs into the row again, and none past the edge',
|
||
back.hidden.length < narrow.hidden.length && spillOf(back, await columnRight()).length === 0,
|
||
JSON.stringify({ hidden: back.hidden, narrow: narrow.hidden }),
|
||
);
|
||
|
||
// 14b-iii. The divider between asset panel and scene objects moves the height from one to the
|
||
// other, and dragged shut either keeps its header to grab it by again.
|
||
const column = () => json('__gm.leftColumn()');
|
||
const near = (a, b) => Math.abs(a - b) <= 1;
|
||
const start = await column();
|
||
await drag(start.splitter.x, start.splitter.y, start.splitter.x, start.splitter.y + 80, { steps: 6 });
|
||
const lower = await column();
|
||
check(
|
||
'dragging the left column’s divider down gives the assets 80 px the scene objects lose',
|
||
near(lower.assets.h, start.assets.h + 80) && near(lower.objects.h, start.objects.h - 80),
|
||
JSON.stringify({ start, lower }),
|
||
);
|
||
await drag(lower.splitter.x, lower.splitter.y, lower.splitter.x, lower.splitter.y - 2000, { steps: 10 });
|
||
const shut = await column();
|
||
const header = await json('__gm.panelHeader');
|
||
check(
|
||
'dragged all the way up the asset panel stops at its header',
|
||
near(shut.assets.h, header) && shut.objects.y > shut.assets.y,
|
||
JSON.stringify(shut),
|
||
);
|
||
await drag(shut.splitter.x, shut.splitter.y, shut.splitter.x, start.splitter.y, { steps: 10 });
|
||
const restored = await column();
|
||
check(
|
||
'and dragged back it returns to the split it started with',
|
||
near(restored.assets.h, start.assets.h) && near(restored.objects.h, start.objects.h),
|
||
JSON.stringify({ start, restored }),
|
||
);
|
||
|
||
// 14c. Every tab, because each one owns a different set of controls.
|
||
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'particles']) {
|
||
await showTab(tab);
|
||
const spills = await json('__gm.textOverflows()');
|
||
check(
|
||
`no label is drawn past its control in the ${tab} tab`,
|
||
spills.length === 0,
|
||
JSON.stringify(spills),
|
||
);
|
||
}
|
||
|
||
// 14d. Shortened, not simply dropped: the value must still be readable and marked as cut.
|
||
await showTab('graph');
|
||
const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement'));
|
||
check(
|
||
'a name too wide for its tab is cut with an ellipsis',
|
||
Boolean(shown) && shown.text.endsWith('…') && shown.text.length > 8,
|
||
JSON.stringify(shown),
|
||
);
|
||
await shot('36-long-labels');
|
||
|
||
// 14e. An inspector full of dropdowns – the parent row and the enum fields – with a selection
|
||
// whose asset name is now overlong too.
|
||
await showTab('scene');
|
||
await evaluate("__gm.state.select({ kind: 'object', objectId: 'otex' })");
|
||
await sleep(500);
|
||
const inspector = await json('__gm.textOverflows()');
|
||
check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector));
|
||
|
||
// 14f. The other direction: a control narrow enough to swallow its whole label draws nothing
|
||
// at all, which the overflow walk cannot see – an empty text spills over no edge.
|
||
for (const tab of ['scene', 'graph', 'texture', 'container', 'particles']) {
|
||
await showTab(tab);
|
||
const dropped = await json('__gm.labelsDropped()');
|
||
check(
|
||
`no button is left without its label in the ${tab} tab`,
|
||
dropped.length === 0,
|
||
JSON.stringify(dropped),
|
||
);
|
||
}
|
||
// The narrowest buttons in the editor sit on a behaviour's variable rows.
|
||
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'mover' })");
|
||
const onVariables = await json('__gm.labelsDropped()');
|
||
check(
|
||
'the variable rows keep their buttons labelled',
|
||
onVariables.length === 0,
|
||
JSON.stringify(onVariables),
|
||
);
|
||
// The weights the theme draws with (#214): a missing face falls back to the next one the browser
|
||
// has, and 500 then draws as 400 – no check on widths notices, the text just looks lighter.
|
||
const faces = await json(
|
||
'[...document.fonts].filter((f) => f.family.replaceAll(\'"\', "") === "Source Sans 3" && f.status === "loaded").map((f) => f.weight).sort()',
|
||
);
|
||
check(
|
||
'the editor font is loaded in every weight the theme uses, 500 included',
|
||
['400', '500', '600'].every((weight) => faces.includes(weight)),
|
||
JSON.stringify(faces),
|
||
);
|
||
for (const doc of (await json('__gm.documents()')).docs)
|
||
await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
|
||
}
|