mirror of
https://github.com/Lissy93/dashy.git
synced 2026-05-25 02:44:51 -04:00
🧪 Tests for search functionality
This commit is contained in:
@@ -384,4 +384,19 @@ describe('ConfigHelpers - getCustomKeyShortcuts', () => {
|
||||
const result = getCustomKeyShortcuts(sections);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips sections that have no items array (widgets-only)', () => {
|
||||
const sections = [
|
||||
{ name: 'Widgets Only', widgets: [{ type: 'embed' }] },
|
||||
{ items: [{ hotkey: 1, url: 'https://a.com' }] },
|
||||
];
|
||||
expect(getCustomKeyShortcuts(sections)).toEqual([
|
||||
{ hotkey: 1, url: 'https://a.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array for null or undefined input', () => {
|
||||
expect(getCustomKeyShortcuts(null)).toEqual([]);
|
||||
expect(getCustomKeyShortcuts(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
334
tests/unit/search.test.js
Normal file
334
tests/unit/search.test.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
searchTiles,
|
||||
getSearchEngineFromBang,
|
||||
stripBangs,
|
||||
findUrlForSearchEngine,
|
||||
isUrlLike,
|
||||
} from '@/utils/Search';
|
||||
|
||||
const tile = (over = {}) => ({
|
||||
title: 'Plex',
|
||||
description: 'Media server',
|
||||
provider: 'Plex Inc',
|
||||
url: 'https://plex.lab:32400',
|
||||
tags: ['media', 'streaming'],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('Search - searchTiles', () => {
|
||||
it('returns the input array for an empty or nullish term', () => {
|
||||
const tiles = [tile(), tile({ title: 'Jellyfin' })];
|
||||
expect(searchTiles(tiles, '')).toBe(tiles);
|
||||
expect(searchTiles(tiles, undefined)).toBe(tiles);
|
||||
expect(searchTiles(tiles, null)).toBe(tiles);
|
||||
});
|
||||
|
||||
it('returns the input array for a whitespace-only term', () => {
|
||||
const tiles = [tile()];
|
||||
expect(searchTiles(tiles, ' ')).toBe(tiles);
|
||||
expect(searchTiles(tiles, '\t\n')).toBe(tiles);
|
||||
});
|
||||
|
||||
it('returns an empty array when allTiles is missing', () => {
|
||||
expect(searchTiles(null, 'plex')).toEqual([]);
|
||||
expect(searchTiles(undefined, 'plex')).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches title case-insensitively', () => {
|
||||
expect(searchTiles([tile()], 'PLEX')).toHaveLength(1);
|
||||
expect(searchTiles([tile()], 'plex')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches description', () => {
|
||||
expect(searchTiles([tile()], 'media server')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches provider', () => {
|
||||
expect(searchTiles([tile()], 'inc')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches tags supplied as an array', () => {
|
||||
expect(searchTiles([tile()], 'streaming')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches tags supplied as a comma-separated string', () => {
|
||||
const t = tile({ tags: 'docker, primary' });
|
||||
expect(searchTiles([t], 'docker')).toHaveLength(1);
|
||||
expect(searchTiles([t], 'primary')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches across multiple fields when the query has several words', () => {
|
||||
const t = tile({ title: 'Plex', description: 'Media server', tags: [] });
|
||||
expect(searchTiles([t], 'plex media')).toHaveLength(1);
|
||||
expect(searchTiles([t], 'media plex')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('requires every word to appear somewhere', () => {
|
||||
expect(searchTiles([tile()], 'plex kubernetes')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when nothing matches', () => {
|
||||
expect(searchTiles([tile()], 'kubernetes')).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips punctuation from the query', () => {
|
||||
expect(searchTiles([tile()], 'plex.lab')).toHaveLength(1);
|
||||
expect(searchTiles([tile()], 'plex,lab')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('searches the URL hostname', () => {
|
||||
expect(searchTiles([tile()], 'plex.lab')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('searches the URL port (regression)', () => {
|
||||
expect(searchTiles([tile()], '32400')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('searches the URL path (regression)', () => {
|
||||
const t = tile({ url: 'https://router.lab/admin' });
|
||||
expect(searchTiles([t], 'admin')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('tolerates tiles with missing optional fields', () => {
|
||||
const t = { title: 'Plex' };
|
||||
expect(searchTiles([t], 'plex')).toHaveLength(1);
|
||||
expect(searchTiles([t], 'media')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters a mixed list to only the matching tiles', () => {
|
||||
const a = tile({ title: 'Plex' });
|
||||
const b = tile({ title: 'Jellyfin' });
|
||||
const c = tile({ title: 'Sonarr' });
|
||||
expect(searchTiles([a, b, c], 'jelly')).toEqual([b]);
|
||||
});
|
||||
|
||||
it('preserves tile object identity in the result', () => {
|
||||
const t = tile();
|
||||
expect(searchTiles([t], 'plex')[0]).toBe(t);
|
||||
});
|
||||
|
||||
it('is stable when called repeatedly with the same tile (cache safe)', () => {
|
||||
const t = tile();
|
||||
expect(searchTiles([t], 'plex')).toHaveLength(1);
|
||||
expect(searchTiles([t], 'plex')).toHaveLength(1);
|
||||
expect(searchTiles([t], 'kubernetes')).toEqual([]);
|
||||
});
|
||||
|
||||
// sub-item searchability: parent surfaces when a child matches
|
||||
it('matches a parent tile via its sub-items title or url', () => {
|
||||
const t = tile({
|
||||
title: 'Arr Stack',
|
||||
subItems: [
|
||||
{ title: 'Sonarr', url: 'https://sonarr.lab' },
|
||||
{ title: 'Radarr', url: 'https://radarr.lab' },
|
||||
],
|
||||
});
|
||||
expect(searchTiles([t], 'sonarr')).toHaveLength(1);
|
||||
expect(searchTiles([t], 'radarr.lab')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// section-name searchability via the extraHaystack argument
|
||||
it('matches every tile when the query matches the extra haystack alone', () => {
|
||||
const a = tile({ title: 'Pi-hole' });
|
||||
const b = tile({ title: 'AdGuard' });
|
||||
const result = searchTiles([a, b], 'networking', 'Networking & VPN');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('combines tile and extra haystacks for multi-word queries', () => {
|
||||
const a = tile({ title: 'Pi-hole' });
|
||||
const b = tile({ title: 'AdGuard' });
|
||||
expect(searchTiles([a, b], 'networking pi', 'Networking & VPN')).toEqual([a]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search - getSearchEngineFromBang', () => {
|
||||
const bangs = {
|
||||
'/b': 'bbc',
|
||||
'/g': 'google',
|
||||
'/w': 'wikipedia',
|
||||
'/y': 'youtube',
|
||||
'/gh': 'github',
|
||||
'/so': 'stackoverflow',
|
||||
'/wa': 'wolframalpha',
|
||||
};
|
||||
|
||||
it('resolves a single-char bang', () => {
|
||||
expect(getSearchEngineFromBang('/g hello', bangs)).toBe('google');
|
||||
expect(getSearchEngineFromBang('/y kittens', bangs)).toBe('youtube');
|
||||
});
|
||||
|
||||
// regression: longest bang wins so /gh isn't shadowed by /g
|
||||
it('prefers the longest matching bang', () => {
|
||||
expect(getSearchEngineFromBang('/gh react', bangs)).toBe('github');
|
||||
expect(getSearchEngineFromBang('/wa pi', bangs)).toBe('wolframalpha');
|
||||
expect(getSearchEngineFromBang('/so vue', bangs)).toBe('stackoverflow');
|
||||
});
|
||||
|
||||
it('matches a bang in the middle of a query', () => {
|
||||
expect(getSearchEngineFromBang('foo /b news', bangs)).toBe('bbc');
|
||||
});
|
||||
|
||||
it('matches a bang at the end of a query', () => {
|
||||
expect(getSearchEngineFromBang('news today /b', bangs)).toBe('bbc');
|
||||
});
|
||||
|
||||
it('matches a bare bang on its own', () => {
|
||||
expect(getSearchEngineFromBang('/gh', bangs)).toBe('github');
|
||||
});
|
||||
|
||||
it('does not match a bang embedded inside another word', () => {
|
||||
expect(getSearchEngineFromBang('foo/b bar', bangs)).toBeUndefined();
|
||||
expect(getSearchEngineFromBang('a/g/b', bangs)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no bang is present', () => {
|
||||
expect(getSearchEngineFromBang('just searching', bangs)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles an empty or missing bang list', () => {
|
||||
expect(getSearchEngineFromBang('/g hello', {})).toBeUndefined();
|
||||
expect(getSearchEngineFromBang('/g hello', null)).toBeUndefined();
|
||||
expect(getSearchEngineFromBang('/g hello', undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search - stripBangs', () => {
|
||||
const bangs = {
|
||||
'/b': 'bbc',
|
||||
'/g': 'google',
|
||||
'/w': 'wikipedia',
|
||||
'/gh': 'github',
|
||||
'/wa': 'wolframalpha',
|
||||
};
|
||||
|
||||
it('strips a leading bang and trims', () => {
|
||||
expect(stripBangs('/g hello world', bangs)).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips a trailing bang and trims', () => {
|
||||
expect(stripBangs('hello world /g', bangs)).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips a mid-query bang and collapses whitespace', () => {
|
||||
expect(stripBangs('foo /b bar', bangs)).toBe('foo bar');
|
||||
});
|
||||
|
||||
// regression: stripping /gh used to leave "h foo" behind
|
||||
it('strips the longest matching bang cleanly', () => {
|
||||
expect(stripBangs('/gh react hooks', bangs)).toBe('react hooks');
|
||||
expect(stripBangs('/wa pi to 50 digits', bangs)).toBe('pi to 50 digits');
|
||||
});
|
||||
|
||||
it('does not strip a bang embedded inside another word', () => {
|
||||
expect(stripBangs('foo/b bar', bangs)).toBe('foo/b bar');
|
||||
});
|
||||
|
||||
it('returns the query unchanged when no bang is present', () => {
|
||||
expect(stripBangs('just searching', bangs)).toBe('just searching');
|
||||
});
|
||||
|
||||
it('handles an empty or null bang list', () => {
|
||||
expect(stripBangs('hello world', null)).toBe('hello world');
|
||||
expect(stripBangs('hello world', undefined)).toBe('hello world');
|
||||
expect(stripBangs('hello world', {})).toBe('hello world');
|
||||
});
|
||||
|
||||
it('collapses runs of whitespace and trims edges', () => {
|
||||
expect(stripBangs(' /g hello world ', bangs)).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips multiple bangs in the same query', () => {
|
||||
expect(stripBangs('/gh /b mixed', bangs)).toBe('mixed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search - isUrlLike', () => {
|
||||
it('accepts an explicit http/https scheme', () => {
|
||||
expect(isUrlLike('https://example.com')).toBe(true);
|
||||
expect(isUrlLike('HTTP://Example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a dotted hostname with or without port and path', () => {
|
||||
expect(isUrlLike('example.com')).toBe(true);
|
||||
expect(isUrlLike('example.com:8080')).toBe(true);
|
||||
expect(isUrlLike('example.com/path?q=1')).toBe(true);
|
||||
expect(isUrlLike('foo.bar.baz')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an IPv4 address with or without port and path', () => {
|
||||
expect(isUrlLike('192.168.1.1')).toBe(true);
|
||||
expect(isUrlLike('192.168.1.1:8080')).toBe(true);
|
||||
expect(isUrlLike('192.168.1.1/admin')).toBe(true);
|
||||
});
|
||||
|
||||
// regression: localhost previously rejected as not URL-like
|
||||
it('accepts localhost with or without port and path', () => {
|
||||
expect(isUrlLike('localhost')).toBe(true);
|
||||
expect(isUrlLike('LocalHost')).toBe(true);
|
||||
expect(isUrlLike('localhost:8080')).toBe(true);
|
||||
expect(isUrlLike('localhost/admin')).toBe(true);
|
||||
});
|
||||
|
||||
// regression: bare hostnames with an explicit port or path opened web search instead
|
||||
it('accepts a bare hostname when port or path makes intent unambiguous', () => {
|
||||
expect(isUrlLike('nas:8080')).toBe(true);
|
||||
expect(isUrlLike('nas/admin')).toBe(true);
|
||||
expect(isUrlLike('router:80/setup')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a bare ambiguous word with no URL signal', () => {
|
||||
expect(isUrlLike('nas')).toBe(false);
|
||||
expect(isUrlLike('kubernetes')).toBe(false);
|
||||
expect(isUrlLike('router')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an input containing whitespace', () => {
|
||||
expect(isUrlLike('example.com /path')).toBe(false);
|
||||
expect(isUrlLike('hello world')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty or nullish input', () => {
|
||||
expect(isUrlLike('')).toBe(false);
|
||||
expect(isUrlLike(' ')).toBe(false);
|
||||
expect(isUrlLike(null)).toBe(false);
|
||||
expect(isUrlLike(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects strings that start or end with a dot', () => {
|
||||
expect(isUrlLike('.com')).toBe(false);
|
||||
expect(isUrlLike('foo.')).toBe(false);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before classifying', () => {
|
||||
expect(isUrlLike(' example.com ')).toBe(true);
|
||||
expect(isUrlLike(' nas ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search - findUrlForSearchEngine', () => {
|
||||
const engines = {
|
||||
duckduckgo: 'https://duckduckgo.com/?q=',
|
||||
google: 'https://google.com/search?q=',
|
||||
};
|
||||
|
||||
it('returns the URL for a known engine key', () => {
|
||||
expect(findUrlForSearchEngine('duckduckgo', engines)).toBe(engines.duckduckgo);
|
||||
});
|
||||
|
||||
it('returns the input as-is when it is already a URL', () => {
|
||||
const url = 'https://custom.example/?q=';
|
||||
expect(findUrlForSearchEngine(url, engines)).toBe(url);
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown key', () => {
|
||||
expect(findUrlForSearchEngine('bogus', engines)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is supplied', () => {
|
||||
expect(findUrlForSearchEngine('', engines)).toBeUndefined();
|
||||
expect(findUrlForSearchEngine(undefined, engines)).toBeUndefined();
|
||||
expect(findUrlForSearchEngine(null, engines)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user