import { createId } from "@paralleldrive/cuid2"; import { describe, expect, test } from "vitest"; import type { GridAlgorithmItem } from "../grid-algorithm"; import { generateResponsiveGridFor } from "../grid-algorithm"; const ROOT_SECTION_ID = "section"; /** * If you want to see how the layouts progress between the different layouts, you can find images here: * https://github.com/homarr-labs/architecture-documentation/tree/main/grid-algorithm#graphical-representation-of-the-algorithm */ describe("Grid Algorithm", () => { test.each(itemTests)("should convert a grid with %i columns to a grid with %i columns", (_, _ignored, item) => { const input = generateInputFromText(item.input); const result = generateResponsiveGridFor({ items: input, width: item.outputColumnCount, previousWidth: item.inputColumnCount, sectionId: ROOT_SECTION_ID, }); const output = generateOutputText(result.items, item.outputColumnCount); expect(output).toBe(item.output); }); test.each(containerTests)( "should convert a grid with nested containers from 16 columns to %i columns", (_, testInput) => { const outerContainerId = "b"; const innerContainerId = "f"; const items = [ algoItem({ id: "a", width: 2, height: 2 }), algoItem({ id: outerContainerId, type: "section", width: 12, height: 3, yOffset: 2 }), algoItem({ id: "a", width: 2, sectionId: outerContainerId }), algoItem({ id: "b", width: 4, sectionId: outerContainerId, xOffset: 2 }), algoItem({ id: "c", width: 2, sectionId: outerContainerId, xOffset: 6 }), algoItem({ id: "d", width: 1, sectionId: outerContainerId, xOffset: 8 }), algoItem({ id: "e", width: 3, sectionId: outerContainerId, xOffset: 9 }), algoItem({ id: innerContainerId, type: "section", width: 8, height: 2, yOffset: 1, sectionId: outerContainerId, }), algoItem({ id: "a", width: 2, sectionId: innerContainerId }), algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerContainerId }), algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerContainerId }), algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerContainerId }), algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerContainerId, xOffset: 8 }), algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerContainerId, xOffset: 8 }), algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerContainerId, xOffset: 11 }), algoItem({ id: "c", width: 5, yOffset: 5 }), ]; const newItems = generateResponsiveGridFor({ items, width: testInput.outputColumns, previousWidth: 16, sectionId: ROOT_SECTION_ID, }); const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID); const outerSection = items.find((item) => item.id === outerContainerId); const outerItems = newItems.items.filter((item) => item.sectionId === outerContainerId); const innerSection = items.find((item) => item.id === innerContainerId); const innerItems = newItems.items.filter((item) => item.sectionId === innerContainerId); expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root); expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe( testInput.outer, ); expect(generateOutputText(innerItems, Math.min(testInput.outputColumns, innerSection?.width ?? 999))).toBe( testInput.inner, ); }, ); }); const algoItem = (item: Partial): GridAlgorithmItem => ({ id: createId(), type: "item", width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId: ROOT_SECTION_ID, ...item, }); const sixteenColumns = ` abbccccddddeeefg hbbccccddddeeeij klllmmmmmnneeeop qlllmmmmmnnrrrst ulllmmmmmnnrrrvw xyz äö`; // Just add two empty columns to the right const eighteenColumns = sixteenColumns .split("\n") .map((line, index) => (index === 0 ? line : `${line} `)) .join("\n"); const tenColumns = ` abbcccceee fbbcccceee ddddghieee ddddjklllo mmmmmplllq mmmmmslllt mmmmmnnrrr uvwxynnrrr zäö nn `; const sixColumns = ` abbfgh ibbjko ccccnn ccccnn ddddnn ddddpq eeelll eeelll eeelll mmmmms mmmmmt mmmmmu rrrvwx rrryzä ö `; const threeColumns = ` abb fbb ccc ccc ddd ddd eee eee eee ghi jko lll lll lll mmm mmm mmm nnp nnq nns rrr rrr tuv wxy zäö`; const itemTests = [ { input: sixteenColumns, inputColumnCount: 16, output: sixteenColumns, outputColumnCount: 16, }, { input: sixteenColumns, inputColumnCount: 16, output: eighteenColumns, outputColumnCount: 18, }, { input: sixteenColumns, inputColumnCount: 16, output: tenColumns, outputColumnCount: 10, }, { input: sixteenColumns, inputColumnCount: 16, output: sixColumns, outputColumnCount: 6, }, { input: sixteenColumns, inputColumnCount: 16, output: threeColumns, outputColumnCount: 3, }, ].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const); const containerTests = [ { outputColumns: 16, root: ` aa aa bbbbbbbbbbbb bbbbbbbbbbbb bbbbbbbbbbbb ccccc `, outer: ` aabbbbccdeee ffffffffgggg ffffffffhhhi`, inner: ` aabbbbbc dddddddc`, }, { outputColumns: 10, root: ` aaccccc aa bbbbbbbbbb bbbbbbbbbb bbbbbbbbbb bbbbbbbbbb`, outer: ` aabbbbccdi eeegggghhh ffffffff ffffffff `, inner: ` aabbbbbc dddddddc`, }, { outputColumns: 6, root: ` aa aa bbbbbb bbbbbb bbbbbb bbbbbb bbbbbb bbbbbb bbbbbb ccccc `, outer: ` aabbbb ccdeee ffffff ffffff ffffff ggggi hhh `, inner: ` aa c bbbbbc dddddd`, }, { outputColumns: 3, root: ` aa aa bbb bbb bbb bbb bbb bbb bbb bbb bbb bbb bbb ccc`, outer: ` aad bbb cci eee fff fff fff fff fff ggg hhh`, inner: ` aa bbb c c ddd`, }, ].map((item) => [item.outputColumns, item] as const); const generateInputFromText = (text: string) => { const lines = text.split("\n").slice(1); // Remove first empty row const items: GridAlgorithmItem[] = []; for (let yOffset = 0; yOffset < lines.length; yOffset++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const line = lines[yOffset]!; for (let xOffset = 0; xOffset < line.length; xOffset++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const char = line[xOffset]!; if (char === " ") continue; if (items.some((item) => item.id === char)) continue; items.push({ id: char, type: "item", width: getWidth(line, xOffset, char), height: getHeight(lines, { x: xOffset, y: yOffset }, char), xOffset, yOffset, sectionId: ROOT_SECTION_ID, }); } } return items; }; const generateOutputText = (items: GridAlgorithmItem[], columnCount: number) => { const occupied2d: string[][] = []; for (const item of items) { addItemToOccupied(occupied2d, item, { x: item.xOffset, y: item.yOffset }, columnCount); } return `\n${occupied2d.map((row) => row.join("")).join("\n")}`; }; const getWidth = (line: string, offset: number, char: string) => { const row = line.split(""); let width = 1; for (let xOffset = offset + 1; xOffset < row.length; xOffset++) { if (row[xOffset] === char) { width++; } else { break; } } return width; }; const getHeight = (lines: string[], position: { x: number; y: number }, char: string) => { let height = 1; for (let yOffset = position.y + 1; yOffset < lines.length; yOffset++) { if (lines[yOffset]?.[position.x] === char) { height++; } else { break; } } return height; }; const addItemToOccupied = ( occupied2d: string[][], item: GridAlgorithmItem, position: { x: number; y: number }, columnCount: number, ) => { for (let yOffset = 0; yOffset < item.height; yOffset++) { let row = occupied2d[position.y + yOffset]; if (!row) { addRow(occupied2d, columnCount); // After adding it, it must exist // eslint-disable-next-line @typescript-eslint/no-non-null-assertion row = occupied2d[position.y + yOffset]!; } for (let xOffset = 0; xOffset < item.width; xOffset++) { row[position.x + xOffset] = item.id; } } }; const addRow = (occupied2d: string[][], columnCount: number) => { occupied2d.push(new Array(columnCount).fill(" ")); };