Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
liamwhite committed Mar 14, 2024
1 parent 886539c commit 6c21112
Show file tree
Hide file tree
Showing 25 changed files with 1,225 additions and 901 deletions.
877 changes: 0 additions & 877 deletions assets/js/match_query.js

This file was deleted.

15 changes: 15 additions & 0 deletions assets/js/match_query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defaultMatcher } from './query/matcher';
import { generateLexArray } from './query/lex';
import { parseTokens } from './query/parse';
import { parseTerm } from './query/term';

function parseWithDefaultMatcher(term: string, fuzz: number) {
return parseTerm(term, fuzz, defaultMatcher);
}

function parseSearch(query: string) {
const tokens = generateLexArray(query, parseWithDefaultMatcher);
return parseTokens(tokens);
}

export default parseSearch;
106 changes: 106 additions & 0 deletions assets/js/query/__tests__/date.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { makeDateMatcher } from '../date';

function daysAgo(days: number) {
return new Date(Date.now() - days * 86400000).toISOString();
}

describe('Date parsing', () => {
it('should match relative dates (upper bound)', () => {
const matcher = makeDateMatcher('3 days ago', 'lte');

expect(matcher(daysAgo(4), 'created_at', 0)).toBe(true);
expect(matcher(daysAgo(2), 'created_at', 0)).toBe(false);
});

it('should match relative dates (lower bound)', () => {
const matcher = makeDateMatcher('3 days ago', 'gte');

expect(matcher(daysAgo(4), 'created_at', 0)).toBe(false);
expect(matcher(daysAgo(2), 'created_at', 0)).toBe(true);
});

it('should match absolute date ranges', () => {
const ltMatcher = makeDateMatcher('2025', 'lt');
const gtMatcher = makeDateMatcher('2023', 'gt');

expect(ltMatcher(new Date(Date.UTC(2025, 5, 21)).toISOString(), 'created_at', 0)).toBe(false);
expect(ltMatcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true);
expect(ltMatcher(new Date(Date.UTC(2023, 5, 21)).toISOString(), 'created_at', 0)).toBe(true);

expect(gtMatcher(new Date(Date.UTC(2025, 5, 21)).toISOString(), 'created_at', 0)).toBe(true);
expect(gtMatcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true);
expect(gtMatcher(new Date(Date.UTC(2023, 5, 21)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should match absolute dates through years', () => {
const matcher = makeDateMatcher('2024', 'eq');

expect(matcher(new Date(Date.UTC(2025, 5, 21)).toISOString(), 'created_at', 0)).toBe(false);
expect(matcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true);
expect(matcher(new Date(Date.UTC(2023, 5, 21)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should match absolute dates through months', () => {
const matcher = makeDateMatcher('2024-06', 'eq');

expect(matcher(new Date(Date.UTC(2024, 6, 21)).toISOString(), 'created_at', 0)).toBe(false);
expect(matcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true);
expect(matcher(new Date(Date.UTC(2024, 4, 21)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should match absolute dates through days', () => {
const matcher = makeDateMatcher('2024-06-21', 'eq');

expect(matcher(new Date(Date.UTC(2024, 5, 22)).toISOString(), 'created_at', 0)).toBe(false);
expect(matcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true);
expect(matcher(new Date(Date.UTC(2024, 5, 20)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should match absolute dates through hours', () => {
const matcher = makeDateMatcher('2024-06-21T06', 'eq');

expect(matcher(new Date(Date.UTC(2024, 5, 21, 7)).toISOString(), 'created_at', 0)).toBe(false);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6)).toISOString(), 'created_at', 0)).toBe(true);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 5)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should match absolute dates through minutes', () => {
const matcher = makeDateMatcher('2024-06-21T06:21', 'eq');

expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 22)).toISOString(), 'created_at', 0)).toBe(false);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 21)).toISOString(), 'created_at', 0)).toBe(true);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 20)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should match absolute dates through seconds', () => {
const matcher = makeDateMatcher('2024-06-21T06:21:30Z', 'eq');

expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 21, 31)).toISOString(), 'created_at', 0)).toBe(false);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 21, 30)).toISOString(), 'created_at', 0)).toBe(true);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 21, 29)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should match absolute dates through seconds with positive timezone offset', () => {
const matcher = makeDateMatcher('2024-06-21T06:21:30+01:30', 'eq');

