feat: Add or and template folder media matchers (#2071)

- For: #1748
This commit is contained in:
Dermot Duffy
2025-05-29 21:21:59 -07:00
committed by GitHub
parent c077f8bf70
commit dc63dcdab9
25 changed files with 502 additions and 145 deletions
@@ -110,7 +110,7 @@ describe('FoldersExecutor', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
path: [{ ha: { id: 'media-source://' } }],
};
const haFolderEngine = mock<HAFoldersEngine>();
@@ -145,7 +145,7 @@ describe('FoldersExecutor', () => {
const folder = createFolder();
const query: FolderQuery = {
folder,
path: ['media-source://'],
path: [{ ha: { id: 'media-source://' } }],
};
const mediaItem = new TestViewMedia({
+35 -14
View File
@@ -1,8 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HAFoldersEngine } from '../../../../src/card-controller/folders/ha/engine';
import { FolderQuery } from '../../../../src/card-controller/folders/types';
import { FolderConfig } from '../../../../src/config/schema/folders';
import { FolderConfig, Matcher } from '../../../../src/config/schema/folders';
import { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item';
import { browseMediaSchema } from '../../../../src/ha/browse-media/types';
import { getMediaDownloadPath } from '../../../../src/ha/download';
@@ -206,9 +205,14 @@ describe('HAFoldersEngine', () => {
.mockResolvedValueOnce([]);
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query, {
useCache: false,
});
const results = await engine.expandFolder(
createHASS(),
query,
{},
{
useCache: false,
},
);
expect(results?.length).toBe(2);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[1]).toBeInstanceOf(ViewFolder);
@@ -263,15 +267,34 @@ describe('HAFoldersEngine', () => {
expect(await engine.expandFolder(createHASS(), query)).toBeNull();
});
// See additional matcher testing in media-matcher.test.ts .
describe('should apply matchers', async () => {
it('should expand folder with title based query', async () => {
it.each([
['title exact', { type: 'title' as const, title: 'Frigate' }, 1],
['title regexp', { type: 'title' as const, regexp: 'rig' }, 1],
[
'or positive',
{
type: 'or' as const,
matchers: [
{ type: 'title' as const, title: 'UNKNOWN' },
{ type: 'title' as const, title: 'Frigate' },
],
},
1,
],
[
'or negative',
{
type: 'or' as const,
matchers: [{ type: 'title' as const, title: 'UNKNOWN' }],
},
0,
],
])('%s', async (_name: string, matcher: Matcher, expectedMatches: number) => {
const query: FolderQuery = {
folder: { type: 'ha' },
path: [
{ ha: { id: 'media-source://' } },
{ ha: { matchers: [{ type: 'title', title: 'Frigate' }] } },
{},
],
path: [{ ha: { id: 'media-source://' } }, { ha: { matchers: [matcher] } }, {}],
};
vi.mocked(homeAssistantWSRequest)
@@ -303,9 +326,7 @@ describe('HAFoldersEngine', () => {
const engine = new HAFoldersEngine();
const results = await engine.expandFolder(createHASS(), query);
expect(results?.length).toBe(1);
expect(results?.[0]).toBeInstanceOf(ViewMedia);
expect(results?.[0].getID()).toBe('media-source://frigate/result');
expect(results?.length).toBe(expectedMatches);
});
});
});
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest';
import { renderTemplate } from 'ha-nunjucks';
import { describe, expect, it, vi } from 'vitest';
import { MediaMatcher } from '../../../../src/card-controller/folders/ha/media-matcher';
import { Matcher } from '../../../../src/config/schema/folders';
import { BrowseMedia } from '../../../../src/ha/browse-media/types';
import { createHASS } from '../../../test-utils';
vi.mock('ha-nunjucks');
describe('MediaMatcher', () => {
describe('match', () => {
@@ -22,25 +26,31 @@ describe('MediaMatcher', () => {
it('should return false if foldersOnly is true and media.can_expand is false', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test File', false);
expect(mediaMatcher.match(media, [], true)).toBe(false);
expect(
mediaMatcher.match(createHASS(), media, { matchers: [], foldersOnly: true }),
).toBe(false);
});
it('should return true if foldersOnly is true and media.can_expand is true', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Folder', true, 'directory');
expect(mediaMatcher.match(media, [], true)).toBe(true);
expect(
mediaMatcher.match(createHASS(), media, { matchers: [], foldersOnly: true }),
).toBe(true);
});
it('should return true if matchers array is empty', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Media', false);
expect(mediaMatcher.match(media, [])).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers: [] })).toBe(true);
});
it('should return true if matchers array is undefined', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Media', false);
expect(mediaMatcher.match(media, undefined)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers: undefined })).toBe(
true,
);
});
describe('with title matcher', () => {
@@ -48,14 +58,14 @@ describe('MediaMatcher', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Exact Title', false);
const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
it('should return false when title does not match exactly', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('DOES NOT MATCH', false);
const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return true when title matches regexp and extracted value matches matcher.title', () => {
@@ -68,7 +78,7 @@ describe('MediaMatcher', () => {
title: 'ImportantPart',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
it('should return false when title matches regexp but extracted value does not match matcher.title', () => {
@@ -81,7 +91,7 @@ describe('MediaMatcher', () => {
title: 'WrongPart',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return true when title matches regexp with an explicit title value', () => {
@@ -94,7 +104,7 @@ describe('MediaMatcher', () => {
// title is undefined.
},
];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
it('should return false when title does not match regexp', () => {
@@ -107,7 +117,7 @@ describe('MediaMatcher', () => {
title: 'ImportantPart',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return false when regexp is provided but does not extract the required group', () => {
@@ -120,14 +130,120 @@ describe('MediaMatcher', () => {
title: 'ImportantPart',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return true when no regexp and no matcher.title (matches any title)', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Any Title Will Do', false);
const matchers: Matcher[] = [{ type: 'title' }];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
});
describe('with template matcher', () => {
it('should return true when template value matches', () => {
const mediaMatcher = new MediaMatcher();
const title = 'Any Title Will Do';
const media = createMediaItem(title, false);
vi.mocked(renderTemplate).mockReturnValue(true);
const matchers: Matcher[] = [
{
type: 'template',
value_template: '{{ acc.media.title == "Any Title Will Do" }}',
},
];
const hass = createHASS();
expect(mediaMatcher.match(hass, media, { matchers })).toBe(true);
expect(renderTemplate).toHaveBeenCalledWith(
hass,
'{{ acc.media.title == "Any Title Will Do" }}',
{
acc: {
media: {
title,
is_folder: false,
},
},
advanced_camera_card: {
media: {
title,
is_folder: false,
},
},
},
);
});
it('should return false when template value does not match', () => {
const mediaMatcher = new MediaMatcher();
const title = 'Any Title Will Do';
const media = createMediaItem(title, false);
vi.mocked(renderTemplate).mockReturnValue(false);
const matchers: Matcher[] = [
{
type: 'template',
value_template: '{{ acc.media.title == "Any Title Will Do" }}',
},
];
const hass = createHASS();
expect(mediaMatcher.match(hass, media, { matchers })).toBe(false);
expect(renderTemplate).toHaveBeenCalledWith(
hass,
'{{ acc.media.title == "Any Title Will Do" }}',
{
acc: {
media: {
title,
is_folder: false,
},
},
advanced_camera_card: {
media: {
title,
is_folder: false,
},
},
},
);
});
});
describe('with or matcher', () => {
it('should return true if at least one sub-matcher matches', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Media', false);
const matcher: Matcher = {
type: 'or',
matchers: [
{ type: 'title', title: 'Non-Matching Title' }, // Fails
{ type: 'title', title: 'Test Media' }, // Passes
],
};
expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe(
true,
);
});
it('should return false if no sub-matcher matches', () => {
const mediaMatcher = new MediaMatcher();
const media = createMediaItem('Test Media', false);
const matcher: Matcher = {
type: 'or',
matchers: [
{ type: 'title', title: 'Non-Matching Title One' }, // Fails
{ type: 'title', title: 'Non-Matching Title Two' }, // Fails
],
};
expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe(
false,
);
});
});
@@ -138,7 +254,7 @@ describe('MediaMatcher', () => {
{ type: 'title', title: 'Test Media One' }, // Pass
{ type: 'title', title: 'Test Media Two' }, // Fail
];
expect(mediaMatcher.match(media, matchers)).toBe(false);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false);
});
it('should return true if all multiple matchers pass', () => {
@@ -152,7 +268,7 @@ describe('MediaMatcher', () => {
title: 'Test Media One',
},
];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
it('should ignore matchers of unknown types', () => {
@@ -160,7 +276,7 @@ describe('MediaMatcher', () => {
const media = createMediaItem('Test Media', false);
const matchers: Matcher[] = [{ type: 'unknownMatcherType' as 'title' }];
expect(mediaMatcher.match(media, matchers)).toBe(true);
expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true);
});
});
});
+18 -7
View File
@@ -130,7 +130,7 @@ describe('FoldersManager', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ id: 'media-source://' }],
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
@@ -146,7 +146,7 @@ describe('FoldersManager', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ id: 'media-source://' }],
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
@@ -162,7 +162,7 @@ describe('FoldersManager', () => {
const folder: FolderConfig = createFolder();
const query: FolderQuery = {
folder,
path: [{ id: 'media-source://' }],
path: [{ ha: { id: 'media-source://' } }],
};
const executor = mock<FoldersExecutor>();
@@ -187,15 +187,23 @@ describe('FoldersManager', () => {
const manager = new FoldersManager(api, executor);
const folder = createFolder({ id: 'folder-1' });
const conditionState = {};
const engineOptions = {};
const query: FolderQuery = {
folder,
path: [{ id: 'media-source://' }],
path: [{ ha: { id: 'media-source://' } }],
};
expect(await manager.expandFolder(query, engineOptions)).toEqual([media]);
expect(await manager.expandFolder(query, conditionState, engineOptions)).toEqual([
media,
]);
expect(executor.expandFolder).toBeCalledWith(hass, query, engineOptions);
expect(executor.expandFolder).toBeCalledWith(
hass,
query,
conditionState,
engineOptions,
);
});
it('should not expand folder with hass', async () => {
@@ -205,7 +213,10 @@ describe('FoldersManager', () => {
const folder = createFolder({ id: 'folder-1' });
expect(
await manager.expandFolder({ folder, path: [{ id: 'media-source://' }] }),
await manager.expandFolder({
folder,
path: [{ ha: { id: 'media-source://' } }],
}),
).toBeNull();
expect(executor.expandFolder).not.toBeCalled();
+29 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { transformPathURLToPathArray } from '../../../src/config/schema/folders';
import { NonEmptyTuple } from 'type-fest';
import { describe, expect, it } from 'vitest';
import {
matcherSchema,
transformPathURLToPathArray,
} from '../../../src/config/schema/folders';
describe('transformURLToMediaSourceRoot', () => {
const prefixes: NonEmptyTuple<string>[] = [
@@ -65,3 +68,27 @@ describe('transformURLToMediaSourceRoot', () => {
});
});
});
describe('should lazy evaluate schemas', () => {
it('should lazy evaluate or matcher', () => {
expect(
matcherSchema.parse({
type: 'or',
matchers: [
{
type: 'title',
title: 'Test Title',
},
],
}),
).toEqual({
type: 'or',
matchers: [
{
type: 'title',
title: 'Test Title',
},
],
});
});
});