diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 419b4a26a..f92f58402 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -44,7 +44,7 @@ jobs: uses: tj-actions/eslint-changed-files@v25 with: config_path: 'eslint.config.mjs' - extra_args: '--max-warnings=0' + extra_args: '--max-warnings=0 --no-warn-ignored' reporter: github-pr-review - name: Run Prettier on changed files diff --git a/eslint.config.mjs b/eslint.config.mjs index 4387914dc..95c437321 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,7 +8,7 @@ import tseslint from 'typescript-eslint'; export default [ { - ignores: ['dist/**/*', '.git/**/*', 'node_modules/**/*', 'src/generated/**/*', 'jobs/archived/*.js'], + ignores: ['dist/**/*', '.git/**/*', 'node_modules/**/*', 'src/client/generated/**/*', 'jobs/archived/*.js'], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/nearley/values.ne b/nearley/values.ne index 2d6887b75..7b10e8b52 100644 --- a/nearley/values.ne +++ b/nearley/values.ne @@ -167,7 +167,7 @@ noQuoteStringValue -> | "and"i [^ \t\n"'\\=<>:] | "o"i [^rR \t\n"'\\=<>:] | "or"i [^ \t\n"'\\=<>:] - ) [^ \t\n"'\\=<>:]:* {% ([startChars, chars]) => startChars.concat(chars).join('').toLowerCase() %} + ) [^ \t\n"\\=<>:]:* {% ([startChars, chars]) => startChars.concat(chars).join('').toLowerCase() %} # " manaCostOpValue -> equalityOperator manaCostValue {% ([op, value]) => manaCostOperation(op, value) %} diff --git a/src/client/filtering/FilterCards.ts b/src/client/filtering/FilterCards.ts index 0b5caaa29..60e9dced4 100644 --- a/src/client/filtering/FilterCards.ts +++ b/src/client/filtering/FilterCards.ts @@ -30,7 +30,9 @@ export function defaultFilter(): FilterFunction { return result as FilterFunction; } -export function makeFilter(filterText: string): { err: any; filter: FilterFunction | null } { +export type FilterResult = { err: any; filter: FilterFunction | null }; + +export function makeFilter(filterText: string): FilterResult { if (!filterText || filterText.trim() === '') { return { err: false, diff --git a/src/client/generated/filtering/cardFilters.js b/src/client/generated/filtering/cardFilters.js index 9ca3992b8..a66724622 100644 --- a/src/client/generated/filtering/cardFilters.js +++ b/src/client/generated/filtering/cardFilters.js @@ -1365,7 +1365,7 @@ var grammar = { {"name": "noQuoteStringValue$subexpression$2$subexpression$5", "symbols": [/[oO]/, /[rR]/], "postprocess": function(d) {return d.join(""); }}, {"name": "noQuoteStringValue$subexpression$2", "symbols": ["noQuoteStringValue$subexpression$2$subexpression$5", /[^ \t\n"'\\=<>:]/]}, {"name": "noQuoteStringValue$ebnf$1", "symbols": []}, - {"name": "noQuoteStringValue$ebnf$1", "symbols": ["noQuoteStringValue$ebnf$1", /[^ \t\n"'\\=<>:]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "noQuoteStringValue$ebnf$1", "symbols": ["noQuoteStringValue$ebnf$1", /[^ \t\n"\\=<>:]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, {"name": "noQuoteStringValue", "symbols": ["noQuoteStringValue$subexpression$2", "noQuoteStringValue$ebnf$1"], "postprocess": ([startChars, chars]) => startChars.concat(chars).join('').toLowerCase()}, {"name": "manaCostOpValue", "symbols": ["equalityOperator", "manaCostValue"], "postprocess": ([op, value]) => manaCostOperation(op, value)}, {"name": "manaCostValue$ebnf$1", "symbols": ["manaSymbol"]}, diff --git a/tests/cards/filtering.test.ts b/tests/cards/filtering.test.ts new file mode 100644 index 000000000..7e50b37f9 --- /dev/null +++ b/tests/cards/filtering.test.ts @@ -0,0 +1,131 @@ +import { FilterResult, makeFilter } from '../../src/client/filtering/FilterCards'; + +describe('Filter syntax', () => { + const assertValidNameFilter = (result: FilterResult) => { + expect(result.err).toBeFalsy(); + expect(result.filter).toBeInstanceOf(Function); + expect(result.filter?.fieldsUsed).toEqual(['name_lower']); + }; + + it('Empty filter is no filtering', async () => { + const result = makeFilter(''); + expect(result.err).toBeFalsy(); + expect(result.filter).toBeNull(); + + const result2 = makeFilter(' '); + expect(result2.err).toBeFalsy(); + expect(result2.filter).toBeNull(); + }); + + it('Default filter is card name', async () => { + assertValidNameFilter(makeFilter('Urza')); + assertValidNameFilter(makeFilter('"Armageddon"')); + assertValidNameFilter(makeFilter("'Goblin Welder'")); + }); + + it('Negative name filter', async () => { + assertValidNameFilter(makeFilter('-mox')); + assertValidNameFilter(makeFilter('-"Diamond"')); + assertValidNameFilter(makeFilter("-'Dockside Chef'")); + }); + + it('Explicit name filter', async () => { + assertValidNameFilter(makeFilter('name:Blood')); + assertValidNameFilter(makeFilter('name:"Caustic Bro"')); + assertValidNameFilter(makeFilter("name:'The Meathook Mass'")); + }); + + it('Short explicit name filter', async () => { + assertValidNameFilter(makeFilter('n:Master of Death')); + assertValidNameFilter(makeFilter('n:"Abrupt Decay"')); + assertValidNameFilter(makeFilter("n:'March of Otherworldly Light'")); + }); + + it('Exact name filter', async () => { + assertValidNameFilter(makeFilter('name=Bloodghast')); + assertValidNameFilter(makeFilter('name="Caustic Bronco"')); + assertValidNameFilter(makeFilter("name='The Meathook Massacre'")); + }); + + it('Not exact name filter', async () => { + assertValidNameFilter(makeFilter('name!=Bloodghast')); + assertValidNameFilter(makeFilter('name!="Caustic Bronco"')); + assertValidNameFilter(makeFilter("name!='The Meathook Massacre'")); + + assertValidNameFilter(makeFilter('name<>Bloodghast')); + assertValidNameFilter(makeFilter('name<>"Caustic Bronco"')); + assertValidNameFilter(makeFilter("name<>'The Meathook Massacre'")); + }); + + it('Combination name filters', async () => { + assertValidNameFilter(makeFilter('Dragon or angel')); + assertValidNameFilter(makeFilter('dragon AND orb')); + assertValidNameFilter(makeFilter('-Dragon AND rage')); + assertValidNameFilter(makeFilter('-Dragon or rage')); + }); + + const workingNamesWithInterestingCharacters = [ + 'Chatterfang, Squirrel', + 'Oni-Cult Anvil', + 'Busted!', + '+2 mace', + 'Mr. Orfeo, the Boulder', + 'Borrowing 100,000 Arrows', + 'TL;DR', + 'Question Elemental?', + '_____ Goblin', + 'The Ultimate Nightmare of Wizards of the Coast® Customer Service', + 'Ratonhnhaké꞉ton', + ]; + + it.each(workingNamesWithInterestingCharacters)('Working names with interesting characters (%s)', async (name) => { + assertValidNameFilter(makeFilter(name)); + assertValidNameFilter(makeFilter(`'${name}'`)); + assertValidNameFilter(makeFilter(`"${name}"`)); + assertValidNameFilter(makeFilter(`name:'${name}'`)); + assertValidNameFilter(makeFilter(`name:"${name}"`)); + }); + + it('Partial working names with single quotes', async () => { + const name = "Urza's Bauble"; + assertValidNameFilter(makeFilter(name)); + assertValidNameFilter(makeFilter(`"${name}"`)); + assertValidNameFilter(makeFilter(`name:"${name}"`)); + + //Cannot single-quote surround a name containing a single quote + const result = makeFilter(`'${name}'`); + expect(result.err).toBeTruthy(); + const result2 = makeFilter(`name:'${name}'`); + expect(result2.err).toBeTruthy(); + + //But it does work if the quote is escaped + assertValidNameFilter(makeFilter(`'Urza\\'s Bauble'`)); + assertValidNameFilter(makeFilter(`n:'Urza\\'s Bauble'`)); + }); + + it('Partial working names with double quotes', async () => { + const name = 'Kongming, "Sleeping Dragon"'; + assertValidNameFilter(makeFilter(name)); + assertValidNameFilter(makeFilter(`'${name}'`)); + assertValidNameFilter(makeFilter(`name:'${name}'`)); + + //Cannot double-quote surround a name containing a double quote + const result = makeFilter(`"${name}"`); + expect(result.err).toBeTruthy(); + const result2 = makeFilter(`name:"${name}"`); + expect(result2.err).toBeTruthy(); + + //But it does work if the quote is escaped + assertValidNameFilter(makeFilter(`"Kongming, \\"Sleeping Dragon\\""`)); + assertValidNameFilter(makeFilter(`n:'Urza\\'s Bauble'`)); + }); + + const failingNamesWithInterestingCharacters = [ + 'Hazmat Suit (Used)', //The brackets get interpreted as another clause and we only want a single filter + ]; + + it.each(failingNamesWithInterestingCharacters)('Failing names with interesting characters (%s)', async (name) => { + const result = makeFilter(name); + expect(result.err).toBeTruthy(); + }); +});