expect(matcher(new Date(Date.UTC(2024, 5, 21, 4, 51, 31)).toISOString(), 'created_at', 0)).toBe(false);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 4, 51, 30)).toISOString(), 'created_at', 0)).toBe(true);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 4, 51, 29)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should match absolute dates through seconds with negative timezone offset', () => {
const matcher = makeDateMatcher('2024-06-21T06:21:30-01:30', 'eq');

expect(matcher(new Date(Date.UTC(2024, 5, 21, 7, 51, 31)).toISOString(), 'created_at', 0)).toBe(false);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 7, 51, 30)).toISOString(), 'created_at', 0)).toBe(true);
expect(matcher(new Date(Date.UTC(2024, 5, 21, 7, 51, 29)).toISOString(), 'created_at', 0)).toBe(false);
});

it('should not match malformed absolute date expressions', () => {
expect(() => makeDateMatcher('2024-06-21T06:21:30+01:3020', 'eq')).toThrow('Cannot parse date string: 2024-06-21T06:21:30+01:3020');
});

it('should not match malformed relative date expressions', () => {
expect(() => makeDateMatcher('3 test failures ago', 'eq')).toThrow('Cannot parse date string: 3 test failures ago');
});
});
36 changes: 36 additions & 0 deletions assets/js/query/__tests__/literal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { makeLiteralMatcher } from '../literal';

describe('Literal field parsing', () => {
it('should handle exact matching in arrayed fields', () => {
const matcher = makeLiteralMatcher('safe', 0, false);
expect(matcher('safe, solo', 'tags', 0)).toBe(true);
expect(matcher('solo', 'tags', 0)).toBe(false);
});

it('should handle exact matching in non-arrayed fields', () => {
const matcher = makeLiteralMatcher('safe', 0, false);
expect(matcher('safe, solo', 'description', 0)).toBe(false);
expect(matcher('safe', 'description', 0)).toBe(true);
expect(matcher('solo', 'description', 0)).toBe(false);
});

it('should handle fuzzy matching based on normalized edit distance', () => {
const matcher = makeLiteralMatcher('fluttersho', 0.8, false);
expect(matcher('fluttershy', 'tags', 0)).toBe(true);
expect(matcher('rarity', 'tags', 0)).toBe(false);
});

it('should handle fuzzy matching based on raw edit distance', () => {
const matcher = makeLiteralMatcher('fluttersho', 1, false);
expect(matcher('fluttershy', 'tags', 0)).toBe(true);
expect(matcher('rarity', 'tags', 0)).toBe(false);
});

it('should handle wildcard matching', () => {
const matcher = makeLiteralMatcher('fl?tter*', 0, true);
expect(matcher('fluttershy', 'tags', 0)).toBe(true);
expect(matcher('flitter', 'tags', 0)).toBe(true);
expect(matcher('rainbow dash', 'tags', 0)).toBe(false);
expect(matcher('gentle flutter', 'tags', 0)).toBe(false);
});
});
53 changes: 53 additions & 0 deletions assets/js/query/__tests__/number.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { makeNumberMatcher } from '../number';

