From 9b95ece284c277e5fc4cda120fd48932d2a171e7 Mon Sep 17 00:00:00 2001 From: mokusone <14061880+mokusone@users.noreply.github.com> Date: Thu, 14 May 2026 11:40:28 +0200 Subject: [PATCH] feat(filter): add isExcluded helper with unit tests Glob-based entity_id matcher used by the upcoming exclude-filter feature. Single wildcard `*` (matches any sequence including dots), all other characters matched literally with regex metacharacters escaped. Case-sensitive, anchored. Empty pattern array always returns false, so the helper is a no-op when the adapter is not configured to filter anything. New mocha unit-test suite invoked via `npm run test:unit` (separate from the existing js-controller-backed test:integration). --- package.json | 1 + src/lib/entityFilter.ts | 25 +++++++++++++++++++++ test/testEntityFilter.js | 47 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 src/lib/entityFilter.ts create mode 100644 test/testEntityFilter.js diff --git a/package.json b/package.json index 09d1fd5..4cc9d1a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "scripts": { "test:integration": "mocha --exit", "test:package": "mocha test/testPackageFiles.js --exit", + "test:unit": "mocha test/testEntityFilter.js --exit", "test": "npm run test:integration", "build:tsc": "tsc -p tsconfig.build.json", "build": "npm run build:tsc", diff --git a/src/lib/entityFilter.ts b/src/lib/entityFilter.ts new file mode 100644 index 0000000..1f73d15 --- /dev/null +++ b/src/lib/entityFilter.ts @@ -0,0 +1,25 @@ +/** + * Returns true if entityId matches any of the supplied glob patterns. + * Glob syntax: `*` is the only wildcard and matches any sequence of characters + * (including dots). All other characters are matched literally (regex + * metacharacters are escaped). Matching is case-sensitive and anchored to + * the full entity_id. + * + * An empty patterns array always returns false. + */ +export function isExcluded(entityId: string, patterns: string[]): boolean { + if (!patterns || patterns.length === 0) { + return false; + } + for (const pattern of patterns) { + if (globToRegex(pattern).test(entityId)) { + return true; + } + } + return false; +} + +function globToRegex(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`); +} diff --git a/test/testEntityFilter.js b/test/testEntityFilter.js new file mode 100644 index 0000000..5dc12a4 --- /dev/null +++ b/test/testEntityFilter.js @@ -0,0 +1,47 @@ +const { expect } = require('chai'); +const { isExcluded } = require('../build/lib/entityFilter'); + +describe('entityFilter.isExcluded', () => { + it('returns false for an empty pattern list', () => { + expect(isExcluded('switch.iob_anything', [])).to.equal(false); + }); + + it('matches a simple glob with leading wildcard', () => { + expect(isExcluded('switch.iob_shelly_xyz', ['*.iob_*'])).to.equal(true); + }); + + it('matches the bridge mirror naming pattern', () => { + expect(isExcluded('light.iob_ha_eg_wz1__e_licht_decke', ['*.iob_*__*'])).to.equal(true); + }); + + it('handles entities that miss a required literal segment', () => { + // *.iob_*__* requires the literal `__` segment — entities without it do not match + expect(isExcluded('switch.iob_', ['*.iob_*__*'])).to.equal(false); + expect(isExcluded('switch.iob_foo', ['*.iob_*__*'])).to.equal(false); + // Naming-Convention safety: similarly named entities without `__` are safe + expect(isExcluded('sensor.scheune_temperatur', ['*.sc_*__*'])).to.equal(false); + }); + + it('matches multiple patterns (OR semantic)', () => { + const patterns = ['*.iob_*__*', '*.knx_*', '*.ha_*__*']; + expect(isExcluded('switch.knx_foo', patterns)).to.equal(true); + expect(isExcluded('switch.ha_eg_wz1__e_licht_decke', patterns)).to.equal(true); + expect(isExcluded('switch.something_else', patterns)).to.equal(false); + }); + + it('is case sensitive', () => { + expect(isExcluded('switch.IOB_foo', ['*.iob_*'])).to.equal(false); + expect(isExcluded('switch.iob_foo', ['*.IOB_*'])).to.equal(false); + }); + + it('treats * as matching any characters including dots', () => { + // We use full entity_id including the leading domain, so this is fine. + expect(isExcluded('switch.iob_foo', ['*foo*'])).to.equal(true); + }); + + it('escapes regex metacharacters in patterns', () => { + // `.` in pattern matches literal `.`, not "any char" + expect(isExcluded('switch.iob_foo', ['switch.iob_foo'])).to.equal(true); + expect(isExcluded('switchXiob_foo', ['switch.iob_foo'])).to.equal(false); + }); +});