Skip to content

Commit e1a7e90

Browse files
committed
Extract testable utilities from grid.ts and add unit tests
Extract pure functions into dedicated modules for unit testing: - src/template.ts: evaluateCssTemplates, evaluateOverlayContent, extractEntitiesFromTemplate - src/grid-utils.ts: detectAllGridAreas, formatAreaName, ensureSectionsForAllAreas - src/yaml.ts: sectionConfigToYaml, yamlScalar, parseYaml grid.ts methods become thin wrappers calling these imports. Add vitest with 67 tests across 4 test files: - template-evaluation: Jinja-like DSL resolution - grid-utils: grid area parsing, formatting, section creation - yaml: serializer/parser round-trips - build-output: compiled JS smoke tests
1 parent 80787f6 commit e1a7e90

13 files changed

Lines changed: 1877 additions & 278 deletions

package-lock.json

Lines changed: 1203 additions & 121 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"private": true,
66
"scripts": {
77
"build": "rollup -c",
8-
"watch": "rollup -c --watch"
8+
"watch": "rollup -c --watch",
9+
"test": "vitest run",
10+
"test:watch": "vitest"
911
},
1012
"author": "Stormsys",
1113
"license": "MIT",
@@ -17,7 +19,8 @@
1719
"@rollup/plugin-terser": "^0.4.4",
1820
"rollup": "^4.40.2",
1921
"rollup-plugin-typescript2": "^0.36.0",
20-
"typescript": "^5.8.3"
22+
"typescript": "^5.8.3",
23+
"vitest": "^4.0.18"
2124
},
2225
"dependencies": {
2326
"lit": "^3.3.0"

sections-grid-layout.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/grid-utils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Pure utility functions for CSS Grid area parsing and section management.
3+
*/
4+
5+
/**
6+
* Parse a grid-template-areas string and return all unique named areas.
7+
* Excludes "." (unnamed cell) and empty strings.
8+
*/
9+
export function detectAllGridAreas(areasString: string | undefined): string[] {
10+
if (!areasString) return [];
11+
const found = new Set<string>();
12+
for (const line of areasString.split("\n").map(l => l.trim()).filter(Boolean)) {
13+
for (const area of line.replace(/['"]/g, "").split(/\s+/)) {
14+
if (area !== "." && area !== "") found.add(area);
15+
}
16+
}
17+
return Array.from(found);
18+
}
19+
20+
/**
21+
* Convert a kebab-case or snake_case area name to Title Case.
22+
* e.g. "footer-right" → "Footer Right", "main_content" → "Main Content"
23+
*/
24+
export function formatAreaName(area: string): string {
25+
return area.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
26+
}
27+
28+
/**
29+
* Ensure every grid area has a corresponding section config.
30+
* Returns a new array with auto-created sections appended for missing areas.
31+
*/
32+
export function ensureSectionsForAllAreas(
33+
allGridAreas: string[],
34+
sections: any[]
35+
): any[] {
36+
if (!allGridAreas.length) return sections || [];
37+
const result = [...(sections || [])];
38+
const existing = new Set(result.map(s => s.grid_area).filter(Boolean));
39+
for (const area of allGridAreas) {
40+
if (!existing.has(area)) {
41+
result.push({ type: "grid", title: formatAreaName(area), grid_area: area, cards: [] });
42+
}
43+
}
44+
return result;
45+
}

src/layouts/grid.ts

Lines changed: 23 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import {
88
LovelaceCard,
99
OverlayConfig,
1010
} from "../types";
11+
import {
12+
evaluateCssTemplates,
13+
evaluateOverlayContent,
14+
extractEntitiesFromTemplate,
15+
} from "../template";
16+
import { detectAllGridAreas, formatAreaName, ensureSectionsForAllAreas } from "../grid-utils";
17+
import { sectionConfigToYaml, parseYaml } from "../yaml";
1118

1219
class GridLayout extends LitElement {
1320
// ── External properties set by HA ───────────────────────────────────────
@@ -437,25 +444,17 @@ class GridLayout extends LitElement {
437444

438445
_extractEntitiesFromTemplates() {
439446
this._trackedEntities.clear();
440-
const extract = (str: string) => {
441-
if (!str) return;
442-
for (const re of [
443-
/is_state\(['"]([^'"]+)['"]/g,
444-
/states\(['"]([^'"]+)['"]/g,
445-
/state_attr\(['"]([^'"]+)['"]/g,
446-
]) {
447-
let m;
448-
while ((m = re.exec(str)) !== null) this._trackedEntities.add(m[1]);
449-
}
447+
const addAll = (str: string) => {
448+
for (const eid of extractEntitiesFromTemplate(str)) this._trackedEntities.add(eid);
450449
};
451-
extract(this._config.layout?.custom_css);
452-
extract(this._config.layout?.background_image);
450+
addAll(this._config.layout?.custom_css);
451+
addAll(this._config.layout?.background_image);
453452
const overlays = this._config.layout?.overlays;
454453
if (overlays) {
455454
for (const overlay of overlays) {
456455
if (overlay.entity) this._trackedEntities.add(overlay.entity);
457-
extract(overlay.custom_css);
458-
extract(overlay.content);
456+
addAll(overlay.custom_css);
457+
addAll(overlay.content);
459458
}
460459
}
461460
}
@@ -464,38 +463,9 @@ class GridLayout extends LitElement {
464463
if (!css || !this.hass) return css;
465464
if (!css.includes("{{") && !css.includes("{%")) return css;
466465
if (css === this._lastEvaluatedCss) return css;
467-
try {
468-
let out = css;
469-
out = out.replace(
470-
/\{%\s*if\s+is_state\(['"]([^'"]+)['"],\s*['"]([^'"]+)['"]\)\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/g,
471-
(_, eid, expected, content) => {
472-
this._trackedEntities.add(eid);
473-
return this.hass.states[eid]?.state === expected ? content : "";
474-
}
475-
);
476-
out = out.replace(
477-
/\{%\s*if\s+not\s+is_state\(['"]([^'"]+)['"],\s*['"]([^'"]+)['"]\)\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/g,
478-
(_, eid, expected, content) => {
479-
this._trackedEntities.add(eid);
480-
return this.hass.states[eid]?.state !== expected ? content : "";
481-
}
482-
);
483-
out = out.replace(/\{\{\s*states\(['"]([^'"]+)['"]\)\s*\}\}/g, (m, eid) => {
484-
this._trackedEntities.add(eid);
485-
return this.hass.states[eid]?.state ?? m;
486-
});
487-
out = out.replace(/\{\{\s*state_attr\(['"]([^'"]+)['"],\s*['"]([^'"]+)['"]\)\s*\}\}/g,
488-
(m, eid, attr) => {
489-
this._trackedEntities.add(eid);
490-
const val = this.hass.states[eid]?.attributes?.[attr];
491-
return val !== undefined ? val : m;
492-
}
493-
);
494-
this._lastEvaluatedCss = out;
495-
return out;
496-
} catch {
497-
return css;
498-
}
466+
const out = evaluateCssTemplates(css, this.hass.states, this._trackedEntities);
467+
this._lastEvaluatedCss = out;
468+
return out;
499469
}
500470

501471
// ── Overlays ─────────────────────────────────────────────────────────
@@ -648,20 +618,8 @@ class GridLayout extends LitElement {
648618
}
649619

650620
_evaluateOverlayContent(content: string): string {
651-
if (!content || !this.hass) return content || "";
652-
if (!content.includes("{{")) return content;
653-
let out = content;
654-
out = out.replace(/\{\{\s*states\(['"]([^'"]+)['"]\)\s*\}\}/g, (m, eid) => {
655-
return this.hass.states[eid]?.state ?? m;
656-
});
657-
out = out.replace(
658-
/\{\{\s*state_attr\(['"]([^'"]+)['"],\s*['"]([^'"]+)['"]\)\s*\}\}/g,
659-
(m, eid, attr) => {
660-
const val = this.hass.states[eid]?.attributes?.[attr];
661-
return val !== undefined ? String(val) : m;
662-
}
663-
);
664-
return out;
621+
if (!this.hass) return content || "";
622+
return evaluateOverlayContent(content, this.hass.states);
665623
}
666624

667625
// ── Background image ─────────────────────────────────────────────────────
@@ -866,31 +824,15 @@ class GridLayout extends LitElement {
866824
}
867825

868826
_detectAllGridAreas(): string[] {
869-
const areas = this._config.layout?.["grid-template-areas"];
870-
if (!areas) return [];
871-
const found = new Set<string>();
872-
for (const line of areas.split("\n").map(l => l.trim()).filter(Boolean)) {
873-
for (const area of line.replace(/['"]/g, "").split(/\s+/)) {
874-
if (area !== "." && area !== "") found.add(area);
875-
}
876-
}
877-
return Array.from(found);
827+
return detectAllGridAreas(this._config.layout?.["grid-template-areas"]);
878828
}
879829

880830
_ensureSectionsForAllAreas(allGridAreas: string[]): any[] {
881-
if (!allGridAreas.length) return this._config.sections || [];
882-
const sections = [...(this._config.sections || [])];
883-
const existing = new Set(sections.map(s => s.grid_area).filter(Boolean));
884-
for (const area of allGridAreas) {
885-
if (!existing.has(area)) {
886-
sections.push({ type: "grid", title: this._formatAreaName(area), grid_area: area, cards: [] });
887-
}
888-
}
889-
return sections;
831+
return ensureSectionsForAllAreas(allGridAreas, this._config.sections || []);
890832
}
891833

892834
_formatAreaName(area: string): string {
893-
return area.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
835+
return formatAreaName(area);
894836
}
895837

896838
_createLooseCardsContainer(): HTMLElement {
@@ -998,83 +940,11 @@ class GridLayout extends LitElement {
998940
}
999941

1000942
_sectionConfigToYaml(config: any): string {
1001-
const SKIP = new Set(["cards"]);
1002-
const serialize = (obj: any, indent: number): string => {
1003-
const pad = " ".repeat(indent);
1004-
const lines: string[] = [];
1005-
for (const [key, value] of Object.entries(obj)) {
1006-
if (indent === 0 && SKIP.has(key)) continue;
1007-
if (value === undefined || value === null) continue;
1008-
if (Array.isArray(value)) {
1009-
lines.push(`${pad}${key}:`);
1010-
for (const item of value) {
1011-
if (typeof item === "object" && item !== null) {
1012-
const inner = serialize(item, indent + 2).replace(/^\s+/, "");
1013-
lines.push(`${pad} - ${inner}`);
1014-
} else {
1015-
lines.push(`${pad} - ${this._yamlScalar(item, indent + 1)}`);
1016-
}
1017-
}
1018-
} else if (typeof value === "object") {
1019-
lines.push(`${pad}${key}:`);
1020-
lines.push(serialize(value, indent + 1));
1021-
} else if (typeof value === "string" && value.includes("\n")) {
1022-
// Multiline string: use YAML block literal style (|)
1023-
const innerPad = " ".repeat(indent + 1);
1024-
lines.push(`${pad}${key}: |`);
1025-
for (const sline of value.split("\n")) {
1026-
lines.push(sline === "" ? "" : `${innerPad}${sline}`);
1027-
}
1028-
} else {
1029-
lines.push(`${pad}${key}: ${this._yamlScalar(value, indent)}`);
1030-
}
1031-
}
1032-
return lines.join("\n");
1033-
};
1034-
return serialize(config, 0);
1035-
}
1036-
1037-
_yamlScalar(value: any, _indent: number = 0): string {
1038-
if (typeof value === "string") {
1039-
if (value === "" || value === "true" || value === "false" ||
1040-
value === "null" || value === "~" ||
1041-
/^[\d.]+$/.test(value) || value.includes(":") ||
1042-
value.includes("#") || value.includes("{") ||
1043-
value.includes("[") || value.startsWith("'") ||
1044-
value.startsWith('"') || value.startsWith("&") ||
1045-
value.startsWith("*")) {
1046-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1047-
}
1048-
return value;
1049-
}
1050-
return String(value);
943+
return sectionConfigToYaml(config);
1051944
}
1052945

1053946
_parseYaml(yaml: string): Record<string, any> | null {
1054-
// Try HA's bundled js-yaml first
1055-
try {
1056-
const jsyaml = (window as any).jsyaml;
1057-
if (jsyaml?.load) return jsyaml.load(yaml) || {};
1058-
} catch { /* fall through */ }
1059-
// Fallback: simple line-based parser (flat keys only)
1060-
try {
1061-
const result: Record<string, any> = {};
1062-
for (const line of yaml.split("\n")) {
1063-
const trimmed = line.trim();
1064-
if (!trimmed || trimmed.startsWith("#")) continue;
1065-
const colonIdx = trimmed.indexOf(":");
1066-
if (colonIdx < 0) continue;
1067-
const key = trimmed.slice(0, colonIdx).trim();
1068-
let val: any = trimmed.slice(colonIdx + 1).trim();
1069-
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
1070-
val = val.slice(1, -1);
1071-
} else if (val === "true") { val = true; }
1072-
else if (val === "false") { val = false; }
1073-
else if (val !== "" && !isNaN(Number(val))) { val = Number(val); }
1074-
if (val !== "") result[key] = val;
1075-
}
1076-
return result;
1077-
} catch { return null; }
947+
return parseYaml(yaml);
1078948
}
1079949

1080950
_openSectionYamlEditor(gridArea: string) {

src/template.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Pure template evaluation functions.
3+
* These handle the Jinja-like DSL used in custom_css, overlay content,
4+
* and background_image fields.
5+
*/
6+
7+
export interface HassStates {
8+
[entityId: string]: {
9+
state: string;
10+
attributes?: Record<string, any>;
11+
} | undefined;
12+
}
13+
14+
/**
15+
* Evaluate Jinja-like templates in a CSS string against HA state.
16+
* Supports: {{ states('...') }}, {{ state_attr('...', '...') }},
17+
* {% if is_state('...', '...') %}...{% endif %},
18+
* {% if not is_state('...', '...') %}...{% endif %}
19+
*/
20+
export function evaluateCssTemplates(
21+
css: string,
22+
states: HassStates,
23+
trackedEntities?: Set<string>
24+
): string {
25+
if (!css) return css;
26+
if (!css.includes("{{") && !css.includes("{%")) return css;
27+
try {
28+
let out = css;
29+
out = out.replace(
30+
/\{%\s*if\s+is_state\(['"]([^'"]+)['"],\s*['"]([^'"]+)['"]\)\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/g,
31+
(_, eid, expected, content) => {
32+
trackedEntities?.add(eid);
33+
return states[eid]?.state === expected ? content : "";
34+
}
35+
);
36+
out = out.replace(
37+
/\{%\s*if\s+not\s+is_state\(['"]([^'"]+)['"],\s*['"]([^'"]+)['"]\)\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/g,
38+
(_, eid, expected, content) => {
39+
trackedEntities?.add(eid);
40+
return states[eid]?.state !== expected ? content : "";
41+
}
42+
);
43+
out = out.replace(/\{\{\s*states\(['"]([^'"]+)['"]\)\s*\}\}/g, (m, eid) => {
44+
trackedEntities?.add(eid);
45+
return states[eid]?.state ?? m;
46+
});
47+
out = out.replace(
48+
/\{\{\s*state_attr\(['"]([^'"]+)['"],\s*['"]([^'"]+)['"]\)\s*\}\}/g,
49+
(m, eid, attr) => {
50+
trackedEntities?.add(eid);
51+
const val = states[eid]?.attributes?.[attr];
52+
return val !== undefined ? val : m;
53+
}
54+
);
55+
return out;
56+
} catch {
57+
return css;
58+
}
59+
}
60+
61+
/**
62+
* Evaluate templates in overlay content strings.
63+
* Supports: {{ states('...') }}, {{ state_attr('...', '...') }}
64+
*/
65+
export function evaluateOverlayContent(content: string, states: HassStates): string {
66+
if (!content) return content || "";
67+
if (!content.includes("{{")) return content;
68+
let out = content;
69+
out = out.replace(/\{\{\s*states\(['"]([^'"]+)['"]\)\s*\}\}/g, (m, eid) => {
70+
return states[eid]?.state ?? m;
71+
});
72+
out = out.replace(
73+
/\{\{\s*state_attr\(['"]([^'"]+)['"],\s*['"]([^'"]+)['"]\)\s*\}\}/g,
74+
(m, eid, attr) => {
75+
const val = states[eid]?.attributes?.[attr];
76+
return val !== undefined ? String(val) : m;
77+
}
78+
);
79+
return out;
80+
}
81+
82+
/**
83+
* Extract entity IDs from template strings for tracking.
84+
* Returns all entity IDs found in is_state(), states(), state_attr() calls.
85+
*/
86+
export function extractEntitiesFromTemplate(str: string): string[] {
87+
if (!str) return [];
88+
const entities = new Set<string>();
89+
for (const re of [
90+
/is_state\(['"]([^'"]+)['"]/g,
91+
/states\(['"]([^'"]+)['"]/g,
92+
/state_attr\(['"]([^'"]+)['"]/g,
93+
]) {
94+
let m;
95+
while ((m = re.exec(str)) !== null) entities.add(m[1]);
96+
}
97+
return Array.from(entities);
98+
}

0 commit comments

Comments
 (0)