describe('Number parsing', () => {
it('should match numbers directly', () => {
const intMatch = makeNumberMatcher(2067, 0, 'eq');

expect(intMatch('2066', 'value', 0)).toBe(false);
expect(intMatch('2067', 'value', 0)).toBe(true);
expect(intMatch('2068', 'value', 0)).toBe(false);
expect(intMatch('20677', 'value', 0)).toBe(false);
});

it('should match number ranges', () => {
const ltMatch = makeNumberMatcher(2067, 0, 'lt');
const lteMatch = makeNumberMatcher(2067, 0, 'lte');
const gtMatch = makeNumberMatcher(2067, 0, 'gt');
const gteMatch = makeNumberMatcher(2067, 0, 'gte');

expect(ltMatch('2066', 'value', 0)).toBe(true);
expect(ltMatch('2067', 'value', 0)).toBe(false);
expect(ltMatch('2068', 'value', 0)).toBe(false);
expect(lteMatch('2066', 'value', 0)).toBe(true);
expect(lteMatch('2067', 'value', 0)).toBe(true);
expect(lteMatch('2068', 'value', 0)).toBe(false);
expect(gtMatch('2066', 'value', 0)).toBe(false);
expect(gtMatch('2067', 'value', 0)).toBe(false);
expect(gtMatch('2068', 'value', 0)).toBe(true);
expect(gteMatch('2066', 'value', 0)).toBe(false);
expect(gteMatch('2067', 'value', 0)).toBe(true);
expect(gteMatch('2068', 'value', 0)).toBe(true);
});

it('should not match unparsed values', () => {
const matcher = makeNumberMatcher(2067, 0, 'eq');

expect(matcher('NaN', 'value', 0)).toBe(false);
expect(matcher('test', 'value', 0)).toBe(false);
});

it('should interpret fuzz as an inclusive range around the value', () => {
const matcher = makeNumberMatcher(2067, 3, 'eq');

expect(matcher('2063', 'value', 0)).toBe(false);
expect(matcher('2064', 'value', 0)).toBe(true);
expect(matcher('2065', 'value', 0)).toBe(true);
expect(matcher('2066', 'value', 0)).toBe(true);
expect(matcher('2067', 'value', 0)).toBe(true);
expect(matcher('2068', 'value', 0)).toBe(true);
expect(matcher('2069', 'value', 0)).toBe(true);
expect(matcher('2070', 'value', 0)).toBe(true);
expect(matcher('2071', 'value', 0)).toBe(false);
});
});
131 changes: 131 additions & 0 deletions assets/js/query/__tests__/term.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { parseTerm } from '../term';
import { MatcherFactory, defaultMatcher } from '../matcher';
import { termSpaceToImageField } from '../fields';

function noMatch() {
return false;
}

class TestMatcherFactory implements MatcherFactory {
public dateVals: string[];
public literalVals: string[];
public numberVals: number[];
public userVals: string[];

constructor() {
this.dateVals = [];
this.literalVals = [];
this.numberVals = [];
this.userVals = [];
}

makeDateMatcher(term: string) {
this.dateVals.push(term);
return noMatch;
}

makeLiteralMatcher(term: string) {
this.literalVals.push(term);
return noMatch;
}

makeNumberMatcher(term: number) {
this.numberVals.push(term);
return noMatch;
}

makeUserMatcher(term: string) {
this.userVals.push(term);
return noMatch;
}
}

