rework live dashboard grid layout and add natural mode

This commit is contained in:
Josh Hawkins
2026-09-24 13:26:57 -05:00
parent 40f8ba1f7f
commit e760868048
19 changed files with 1375 additions and 253 deletions
+204
View File
@@ -0,0 +1,204 @@
/**
* Helpers for the live dashboard's draggable grid layout: reading and seeding
* the persisted layout, and measuring rendered tiles.
*
* DraggableGridLayout persists through useUserPersistence, which namespaces
* keys by username, and every write is an async idb put. A test that seeds the
* bare key, or seeds before the app's own first write has landed, silently
* asserts against a key the app never reads. persistedLayoutKey() closes both
* holes, so prefer it over building the key by hand.
*
* Geometry has its own trap: the grid first lays out against window.innerWidth,
* then reflows narrower once useResizeObserver reports the real container.
* Tiles measured in separate round-trips can straddle that reflow and disagree
* on scale, so cameraBoxes() takes every measurement in one evaluate.
*
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
*/
import { expect, type Page } from "@playwright/test";
export type LayoutItem = {
i: string;
x: number;
y: number;
w: number;
h: number;
};
export type PersistedLayout = {
version: number;
naturalAspect: boolean;
layout: LayoutItem[];
};
function layoutKeySuffix(group: string): string {
return `${group}-draggable-layout`;
}
/**
* The key the app has actually written an envelope to, or undefined while its
* first write is still in flight.
*/
function findWrittenKey(
page: Page,
group: string,
): Promise<string | undefined> {
return page.evaluate(
(suffix) =>
new Promise<string | undefined>((resolve) => {
const open = indexedDB.open("keyval-store");
open.onsuccess = () => {
const store = open.result
.transaction("keyval", "readonly")
.objectStore("keyval");
// getAllKeys and getAll both return in key order, so the indexes align
const keys = store.getAllKeys();
const values = store.getAll();
keys.transaction.oncomplete = () => {
open.result.close();
const names = keys.result as string[];
const stored = values.result as { version?: number }[];
const match = names.findIndex(
(name, index) =>
(name === suffix || name.startsWith(`${suffix}:`)) &&
typeof stored[index]?.version === "number",
);
resolve(match === -1 ? undefined : names[match]);
};
};
open.onerror = () => resolve(undefined);
}),
layoutKeySuffix(group),
);
}
/**
* Wait for the grid to persist its own layout, then return the key it used.
* Waiting for that write is what makes a later seed meaningful: it proves the
* key is live, and it rules out the app overwriting the seed a moment later.
*/
export async function persistedLayoutKey(
page: Page,
group: string,
): Promise<string> {
let key: string | undefined;
await expect
.poll(async () => (key = await findWrittenKey(page, group)), {
timeout: 10_000,
message: `grid never persisted a layout for group "${group}"`,
})
.not.toBeUndefined();
return key!;
}
/** Overwrite the stored layout, resolving only once the put has committed. */
export function seedLayout(
page: Page,
key: string,
value: unknown,
): Promise<void> {
return page.evaluate(
([key, value]) =>
new Promise<void>((resolve, reject) => {
const open = indexedDB.open("keyval-store");
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
open.onsuccess = () => {
const tx = open.result.transaction("keyval", "readwrite");
tx.objectStore("keyval").put(value, key as string);
tx.oncomplete = () => {
open.result.close();
resolve();
};
tx.onerror = () => reject(tx.error);
};
open.onerror = () => reject(open.error);
}),
[key, value] as const,
);
}
/** Read the stored layout back. Undefined until the app writes it. */
export function readLayout(
page: Page,
key: string,
): Promise<PersistedLayout | undefined> {
return page.evaluate(
(target) =>
new Promise((resolve) => {
const open = indexedDB.open("keyval-store");
open.onsuccess = () => {
const tx = open.result.transaction("keyval", "readonly");
const request = tx.objectStore("keyval").get(target);
tx.oncomplete = () => {
open.result.close();
resolve(request.result);
};
};
open.onerror = () => resolve(undefined);
}),
key,
) as Promise<PersistedLayout | undefined>;
}
export type Box = { w: number; h: number; x: number; y: number };
/** The card is the player root; the cell is the grid slot it sits in. */
export type BoxTarget = "card" | "cell";
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
function snapshotBoxes(
page: Page,
cameras: readonly string[],
target: BoxTarget,
): Promise<Record<string, Box> | null> {
return page.evaluate(
({ cams, target }) => {
const boxes: Record<string, Box> = {};
for (const cam of cams) {
const card = document.querySelector(`[data-camera='${cam}']`);
const el = target === "cell" ? card?.closest(".p-1") : card;
if (!el) {
return null;
}
const r = el.getBoundingClientRect();
// a re-rendering tile can briefly report no box at all
if (!r.width || !r.height) {
return null;
}
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
}
return boxes;
},
{ cams: cameras as readonly string[], target },
);
}
/**
* Measure the given cameras' tiles together, once they have all rendered.
* Measuring in one evaluate is what keeps the numbers mutually comparable.
*/
export async function cameraBoxes<T extends string>(
page: Page,
cameras: readonly T[],
target: BoxTarget = "cell",
): Promise<Record<T, Box>> {
let boxes: Record<string, Box> | null = null;
await expect
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
timeout: 10_000,
message: `${target}s never rendered for ${cameras.join(", ")}`,
})
.not.toBeNull();
return boxes as unknown as Record<T, Box>;
}
+5
View File
@@ -45,6 +45,11 @@ export class LivePage extends BasePage {
);
}
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
get editLayoutButton(): Locator {
return this.page.getByTestId("toggle-edit-layout");
}
/** Open the right-click context menu on a camera card (desktop only). */
async openContextMenuOn(cameraName: string): Promise<Locator> {
await this.cameraCard(cameraName).first().click({ button: "right" });
@@ -0,0 +1,185 @@
/**
* Live grid aspect modes.
*
* Bucketed mode (the default) snaps every camera to a wide, landscape or tall
* tile, and converts layouts saved by pre-masonry versions instead of
* discarding them. Natural mode sizes each tile to its own camera.
*/
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
import {
cameraBoxes,
persistedLayoutKey,
readLayout,
seedLayout,
type LayoutItem,
} from "../helpers/grid-layout";
const GROUP = "outdoor";
const GRID_COLS = 96;
test.describe("Live grid aspect modes @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Draggable grid is desktop-only",
);
test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 2560, height: 720 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
const { backyard: wide, front_door: normal } = await cameraBoxes(
frigateApp.page,
["backyard", "front_door"] as const,
);
expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1);
expect(wide.w / normal.w).toBeCloseTo(2, 1);
expect(wide.h).toBeCloseTo(normal.h, 0);
});
test("a letterboxed still image rounds its own corners", async ({
frigateApp,
}) => {
// A portrait camera pillarboxes inside its 8:9 bucket, so the card's
// overflow-hidden clip never reaches the picture's corners. The image has
// to carry the radius itself or it renders with square edges on the tile.
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
const radii = await frigateApp.page.evaluate(() => {
const card = document.querySelector("[data-camera='backyard']");
const img = card?.querySelector("img");
return {
card: card ? getComputedStyle(card).borderTopLeftRadius : null,
img: img ? getComputedStyle(img).borderTopLeftRadius : null,
};
});
expect(radii.card).not.toBe("0px");
expect(radii.img).toBe(radii.card);
});
test("a pre-masonry layout is converted, keeping resized tiles", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles.
// backyard was manually resized to 8x8 and sits beside front_door's column,
// front_door is a standard tile on the row below.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, [
{ i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false },
{ i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false },
]);
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// The conversion is written back on first load, replacing the legacy array
// with an envelope. Poll for it: that write is an async idb put.
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBe(2);
const stored = (await readLayout(frigateApp.page, key))!;
expect(stored).toMatchObject({ version: 2, naturalAspect: false });
// x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per
// standard tile), so the manual resize survives instead of snapping back.
expect(
stored.layout.find((i: LayoutItem) => i.i === "backyard"),
).toMatchObject({
x: 32,
y: 0,
w: 64,
h: 144,
});
expect(
stored.layout.find((i: LayoutItem) => i.i === "front_door"),
).toMatchObject({
x: 0,
y: 144,
w: 32,
h: 72,
});
// arrangement on screen: backyard indented, front_door below it
const { backyard, front_door: frontDoor } = await cameraBoxes(
frigateApp.page,
["backyard", "front_door"] as const,
);
expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2);
expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2);
});
test("conversion is a pure scale, so odd sizes and positions survive", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// The old grid exposed all four resize corners with no aspect constraint,
// so a stored tile can be any size. These two are adjacent and non-standard.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, [
{ i: "front_door", x: 0, y: 3, w: 5, h: 5 },
{ i: "backyard", x: 5, y: 3, w: 7, h: 5 },
]);
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
await expect(live.cameraCard("backyard").first()).toBeVisible({
timeout: 10_000,
});
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBe(2);
const stored = (await readLayout(frigateApp.page, key))!;
const frontDoor = stored.layout.find(
(i: LayoutItem) => i.i === "front_door",
)!;
const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!;
expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 });
expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 });
// still adjacent, still inside the grid, still not overlapping
expect(frontDoor.x + frontDoor.w).toBe(backyard.x);
expect(backyard.x + backyard.w).toBe(GRID_COLS);
});
});
+269
View File
@@ -0,0 +1,269 @@
/**
* Masonry live grid -- custom-group draggable layout.
*
* Verifies natural-aspect tile sizing and that a saved layout the current
* version cannot read is regenerated cleanly. The grid renders only for a
* custom camera group (here: "outdoor") on desktop; mobile keeps the static
* grid, which the @mobile block below guards.
*/
import { test, expect } from "../fixtures/frigate-test";
import { LivePage } from "../pages/live.page";
import {
cameraBoxes,
persistedLayoutKey,
readLayout,
seedLayout,
} from "../helpers/grid-layout";
const GROUP = "outdoor"; // custom group: front_door + backyard
test.describe("Masonry live grid @critical", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Draggable masonry grid is desktop-only",
);
test("custom group renders its cameras in the draggable grid", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
});
test("tiles render at their camera's natural aspect ratio", async ({
frigateApp,
}) => {
// front_door stays 16:9; backyard is overridden to portrait so the two
// tiles must render with opposite orientations.
await frigateApp.installDefaults({
config: {
cameras: { backyard: { detect: { width: 720, height: 1280 } } },
},
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
const { front_door: landscape, backyard: portrait } = await cameraBoxes(
frigateApp.page,
["front_door", "backyard"] as const,
"card",
);
// 16:9 tile is clearly wider than tall; portrait tile is taller than wide.
expect(landscape.w / landscape.h).toBeGreaterThan(1.4);
expect(portrait.w / portrait.h).toBeLessThan(1);
});
test("dragging a tile does not shove other tiles far away", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await live.editLayoutButton.click();
const cameras = ["front_door", "backyard"] as const;
const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes(
frigateApp.page,
cameras,
"card",
);
// Drag backyard onto front_door's position (a deliberate collision). With
// free-placement + prevent-collision, front_door must NOT be shoved down.
const from = {
x: draggedBox.x + draggedBox.w / 2,
y: draggedBox.y + draggedBox.h / 2,
};
const to = {
x: fixedBefore.x + fixedBefore.w / 2,
y: fixedBefore.y + fixedBefore.h / 2,
};
await frigateApp.page.mouse.move(from.x, from.y);
await frigateApp.page.mouse.down();
await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 });
await frigateApp.page.mouse.up();
const { front_door: fixedAfter } = await cameraBoxes(
frigateApp.page,
cameras,
"card",
);
// Allow a few px of snap; a collision-push would move it a whole tile down.
expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40);
});
test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({
frigateApp,
}) => {
// A lone top tile has room to grow sideways, which is what exposed the bug:
// a top-edge handle let width grow while height stayed clamped at y=0.
await frigateApp.installDefaults({
config: { camera_groups: { outdoor: { cameras: ["front_door"] } } },
});
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await live.editLayoutButton.click();
const tile = frigateApp.page.locator(".react-grid-item", {
has: frigateApp.page.locator("[data-camera='front_door']"),
});
const only = ["front_door"] as const;
const { front_door: before } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
const aspect = before.w / before.h;
// Regression: if a top-edge handle is exposed, dragging it up/out must NOT
// distort the aspect (the old bug grew width while height stayed clamped).
const ne = tile.locator(".react-resizable-handle-ne");
if (await ne.count()) {
await ne.dragTo(tile, {
force: true,
targetPosition: { x: 1000, y: -160 },
});
const { front_door: afterNe } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
// It must actually resize (not a silent no-op) AND keep its aspect.
expect(afterNe.w).toBeGreaterThan(before.w);
expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2);
}
// Positive: growing from the bottom-right corner resizes and keeps aspect.
const se = tile.locator(".react-resizable-handle-se");
await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } });
const { front_door: grown } = await cameraBoxes(
frigateApp.page,
only,
"card",
);
expect(grown.w).toBeGreaterThan(before.w);
expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2);
});
test("the grid keeps its measured width after a back navigation", async ({
frigateApp,
}) => {
// The grid sizes itself from window.innerWidth until its container is
// measured. On a warm back navigation nothing re-renders after that
// container mounts, so an observer that never attaches leaves every tile
// sized against the full window: the layout widens by the sidebar's width
// and the rightmost column clips on a full row.
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// total width the tiles span; tracks the width the grid laid out against
const span = () =>
frigateApp.page.evaluate(() => {
const tiles = [...document.querySelectorAll(".react-grid-item")];
if (!tiles.length) {
return null;
}
const rects = tiles.map((tile) => tile.getBoundingClientRect());
return +(
Math.max(...rects.map((r) => r.right)) -
Math.min(...rects.map((r) => r.left))
).toFixed(1);
});
let fresh: number | null = null;
await expect
.poll(async () => (fresh = await span()), { timeout: 10_000 })
.not.toBeNull();
await live.cameraCard("front_door").first().click();
await expect(frigateApp.page).toHaveURL(/#front_door/);
await frigateApp.page.goBack();
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// the layout must settle back to the measured width, not window.innerWidth
await expect
.poll(span, { timeout: 10_000 })
.toBeLessThanOrEqual(fresh! + 2);
});
test("saved layout from an unreadable version regenerates without error", async ({
frigateApp,
}) => {
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, true);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
// A bare array is converted rather than discarded (covered in
// live-grid-aspect-modes), so use a version the current grid cannot read.
const key = await persistedLayoutKey(frigateApp.page, GROUP);
await seedLayout(frigateApp.page, key, {
version: 1,
naturalAspect: false,
layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }],
});
await frigateApp.page.reload();
await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 });
// Grid regenerated; both cameras still render and the error collector
// (frigate-test fixture) catches any crash.
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
// The app must have replaced the value it could not read. Without this the
// test would still pass against a key the app never touches.
await expect
.poll(async () => (await readLayout(frigateApp.page, key))?.version, {
timeout: 10_000,
})
.toBeGreaterThan(1);
});
});
test.describe("Masonry live grid on mobile @critical @mobile", () => {
test("custom group keeps the static grid, with no draggable layout", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile-only");
await frigateApp.goto(`/?group=${GROUP}`);
const live = new LivePage(frigateApp.page, false);
await expect(live.cameraCard("front_door").first()).toBeVisible({
timeout: 10_000,
});
await expect(live.cameraCard("backyard").first()).toBeVisible();
// isMobileOnly routes around DraggableGridLayout entirely, so neither the
// grid items nor the edit-layout toggle may appear.
await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0);
await expect(live.editLayoutButton).toHaveCount(0);
});
});
@@ -13,7 +13,26 @@ import type { Page } from "@playwright/test";
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
const STREAMING_KEY = "streaming-settings:admin";
const OUTDOOR_LAYOUT = [
// the shape DraggableGridLayout writes
const OUTDOOR_LAYOUT = {
version: 2,
naturalAspect: false,
layout: [
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
{ i: "backyard", x: 32, y: 0, w: 32, h: 72 },
],
};
const NATURAL_OUTDOOR_LAYOUT = {
version: 2,
naturalAspect: true,
layout: [
{ i: "front_door", x: 0, y: 0, w: 32, h: 72 },
{ i: "backyard", x: 32, y: 0, w: 24, h: 96 },
],
};
const LEGACY_OUTDOOR_LAYOUT = [
{ i: "front_door", x: 0, y: 0, w: 6, h: 4 },
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
];
@@ -159,6 +178,111 @@ test.describe("UI settings import/export @medium", () => {
expect(payload.sections.preferences.playbackRate).toBe(2);
});
test("round-trips a layout left unconverted by an upgrade", async ({
frigateApp,
}) => {
// DraggableGridLayout rewrites a pre-0.19 layout only when that group's
// dashboard is opened, so exporting first carries the bare array into the
// file. Import must accept it back rather than rejecting the whole file.
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, {
[OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT,
"playbackRate:admin": 2,
});
const downloadPromise = frigateApp.page.waitForEvent("download");
await frigateApp.page
.getByRole("button", { name: "Export Settings" })
.click();
const download = await downloadPromise;
const contents = readFileSync((await download.path())!, "utf-8");
expect(JSON.parse(contents).sections.layouts.outdoor).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
await clearIdb(frigateApp.page);
await chooseImportText(frigateApp.page, contents);
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
// the rest of the file must survive alongside it
expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2);
});
test("legacy layouts import turns natural aspect off so they display", async ({
frigateApp,
}) => {
// Bare-array layouts only render in bucketed mode; with natural aspect on
// they would be discarded and regenerated on the next dashboard visit. The
// import applies the mode the layouts were built for, and the file's own
// naturalAspectLayout preference must not override that.
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
await chooseImportFile(
frigateApp.page,
importPayload({
sections: {
layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT },
streaming: {},
preferences: { naturalAspectLayout: true },
},
}),
);
const note = frigateApp.page.getByText(/standard tile sizing/);
await expect(note).toBeVisible();
// the note is about the layouts section, so it follows its switch
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
await expect(note).toBeHidden();
await frigateApp.page.getByText("Camera group layouts (1 group)").click();
await expect(note).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
LEGACY_OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
false,
);
});
test("natural aspect layouts import turns the setting on", async ({
frigateApp,
}) => {
await frigateApp.goto("/settings?page=uiSettings");
await chooseImportFile(
frigateApp.page,
importPayload({
sections: {
layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT },
streaming: {},
preferences: {},
},
}),
);
await expect(
frigateApp.page.getByText(/camera aspect ratio tile sizing/),
).toBeVisible();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
NATURAL_OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
true,
);
});
test("omits settings that were never stored", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=uiSettings");