import test from 'node:test'; import assert from 'node:assert/strict'; import { lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, } from '../test-build/logic.js'; import { iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses, } from '../test-build/rules.js'; test('lqiColor: boundaries and midpoint', () => { assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)'); assert.equal(lqiColor(180), 'hsl(120, 85%, 55%)'); assert.equal(lqiColor(110), 'hsl(60, 85%, 55%)'); assert.equal(lqiColor(0), 'hsl(0, 85%, 55%)'); assert.equal(lqiColor(255), 'hsl(120, 85%, 55%)'); }); test('snapToGrid', () => { assert.equal(snapToGrid(0, 10), 0); assert.equal(snapToGrid(14, 10), 10); assert.equal(snapToGrid(16, 10), 20); }); test('segKey: direction does not matter', () => { assert.equal(segKey([0, 0], [10, 5]), segKey([10, 5], [0, 0])); assert.notEqual(segKey([0, 0], [10, 5]), segKey([0, 0], [10, 6])); }); test('samePoint with tolerance', () => { assert.ok(samePoint([1, 1], [1.0005, 0.9995])); assert.ok(!samePoint([1, 1], [1.5, 1])); }); test('pointInPolygon: square', () => { const sq = [[0, 0], [10, 0], [10, 10], [0, 10]]; assert.ok(pointInPolygon([5, 5], sq)); assert.ok(!pointInPolygon([15, 5], sq)); assert.ok(!pointInPolygon([-1, -1], sq)); }); test('pointInPolygon: L-shaped polygon', () => { const L = [[0, 0], [6, 0], [6, 2], [2, 2], [2, 6], [0, 6]]; assert.ok(pointInPolygon([1, 5], L)); assert.ok(pointInPolygon([5, 1], L)); assert.ok(!pointInPolygon([5, 5], L)); }); test('markerIdForBinding', () => { let n = 0; const nid = () => 'v_new' + n++; assert.equal(markerIdForBinding('device:abc', undefined, nid), 'abc'); assert.equal(markerIdForBinding('entity:light.x', undefined, nid), 'lg_light.x'); assert.equal(markerIdForBinding('virtual', 'v_existing', nid), 'v_existing'); assert.equal(markerIdForBinding('virtual', undefined, nid), 'v_new0'); assert.equal(markerIdForBinding('virtual', 'abc', nid), 'v_new1'); }); test('averageLqi', () => { assert.equal(averageLqi([]), null); assert.equal(averageLqi([100]), 100); assert.equal(averageLqi([100, 200]), 150); assert.equal(averageLqi([1, 2, 2]), 2); }); test('iconFor: key rules', () => { // Russian device names below are intentional: iconFor rules match Russian names (see src/rules.ts). assert.equal(iconFor('Датчик протечки кухня', 'HOBEIAN'), 'mdi:water-alert'); assert.equal(iconFor('Замок Терраса', 'TTLock'), 'mdi:lock'); assert.equal(iconFor('Настенная лампа 1', 'Yandex Bulb'), 'mdi:lightbulb'); assert.equal(iconFor('Ворота', 'Tuya Garage'), 'mdi:garage-variant'); assert.equal(iconFor('Термоголовка', 'Aqara'), 'mdi:radiator'); assert.equal(iconFor('Unknown gadget', 'XYZ'), 'mdi:chip'); }); test('fitView: portrait plan in a wide scene — margins on the sides, whole plan inside', () => { // vb 100x200 (aspect 0.5), scene aspect 2 → view wider than the plan, height = 200 const v = fitView([0, 0, 100, 200], 2); assert.equal(v.h, 200); assert.equal(v.w, 400); // 200*2 assert.equal(v.x, -150); // (100-400)/2 centering assert.equal(v.y, 0); // the whole plan is inside the view assert.ok(v.x <= 0 && v.x + v.w >= 100 && v.y <= 0 && v.y + v.h >= 200); }); test('fitView: scene aspect matches the plan — view == vb', () => { const v = fitView([10, 20, 300, 150], 2); // plan aspect = 2 == scene aspect assert.equal(v.x, 10); assert.equal(v.y, 20); assert.equal(v.w, 300); assert.equal(v.h, 150); }); test('declump: close points spread apart no closer than minDist and stay within bounds', () => { const b = { x: 0, y: 0, w: 100, h: 100 }; const pts = [ { x: 50, y: 50 }, { x: 51, y: 50 }, { x: 50, y: 51 } ]; declump(pts, b, 20, 5); // all pairs no closer than ~minDist (with tolerance for clamping to the bounds) for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) { const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y); assert.ok(d > 12, `pair ${i},${j} too close: ${d}`); } // within the bounds [5..95] for (const q of pts) { assert.ok(q.x >= 5 && q.x <= 95 && q.y >= 5 && q.y <= 95); } }); test('declump: a single point does not move', () => { const pts = [{ x: 30, y: 40 }]; declump(pts, { x: 0, y: 0, w: 100, h: 100 }, 20, 5); assert.deepEqual(pts, [{ x: 30, y: 40 }]); }); test('averageLqi: empty → null, otherwise the rounded average', () => { assert.equal(averageLqi([]), null); assert.equal(averageLqi([100, 200]), 150); assert.equal(averageLqi([1, 2, 2]), 2); }); test('safeUrl: allows http(s) and relative paths, cuts dangerous schemes', () => { assert.equal(safeUrl('https://example.com/a?b=1'), 'https://example.com/a?b=1'); assert.equal(safeUrl('http://x.ru'), 'http://x.ru'); assert.equal(safeUrl('//cdn.x.ru/f.pdf'), '//cdn.x.ru/f.pdf'); assert.equal(safeUrl('/houseplan_files/files/m1/doc.pdf?v=1'), '/houseplan_files/files/m1/doc.pdf?v=1'); assert.equal(safeUrl('docs/manual.pdf'), 'docs/manual.pdf'); assert.equal(safeUrl('javascript:alert(1)'), null); assert.equal(safeUrl('data:text/html,