describe('Search terms', () => {
let factory: TestMatcherFactory;

beforeEach(() => {
factory = new TestMatcherFactory();
});

it('should parse the default field', () => {
parseTerm('default', 0, factory);
expect(factory.literalVals).toEqual(['default']);
});

it('should parse the default field with wildcarding', () => {
parseTerm('def?ul*', 0, factory);
expect(factory.literalVals).toEqual(['def?ul*']);
});

it('should parse the default field with fuzzing', () => {
parseTerm('default', 1, factory);
expect(factory.literalVals).toEqual(['default']);
});

it('should parse the default field within quotes', () => {
parseTerm('"default"', 0, factory);
expect(factory.literalVals).toEqual(['default']);
});

it('should parse exact date field values', () => {
parseTerm('created_at:2024', 0, factory);
expect(factory.dateVals).toEqual(['2024']);
});

it('should parse ranged date field values', () => {
parseTerm('created_at.lte:2024', 0, factory);
parseTerm('created_at.lt:2024', 0, factory);
parseTerm('created_at.gte:2024', 0, factory);
parseTerm('created_at.gt:2024', 0, factory);
expect(factory.dateVals).toEqual(['2024', '2024', '2024', '2024']);
});

it('should parse exact number field values', () => {
parseTerm('width:1920', 0, factory);
expect(factory.numberVals).toEqual([1920]);
});

it('should parse ranged number field values', () => {
parseTerm('width.lte:1920', 0, factory);
parseTerm('width.lt:1920', 0, factory);
parseTerm('width.gte:1920', 0, factory);
parseTerm('width.gt:1920', 0, factory);
expect(factory.numberVals).toEqual([1920, 1920, 1920, 1920]);
});

it('should parse literal field values', () => {
parseTerm('source_url:*twitter*', 0, factory);
expect(factory.literalVals).toEqual(['*twitter*']);
});

it('should parse user field values', () => {
parseTerm('my:upvotes', 0, factory);
parseTerm('my:downvotes', 0, factory);
parseTerm('my:faves', 0, factory);
expect(factory.userVals).toEqual(['upvotes', 'downvotes', 'faves']);
});

it('should match document with proper field values', () => {
const idMatcher = parseTerm('id.lt:1', 0, defaultMatcher);
const sourceMatcher = parseTerm('source_url:twitter.com', 0, defaultMatcher);

const idAttribute = termSpaceToImageField.id;
const sourceUrlAttribute = termSpaceToImageField.source_url;

const properElement = document.createElement('div');
properElement.setAttribute(idAttribute, '0');
properElement.setAttribute(sourceUrlAttribute, 'twitter.com');

expect(idMatcher(properElement)).toBe(true);
expect(sourceMatcher(properElement)).toBe(true);
});

it('should not match document without field values', () => {
const idMatcher = parseTerm('id.lt:1', 0, defaultMatcher);
const sourceMatcher = parseTerm('source_url:twitter.com', 0, defaultMatcher);
const improperElement = document.createElement('div');

expect(idMatcher(improperElement)).toBe(false);
expect(sourceMatcher(improperElement)).toBe(false);
});
});
50 changes: 50 additions & 0 deletions assets/js/query/__tests__/user.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { makeUserMatcher } from '../user';

describe('User field parsing', () => {
beforeEach(() => {
/* eslint-disable camelcase */
window.booru.interactions = [
{image_id: 0, user_id: 0, interaction_type: 'faved', value: null},
{image_id: 0, user_id: 0, interaction_type: 'voted', value: 'up'},
{image_id: 1, user_id: 0, interaction_type: 'voted', value: 'down'},
{image_id: 2, user_id: 0, interaction_type: 'hidden', value: null},
];
/* eslint-enable camelcase */
});

it('should parse my:faves', () => {
const matcher = makeUserMatcher('faves');

expect(matcher('', 'my', 0)).toBe(true);
expect(matcher('', 'my', 1)).toBe(false);
expect(matcher('', 'my', 2)).toBe(false);
});

it('should parse my:upvotes', () => {
const matcher = makeUserMatcher('upvotes');

expect(matcher('', 'my', 0)).toBe(true);
expect(matcher('', 'my', 1)).toBe(false);
expect(matcher('', 'my', 2)).toBe(false);
});

it('should parse my:downvotes', () => {
const matcher = makeUserMatcher('downvotes');

expect(matcher('', 'my', 0)).toBe(false);
expect(matcher('', 'my', 1)).toBe(true);
expect(matcher('', 'my', 2)).toBe(false);
});

it('should not parse other my: fields', () => {
const hiddenMatcher = makeUserMatcher('hidden');
const watchedMatcher = makeUserMatcher('watched');

expect(hiddenMatcher('', 'my', 0)).toBe(false);
expect(hiddenMatcher('', 'my', 1)).toBe(false);
expect(hiddenMatcher('', 'my', 2)).toBe(false);
expect(watchedMatcher('', 'my', 0)).toBe(false);
expect(watchedMatcher('', 'my', 1)).toBe(false);
expect(watchedMatcher('', 'my', 2)).toBe(false);
});
});
Loading

0 comments on commit 6c21112

Please sign in to comment.