diff --git a/web/messages/de.json b/web/messages/de.json index 27fe9ce2c..e04edcb0d 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Boardtyp und Größe", + "deviceGroupLabel": "Geräte", + "noteArrayGroupLabel": "Note-Arrays", + "customLabel": "Benutzerdefiniert…", + "presets": { + "2_wide": "2 nebeneinander", + "4_wide": "4 nebeneinander", + "2_tall": "2 übereinander", + "4_tall": "4 übereinander", + "2x2_grid": "2×2-Raster" + }, + "notesWideLabel": "Notes breit", + "notesTallLabel": "Notes hoch", + "customRangeError": "Jede Dimension muss zwischen 1 und {max} liegen.", + "noteArrayTokenLabel": "Cloud-API-Token", + "noteArrayTokenPlaceholder": "X-Vestaboard-Token eingeben", + "noteArrayTokenSetPlaceholder": "••••••••••• (gesetzt)", + "noteArrayTokenHelp": "Der X-Vestaboard-Token für dieses Note-Array aus Ihrem Vestaboard-Cloud-API-Abonnement.", + "autoDetect": "Automatisch vom Board erkennen", + "detecting": "Erkennen…", + "detectFailed": "Die Board-Konfiguration konnte nicht erkannt werden." }, "systemUpdate": { "updateAvailable": "Update verfügbar", diff --git a/web/messages/en.json b/web/messages/en.json index a177de420..64dd4bef2 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -723,7 +723,28 @@ "boardNamePlaceholder": "e.g. Kitchen Board, Office Note", "flagshipLabel": "Flagship", "noteLabel": "Note", - "failedToEnable": "Failed to enable local API" + "failedToEnable": "Failed to enable local API", + "deviceTypeAriaLabel": "Board type and size", + "deviceGroupLabel": "Devices", + "noteArrayGroupLabel": "Note arrays", + "customLabel": "Custom…", + "presets": { + "2_wide": "2 side-by-side", + "4_wide": "4 side-by-side", + "2_tall": "2 stacked", + "4_tall": "4 stacked", + "2x2_grid": "2×2 grid" + }, + "notesWideLabel": "Notes wide", + "notesTallLabel": "Notes tall", + "customRangeError": "Each dimension must be between 1 and {max}.", + "noteArrayTokenLabel": "Cloud API Token", + "noteArrayTokenPlaceholder": "Enter X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (set)", + "noteArrayTokenHelp": "The X-Vestaboard-Token for this Note array, from your Vestaboard Cloud API subscription.", + "autoDetect": "Auto-detect from board", + "detecting": "Detecting…", + "detectFailed": "Could not detect the board configuration." }, "systemUpdate": { "updateAvailable": "Update Available", diff --git a/web/messages/es.json b/web/messages/es.json index b22931d4e..e5a89199f 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Tipo y tamaño del tablero", + "deviceGroupLabel": "Dispositivos", + "noteArrayGroupLabel": "Matrices de Note", + "customLabel": "Personalizado…", + "presets": { + "2_wide": "2 en paralelo", + "4_wide": "4 en paralelo", + "2_tall": "2 apilados", + "4_tall": "4 apilados", + "2x2_grid": "Cuadrícula 2×2" + }, + "notesWideLabel": "Notes de ancho", + "notesTallLabel": "Notes de alto", + "customRangeError": "Cada dimensión debe estar entre 1 y {max}.", + "noteArrayTokenLabel": "Token de API en la nube", + "noteArrayTokenPlaceholder": "Introduce X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (configurado)", + "noteArrayTokenHelp": "El X-Vestaboard-Token de esta matriz de Note, de tu suscripción a la API en la nube de Vestaboard.", + "autoDetect": "Detectar automáticamente del tablero", + "detecting": "Detectando…", + "detectFailed": "No se pudo detectar la configuración del tablero." }, "systemUpdate": { "updateAvailable": "Actualización disponible", diff --git a/web/messages/fr.json b/web/messages/fr.json index 624421f5f..d6b4648f3 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Type et taille du tableau", + "deviceGroupLabel": "Appareils", + "noteArrayGroupLabel": "Matrices de Note", + "customLabel": "Personnalisé…", + "presets": { + "2_wide": "2 côte à côte", + "4_wide": "4 côte à côte", + "2_tall": "2 empilés", + "4_tall": "4 empilés", + "2x2_grid": "Grille 2×2" + }, + "notesWideLabel": "Notes en largeur", + "notesTallLabel": "Notes en hauteur", + "customRangeError": "Chaque dimension doit être comprise entre 1 et {max}.", + "noteArrayTokenLabel": "Jeton API cloud", + "noteArrayTokenPlaceholder": "Saisir le X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (défini)", + "noteArrayTokenHelp": "Le X-Vestaboard-Token de cette matrice de Note, issu de votre abonnement à l’API cloud Vestaboard.", + "autoDetect": "Détecter automatiquement depuis le tableau", + "detecting": "Détection…", + "detectFailed": "Impossible de détecter la configuration du tableau." }, "systemUpdate": { "updateAvailable": "Mise à jour disponible", diff --git a/web/messages/it.json b/web/messages/it.json index cb1c8b897..f370dcc4c 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Tipo e dimensione del tabellone", + "deviceGroupLabel": "Dispositivi", + "noteArrayGroupLabel": "Matrici di Note", + "customLabel": "Personalizzato…", + "presets": { + "2_wide": "2 affiancati", + "4_wide": "4 affiancati", + "2_tall": "2 impilati", + "4_tall": "4 impilati", + "2x2_grid": "Griglia 2×2" + }, + "notesWideLabel": "Notes in larghezza", + "notesTallLabel": "Notes in altezza", + "customRangeError": "Ogni dimensione deve essere compresa tra 1 e {max}.", + "noteArrayTokenLabel": "Token API cloud", + "noteArrayTokenPlaceholder": "Inserisci X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (impostato)", + "noteArrayTokenHelp": "Il X-Vestaboard-Token per questa matrice di Note, dal tuo abbonamento all’API cloud Vestaboard.", + "autoDetect": "Rileva automaticamente dal tabellone", + "detecting": "Rilevamento…", + "detectFailed": "Impossibile rilevare la configurazione del tabellone." }, "systemUpdate": { "updateAvailable": "Aggiornamento disponibile", diff --git a/web/messages/ja.json b/web/messages/ja.json index 087aac026..66360c2a1 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "ボードの種類とサイズ", + "deviceGroupLabel": "デバイス", + "noteArrayGroupLabel": "Note アレイ", + "customLabel": "カスタム…", + "presets": { + "2_wide": "横2枚", + "4_wide": "横4枚", + "2_tall": "縦2枚", + "4_tall": "縦4枚", + "2x2_grid": "2×2 グリッド" + }, + "notesWideLabel": "Note 横数", + "notesTallLabel": "Note 縦数", + "customRangeError": "各寸法は 1 ~ {max} の範囲でなければなりません。", + "noteArrayTokenLabel": "クラウド API トークン", + "noteArrayTokenPlaceholder": "X-Vestaboard-Token を入力", + "noteArrayTokenSetPlaceholder": "••••••••••• (設定済み)", + "noteArrayTokenHelp": "この Note アレイの X-Vestaboard-Token。Vestaboard クラウド API サブスクリプションから取得します。", + "autoDetect": "ボードから自動検出", + "detecting": "検出中…", + "detectFailed": "ボードの構成を検出できませんでした。" }, "systemUpdate": { "updateAvailable": "アップデートがあります", diff --git a/web/messages/ko.json b/web/messages/ko.json index bc46d334d..d33daab2b 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "보드 유형 및 크기", + "deviceGroupLabel": "기기", + "noteArrayGroupLabel": "Note 어레이", + "customLabel": "사용자 지정…", + "presets": { + "2_wide": "가로 2개", + "4_wide": "가로 4개", + "2_tall": "세로 2개", + "4_tall": "세로 4개", + "2x2_grid": "2×2 그리드" + }, + "notesWideLabel": "Note 가로 수", + "notesTallLabel": "Note 세로 수", + "customRangeError": "각 치수는 1에서 {max} 사이여야 합니다.", + "noteArrayTokenLabel": "클라우드 API 토큰", + "noteArrayTokenPlaceholder": "X-Vestaboard-Token 입력", + "noteArrayTokenSetPlaceholder": "••••••••••• (설정됨)", + "noteArrayTokenHelp": "이 Note 어레이의 X-Vestaboard-Token으로, Vestaboard 클라우드 API 구독에서 가져옵니다.", + "autoDetect": "보드에서 자동 감지", + "detecting": "감지 중…", + "detectFailed": "보드 구성을 감지할 수 없습니다." }, "systemUpdate": { "updateAvailable": "업데이트 가능", diff --git a/web/messages/nl.json b/web/messages/nl.json index 8cce17b35..f6369d569 100644 --- a/web/messages/nl.json +++ b/web/messages/nl.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Bordtype en -grootte", + "deviceGroupLabel": "Apparaten", + "noteArrayGroupLabel": "Note-arrays", + "customLabel": "Aangepast…", + "presets": { + "2_wide": "2 naast elkaar", + "4_wide": "4 naast elkaar", + "2_tall": "2 gestapeld", + "4_tall": "4 gestapeld", + "2x2_grid": "2×2-raster" + }, + "notesWideLabel": "Notes breed", + "notesTallLabel": "Notes hoog", + "customRangeError": "Elke dimensie moet tussen 1 en {max} liggen.", + "noteArrayTokenLabel": "Cloud-API-token", + "noteArrayTokenPlaceholder": "Voer X-Vestaboard-Token in", + "noteArrayTokenSetPlaceholder": "••••••••••• (ingesteld)", + "noteArrayTokenHelp": "De X-Vestaboard-Token voor deze Note-array, uit je Vestaboard Cloud API-abonnement.", + "autoDetect": "Automatisch detecteren vanaf bord", + "detecting": "Detecteren…", + "detectFailed": "Kan de bordconfiguratie niet detecteren." }, "systemUpdate": { "updateAvailable": "Update beschikbaar", diff --git a/web/messages/pl.json b/web/messages/pl.json index cd2163397..8b9e39f15 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Typ i rozmiar tablicy", + "deviceGroupLabel": "Urządzenia", + "noteArrayGroupLabel": "Macierze Note", + "customLabel": "Niestandardowy…", + "presets": { + "2_wide": "2 obok siebie", + "4_wide": "4 obok siebie", + "2_tall": "2 jedna nad drugą", + "4_tall": "4 jedna nad drugą", + "2x2_grid": "Siatka 2×2" + }, + "notesWideLabel": "Notes w poziomie", + "notesTallLabel": "Notes w pionie", + "customRangeError": "Każdy wymiar musi mieścić się w zakresie od 1 do {max}.", + "noteArrayTokenLabel": "Token API w chmurze", + "noteArrayTokenPlaceholder": "Wprowadź X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (ustawiony)", + "noteArrayTokenHelp": "Token X-Vestaboard-Token dla tej macierzy Note, z subskrypcji API w chmurze Vestaboard.", + "autoDetect": "Wykryj automatycznie z tablicy", + "detecting": "Wykrywanie…", + "detectFailed": "Nie udało się wykryć konfiguracji tablicy." }, "systemUpdate": { "updateAvailable": "Dostępna aktualizacja", diff --git a/web/messages/pt.json b/web/messages/pt.json index b5cb79f40..6868d86b3 100644 --- a/web/messages/pt.json +++ b/web/messages/pt.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Tipo e tamanho do quadro", + "deviceGroupLabel": "Dispositivos", + "noteArrayGroupLabel": "Matrizes de Note", + "customLabel": "Personalizado…", + "presets": { + "2_wide": "2 lado a lado", + "4_wide": "4 lado a lado", + "2_tall": "2 empilhados", + "4_tall": "4 empilhados", + "2x2_grid": "Grade 2×2" + }, + "notesWideLabel": "Notes de largura", + "notesTallLabel": "Notes de altura", + "customRangeError": "Cada dimensão deve estar entre 1 e {max}.", + "noteArrayTokenLabel": "Token da API na nuvem", + "noteArrayTokenPlaceholder": "Insira o X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (definido)", + "noteArrayTokenHelp": "O X-Vestaboard-Token para esta matriz de Note, da sua assinatura da API na nuvem da Vestaboard.", + "autoDetect": "Detectar automaticamente do quadro", + "detecting": "Detectando…", + "detectFailed": "Não foi possível detectar a configuração do quadro." }, "systemUpdate": { "updateAvailable": "Atualização Disponível", diff --git a/web/messages/ru.json b/web/messages/ru.json index 0c7908313..089c93d4e 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Тип и размер табло", + "deviceGroupLabel": "Устройства", + "noteArrayGroupLabel": "Массивы Note", + "customLabel": "Пользовательский…", + "presets": { + "2_wide": "2 в ряд", + "4_wide": "4 в ряд", + "2_tall": "2 в столбик", + "4_tall": "4 в столбик", + "2x2_grid": "Сетка 2×2" + }, + "notesWideLabel": "Note в ширину", + "notesTallLabel": "Note в высоту", + "customRangeError": "Каждый размер должен быть от 1 до {max}.", + "noteArrayTokenLabel": "Токен облачного API", + "noteArrayTokenPlaceholder": "Введите X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (задан)", + "noteArrayTokenHelp": "Токен X-Vestaboard-Token для этого массива Note из вашей подписки на облачный API Vestaboard.", + "autoDetect": "Автоопределение с табло", + "detecting": "Определение…", + "detectFailed": "Не удалось определить конфигурацию табло." }, "systemUpdate": { "updateAvailable": "Доступно обновление", diff --git a/web/messages/sv.json b/web/messages/sv.json index 31b336381..34105592a 100644 --- a/web/messages/sv.json +++ b/web/messages/sv.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Tavlans typ och storlek", + "deviceGroupLabel": "Enheter", + "noteArrayGroupLabel": "Note-matriser", + "customLabel": "Anpassad…", + "presets": { + "2_wide": "2 sida vid sida", + "4_wide": "4 sida vid sida", + "2_tall": "2 staplade", + "4_tall": "4 staplade", + "2x2_grid": "2×2-rutnät" + }, + "notesWideLabel": "Notes breda", + "notesTallLabel": "Notes höga", + "customRangeError": "Varje dimension måste vara mellan 1 och {max}.", + "noteArrayTokenLabel": "Moln-API-token", + "noteArrayTokenPlaceholder": "Ange X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (angiven)", + "noteArrayTokenHelp": "X-Vestaboard-Token för denna Note-matris, från din Vestaboard Cloud API-prenumeration.", + "autoDetect": "Identifiera automatiskt från tavlan", + "detecting": "Identifierar…", + "detectFailed": "Det gick inte att identifiera tavlans konfiguration." }, "systemUpdate": { "updateAvailable": "Uppdatering tillgänglig", diff --git a/web/messages/tr.json b/web/messages/tr.json index ce4c5d1c9..3fa95aa55 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "Pano türü ve boyutu", + "deviceGroupLabel": "Cihazlar", + "noteArrayGroupLabel": "Note dizileri", + "customLabel": "Özel…", + "presets": { + "2_wide": "2 yan yana", + "4_wide": "4 yan yana", + "2_tall": "2 üst üste", + "4_tall": "4 üst üste", + "2x2_grid": "2×2 ızgara" + }, + "notesWideLabel": "Note genişliği", + "notesTallLabel": "Note yüksekliği", + "customRangeError": "Her boyut 1 ile {max} arasında olmalıdır.", + "noteArrayTokenLabel": "Bulut API Belirteci", + "noteArrayTokenPlaceholder": "X-Vestaboard-Token girin", + "noteArrayTokenSetPlaceholder": "••••••••••• (ayarlandı)", + "noteArrayTokenHelp": "Bu Note dizisi için X-Vestaboard-Token; Vestaboard Bulut API aboneliğinizden alınır.", + "autoDetect": "Panodan otomatik algıla", + "detecting": "Algılanıyor…", + "detectFailed": "Pano yapılandırması algılanamadı." }, "systemUpdate": { "updateAvailable": "Güncelleme Mevcut", diff --git a/web/messages/zh.json b/web/messages/zh.json index e622fdacc..53c48cf3b 100644 --- a/web/messages/zh.json +++ b/web/messages/zh.json @@ -766,7 +766,28 @@ "resumeToggle": "Resume sends", "badge": "Paused", "tooltip": "When paused, FiestaBoard does not push anything to this board — scheduled pages, manual sends, plugin triggers, and MQTT commands are all blocked until you resume." - } + }, + "deviceTypeAriaLabel": "板子类型和尺寸", + "deviceGroupLabel": "设备", + "noteArrayGroupLabel": "Note 阵列", + "customLabel": "自定义…", + "presets": { + "2_wide": "2 个并排", + "4_wide": "4 个并排", + "2_tall": "2 个堆叠", + "4_tall": "4 个堆叠", + "2x2_grid": "2×2 网格" + }, + "notesWideLabel": "Note 宽度", + "notesTallLabel": "Note 高度", + "customRangeError": "每个尺寸必须介于 1 和 {max} 之间。", + "noteArrayTokenLabel": "云 API 令牌", + "noteArrayTokenPlaceholder": "输入 X-Vestaboard-Token", + "noteArrayTokenSetPlaceholder": "••••••••••• (已设置)", + "noteArrayTokenHelp": "此 Note 阵列的 X-Vestaboard-Token,来自您的 Vestaboard 云 API 订阅。", + "autoDetect": "从板子自动检测", + "detecting": "检测中…", + "detectFailed": "无法检测板子的配置。" }, "systemUpdate": { "updateAvailable": "有可用更新", diff --git a/web/src/__tests__/display-settings.test.tsx b/web/src/__tests__/display-settings.test.tsx new file mode 100644 index 000000000..c578b5026 --- /dev/null +++ b/web/src/__tests__/display-settings.test.tsx @@ -0,0 +1,372 @@ +/** + * Tests for the note-array settings UI in DisplaySettings: + * - Grouped device/preset Select (replaces the old flagship/note pills) + * - Custom W×H inputs with 1..MAX_NOTES_PER_AXIS validation + * - note_array_token field (masked-secret round trip) + * - Auto-detect from board (success → flagship/note/array, errors inline) + * + * The per-board controls live inside a collapsed Radix Collapsible, so each + * test expands the card first by clicking its trigger. Radix Select / detect + * interactions rely on the pointer-capture + scrollIntoView mocks in setup.ts. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { DisplaySettings } from "@/components/settings/display-settings"; + +import { server } from "./mocks/server"; + +const API_BASE = "/api"; + +type BoardOverride = Record; +type BoardRecord = Record; + +/** + * Stateful board fixture. GET returns the current board; PUT persists the + * incoming boards and records the request body — mirroring the real backend + * so the component's invalidate→refetch cycle reflects each save (the + * controlled Select reads from the refetched query data, not local state). + * + * `put.body` is `null` until a PUT fires; reset it between assertions. + */ +function setupBoard(board: BoardOverride) { + const state: { boards: BoardRecord[] } = { + boards: [ + { + id: "default", + name: "My Board", + board_color: "black", + api_mode: "cloud", + cloud_key: "***", + ...board, + }, + ], + }; + const put: { body: { boards?: BoardRecord[] } | null } = { body: null }; + + server.use( + http.get(`${API_BASE}/settings/board`, () => + HttpResponse.json({ + board_type: "black", + boards: state.boards, + devices: state.boards.map((b) => b.device_type), + }), + ), + http.put(`${API_BASE}/settings/board`, async ({ request }) => { + const body = (await request.json()) as { boards?: BoardRecord[] }; + put.body = body; + if (body.boards) { + // Persist, masking the token like the real backend would on read-back. + state.boards = body.boards.map((b) => ({ + ...b, + note_array_token: b.note_array_token ? "***" : b.note_array_token, + })); + } + return HttpResponse.json({ + status: "success", + settings: { board_type: "black", boards: state.boards, devices: state.boards.map((b) => b.device_type) }, + }); + }), + ); + + return put; +} + +function TestWrapper({ children }: { children: React.ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return {children}; +} + +/** Render, wait for the board card, expand it, and return the card element. */ +async function renderAndExpand(user: ReturnType) { + render(, { wrapper: TestWrapper }); + const trigger = await screen.findByText("My Board"); + await user.click(trigger); + const card = await screen.findByTestId("board-card"); + return card; +} + +describe("DisplaySettings — note-array selector", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders grouped device + preset options", async () => { + const user = userEvent.setup(); + setupBoard({ device_type: "flagship" }); + await renderAndExpand(user); + + const combo = screen.getByLabelText("Board type and size"); + await user.click(combo); + + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + const listbox = screen.getByRole("listbox"); + // Group labels + expect(within(listbox).getByText("Devices")).toBeInTheDocument(); + expect(within(listbox).getByText("Note arrays")).toBeInTheDocument(); + // Options + expect(within(listbox).getByRole("option", { name: "Flagship" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "Note" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "2 side-by-side" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "4 side-by-side" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "2 stacked" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "4 stacked" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "2×2 grid" })).toBeInTheDocument(); + expect(within(listbox).getByRole("option", { name: "Custom…" })).toBeInTheDocument(); + }); + + it("selecting a preset saves note_array dimensions", async () => { + const user = userEvent.setup(); + const put = setupBoard({ device_type: "flagship" }); + await renderAndExpand(user); + + const combo = screen.getByLabelText("Board type and size"); + await user.click(combo); + await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()); + await user.click(screen.getByRole("option", { name: "2×2 grid" })); + + await waitFor(() => expect(put.body).not.toBeNull()); + const b = put.body!.boards![0]; + expect(b.device_type).toBe("note_array"); + expect(b.notes_wide).toBe(2); + expect(b.notes_tall).toBe(2); + }); + + it("selecting Flagship from a note array saves device_type", async () => { + const user = userEvent.setup(); + const put = setupBoard({ device_type: "note_array", notes_wide: 2, notes_tall: 2 }); + await renderAndExpand(user); + + const combo = screen.getByLabelText("Board type and size"); + await user.click(combo); + await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()); + await user.click(screen.getByRole("option", { name: "Flagship" })); + + await waitFor(() => expect(put.body).not.toBeNull()); + expect(put.body!.boards![0].device_type).toBe("flagship"); + }); +}); + +describe("DisplaySettings — custom W×H inputs", () => { + it("selecting Custom reveals the W×H inputs", async () => { + const user = userEvent.setup(); + setupBoard({ device_type: "flagship" }); + const card = await renderAndExpand(user); + + const combo = screen.getByLabelText("Board type and size"); + await user.click(combo); + await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()); + await user.click(screen.getByRole("option", { name: "Custom…" })); + + // Two number inputs render once Custom is chosen (local customOpen state). + expect(await within(card).findByLabelText("Notes wide")).toBeInTheDocument(); + expect(within(card).getByLabelText("Notes tall")).toBeInTheDocument(); + }); + + it("custom inputs validate the range (block out-of-range, persist valid)", async () => { + const user = userEvent.setup(); + // 3×1 is a note array that matches no preset → renders as "Custom" with inputs. + const put = setupBoard({ device_type: "note_array", notes_wide: 3, notes_tall: 1 }); + const card = await renderAndExpand(user); + + const wide = (await within(card).findByLabelText("Notes wide")) as HTMLInputElement; + expect(within(card).getByLabelText("Notes tall")).toBeInTheDocument(); + put.body = null; + + // Out-of-range value → inline error, no PUT. fireEvent.change sets the exact + // value in one shot (controlled number inputs drift under clear()+type()). + fireEvent.change(wide, { target: { value: "9" } }); + expect(await within(card).findByText("Each dimension must be between 1 and 8.")).toBeInTheDocument(); + expect(put.body).toBeNull(); + + // Valid value → error clears, PUT carries the new dim. + fireEvent.change(wide, { target: { value: "5" } }); + await waitFor(() => expect(put.body).not.toBeNull()); + expect(put.body!.boards![0].notes_wide).toBe(5); + expect(within(card).queryByText("Each dimension must be between 1 and 8.")).not.toBeInTheDocument(); + }); + + it("custom inputs block zero values", async () => { + const user = userEvent.setup(); + const put = setupBoard({ device_type: "note_array", notes_wide: 3, notes_tall: 1 }); + const card = await renderAndExpand(user); + + const wide = (await within(card).findByLabelText("Notes wide")) as HTMLInputElement; + put.body = null; + + fireEvent.change(wide, { target: { value: "0" } }); + expect(await within(card).findByText("Each dimension must be between 1 and 8.")).toBeInTheDocument(); + expect(put.body).toBeNull(); + + // Empty value is likewise blocked (NaN guard). + fireEvent.change(wide, { target: { value: "" } }); + expect(within(card).getByText("Each dimension must be between 1 and 8.")).toBeInTheDocument(); + expect(put.body).toBeNull(); + }); +}); + +describe("DisplaySettings — note_array_token field", () => { + it("hides the token field for flagship and shows it for a note array", async () => { + const user = userEvent.setup(); + setupBoard({ device_type: "flagship" }); + const card = await renderAndExpand(user); + + expect(within(card).queryByText("Cloud API Token")).not.toBeInTheDocument(); + + const combo = screen.getByLabelText("Board type and size"); + await user.click(combo); + await waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()); + await user.click(screen.getByRole("option", { name: "2×2 grid" })); + + expect(await within(card).findByText("Cloud API Token")).toBeInTheDocument(); + }); + + it("token field is masked and saves a freshly typed value", async () => { + const user = userEvent.setup(); + const put = setupBoard({ device_type: "note_array", notes_wide: 2, notes_tall: 2, note_array_token: "***" }); + const card = await renderAndExpand(user); + + const label = await within(card).findByText("Cloud API Token"); + const tokenInput = label.parentElement!.querySelector("input") as HTMLInputElement; + // Masked: empty value with the "(set)" placeholder. + expect(tokenInput.value).toBe(""); + expect(tokenInput.placeholder).toBe("••••••••••• (set)"); + + // Leaving it untouched fires no token change. + tokenInput.focus(); + tokenInput.blur(); + expect(put.body).toBeNull(); + + // Typing a new value + blur persists it. + await user.type(tokenInput, "new-secret-token"); + tokenInput.blur(); + await waitFor(() => expect(put.body).not.toBeNull()); + expect(put.body!.boards![0].note_array_token).toBe("new-secret-token"); + }); +}); + +describe("DisplaySettings — auto-detect", () => { + it("success → note array resolves the matching preset and persists", async () => { + const user = userEvent.setup(); + const put = setupBoard({ device_type: "flagship" }); + server.use( + http.post(`${API_BASE}/settings/board/:boardId/detect-size`, () => + HttpResponse.json({ + device_type: "note_array", + rows: 6, + cols: 30, + notes_wide: 2, + notes_tall: 2, + matched_preset: "2×2 grid", + }), + ), + ); + const card = await renderAndExpand(user); + + await user.click(within(card).getByRole("button", { name: "Auto-detect from board" })); + + await waitFor(() => expect(put.body).not.toBeNull()); + const b = put.body!.boards![0]; + expect(b.device_type).toBe("note_array"); + expect(b.notes_wide).toBe(2); + expect(b.notes_tall).toBe(2); + }); + + it("success → flagship hides the token field", async () => { + const user = userEvent.setup(); + const put = setupBoard({ device_type: "note_array", notes_wide: 2, notes_tall: 2 }); + server.use( + http.post(`${API_BASE}/settings/board/:boardId/detect-size`, () => + HttpResponse.json({ device_type: "flagship", rows: 6, cols: 22 }), + ), + ); + const card = await renderAndExpand(user); + + // Token field visible for the note array. + expect(within(card).getByText("Cloud API Token")).toBeInTheDocument(); + + await user.click(within(card).getByRole("button", { name: "Auto-detect from board" })); + + await waitFor(() => expect(put.body).not.toBeNull()); + expect(put.body!.boards![0].device_type).toBe("flagship"); + }); + + it("success → custom (no preset) opens the W×H inputs", async () => { + const user = userEvent.setup(); + const put = setupBoard({ device_type: "flagship" }); + server.use( + http.post(`${API_BASE}/settings/board/:boardId/detect-size`, () => + HttpResponse.json({ + device_type: "note_array", + rows: 3, + cols: 45, + notes_wide: 3, + notes_tall: 1, + matched_preset: null, + }), + ), + ); + const card = await renderAndExpand(user); + + await user.click(within(card).getByRole("button", { name: "Auto-detect from board" })); + + await waitFor(() => expect(put.body).not.toBeNull()); + expect(put.body!.boards![0].notes_wide).toBe(3); + // Custom inputs reveal because 3×1 matches no preset. + expect(await within(card).findByLabelText("Notes wide")).toBeInTheDocument(); + expect(within(card).getByLabelText("Notes tall")).toBeInTheDocument(); + }); + + it("error (422) shows the FastAPI detail inline and fires no board PUT", async () => { + const user = userEvent.setup(); + const put = setupBoard({ device_type: "flagship" }); + server.use( + http.post(`${API_BASE}/settings/board/:boardId/detect-size`, () => + HttpResponse.json({ detail: "Board returned no layout — board may be blank or unreachable" }, { status: 422 }), + ), + ); + const card = await renderAndExpand(user); + + await user.click(within(card).getByRole("button", { name: "Auto-detect from board" })); + + expect( + await within(card).findByText("Board returned no layout — board may be blank or unreachable"), + ).toBeInTheDocument(); + expect(put.body).toBeNull(); + }); + + it("error (404) surfaces the detail string", async () => { + const user = userEvent.setup(); + setupBoard({ device_type: "flagship" }); + server.use( + http.post(`${API_BASE}/settings/board/:boardId/detect-size`, () => + HttpResponse.json({ detail: "Board not found" }, { status: 404 }), + ), + ); + const card = await renderAndExpand(user); + + await user.click(within(card).getByRole("button", { name: "Auto-detect from board" })); + expect(await within(card).findByText("Board not found")).toBeInTheDocument(); + }); + + it("error (400) surfaces the detail string", async () => { + const user = userEvent.setup(); + setupBoard({ device_type: "flagship" }); + server.use( + http.post(`${API_BASE}/settings/board/:boardId/detect-size`, () => + HttpResponse.json({ detail: "Board is not configured" }, { status: 400 }), + ), + ); + const card = await renderAndExpand(user); + + await user.click(within(card).getByRole("button", { name: "Auto-detect from board" })); + expect(await within(card).findByText("Board is not configured")).toBeInTheDocument(); + }); +}); diff --git a/web/src/__tests__/mocks/handlers.ts b/web/src/__tests__/mocks/handlers.ts index 8a07c0a3d..1f8a966e4 100644 --- a/web/src/__tests__/mocks/handlers.ts +++ b/web/src/__tests__/mocks/handlers.ts @@ -1330,6 +1330,20 @@ export const handlers = [ }); }), + // Auto-detect board size. Default returns a 2×2 note array; tests override + // per-case via server.use(...). MSW runs with onUnhandledRequest: "error", + // so this handler must exist for any auto-detect interaction. + http.post(`${API_BASE}/settings/board/:boardId/detect-size`, () => { + return HttpResponse.json({ + device_type: "note_array", + rows: 6, + cols: 30, + notes_wide: 2, + notes_tall: 2, + matched_preset: "2×2 grid", + }); + }), + // Location settings endpoints http.get(`${API_BASE}/settings/location`, () => { return HttpResponse.json({ diff --git a/web/src/components/settings/display-settings.tsx b/web/src/components/settings/display-settings.tsx index 3de4e49bb..2b73e4879 100644 --- a/web/src/components/settings/display-settings.tsx +++ b/web/src/components/settings/display-settings.tsx @@ -26,12 +26,22 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { queryKeys, useBoardSettings } from "@/hooks/use-board"; import { useTranslations } from "@/i18n/translations"; import type { BoardInstance, DeviceType } from "@/lib/api"; import { api } from "@/lib/api"; +import { isNoteArray, MAX_NOTES_PER_AXIS, NOTE_ARRAY_PRESETS } from "@/lib/board-dimensions"; function BoardConnectionForm({ board, @@ -49,6 +59,7 @@ function BoardConnectionForm({ const apiMode = board.api_mode ?? "local"; const hasLocalKey = board.local_api_key === "***" || (board.local_api_key && board.local_api_key.length > 0); const hasCloudKey = board.cloud_key === "***" || (board.cloud_key && board.cloud_key.length > 0); + const hasNoteArrayToken = board.note_array_token === "***" || Boolean(board.note_array_token); const hasHost = board.host && board.host.length > 0; const isConfigured = (apiMode === "local" && hasLocalKey && hasHost) || (apiMode === "cloud" && hasCloudKey); @@ -287,6 +298,38 @@ function BoardConnectionForm({ )} + {/* Note-array Cloud API token (X-Vestaboard-Token) */} + {isNoteArray(board.device_type) && ( +
+ +
+ { + const val = e.target.value; + if (val && val !== "***" && val !== board.note_array_token) { + onUpdate(board.id, { note_array_token: val }); + } + }} + placeholder={hasNoteArrayToken ? t("noteArrayTokenSetPlaceholder") : t("noteArrayTokenPlaceholder")} + className="flex-1 h-8 px-2 text-xs rounded-md border bg-background font-mono" + /> + +
+

{t("noteArrayTokenHelp")}

+
+ )} + {/* Validation message */} {!isConfigured && (
@@ -304,6 +347,11 @@ export function DisplaySettings() { const queryClient = useQueryClient(); const { data: boardSettings, isLoading } = useBoardSettings(); const [showTypePicker, setShowTypePicker] = useState(false); + // Per-board UI state for the note-array selector / custom inputs / auto-detect. + const [customOpen, setCustomOpen] = useState>({}); + const [dimError, setDimError] = useState>({}); + const [detectingBoardId, setDetectingBoardId] = useState(null); + const [detectError, setDetectError] = useState>({}); const invalidate = () => { queryClient.invalidateQueries({ queryKey: queryKeys.boardSettings }); queryClient.invalidateQueries({ queryKey: ["all-settings"] }); @@ -366,6 +414,76 @@ export function DisplaySettings() { updateMutation.mutate({ boards: updated }); }; + // Map a board to the synthetic Select value. Note arrays whose dims match a + // preset resolve to that preset; otherwise to "custom". (Match by dimensions, + // never by the detect endpoint's `matched_preset` label string.) + const currentConfigValue = (board: BoardInstance): string => { + if (board.device_type !== "note_array") return board.device_type; // "flagship" | "note" + const match = NOTE_ARRAY_PRESETS.find( + (p) => p.notes_wide === (board.notes_wide ?? 1) && p.notes_tall === (board.notes_tall ?? 1), + ); + return match ? `preset:${match.id}` : "custom"; + }; + + const handleConfigChange = (board: BoardInstance, value: string) => { + if (value === "flagship" || value === "note") { + setCustomOpen((prev) => ({ ...prev, [board.id]: false })); + handleUpdateBoard(board.id, { device_type: value }); + return; + } + if (value === "custom") { + setCustomOpen((prev) => ({ ...prev, [board.id]: true })); + const w = board.device_type === "note_array" ? (board.notes_wide ?? 1) : 1; + const h = board.device_type === "note_array" ? (board.notes_tall ?? 1) : 1; + handleUpdateBoard(board.id, { device_type: "note_array", notes_wide: w, notes_tall: h }); + return; + } + // value === "preset:" + setCustomOpen((prev) => ({ ...prev, [board.id]: false })); + const preset = NOTE_ARRAY_PRESETS.find((p) => `preset:${p.id}` === value); + if (!preset) return; + handleUpdateBoard(board.id, { + device_type: "note_array", + notes_wide: preset.notes_wide, + notes_tall: preset.notes_tall, + }); + }; + + const handleCustomDim = (board: BoardInstance, key: "notes_wide" | "notes_tall", rawValue: string) => { + const n = Number.parseInt(rawValue, 10); + if (!Number.isInteger(n) || n < 1 || n > MAX_NOTES_PER_AXIS) { + setDimError((prev) => ({ ...prev, [board.id]: t("customRangeError", { max: MAX_NOTES_PER_AXIS }) })); + return; // Block the save — never persist an invalid dimension. + } + setDimError((prev) => ({ ...prev, [board.id]: undefined })); + handleUpdateBoard(board.id, { device_type: "note_array", [key]: n }); + }; + + const handleAutoDetect = async (board: BoardInstance) => { + setDetectingBoardId(board.id); + setDetectError((prev) => ({ ...prev, [board.id]: undefined })); + try { + const res = await api.detectBoardSize(board.id); + if (res.device_type === "note_array") { + const w = res.notes_wide ?? 1; + const h = res.notes_tall ?? 1; + const isPreset = NOTE_ARRAY_PRESETS.some((p) => p.notes_wide === w && p.notes_tall === h); + setCustomOpen((prev) => ({ ...prev, [board.id]: !isPreset })); + handleUpdateBoard(board.id, { device_type: "note_array", notes_wide: w, notes_tall: h }); + } else { + setCustomOpen((prev) => ({ ...prev, [board.id]: false })); + handleUpdateBoard(board.id, { device_type: res.device_type }); + } + } catch (err) { + setDetectError((prev) => ({ + ...prev, + [board.id]: err instanceof Error ? err.message : t("detectFailed"), + })); + } finally { + setDetectingBoardId(null); + } + }; + const pauseMutation = useMutation({ mutationFn: ({ boardId, paused }: { boardId: string; paused: boolean }) => api.setBoardPaused(boardId, paused), onSuccess: () => { @@ -503,58 +621,116 @@ export function DisplaySettings() {
{/* Type + Color row */} -
-
- Type -
- - +
+
+
+ {t("typeLabel")} + +
+
+ {t("colorLabel")} +
+
-
- {t("colorLabel")} -
- + {detectError[board.id] && ( +
+ + {detectError[board.id]} +
+ )}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e3300a1b4..e73b8d1fd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -508,6 +508,8 @@ export interface BoardInstance { host: string; local_api_key: string; cloud_key: string; + /** X-Vestaboard-Token for a Note array (note_array only). Masked as "***" on read. */ + note_array_token?: string; /** Number of Notes arranged horizontally (note_array only; default 1). */ notes_wide?: number; /** Number of Notes arranged vertically (note_array only; default 1). */ @@ -520,6 +522,22 @@ export interface BoardSettings { devices: DeviceType[]; // Computed from boards for backward compat } +/** + * Response from POST /settings/board/{id}/detect-size. Mirrors the Python + * `classify_dimensions()` return shape. For flagship/note only `device_type`, + * `rows`, `cols` are present; for note arrays the note-grid fields are filled. + * `matched_preset` is a human-readable preset LABEL (not an id) or null — do + * not key UI off it; match presets by (notes_wide, notes_tall) instead. + */ +export interface DetectBoardSizeResponse { + device_type: DeviceType; + rows: number; + cols: number; + notes_wide?: number; + notes_tall?: number; + matched_preset?: string | null; +} + // Schedule types export type DayPattern = "all" | "weekdays" | "weekends" | "custom"; export type TimeType = "fixed" | "sunrise" | "sunset"; @@ -1728,6 +1746,10 @@ export const api = { body: JSON.stringify({ paused }), }, ), + detectBoardSize: (boardId: string) => + fetchApi(`/settings/board/${boardId}/detect-size`, { + method: "POST", + }), getAllSettings: () => fetchApi("/settings/all"), // Display settings diff --git a/web/tests/multi-board.spec.ts b/web/tests/multi-board.spec.ts index 739378040..772fbba12 100644 --- a/web/tests/multi-board.spec.ts +++ b/web/tests/multi-board.spec.ts @@ -164,7 +164,7 @@ test.describe("Settings – Board Instance CRUD", () => { expect(types.filter((t: string) => t === "flagship").length).toBe(2); }); - test("can change device type via type pills", async ({ page }) => { + test("can change device type via the type selector", async ({ page }) => { await page.goto("/settings"); await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible({ timeout: 15_000 }); @@ -172,12 +172,14 @@ test.describe("Settings – Board Instance CRUD", () => { // Expand board card await page.getByText("My Board").first().click(); - await expect(page.getByText("Type").first()).toBeVisible({ - timeout: 5_000, - }); - // Click Note pill (the small pill button, not the header text) - await page.getByRole("button", { name: "Note", exact: true }).first().click(); + // Open the device-type Select (grouped flagship/note + note-array presets) + const typeSelect = page.getByRole("combobox", { name: "Board type and size" }).first(); + await expect(typeSelect).toBeVisible({ timeout: 5_000 }); + await typeSelect.click(); + + // Choose "Note" from the Devices group + await page.getByRole("option", { name: "Note", exact: true }).click(); await page.waitForTimeout(1_500); // Header should now show 15 × 3 dimensions @@ -190,6 +192,87 @@ test.describe("Settings – Board Instance CRUD", () => { expect(data.boards[0].device_type).toBe("note"); }); + test("can select a note-array preset and persist W×H + token", async ({ page }) => { + await page.goto("/settings"); + await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible({ timeout: 15_000 }); + + await openSettingsTab(page, "Hardware"); + + // Expand board card + await page.getByText("My Board").first().click(); + + // Open the type Select and pick the "4 side-by-side" note-array preset + const typeSelect = page.getByRole("combobox", { name: "Board type and size" }).first(); + await expect(typeSelect).toBeVisible({ timeout: 5_000 }); + await typeSelect.click(); + await page.getByRole("option", { name: "4 side-by-side" }).click(); + await page.waitForTimeout(1_500); + + // 4×1 notes → 60 × 3 characters + await expect(page.getByText("60 × 3").first()).toBeVisible({ timeout: 5_000 }); + + let res = await fetch(`${API_URL}/settings/board`); + let data = await res.json(); + expect(data.boards[0].device_type).toBe("note_array"); + expect(data.boards[0].notes_wide).toBe(4); + expect(data.boards[0].notes_tall).toBe(1); + + // The Cloud API Token field appears for note arrays — enter a token. + const tokenInput = page.getByText("Cloud API Token").locator("..").locator("input"); + await tokenInput.fill("test-vestaboard-token"); + await tokenInput.blur(); + await page.waitForTimeout(1_500); + + // Reload and confirm the selection + token persisted (token masked as "***"). + await page.reload(); + await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible({ timeout: 15_000 }); + await openSettingsTab(page, "Hardware"); + await expect(page.getByText("60 × 3").first()).toBeVisible({ timeout: 10_000 }); + + res = await fetch(`${API_URL}/settings/board`); + data = await res.json(); + expect(data.boards[0].notes_wide).toBe(4); + expect(data.boards[0].note_array_token).toBe("***"); + }); + + test("auto-detect populates type + dimensions from a mocked board read", async ({ page }) => { + await page.goto("/settings"); + await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible({ timeout: 15_000 }); + + await openSettingsTab(page, "Hardware"); + + // Route-mock the detect-size endpoint to return a 2×2 grid (6×30 chars). + await page.route("**/settings/board/*/detect-size", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + device_type: "note_array", + rows: 6, + cols: 30, + notes_wide: 2, + notes_tall: 2, + matched_preset: "2×2 grid", + }), + }); + }); + + // Expand board card and click Auto-detect. + await page.getByText("My Board").first().click(); + const detectBtn = page.getByRole("button", { name: "Auto-detect from board" }).first(); + await expect(detectBtn).toBeVisible({ timeout: 5_000 }); + await detectBtn.click(); + + // The selector lands on the 2×2 grid preset → 30 × 6 characters. + await expect(page.getByText("30 × 6").first()).toBeVisible({ timeout: 10_000 }); + + const res = await fetch(`${API_URL}/settings/board`); + const data = await res.json(); + expect(data.boards[0].device_type).toBe("note_array"); + expect(data.boards[0].notes_wide).toBe(2); + expect(data.boards[0].notes_tall).toBe(2); + }); + test("can change board color via swatches", async ({ page }) => { await page.goto("/settings"); await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible({ timeout: 15_000 });