mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(tabs): Tab Layout setting with by case (A) and ledger (B), reversible state order, cleaner tiles (#426)
Tab Grouping becomes Tab Layout (tabArrangement: 'state' | 'case' | 'ledger' | 'classic', default 'state'), the first row of App Settings, Appearance, Tabs, so the old and the new strip are one choice apart. - By case (option A): each case's tabs sit in one .tab-cluster box in first-appearance order, labelled with the case and its count, coloured by a stable hash into the session palette. Membership is _mobileOverviewCaseFor(), the home screens' own match. Inside a box with company a generated w75-api-gateway reads w75; the -<case> stays in the DOM in a .tab-name-case span only .tabs-clusters hides. The incremental render path rebuilds only when the cluster structure key changes. The rail and the sidebar get a labelled section per case; phones dissolve the boxes into the chip row. Drag stays inside one box. - Ledger (option B): CSS only on .tabs-ledger, desktop header strip: an auto-fill column grid of equal cells in mono type with a 3px status bar. Its markup is identical to classic's. - State Order (tabStateOrder: 'urgent-first' | 'urgent-last'): flips the by-state groups so needs you can be the bottom row. - By state: the label column is measured to the widest label on screen and the labels are right-aligned in it, instead of a fixed 92px gutter that left short labels far from their tabs. - Tiles: a three-row grid (label, value, bar) with pixel line-heights in the bundled JetBrains Mono, 36px like the header. The bar used to lie over a fixed 28px tile, and a taller system mono (SF Mono) pushed the value into it. The WS tile's grid moved onto an inner .connection-tile span because JS writes the indicator's display inline. Compact uses the same font and a matched WS size. Tests: test/tab-clusters.test.ts (new), plus the rename and the reversed order in test/tab-triage.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -201,7 +201,7 @@ describe('header stats wiring (static)', () => {
|
||||
|
||||
it('hides the new-style parts by default, so classic looks exactly as before', () => {
|
||||
expect(css).toMatch(
|
||||
/\.stat-spark,\s*\.connection-tile-label,\s*\.connection-tile-value,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
|
||||
/\.stat-spark,\s*\.connection-tile,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @fileoverview Tab layouts by case and ledger (`tabArrangement: 'case'` and
|
||||
* `'ledger'`, Discussion #426 options A and B).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - By case, each case's tabs sit in ONE box, in the order the case first
|
||||
* appears in the tab order, with a colour that is a pure function of the
|
||||
* case (no storage, the same on every device).
|
||||
* - Inside a box with company, a generated `w75-api-gateway` reads `w75`, but
|
||||
* the full name stays in the DOM and in the accessible name; a custom or
|
||||
* described name is never touched.
|
||||
* - The Alt+N badges keep counting the tab order, and an incremental pass
|
||||
* patches tabs in place unless the cluster structure changed.
|
||||
* - Drag only reorders inside a box. Named groups in the vertical rail win.
|
||||
* - The ledger changes no markup at all: it is a class on #sessionTabs and
|
||||
* CSS scoped to the desktop header strip.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
type Cluster = { key: string; label: string; color: string; ids: string[] };
|
||||
type Clusters = {
|
||||
COLORS: string[];
|
||||
colorFor: (key: string) => string;
|
||||
compute: (rows: Array<{ id: string; key: string; label: string }>) => Cluster[];
|
||||
nameSplit: (name: string, label: string) => { shown: string; hidden: string } | null;
|
||||
};
|
||||
|
||||
function loadClusters(): Clusters {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabClusters: Clusters }).CodemanTabClusters;
|
||||
}
|
||||
|
||||
describe('CodemanTabClusters (pure)', () => {
|
||||
const C = loadClusters();
|
||||
|
||||
it('groups by case in first-appearance order, members in tab order', () => {
|
||||
const out = C.compute([
|
||||
{ id: 'a', key: '/c/web', label: 'web' },
|
||||
{ id: 'b', key: '/c/api', label: 'api' },
|
||||
{ id: 'c', key: '/c/web', label: 'web' },
|
||||
]);
|
||||
expect(out.map((c) => [c.key, c.ids])).toEqual([
|
||||
['/c/web', ['a', 'c']],
|
||||
['/c/api', ['b']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a session with no case a cluster of its own', () => {
|
||||
const out = C.compute([
|
||||
{ id: 'a', key: '', label: '' },
|
||||
{ id: 'b', key: '', label: '' },
|
||||
]);
|
||||
expect(out.map((c) => c.ids)).toEqual([['a'], ['b']]);
|
||||
});
|
||||
|
||||
it('colours a case from the session palette, the same way every time', () => {
|
||||
for (const key of ['/c/web', '/c/api', '/home/x/codeman-cases/long-name', '']) {
|
||||
expect(C.COLORS).toContain(C.colorFor(key));
|
||||
expect(C.colorFor(key)).toBe(C.colorFor(key));
|
||||
}
|
||||
});
|
||||
|
||||
it('only shortens a generated name that carries this case', () => {
|
||||
expect(C.nameSplit('w75-api-gateway', 'api-gateway')).toEqual({ shown: 'w75', hidden: '-api-gateway' });
|
||||
expect(C.nameSplit('s2-API-Gateway', 'api-gateway')).toEqual({ shown: 's2', hidden: '-API-Gateway' });
|
||||
expect(C.nameSplit('w75-api-gateway', 'webshop')).toBeNull();
|
||||
expect(C.nameSplit('w3-x: fix login', 'x')).toBeNull();
|
||||
expect(C.nameSplit('DocsBot', 'docsbot')).toBeNull();
|
||||
expect(C.nameSplit('w1-webshop', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab layouts by case and ledger (app.js)', () => {
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let window: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
window.setInterval = () => 0;
|
||||
window.requestAnimationFrame = () => 0;
|
||||
window.CSS = { escape: (value: string) => value };
|
||||
window.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('mobile-overview.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__ClusterCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = window.__ClusterCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'case') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.cases = [
|
||||
{ name: 'webshop', path: '/c/webshop' },
|
||||
{ name: 'api-gateway', path: '/c/api-gateway' },
|
||||
];
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'w1-webshop', status: 'idle', workingDir: '/c/webshop' }],
|
||||
['s2', { id: 's2', name: 'w75-api-gateway', status: 'busy', workingDir: '/c/api-gateway' }],
|
||||
['s3', { id: 's3', name: 'w3-webshop', status: 'idle', workingDir: '/c/webshop/sub' }],
|
||||
['s4', { id: 's4', name: 'landing', status: 'idle', workingDir: '/srv/landing/' }],
|
||||
['s5', { id: 's5', name: 'w4-webshop: fix login', status: 'busy', workingDir: '/c/webshop' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3', 's4', 's5'];
|
||||
app.pendingHooks = new Map();
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
return app;
|
||||
}
|
||||
|
||||
const container = () => document.getElementById('sessionTabs')!;
|
||||
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
const boxes = () =>
|
||||
[...container().querySelectorAll<HTMLElement>(':scope > .tab-cluster')].map((box) => ({
|
||||
name: box.querySelector('.tab-cluster-name')?.textContent ?? null,
|
||||
count: box.querySelector('.tab-cluster-count')?.textContent ?? null,
|
||||
single: box.classList.contains('tab-cluster--single'),
|
||||
rows: [...box.querySelectorAll<HTMLElement>('.session-tab')].map((t) => t.dataset.id || `web:${t.dataset.webviewId}`),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('puts each case in one box, in first-appearance order, a web tab in its own', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(true);
|
||||
expect(boxes()).toEqual([
|
||||
// s3 sits in a subdirectory of the case and still joins it.
|
||||
{ name: 'webshop', count: '3', single: false, rows: ['s1', 's3', 's5'] },
|
||||
{ name: 'api-gateway', count: '1', single: true, rows: ['s2'] },
|
||||
// No case matches: the directory names the cluster, trailing slash and all.
|
||||
{ name: 'landing', count: '1', single: true, rows: ['s4'] },
|
||||
{ name: null, count: null, single: true, rows: ['web:w1'] },
|
||||
]);
|
||||
for (const box of container().querySelectorAll<HTMLElement>(':scope > .tab-cluster:not(.tab-cluster--web)')) {
|
||||
expect(box.getAttribute('style')).toMatch(/^--cluster-color: var\(--session-(blue|green|purple|orange|pink|yellow|red)\)$/);
|
||||
expect(box.getAttribute('role')).toBe('presentation');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the case from a generated name inside a box with company, and only there', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
const nameOf = (id: string) => tab(id).querySelector('.tab-name')!;
|
||||
expect(nameOf('s1').innerHTML).toBe('w1<span class="tab-name-case">-webshop</span>');
|
||||
expect(nameOf('s1').textContent).toBe('w1-webshop');
|
||||
expect(tab('s1').getAttribute('aria-label')).toContain('w1-webshop');
|
||||
// A described name keeps its #232 treatment.
|
||||
expect(nameOf('s5').querySelector('.tab-name-prefix')?.textContent).toBe('w4-webshop: ');
|
||||
// Alone in its box, nothing is redundant, so nothing is hidden.
|
||||
expect(nameOf('s2').innerHTML).toBe('w75-api-gateway');
|
||||
});
|
||||
|
||||
it('keeps the Alt+N badges on the tab order', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
|
||||
expect(tab('s2').querySelector('.tab-number')?.textContent).toBe('2');
|
||||
expect(container().querySelector('.session-tab[data-webview-id="w1"] .tab-number')?.textContent).toBe('6');
|
||||
});
|
||||
|
||||
it('patches in place while the clusters stay the same, and rebuilds when they change', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const s1 = tab('s1');
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.sessions.get('s1').status = 'busy';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(tab('s1')).toBe(s1);
|
||||
// s4 moves into the webshop case: a different box, so a full rebuild.
|
||||
app.sessions.get('s4').workingDir = '/c/webshop';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).toHaveBeenCalledTimes(1);
|
||||
expect(boxes()[0].rows).toEqual(['s1', 's3', 's4', 's5']);
|
||||
});
|
||||
|
||||
it('only accepts a drop inside the same box', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's1';
|
||||
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('lets named groups in the vertical rail win', () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
app.tabLayout = {
|
||||
version: 1,
|
||||
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
app._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(false);
|
||||
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
|
||||
});
|
||||
|
||||
it('switching back to classic leaves no boxes and no name splits', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-cluster, .tab-name-case')).toBeNull();
|
||||
expect(container().classList.contains('tabs-clusters')).toBe(false);
|
||||
});
|
||||
|
||||
it('draws the ledger with the classic markup and one class', () => {
|
||||
makeApp('classic')._fullRenderSessionTabs();
|
||||
const classic = container().innerHTML;
|
||||
makeApp('ledger')._fullRenderSessionTabs();
|
||||
expect(container().innerHTML).toBe(classic);
|
||||
expect(container().classList.contains('tabs-ledger')).toBe(true);
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab layouts by case and ledger (static)', () => {
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('hides the case part of a name only in the clustered strip', () => {
|
||||
expect(css).toMatch(/\.session-tabs\.tabs-clusters \.tab-name-case \{\s*display: none;/);
|
||||
});
|
||||
|
||||
it('keeps the ledger to the desktop header strip', () => {
|
||||
const media = css.indexOf('@media (min-width: 768px) {\n .session-tabs-host > .session-tabs.tabs-ledger {');
|
||||
expect(media).toBeGreaterThan(-1);
|
||||
// Every ledger rule lives inside that block.
|
||||
const before = css.slice(0, media);
|
||||
expect(before).not.toContain('.tabs-ledger');
|
||||
});
|
||||
|
||||
it('dissolves the boxes into the one chip row on phones', () => {
|
||||
expect(mobileCss).toMatch(
|
||||
/:where\(\.header\) \.session-tabs-host > \.session-tabs\.tabs-clusters > \.tab-cluster \{\s*display: contents;/
|
||||
);
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
@@ -281,9 +281,9 @@ describe('vertical tab rail sort setting', () => {
|
||||
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
|
||||
// A sort flip leaves orientation on 'vertical' both times, so it has to
|
||||
// count as a change of its own or nothing re-renders. The same holds for a
|
||||
// grouping flip (tabGrouping, test/tab-triage.test.ts).
|
||||
// tab layout or state order flip (test/tab-triage.test.ts).
|
||||
expect(settingsUi).toContain(
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousGrouping !== grouping;'
|
||||
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousArrangement !== arrangement || previousStateOrder !== stateOrder;'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+65
-18
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Tab grouping by state (`tabGrouping: 'state'`, Discussion #426
|
||||
* @fileoverview Tab grouping by state (`tabArrangement: 'state'`, Discussion #426
|
||||
* option C): the pure layout in constants.js and the render paths in app.js.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
@@ -12,7 +12,7 @@
|
||||
* - A state change is an INCREMENTAL pass: the tab element survives, only its
|
||||
* `order` and the heading counts move, and a group that empties loses its
|
||||
* heading.
|
||||
* - `tabGrouping: 'none'` leaves no trace (no headings, no inline order, no
|
||||
* - `tabArrangement: 'classic'` leaves no trace (no headings, no inline order, no
|
||||
* class), and named groups in the vertical rail win over it.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
@@ -116,6 +116,24 @@ describe('CodemanTabTriage (pure)', () => {
|
||||
expect(out.webOrder.get('w2')).toBeLessThan(out.groups[0].breakOrder);
|
||||
});
|
||||
|
||||
it('turns the groups the other way up with reverse, rows unchanged inside them', () => {
|
||||
const rows = [
|
||||
{ id: 'a', state: 'idle', pos: 0 },
|
||||
{ id: 'b', state: 'needs', pos: 1 },
|
||||
{ id: 'c', state: 'working', pos: 2 },
|
||||
{ id: 'd', state: 'working', pos: 3 },
|
||||
];
|
||||
const out = triage.layout(rows, ['w1'], { reverse: true });
|
||||
expect(out.groups.map((g) => g.key)).toEqual(['idle', 'working', 'needs']);
|
||||
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
|
||||
expect(sorted).toEqual(['a', 'c', 'd', 'b']);
|
||||
// Web tabs still close the idle group, which now comes first.
|
||||
expect(out.webOrder.get('w1')).toBeLessThan(out.order.get('c')!);
|
||||
for (let i = 1; i < out.groups.length; i++) {
|
||||
expect(out.groups[i].headOrder).toBeGreaterThan(out.groups[i - 1].breakOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the idle group for web tabs alone, and nothing for nothing', () => {
|
||||
expect(triage.layout([], ['w1']).groups.map((g) => g.key)).toEqual(['idle']);
|
||||
expect(triage.layout([], []).groups).toEqual([]);
|
||||
@@ -158,12 +176,13 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
CodemanApp = window.__TriageCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp(grouping: 'state' | 'none' = 'state') {
|
||||
function makeApp(arrangement: 'state' | 'case' | 'ledger' | 'classic' = 'state') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-tab-orientation', 'horizontal');
|
||||
root.dataset.tabRailSort = 'activity';
|
||||
root.dataset.tabGrouping = grouping;
|
||||
root.dataset.tabArrangement = arrangement;
|
||||
delete root.dataset.tabStateOrder;
|
||||
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
@@ -246,6 +265,24 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("puts needs you in the bottom row with tabStateOrder 'urgent-last'", () => {
|
||||
const app = makeApp();
|
||||
document.documentElement.dataset.tabStateOrder = 'urgent-last';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(heads()).toEqual(['Idle:1', 'Working:2', 'Waiting:1', 'Needs you:1']);
|
||||
expect(visual()).toEqual([
|
||||
'head:idle',
|
||||
'web:w1',
|
||||
'head:working',
|
||||
's2',
|
||||
's4',
|
||||
'head:waiting',
|
||||
's1',
|
||||
'head:needs',
|
||||
's3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the DOM, and with it the Alt+N badges, in tab order', () => {
|
||||
makeApp()._fullRenderSessionTabs();
|
||||
const domOrder = [...container().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.dataset.id);
|
||||
@@ -289,8 +326,8 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
expect(visual()).toEqual(['head:needs', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
|
||||
});
|
||||
|
||||
it("leaves no trace with tabGrouping 'none'", () => {
|
||||
makeApp('none')._fullRenderSessionTabs();
|
||||
it("leaves no trace with tabArrangement 'classic'", () => {
|
||||
makeApp('classic')._fullRenderSessionTabs();
|
||||
expect(container().classList.contains('tabs-triage')).toBe(false);
|
||||
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
|
||||
expect(container().innerHTML).not.toContain('order:');
|
||||
@@ -300,7 +337,7 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
it('switching off removes the headings and every inline order it wrote', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabGrouping = 'none';
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
|
||||
for (const el of container().querySelectorAll<HTMLElement>('.session-tab')) expect(el.style.order).toBe('');
|
||||
@@ -345,12 +382,12 @@ describe('tab grouping in the render paths (app.js)', () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.draggedTabId = 's2';
|
||||
expect(app._isTabDropAcrossTriageGroups(tab('s4'))).toBe(false);
|
||||
expect(app._isTabDropAcrossTriageGroups(tab('s1'))).toBe(true);
|
||||
expect(app._isTabDropAcrossTriageGroups(tab('s3'))).toBe(true);
|
||||
document.documentElement.dataset.tabGrouping = 'none';
|
||||
expect(app._isTabDropAcrossGroups(tab('s4'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(true);
|
||||
expect(app._isTabDropAcrossGroups(tab('s3'))).toBe(true);
|
||||
document.documentElement.dataset.tabArrangement = 'classic';
|
||||
app._fullRenderSessionTabs();
|
||||
expect(app._isTabDropAcrossTriageGroups(tab('s1'))).toBe(false);
|
||||
expect(app._isTabDropAcrossGroups(tab('s1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
|
||||
@@ -367,14 +404,24 @@ describe('tab grouping wiring (static)', () => {
|
||||
const css = read('styles.css');
|
||||
const mobileCss = read('mobile.css');
|
||||
|
||||
it('stamps data-tab-grouping before first paint, defaulting to state', () => {
|
||||
expect(html).toContain("dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state'");
|
||||
// The catch branch (localStorage threw) must set it too.
|
||||
expect(html).toContain("document.documentElement.dataset.tabGrouping='state';");
|
||||
it('stamps data-tab-arrangement and data-tab-state-order before first paint', () => {
|
||||
expect(html).toContain(
|
||||
"dataset.tabArrangement=(T==='case'||T==='ledger'||T==='classic')?T:'state'"
|
||||
);
|
||||
expect(html).toContain("dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first'");
|
||||
// The catch branch (localStorage threw) must set both too.
|
||||
expect(html).toContain(
|
||||
"document.documentElement.dataset.tabArrangement='state';document.documentElement.dataset.tabStateOrder='urgent-first';"
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the setting with "By state" as the default', () => {
|
||||
expect(html).toMatch(/<select id="appSettingsTabGrouping"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="none">/);
|
||||
it('offers the four layouts with "By state" as the default, and the state order', () => {
|
||||
expect(html).toMatch(
|
||||
/<select id="appSettingsTabArrangement"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="case">[^<]+<\/option>\s*<option value="ledger">[^<]+<\/option>\s*<option value="classic">Classic \(as before\)<\/option>/
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<select id="appSettingsTabStateOrder"[^>]*>\s*<option value="urgent-first">Needs you on top \(default\)<\/option>\s*<option value="urgent-last">/
|
||||
);
|
||||
});
|
||||
|
||||
it('only shows row breaks in a wrapping header strip', () => {
|
||||
|
||||
Reference in New Issue
Block a user