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); + }); +});