From 2990f9aa4da7fd604a93094224e389f1bb5a1bf4 Mon Sep 17 00:00:00 2001 From: Christian Mueller Date: Mon, 6 Apr 2026 12:56:01 +0200 Subject: [PATCH] feat(display): add display builder for ESP32 JSON + tests (Task 7) Co-Authored-By: Claude Sonnet 4.6 --- .../src/modules/display/display-builder.ts | 176 ++++++++++++++++++ .../middleware/tests/display-builder.test.ts | 116 ++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 display/middleware/src/modules/display/display-builder.ts create mode 100644 display/middleware/tests/display-builder.test.ts diff --git a/display/middleware/src/modules/display/display-builder.ts b/display/middleware/src/modules/display/display-builder.ts new file mode 100644 index 0000000..7ed92bc --- /dev/null +++ b/display/middleware/src/modules/display/display-builder.ts @@ -0,0 +1,176 @@ +import { getDb } from '../../db/index'; + +export interface DisplayElement { + id: number; + type: string; + x: number; + y: number; + sort_order: number; + // Optional fields (only included when non-default) + width?: number; + height?: number; + icon?: string; + style?: string; + font_size?: number; + font_weight?: string; + text_align?: string; + // Type-specific + text?: string; // for label type + label?: string; // for value type + value?: string | null; // for value type + unit?: string | null; // for value type + grid_col?: number; + grid_row?: number; +} + +export interface DisplayJson { + display: { + id: number; + name: string; + width: number; + height: number; + refresh_sec: number; + }; + layout: { + type: string; + grid_cols?: number; + grid_rows?: number; + }; + elements: DisplayElement[]; +} + +interface RawDisplay { + id: number; + name: string; + description: string | null; + layout_id: number | null; + api_token: string | null; + last_seen: string | null; + refresh_sec: number; + created_at: string; +} + +interface RawLayout { + id: number; + name: string; + type: string; + width: number; + height: number; + grid_cols: number | null; + grid_rows: number | null; +} + +interface RawElement { + id: number; + layout_id: number; + datapoint_id: number | null; + type: string; + x: number; + y: number; + width: number | null; + height: number | null; + font_size: number; + font_weight: string; + text_align: string; + icon: string | null; + style: string; + static_text: string | null; + grid_col: number | null; + grid_row: number | null; + sort_order: number; + // Joined from datapoints + dp_label: string | null; + dp_value: string | null; + dp_unit: string | null; +} + +export function buildDisplayJson(displayId: number): DisplayJson | null { + const db = getDb(); + + const display = db + .prepare('SELECT * FROM displays WHERE id = ?') + .get(displayId) as RawDisplay | undefined; + + if (!display) return null; + + if (!display.layout_id) { + return { + display: { + id: display.id, + name: display.name, + width: 800, + height: 480, + refresh_sec: display.refresh_sec, + }, + layout: { type: 'free' }, + elements: [], + }; + } + + const layout = db + .prepare('SELECT * FROM layouts WHERE id = ?') + .get(display.layout_id) as RawLayout | undefined; + + if (!layout) return null; + + const rawElements = db + .prepare(` + SELECT + le.*, + d.label AS dp_label, + d.value AS dp_value, + d.unit AS dp_unit + FROM layout_elements le + LEFT JOIN datapoints d ON d.id = le.datapoint_id + WHERE le.layout_id = ? + ORDER BY le.sort_order ASC, le.id ASC + `) + .all(display.layout_id) as RawElement[]; + + const elements: DisplayElement[] = rawElements.map((el) => { + const base: DisplayElement = { + id: el.id, + type: el.type, + x: el.x, + y: el.y, + sort_order: el.sort_order, + }; + + // Include optional fields only when non-default + if (el.width != null) base.width = el.width; + if (el.height != null) base.height = el.height; + if (el.icon) base.icon = el.icon; + if (el.style && el.style !== 'plain') base.style = el.style; + if (el.font_size && el.font_size !== 24) base.font_size = el.font_size; + if (el.font_weight && el.font_weight !== 'normal') base.font_weight = el.font_weight; + if (el.text_align && el.text_align !== 'left') base.text_align = el.text_align; + if (el.grid_col != null) base.grid_col = el.grid_col; + if (el.grid_row != null) base.grid_row = el.grid_row; + + if (el.type === 'label') { + base.text = el.static_text ?? ''; + } else if (el.type === 'value') { + base.label = el.dp_label ?? ''; + base.value = el.dp_value ?? null; + base.unit = el.dp_unit ?? null; + } + + return base; + }); + + const layoutResult: DisplayJson['layout'] = { type: layout.type }; + if (layout.grid_cols != null) layoutResult.grid_cols = layout.grid_cols; + if (layout.grid_rows != null) layoutResult.grid_rows = layout.grid_rows; + + return { + display: { + id: display.id, + name: display.name, + width: layout.width, + height: layout.height, + refresh_sec: display.refresh_sec, + }, + layout: layoutResult, + elements, + }; +} diff --git a/display/middleware/tests/display-builder.test.ts b/display/middleware/tests/display-builder.test.ts new file mode 100644 index 0000000..5ddddc7 --- /dev/null +++ b/display/middleware/tests/display-builder.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { initDb, closeDb } from '../src/db/index'; +import { buildDisplayJson } from '../src/modules/display/display-builder'; + +beforeAll(() => { + const db = initDb(':memory:'); + + // Create a layout + db.run( + `INSERT INTO layouts (id, name, type, width, height, grid_cols, grid_rows) + VALUES (1, 'Test Layout', 'grid', 800, 480, 2, 3)` + ); + + // Create a display with the layout + db.run( + `INSERT INTO displays (id, name, layout_id, refresh_sec) + VALUES (1, 'Test Display', 1, 60)` + ); + + // Create a display without layout + db.run( + `INSERT INTO displays (id, name, layout_id, refresh_sec) + VALUES (2, 'Empty Display', NULL, 300)` + ); + + // Create a broker and topic for datapoint + db.run( + `INSERT INTO mqtt_brokers (id, name, host) VALUES (1, 'Test Broker', 'localhost')` + ); + db.run( + `INSERT INTO mqtt_topics (id, broker_id, topic) VALUES (1, 1, 'sensors/temp')` + ); + + // Create a datapoint + db.run( + `INSERT INTO datapoints (id, name, label, unit, value, source_type, source_id, topic_id, enabled) + VALUES (1, 'temperature', 'Temperature', '°C', '22.5', 'mqtt', 1, 1, 1)` + ); + + // Create layout elements + // Label element + db.run( + `INSERT INTO layout_elements (id, layout_id, type, x, y, static_text, sort_order) + VALUES (1, 1, 'label', 0, 0, 'Hello World', 0)` + ); + // Value element linked to datapoint + db.run( + `INSERT INTO layout_elements (id, layout_id, datapoint_id, type, x, y, font_size, sort_order) + VALUES (2, 1, 1, 'value', 100, 0, 32, 1)` + ); +}); + +afterAll(() => { + closeDb(); +}); + +describe('buildDisplayJson', () => { + it('returns null for non-existent display', () => { + const result = buildDisplayJson(999); + expect(result).toBeNull(); + }); + + it('returns complete JSON for a display with layout', () => { + const result = buildDisplayJson(1); + expect(result).not.toBeNull(); + expect(result!.display.id).toBe(1); + expect(result!.display.name).toBe('Test Display'); + expect(result!.display.width).toBe(800); + expect(result!.display.height).toBe(480); + expect(result!.display.refresh_sec).toBe(60); + }); + + it('includes grid layout info when grid_cols and grid_rows are set', () => { + const result = buildDisplayJson(1); + expect(result!.layout.type).toBe('grid'); + expect(result!.layout.grid_cols).toBe(2); + expect(result!.layout.grid_rows).toBe(3); + }); + + it('includes label element with text', () => { + const result = buildDisplayJson(1); + const labelEl = result!.elements.find((e) => e.type === 'label'); + expect(labelEl).toBeDefined(); + expect(labelEl!.text).toBe('Hello World'); + }); + + it('includes value element with datapoint data', () => { + const result = buildDisplayJson(1); + const valueEl = result!.elements.find((e) => e.type === 'value'); + expect(valueEl).toBeDefined(); + expect(valueEl!.label).toBe('Temperature'); + expect(valueEl!.value).toBe('22.5'); + expect(valueEl!.unit).toBe('°C'); + }); + + it('includes non-default font_size in value element', () => { + const result = buildDisplayJson(1); + const valueEl = result!.elements.find((e) => e.type === 'value'); + expect(valueEl!.font_size).toBe(32); + }); + + it('does not include default font_size in label element', () => { + const result = buildDisplayJson(1); + const labelEl = result!.elements.find((e) => e.type === 'label'); + // font_size 24 is default, should not be included + expect(labelEl!.font_size).toBeUndefined(); + }); + + it('returns empty elements and default dimensions for display without layout', () => { + const result = buildDisplayJson(2); + expect(result).not.toBeNull(); + expect(result!.elements).toHaveLength(0); + expect(result!.display.width).toBe(800); + expect(result!.display.height).toBe(480); + }); +});