feat(display): add display builder for ESP32 JSON + tests (Task 7)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
47d746cf71
commit
2990f9aa4d
176
display/middleware/src/modules/display/display-builder.ts
Normal file
176
display/middleware/src/modules/display/display-builder.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
116
display/middleware/tests/display-builder.test.ts
Normal file
116
display/middleware/tests/display-builder.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user