Harden disclosure visuals and download toolbar tail
Replace the synthetic disclosure interpolation test with a real Chrome headless run against the existing visual harness, sampling rendered height, transform, opacity, and adjacent row geometry during collapse and expansion. Group the global downloads disclosure action in a responsive toolbar tail, remove the horizontal toolbar scroll path, and keep the action pinned within the supported 1120 pixel layout while the schedule is open. Disable the global disclosure action for a truly empty queue and cover the empty model plus responsive DOM boundaries.
This commit is contained in:
@@ -149,10 +149,12 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
|||||||
</span>
|
</span>
|
||||||
<span className="downloads-toolbar-divider" />
|
<span className="downloads-toolbar-divider" />
|
||||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
|
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
|
||||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
||||||
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
||||||
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
||||||
<button className="downloads-toolbar-toggle-all" onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
<span aria-label="Paketdarstellung" className="downloads-toolbar-tail" role="group">
|
||||||
|
<button className="downloads-toolbar-toggle-all" disabled={model.empty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,8 +204,10 @@
|
|||||||
.downloads-toolbar {
|
.downloads-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
|
overflow-x: hidden;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-bottom: 1px solid var(--ui-border);
|
border-bottom: 1px solid var(--ui-border);
|
||||||
background: var(--ui-surface);
|
background: var(--ui-surface);
|
||||||
@@ -237,7 +239,10 @@
|
|||||||
background: var(--ui-border);
|
background: var(--ui-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-toolbar-toggle-all {
|
.downloads-toolbar-tail {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,7 +950,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.downloads-toolbar,
|
|
||||||
.downloads-footer {
|
.downloads-footer {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1108,13 +1108,23 @@ describe("downloads view", () => {
|
|||||||
visitElements(toolbar, (element) => {
|
visitElements(toolbar, (element) => {
|
||||||
if (element.type === "button") toolbarButtons.push(element);
|
if (element.type === "button") toolbarButtons.push(element);
|
||||||
});
|
});
|
||||||
|
const tail = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-toolbar-tail"));
|
||||||
|
|
||||||
expect(sidebar).not.toContain("Alle ein-/ausklappen");
|
expect(sidebar).not.toContain("Alle ein-/ausklappen");
|
||||||
|
expect(tail.props.children).toBeTruthy();
|
||||||
expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen");
|
expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen");
|
||||||
findButton(toolbar, "Alle ein-/ausklappen").props.onClick();
|
findButton(toolbar, "Alle ein-/ausklappen").props.onClick();
|
||||||
expect(toggles).toBe(1);
|
expect(toggles).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables the global package disclosure action for a truly empty queue", () => {
|
||||||
|
const model = withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false });
|
||||||
|
const toolbar = DownloadsToolbar({ actions: createActions(), model });
|
||||||
|
|
||||||
|
expect(model.empty).toBe(true);
|
||||||
|
expect(findButton(toolbar, "Alle ein-/ausklappen").props.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("blocks native package dragging while preserving explicit reorder actions", () => {
|
it("blocks native package dragging while preserving explicit reorder actions", () => {
|
||||||
const model = withRuntime(createInput());
|
const model = withRuntime(createInput());
|
||||||
const component = PackageCardContent({
|
const component = PackageCardContent({
|
||||||
|
|||||||
@@ -1,107 +1,351 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import type { DownloadLogicalRow } from "../../src/renderer/views/downloads/downloads-model";
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import {
|
import { createServer } from "node:net";
|
||||||
activateDownloadDisclosureTransition,
|
import { tmpdir } from "node:os";
|
||||||
prepareDownloadDisclosureTransition,
|
import path from "node:path";
|
||||||
stableDownloadDisclosureRows,
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
type DownloadDisclosureRow
|
|
||||||
} from "../../src/renderer/views/downloads/download-disclosure-transition";
|
|
||||||
import {
|
|
||||||
DOWNLOAD_FILE_ROW_HEIGHT,
|
|
||||||
DOWNLOAD_PACKAGE_ROW_HEIGHT
|
|
||||||
} from "../../src/renderer/views/downloads/download-virtualizer";
|
|
||||||
|
|
||||||
type DisclosureFrameRow = {
|
type CdpResponse = {
|
||||||
id: string;
|
id?: number;
|
||||||
top: number;
|
error?: unknown;
|
||||||
bottom: number;
|
result?: {
|
||||||
opacity: number;
|
result?: {
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
exceptionDetails?: unknown;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function packageRow(id: string, collapsed: boolean, itemIds: readonly string[]): DownloadLogicalRow {
|
type DisclosureSample = {
|
||||||
return {
|
elapsed: number;
|
||||||
type: "package",
|
groupBottom: number;
|
||||||
id,
|
followingTop: number;
|
||||||
packageId: id,
|
cssHeight: number;
|
||||||
height: DOWNLOAD_PACKAGE_ROW_HEIGHT,
|
cssTransform: string;
|
||||||
packageRow: {
|
cssOpacity: number;
|
||||||
id,
|
};
|
||||||
collapsed,
|
|
||||||
items: itemIds.map((itemId) => ({ id: itemId, packageId: id }))
|
type ToolbarMeasurement = {
|
||||||
|
expanded: string | null;
|
||||||
|
clientWidth: number;
|
||||||
|
scrollWidth: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
toolbar: DOMRect;
|
||||||
|
tail: DOMRect | null;
|
||||||
|
toggle: DOMRect;
|
||||||
|
schedule: DOMRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CdpClient {
|
||||||
|
private nextId = 0;
|
||||||
|
private readonly pending = new Map<number, {
|
||||||
|
resolve: (value: CdpResponse["result"]) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
constructor(private readonly socket: WebSocket) {
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
const response = JSON.parse(String(event.data)) as CdpResponse;
|
||||||
|
if (!response.id) return;
|
||||||
|
const pending = this.pending.get(response.id);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(response.id);
|
||||||
|
if (response.error) pending.reject(new Error(JSON.stringify(response.error)));
|
||||||
|
else pending.resolve(response.result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(url: string): Promise<CdpClient> {
|
||||||
|
const socket = new WebSocket(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("Chrome DevTools connection timed out")), 10_000);
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}, { once: true });
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("Chrome DevTools connection failed"));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
return new CdpClient(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(method: string, params: Record<string, unknown> = {}): Promise<CdpResponse["result"]> {
|
||||||
|
const id = ++this.nextId;
|
||||||
|
this.socket.send(JSON.stringify({ id, method, params }));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate<T>(expression: string): Promise<T> {
|
||||||
|
const response = await this.send("Runtime.evaluate", {
|
||||||
|
expression,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true
|
||||||
|
});
|
||||||
|
if (response?.exceptionDetails) {
|
||||||
|
throw new Error(JSON.stringify(response.exceptionDetails));
|
||||||
}
|
}
|
||||||
} as unknown as DownloadLogicalRow;
|
return response?.result?.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemRow(id: string, packageId: string): DownloadLogicalRow {
|
function delay(milliseconds: number): Promise<void> {
|
||||||
return {
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
type: "item",
|
|
||||||
id,
|
|
||||||
packageId,
|
|
||||||
height: DOWNLOAD_FILE_ROW_HEIGHT,
|
|
||||||
item: { id, packageId }
|
|
||||||
} as unknown as DownloadLogicalRow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function logicalRows(firstCollapsed: boolean): DownloadLogicalRow[] {
|
function reservePort(): Promise<number> {
|
||||||
return [
|
return new Promise((resolve, reject) => {
|
||||||
packageRow("package-a", firstCollapsed, ["a-1", "a-2"]),
|
const server = createServer();
|
||||||
...(firstCollapsed ? [] : [itemRow("a-1", "package-a"), itemRow("a-2", "package-a")]),
|
server.once("error", reject);
|
||||||
packageRow("package-b", false, ["b-1"]),
|
server.listen(0, "127.0.0.1", () => {
|
||||||
itemRow("b-1", "package-b")
|
const address = server.address();
|
||||||
];
|
if (!address || typeof address === "string") {
|
||||||
}
|
server.close();
|
||||||
|
reject(new Error("Could not reserve a local port"));
|
||||||
function disclosureFrame(
|
return;
|
||||||
startRows: readonly DownloadDisclosureRow[],
|
}
|
||||||
targetRows: readonly DownloadDisclosureRow[],
|
server.close((error) => {
|
||||||
progress: number
|
if (error) reject(error);
|
||||||
): DisclosureFrameRow[] {
|
else resolve(address.port);
|
||||||
const targetById = new Map(targetRows.map((row) => [row.id, row]));
|
});
|
||||||
let top = 0;
|
});
|
||||||
return startRows.map((row) => {
|
|
||||||
const target = targetById.get(row.id) ?? row;
|
|
||||||
const height = row.height + (target.height - row.height) * progress;
|
|
||||||
const opacity = row.disclosureOpacity + (target.disclosureOpacity - row.disclosureOpacity) * progress;
|
|
||||||
const frameRow = { id: row.id, top, bottom: top + height, opacity };
|
|
||||||
top += height;
|
|
||||||
return frameRow;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareFrameRows(beforeCollapsed: boolean, afterCollapsed: boolean): {
|
async function waitForJson<T>(url: string, timeoutMs = 20_000): Promise<T> {
|
||||||
start: DownloadDisclosureRow[];
|
const deadline = Date.now() + timeoutMs;
|
||||||
target: DownloadDisclosureRow[];
|
let lastError: unknown;
|
||||||
} {
|
while (Date.now() < deadline) {
|
||||||
const prepared = prepareDownloadDisclosureTransition(
|
try {
|
||||||
stableDownloadDisclosureRows(logicalRows(beforeCollapsed)),
|
const response = await fetch(url);
|
||||||
logicalRows(afterCollapsed)
|
if (response.ok) return await response.json() as T;
|
||||||
);
|
} catch (error) {
|
||||||
expect(prepared.animated).toBe(true);
|
lastError = error;
|
||||||
return {
|
}
|
||||||
start: prepared.rows,
|
await delay(50);
|
||||||
target: activateDownloadDisclosureTransition(prepared.rows)
|
}
|
||||||
};
|
throw new Error(`Endpoint did not become ready: ${url} ${String(lastError ?? "")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("download disclosure render contract", () => {
|
function resolveChromeExecutable(): string {
|
||||||
it.each([
|
const candidates = [
|
||||||
{ beforeCollapsed: true, afterCollapsed: false, startOpacity: 0, targetOpacity: 1 },
|
process.env.CHROME_PATH,
|
||||||
{ beforeCollapsed: false, afterCollapsed: true, startOpacity: 1, targetOpacity: 0 }
|
process.env.PROGRAMFILES ? path.join(process.env.PROGRAMFILES, "Google", "Chrome", "Application", "chrome.exe") : undefined,
|
||||||
])("keeps the shared clipping group ahead of the following package in every measured frame", ({ beforeCollapsed, afterCollapsed, startOpacity, targetOpacity }) => {
|
process.env["PROGRAMFILES(X86)"] ? path.join(process.env["PROGRAMFILES(X86)"], "Microsoft", "Edge", "Application", "msedge.exe") : undefined,
|
||||||
const { start, target } = prepareFrameRows(beforeCollapsed, afterCollapsed);
|
"/usr/bin/google-chrome",
|
||||||
const measuredFrames = [0, 0.2, 0.5, 0.8, 1].map((progress) => disclosureFrame(start, target, progress));
|
"/usr/bin/google-chrome-stable",
|
||||||
|
"/usr/bin/chromium",
|
||||||
|
"/usr/bin/chromium-browser"
|
||||||
|
].filter((candidate): candidate is string => Boolean(candidate));
|
||||||
|
const executable = candidates.find(existsSync);
|
||||||
|
if (!executable) throw new Error("Chrome or Chromium executable is missing");
|
||||||
|
return executable;
|
||||||
|
}
|
||||||
|
|
||||||
for (const frame of measuredFrames) {
|
describe("download disclosure in the headless visual harness", () => {
|
||||||
const group = frame.find((row) => row.id === "package-a:items");
|
let visualPort = 0;
|
||||||
const following = frame.find((row) => row.id === "package-b");
|
let chromePort = 0;
|
||||||
expect(group).toBeDefined();
|
let viteProcess: ChildProcess | null = null;
|
||||||
expect(following).toBeDefined();
|
let chromeProcess: ChildProcess | null = null;
|
||||||
expect(group!.bottom).toBeLessThanOrEqual(following!.top + 1);
|
let chromeProfile = "";
|
||||||
|
let client: CdpClient | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
visualPort = await reservePort();
|
||||||
|
chromePort = await reservePort();
|
||||||
|
const viteExecutable = path.join(process.cwd(), "node_modules", "vite", "bin", "vite.js");
|
||||||
|
viteProcess = spawn(process.execPath, [
|
||||||
|
viteExecutable,
|
||||||
|
"--config",
|
||||||
|
"tests/visual/vite.config.mts",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
String(visualPort),
|
||||||
|
"--strictPort"
|
||||||
|
], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
await waitForJson(`http://127.0.0.1:${visualPort}/capture-manifest.json`);
|
||||||
|
|
||||||
|
chromeProfile = mkdtempSync(path.join(tmpdir(), "mdd-task4-chrome-"));
|
||||||
|
chromeProcess = spawn(resolveChromeExecutable(), [
|
||||||
|
"--headless=new",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-default-apps",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--no-first-run",
|
||||||
|
"--remote-debugging-address=127.0.0.1",
|
||||||
|
`--remote-debugging-port=${chromePort}`,
|
||||||
|
`--user-data-dir=${chromeProfile}`,
|
||||||
|
`http://127.0.0.1:${visualPort}/?scenario=dense&motion=1`
|
||||||
|
], {
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
const targets = await waitForJson<Array<{ type: string; url: string; webSocketDebuggerUrl: string }>>(
|
||||||
|
`http://127.0.0.1:${chromePort}/json`
|
||||||
|
);
|
||||||
|
const target = targets.find((entry) => entry.type === "page" && entry.url.includes(`127.0.0.1:${visualPort}`));
|
||||||
|
if (!target) throw new Error("Visual harness page target is missing");
|
||||||
|
client = await CdpClient.connect(target.webSocketDebuggerUrl);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await Promise.race([client.send("Browser.close"), delay(1_000)]);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
if (chromeProcess && chromeProcess.exitCode === null) chromeProcess.kill();
|
||||||
|
if (viteProcess && viteProcess.exitCode === null) viteProcess.kill();
|
||||||
|
await delay(250);
|
||||||
|
if (chromeProfile) {
|
||||||
|
const resolvedProfile = path.resolve(chromeProfile);
|
||||||
|
const resolvedTempRoot = `${path.resolve(tmpdir())}${path.sep}`;
|
||||||
|
if (!resolvedProfile.startsWith(resolvedTempRoot)) {
|
||||||
|
throw new Error(`Chrome profile escaped the temporary directory: ${resolvedProfile}`);
|
||||||
|
}
|
||||||
|
rmSync(resolvedProfile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
async function loadDenseDownloads(width: number): Promise<void> {
|
||||||
|
if (!client) throw new Error("Chrome DevTools client is missing");
|
||||||
|
await client.send("Emulation.setDeviceMetricsOverride", {
|
||||||
|
width,
|
||||||
|
height: 760,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
mobile: false
|
||||||
|
});
|
||||||
|
await client.send("Page.navigate", {
|
||||||
|
url: `http://127.0.0.1:${visualPort}/?scenario=dense&motion=1&run=${Date.now()}`
|
||||||
|
});
|
||||||
|
const deadline = Date.now() + 20_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const ready = await client.evaluate<boolean>("document.documentElement.dataset.visualReady === 'true'");
|
||||||
|
if (ready) return;
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
await delay(50);
|
||||||
|
}
|
||||||
|
throw new Error("Visual harness did not reach its ready state");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise<DisclosureSample[]> {
|
||||||
|
if (!client) throw new Error("Chrome DevTools client is missing");
|
||||||
|
return client.evaluate<DisclosureSample[]>(`(async () => {
|
||||||
|
const button = [...document.querySelectorAll(".downloads-collapse-button")]
|
||||||
|
.find((element) => element.getAttribute("aria-label")?.endsWith(" ${action}"));
|
||||||
|
if (!(button instanceof HTMLButtonElement)) throw new Error("Disclosure button is missing");
|
||||||
|
button.click();
|
||||||
|
const samples = [];
|
||||||
|
const startedAt = performance.now();
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const measure = (now) => {
|
||||||
|
const rows = [...document.querySelectorAll(".downloads-virtual-row")];
|
||||||
|
const group = rows.find((row) => row.classList.contains("is-disclosure-group"));
|
||||||
|
const groupIndex = rows.indexOf(group);
|
||||||
|
const following = rows.slice(groupIndex + 1).find((row) => !row.classList.contains("is-disclosure-group"));
|
||||||
|
if (group instanceof HTMLElement && following instanceof HTMLElement) {
|
||||||
|
const groupRect = group.getBoundingClientRect();
|
||||||
|
const followingRect = following.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(group);
|
||||||
|
samples.push({
|
||||||
|
elapsed: now - startedAt,
|
||||||
|
groupBottom: groupRect.bottom,
|
||||||
|
followingTop: followingRect.top,
|
||||||
|
cssHeight: Number.parseFloat(style.height),
|
||||||
|
cssTransform: style.transform,
|
||||||
|
cssOpacity: Number.parseFloat(style.opacity)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (now - startedAt < 1_650) requestAnimationFrame(measure);
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
requestAnimationFrame(measure);
|
||||||
|
});
|
||||||
|
return samples;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("measures real collapse and expand frames without overlap", async () => {
|
||||||
|
await loadDenseDownloads(1120);
|
||||||
|
const collapse = await measureDisclosure("einklappen");
|
||||||
|
const expand = await measureDisclosure("ausklappen");
|
||||||
|
|
||||||
|
for (const samples of [collapse, expand]) {
|
||||||
|
expect(samples.length).toBeGreaterThan(20);
|
||||||
|
expect(samples.every((sample) => sample.groupBottom <= sample.followingTop + 1)).toBe(true);
|
||||||
|
expect(samples.some((sample) => sample.cssHeight > 1 && sample.cssHeight < 75)).toBe(true);
|
||||||
|
expect(samples.some((sample) => sample.cssOpacity > 0.01 && sample.cssOpacity < 0.99)).toBe(true);
|
||||||
|
expect(samples.every((sample) => /^matrix/.test(sample.cssTransform))).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupOpacities = measuredFrames.map((frame) => frame.find((row) => row.id === "package-a:items")!.opacity);
|
expect(collapse[0].cssHeight).toBeGreaterThan(75);
|
||||||
expect(groupOpacities[0]).toBe(startOpacity);
|
expect(collapse[0].cssOpacity).toBeGreaterThan(0.99);
|
||||||
expect(groupOpacities.at(-1)).toBe(targetOpacity);
|
expect(collapse.at(-1)!.cssHeight).toBeLessThan(1);
|
||||||
expect(groupOpacities[2]).toBe((startOpacity + targetOpacity) / 2);
|
expect(collapse.at(-1)!.cssOpacity).toBeLessThan(0.01);
|
||||||
});
|
expect(expand[0].cssHeight).toBeLessThan(1);
|
||||||
|
expect(expand[0].cssOpacity).toBeLessThan(0.01);
|
||||||
|
expect(expand.at(-1)!.cssHeight).toBeGreaterThan(75);
|
||||||
|
expect(expand.at(-1)!.cssOpacity).toBeGreaterThan(0.99);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("keeps the open schedule and toolbar tail visible at 1120 pixels without horizontal scrolling", async () => {
|
||||||
|
await loadDenseDownloads(1120);
|
||||||
|
if (!client) throw new Error("Chrome DevTools client is missing");
|
||||||
|
const measurement = await client.evaluate<ToolbarMeasurement>(`(async () => {
|
||||||
|
const toolbar = document.querySelector(".downloads-toolbar");
|
||||||
|
const trigger = toolbar?.querySelector("button[aria-expanded]");
|
||||||
|
if (!(toolbar instanceof HTMLElement) || !(trigger instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error("Download toolbar or schedule trigger is missing");
|
||||||
|
}
|
||||||
|
trigger.click();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||||
|
const tail = toolbar.querySelector(".downloads-toolbar-tail");
|
||||||
|
const toggle = [...toolbar.querySelectorAll("button")]
|
||||||
|
.find((button) => button.textContent === "Alle ein-/ausklappen");
|
||||||
|
const schedule = toolbar.querySelector(".downloads-schedule-slot");
|
||||||
|
if (!(toggle instanceof HTMLButtonElement) || !(schedule instanceof HTMLElement)) {
|
||||||
|
throw new Error("Toolbar toggle or schedule slot is missing");
|
||||||
|
}
|
||||||
|
toolbar.scrollLeft = 10_000;
|
||||||
|
return {
|
||||||
|
expanded: trigger.getAttribute("aria-expanded"),
|
||||||
|
clientWidth: toolbar.clientWidth,
|
||||||
|
scrollWidth: toolbar.scrollWidth,
|
||||||
|
scrollLeft: toolbar.scrollLeft,
|
||||||
|
toolbar: toolbar.getBoundingClientRect().toJSON(),
|
||||||
|
tail: tail?.getBoundingClientRect().toJSON() ?? null,
|
||||||
|
toggle: toggle.getBoundingClientRect().toJSON(),
|
||||||
|
schedule: schedule.getBoundingClientRect().toJSON()
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
expect(measurement.expanded).toBe("true");
|
||||||
|
expect(measurement.tail).not.toBeNull();
|
||||||
|
expect(measurement.scrollWidth).toBeLessThanOrEqual(measurement.clientWidth + 1);
|
||||||
|
expect(measurement.scrollLeft).toBe(0);
|
||||||
|
expect(measurement.schedule.width).toBeGreaterThan(0);
|
||||||
|
expect(measurement.tail!.left).toBeGreaterThanOrEqual(measurement.toolbar.left - 1);
|
||||||
|
expect(measurement.tail!.right).toBeLessThanOrEqual(measurement.toolbar.right + 1);
|
||||||
|
expect(measurement.toggle.left).toBeGreaterThanOrEqual(measurement.toolbar.left - 1);
|
||||||
|
expect(measurement.toggle.right).toBeLessThanOrEqual(measurement.toolbar.right + 1);
|
||||||
|
expect(measurement.toolbar.right - measurement.toggle.right).toBeLessThanOrEqual(12);
|
||||||
|
}, 30_000);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user