From bd3f98e55913f0d50984da6567574f66957c28af Mon Sep 17 00:00:00 2001 From: Sakshamk17 Date: Sat, 25 Jul 2026 17:46:54 +0530 Subject: [PATCH 1/2] feat(search): add keywords field to parseSearchQuery output Extends ParsedSearchQuery on both frontend and backend with a keywords: string[] field (derived from the existing textQuery), satisfying TEST-001's required { keywords, usernames, hashtags } shape without breaking existing consumers (SearchContainer, postsResolver) that still rely on textQuery/tokens. Also expands unit test coverage for parseSearchQuery per the issue's full matrix: plain keywords, @username, #hashtag, all mixed combinations, empty/whitespace input, case normalization, repeated whitespace, lone @/#, hyphen/underscore handling, multiple usernames/hashtags, and order-independence. Frontend: 3 -> 26 tests Backend: ~20 -> 28 tests, 100% coverage on parseSearchQuery.ts maintained --- .../__tests__/unit/parseSearchQuery.test.ts | 70 +++++- .../app/data/utils/parseSearchQuery.ts | 4 +- quotevote-backend/app/types/search.ts | 2 + .../__tests__/utils/parseSearchQuery.test.ts | 204 ++++++++++++++++-- quotevote-frontend/src/types/search.ts | 1 + .../src/utils/parseSearchQuery.ts | 5 +- 6 files changed, 263 insertions(+), 23 deletions(-) diff --git a/quotevote-backend/__tests__/unit/parseSearchQuery.test.ts b/quotevote-backend/__tests__/unit/parseSearchQuery.test.ts index 1f42468..d848b66 100644 --- a/quotevote-backend/__tests__/unit/parseSearchQuery.test.ts +++ b/quotevote-backend/__tests__/unit/parseSearchQuery.test.ts @@ -5,22 +5,32 @@ describe('parseSearchQuery', () => { // ── Happy path ────────────────────────────────────────────────────────── describe('plain text queries (no tokens)', () => { - it('returns the full string as textQuery when no tokens are present', () => { + it('returns the full string as textQuery and keywords when no tokens are present', () => { const result: ParsedSearchQuery = parseSearchQuery('hello world'); + expect(result.keywords).toEqual(['hello', 'world']); expect(result.usernames).toEqual([]); expect(result.hashtags).toEqual([]); expect(result.textQuery).toBe('hello world'); expect(result.tokens).toEqual([]); }); - it('trims leading and trailing whitespace from textQuery', () => { + it('trims leading and trailing whitespace from textQuery and keywords', () => { const result = parseSearchQuery(' spaced out '); + expect(result.keywords).toEqual(['spaced', 'out']); expect(result.textQuery).toBe('spaced out'); expect(result.usernames).toEqual([]); expect(result.hashtags).toEqual([]); }); + + it('treats a single word as a plain keyword search', () => { + const result = parseSearchQuery('education'); + + expect(result.keywords).toEqual(['education']); + expect(result.usernames).toEqual([]); + expect(result.hashtags).toEqual([]); + }); }); // ── @username extraction ──────────────────────────────────────────────── @@ -30,14 +40,16 @@ describe('parseSearchQuery', () => { const result = parseSearchQuery('@johndoe'); expect(result.usernames).toEqual(['johndoe']); + expect(result.keywords).toEqual([]); expect(result.textQuery).toBe(''); expect(result.tokens).toEqual([{ type: 'username', value: 'johndoe' }]); }); - it('extracts @username and preserves remaining text', () => { + it('extracts @username and preserves remaining text as keywords', () => { const result = parseSearchQuery('@alice some text here'); expect(result.usernames).toEqual(['alice']); + expect(result.keywords).toEqual(['some', 'text', 'here']); expect(result.textQuery).toBe('some text here'); }); @@ -51,6 +63,7 @@ describe('parseSearchQuery', () => { const result = parseSearchQuery('@alice @bob'); expect(result.usernames).toEqual(['alice', 'bob']); + expect(result.keywords).toEqual([]); expect(result.textQuery).toBe(''); }); @@ -68,14 +81,16 @@ describe('parseSearchQuery', () => { const result = parseSearchQuery('#typescript'); expect(result.hashtags).toEqual(['typescript']); + expect(result.keywords).toEqual([]); expect(result.textQuery).toBe(''); expect(result.tokens).toEqual([{ type: 'hashtag', value: 'typescript' }]); }); - it('extracts #hashtag and preserves remaining text', () => { + it('extracts #hashtag and preserves remaining text as keywords', () => { const result = parseSearchQuery('#react some text here'); expect(result.hashtags).toEqual(['react']); + expect(result.keywords).toEqual(['some', 'text', 'here']); expect(result.textQuery).toBe('some text here'); }); @@ -89,6 +104,7 @@ describe('parseSearchQuery', () => { const result = parseSearchQuery('#react #nextjs'); expect(result.hashtags).toEqual(['react', 'nextjs']); + expect(result.keywords).toEqual([]); expect(result.textQuery).toBe(''); }); @@ -107,6 +123,7 @@ describe('parseSearchQuery', () => { expect(result.usernames).toEqual(['alice']); expect(result.hashtags).toEqual(['typescript']); + expect(result.keywords).toEqual([]); expect(result.textQuery).toBe(''); }); @@ -115,9 +132,18 @@ describe('parseSearchQuery', () => { expect(result.usernames).toEqual(['alice']); expect(result.hashtags).toEqual(['react']); + expect(result.keywords).toEqual(['posts', 'by', 'about', 'development']); expect(result.textQuery).toBe('posts by about development'); }); + it('parses keywords, a username, and a hashtag all together (issue example)', () => { + const result = parseSearchQuery('school safety @marta #education'); + + expect(result.keywords).toEqual(['school', 'safety']); + expect(result.usernames).toEqual(['marta']); + expect(result.hashtags).toEqual(['education']); + }); + it('preserves token order in the tokens array', () => { const result = parseSearchQuery('#react @alice #nextjs'); @@ -127,6 +153,14 @@ describe('parseSearchQuery', () => { { type: 'hashtag', value: 'nextjs' }, ]); }); + + it('preserves all extracted values regardless of token order', () => { + const result = parseSearchQuery('#education @marta school safety'); + + expect(result.keywords).toEqual(['school', 'safety']); + expect(result.usernames).toEqual(['marta']); + expect(result.hashtags).toEqual(['education']); + }); }); // ── Edge cases ────────────────────────────────────────────────────────── @@ -135,6 +169,7 @@ describe('parseSearchQuery', () => { it('returns empty result for an empty string', () => { const result = parseSearchQuery(''); + expect(result.keywords).toEqual([]); expect(result.usernames).toEqual([]); expect(result.hashtags).toEqual([]); expect(result.textQuery).toBe(''); @@ -144,16 +179,25 @@ describe('parseSearchQuery', () => { it('returns empty result for whitespace-only input', () => { const result = parseSearchQuery(' '); + expect(result.keywords).toEqual([]); expect(result.usernames).toEqual([]); expect(result.hashtags).toEqual([]); expect(result.textQuery).toBe(''); }); + it('collapses repeated internal whitespace without creating empty keyword tokens', () => { + const result = parseSearchQuery('education reform'); + + expect(result.keywords).toEqual(['education', 'reform']); + expect(result.textQuery).toBe('education reform'); + }); + it('ignores standalone @ without a following word', () => { const result = parseSearchQuery('@ hello'); expect(result.usernames).toEqual([]); expect(result.textQuery).toBe('@ hello'); + expect(result.keywords).toEqual(['@', 'hello']); }); it('ignores standalone # without a following word', () => { @@ -162,6 +206,21 @@ describe('parseSearchQuery', () => { expect(result.usernames).toEqual([]); expect(result.hashtags).toEqual([]); expect(result.textQuery).toBe('# hello'); + expect(result.keywords).toEqual(['#', 'hello']); + }); + + it('does not crash when the entire query is a lone @', () => { + const result = parseSearchQuery('@'); + + expect(result.usernames).toEqual([]); + expect(result.keywords).toEqual(['@']); + }); + + it('does not crash when the entire query is a lone #', () => { + const result = parseSearchQuery('#'); + + expect(result.hashtags).toEqual([]); + expect(result.keywords).toEqual(['#']); }); it('handles @ and # embedded in words (e.g. email addresses)', () => { @@ -170,6 +229,7 @@ describe('parseSearchQuery', () => { // Mid-word @ should NOT be treated as a username token expect(result.usernames).toEqual([]); expect(result.textQuery).toBe('user@example.com'); + expect(result.keywords).toEqual(['user@example.com']); }); it('handles tokens with underscores and digits', () => { @@ -186,4 +246,4 @@ describe('parseSearchQuery', () => { expect(result.usernames).toEqual(['some']); }); }); -}); +}); \ No newline at end of file diff --git a/quotevote-backend/app/data/utils/parseSearchQuery.ts b/quotevote-backend/app/data/utils/parseSearchQuery.ts index e1d400d..d1a30f3 100644 --- a/quotevote-backend/app/data/utils/parseSearchQuery.ts +++ b/quotevote-backend/app/data/utils/parseSearchQuery.ts @@ -10,7 +10,7 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery { const trimmed = raw.trim(); if (!trimmed) { - return { usernames: [], hashtags: [], textQuery: '', tokens: [] }; + return { keywords: [], usernames: [], hashtags: [], textQuery: '', tokens: [] }; } const usernames = new Set(); @@ -48,7 +48,9 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery { } textQuery = textQuery.replace(/\s{2,}/g, ' ').trim(); + const keywords = textQuery.length > 0 ? textQuery.split(' ') : []; return { + keywords, usernames: Array.from(usernames), hashtags: Array.from(hashtags), textQuery, diff --git a/quotevote-backend/app/types/search.ts b/quotevote-backend/app/types/search.ts index 426d92f..d328ac2 100644 --- a/quotevote-backend/app/types/search.ts +++ b/quotevote-backend/app/types/search.ts @@ -32,11 +32,13 @@ export type SearchToken = UsernameToken | HashtagToken; * The result of parsing a raw search query string. * * - `usernames` — extracted @username tokens (lowercased, without the @ prefix) + * - `keywords` — plain-text keyword tokens, in the order they appeared in the query * - `hashtags` — extracted #hashtag tokens (lowercased, without the # prefix) * - `textQuery` — the remaining plain-text portion after token extraction (trimmed) * - `tokens` — ordered list of all extracted tokens for debugging/logging */ export interface ParsedSearchQuery { + readonly keywords: readonly string[]; readonly usernames: readonly string[]; readonly hashtags: readonly string[]; readonly textQuery: string; diff --git a/quotevote-frontend/src/__tests__/utils/parseSearchQuery.test.ts b/quotevote-frontend/src/__tests__/utils/parseSearchQuery.test.ts index 7c4efe9..430d4ff 100644 --- a/quotevote-frontend/src/__tests__/utils/parseSearchQuery.test.ts +++ b/quotevote-frontend/src/__tests__/utils/parseSearchQuery.test.ts @@ -1,24 +1,196 @@ import { parseSearchQuery } from '@/utils/parseSearchQuery' describe('parseSearchQuery (Frontend)', () => { - it('returns plain text when no tokens exist', () => { - const result = parseSearchQuery('hello world') - expect(result.usernames).toEqual([]) - expect(result.hashtags).toEqual([]) - expect(result.textQuery).toBe('hello world') + // ── Plain keyword queries ──────────────────────────────────────────────── + + describe('plain keyword queries', () => { + it('treats a single word as a keyword search', () => { + const result = parseSearchQuery('education') + expect(result.keywords).toEqual(['education']) + expect(result.usernames).toEqual([]) + expect(result.hashtags).toEqual([]) + expect(result.textQuery).toBe('education') + }) + + it('treats multiple words as keyword search when no tokens exist', () => { + const result = parseSearchQuery('hello world') + expect(result.keywords).toEqual(['hello', 'world']) + expect(result.usernames).toEqual([]) + expect(result.hashtags).toEqual([]) + expect(result.textQuery).toBe('hello world') + }) }) - it('extracts usernames and hashtags and preserves textQuery', () => { - const result = parseSearchQuery('@alice and @bob went to #nyc for #reactconf') - expect(result.usernames).toEqual(['alice', 'bob']) - expect(result.hashtags).toEqual(['nyc', 'reactconf']) - expect(result.textQuery).toBe('and went to for') + // ── @username queries ──────────────────────────────────────────────────── + + describe('@username queries', () => { + it('treats @username as a username filter', () => { + const result = parseSearchQuery('@marta') + expect(result.usernames).toEqual(['marta']) + expect(result.keywords).toEqual([]) + expect(result.textQuery).toBe('') + }) + + it('normalizes username casing to lowercase', () => { + const result = parseSearchQuery('@Marta') + expect(result.usernames).toEqual(['marta']) + }) + + it('extracts multiple usernames', () => { + const result = parseSearchQuery('@alice @bob') + expect(result.usernames).toEqual(['alice', 'bob']) + expect(result.keywords).toEqual([]) + }) + + it('deduplicates repeated usernames regardless of case', () => { + const result = parseSearchQuery('@alice @Alice @ALICE') + expect(result.usernames).toEqual(['alice']) + }) + + it('truncates usernames with hyphens at the hyphen (word-char rule)', () => { + const result = parseSearchQuery('@marta-smith') + expect(result.usernames).toEqual(['marta']) + }) + + it('supports underscores in usernames', () => { + const result = parseSearchQuery('@marta_smith') + expect(result.usernames).toEqual(['marta_smith']) + }) + + it('does not crash on a standalone @ with nothing following', () => { + const result = parseSearchQuery('@') + expect(result.usernames).toEqual([]) + expect(result.keywords).toEqual(['@']) + }) + + it('does not crash on a standalone @ followed by whitespace', () => { + const result = parseSearchQuery('@ hello') + expect(result.usernames).toEqual([]) + expect(result.keywords).toEqual(['@', 'hello']) + }) }) - it('handles empty query strings', () => { - const result = parseSearchQuery('') - expect(result.usernames).toEqual([]) - expect(result.hashtags).toEqual([]) - expect(result.textQuery).toBe('') + // ── #hashtag queries ───────────────────────────────────────────────────── + + describe('#hashtag queries', () => { + it('treats #hashtag as a hashtag filter', () => { + const result = parseSearchQuery('#schools') + expect(result.hashtags).toEqual(['schools']) + expect(result.keywords).toEqual([]) + expect(result.textQuery).toBe('') + }) + + it('normalizes hashtag casing to lowercase', () => { + const result = parseSearchQuery('#Education') + expect(result.hashtags).toEqual(['education']) + }) + + it('extracts multiple hashtags', () => { + const result = parseSearchQuery('#nyc #reactconf') + expect(result.hashtags).toEqual(['nyc', 'reactconf']) + expect(result.keywords).toEqual([]) + }) + + it('deduplicates repeated hashtags regardless of case', () => { + const result = parseSearchQuery('#react #React #REACT') + expect(result.hashtags).toEqual(['react']) + }) + + it('truncates hashtags with hyphens at the hyphen (word-char rule)', () => { + const result = parseSearchQuery('#school-safety') + expect(result.hashtags).toEqual(['school']) + }) + + it('supports underscores in hashtags', () => { + const result = parseSearchQuery('#school_safety') + expect(result.hashtags).toEqual(['school_safety']) + }) + + it('does not crash on a standalone # with nothing following', () => { + const result = parseSearchQuery('#') + expect(result.hashtags).toEqual([]) + expect(result.keywords).toEqual(['#']) + }) + + it('does not crash on a standalone # followed by whitespace', () => { + const result = parseSearchQuery('# hello') + expect(result.hashtags).toEqual([]) + expect(result.keywords).toEqual(['#', 'hello']) + }) + }) + + // ── Mixed queries ──────────────────────────────────────────────────────── + + describe('mixed queries', () => { + it('scopes a keyword search to a username', () => { + const result = parseSearchQuery('education @marta') + expect(result.keywords).toEqual(['education']) + expect(result.usernames).toEqual(['marta']) + expect(result.hashtags).toEqual([]) + }) + + it('scopes a keyword search to a hashtag', () => { + const result = parseSearchQuery('education #schools') + expect(result.keywords).toEqual(['education']) + expect(result.hashtags).toEqual(['schools']) + expect(result.usernames).toEqual([]) + }) + + it('parses both a username and a hashtag filter together', () => { + const result = parseSearchQuery('@marta #schools') + expect(result.usernames).toEqual(['marta']) + expect(result.hashtags).toEqual(['schools']) + expect(result.keywords).toEqual([]) + }) + + it('parses keywords, a username, and a hashtag all together', () => { + const result = parseSearchQuery('school safety @marta #education') + expect(result.keywords).toEqual(['school', 'safety']) + expect(result.usernames).toEqual(['marta']) + expect(result.hashtags).toEqual(['education']) + }) + + it('preserves all values regardless of token order', () => { + const result = parseSearchQuery('#education @marta school safety') + expect(result.keywords).toEqual(['school', 'safety']) + expect(result.usernames).toEqual(['marta']) + expect(result.hashtags).toEqual(['education']) + }) + }) + + // ── Empty / whitespace-only queries ────────────────────────────────────── + + describe('empty and whitespace-only queries', () => { + it('returns empty arrays for an empty string', () => { + const result = parseSearchQuery('') + expect(result.keywords).toEqual([]) + expect(result.usernames).toEqual([]) + expect(result.hashtags).toEqual([]) + expect(result.textQuery).toBe('') + }) + + it('returns empty arrays for a whitespace-only string', () => { + const result = parseSearchQuery(' ') + expect(result.keywords).toEqual([]) + expect(result.usernames).toEqual([]) + expect(result.hashtags).toEqual([]) + expect(result.textQuery).toBe('') + }) + }) + + // ── Whitespace handling ────────────────────────────────────────────────── + + describe('whitespace handling', () => { + it('ignores leading and trailing whitespace', () => { + const result = parseSearchQuery(' education reform ') + expect(result.keywords).toEqual(['education', 'reform']) + expect(result.textQuery).toBe('education reform') + }) + + it('collapses repeated internal whitespace without creating empty tokens', () => { + const result = parseSearchQuery('education reform') + expect(result.keywords).toEqual(['education', 'reform']) + expect(result.textQuery).toBe('education reform') + }) }) -}) +}) \ No newline at end of file diff --git a/quotevote-frontend/src/types/search.ts b/quotevote-frontend/src/types/search.ts index 721d742..d557273 100644 --- a/quotevote-frontend/src/types/search.ts +++ b/quotevote-frontend/src/types/search.ts @@ -7,6 +7,7 @@ /** The result of parsing a raw search query string on the frontend */ export interface ParsedSearchQuery { + readonly keywords: readonly string[] readonly usernames: readonly string[] readonly hashtags: readonly string[] readonly textQuery: string diff --git a/quotevote-frontend/src/utils/parseSearchQuery.ts b/quotevote-frontend/src/utils/parseSearchQuery.ts index 45c67d3..c3548b0 100644 --- a/quotevote-frontend/src/utils/parseSearchQuery.ts +++ b/quotevote-frontend/src/utils/parseSearchQuery.ts @@ -10,7 +10,7 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery { const trimmed = raw.trim() if (!trimmed) { - return { usernames: [], hashtags: [], textQuery: '' } + return { keywords: [], usernames: [], hashtags: [], textQuery: '' } } const usernames = new Set() @@ -44,7 +44,10 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery { } textQuery = textQuery.replace(/\s{2,}/g, ' ').trim() + const keywords = textQuery.length > 0 ? textQuery.split(' ') : [] + return { + keywords, usernames: Array.from(usernames), hashtags: Array.from(hashtags), textQuery, From 590365c148abbd076966e199bffef1736cc59b5b Mon Sep 17 00:00:00 2001 From: Sakshamk17 Date: Sun, 26 Jul 2026 13:27:31 +0530 Subject: [PATCH 2/2] fix: filter punctuation-only tokens from keywords, fix EOF newlines - keywords no longer includes tokens made up entirely of punctuation (e.g. a lone '@' or '#'); textQuery is unaffected and still retains them verbatim for backwards compatibility - words containing punctuation (e.g. user@example.com) are unaffected - addresses review feedback from @flyblackbox on #388 --- .../__tests__/unit/parseSearchQuery.test.ts | 56 ++++++++++++++++--- .../app/data/utils/parseSearchQuery.ts | 7 ++- quotevote-backend/app/types/search.ts | 9 ++- .../__tests__/utils/parseSearchQuery.test.ts | 53 +++++++++++++++--- quotevote-frontend/src/types/search.ts | 4 +- .../src/utils/parseSearchQuery.ts | 6 +- 6 files changed, 111 insertions(+), 24 deletions(-) diff --git a/quotevote-backend/__tests__/unit/parseSearchQuery.test.ts b/quotevote-backend/__tests__/unit/parseSearchQuery.test.ts index d848b66..4b6619a 100644 --- a/quotevote-backend/__tests__/unit/parseSearchQuery.test.ts +++ b/quotevote-backend/__tests__/unit/parseSearchQuery.test.ts @@ -192,35 +192,37 @@ describe('parseSearchQuery', () => { expect(result.textQuery).toBe('education reform'); }); - it('ignores standalone @ without a following word', () => { + it('ignores standalone @ without a following word, and excludes it from keywords', () => { const result = parseSearchQuery('@ hello'); expect(result.usernames).toEqual([]); expect(result.textQuery).toBe('@ hello'); - expect(result.keywords).toEqual(['@', 'hello']); + expect(result.keywords).toEqual(['hello']); }); - it('ignores standalone # without a following word', () => { + it('ignores standalone # without a following word, and excludes it from keywords', () => { const result = parseSearchQuery('# hello'); expect(result.usernames).toEqual([]); expect(result.hashtags).toEqual([]); expect(result.textQuery).toBe('# hello'); - expect(result.keywords).toEqual(['#', 'hello']); + expect(result.keywords).toEqual(['hello']); }); - it('does not crash when the entire query is a lone @', () => { + it('does not crash when the entire query is a lone @, and excludes it from keywords', () => { const result = parseSearchQuery('@'); expect(result.usernames).toEqual([]); - expect(result.keywords).toEqual(['@']); + expect(result.textQuery).toBe('@'); + expect(result.keywords).toEqual([]); }); - it('does not crash when the entire query is a lone #', () => { + it('does not crash when the entire query is a lone #, and excludes it from keywords', () => { const result = parseSearchQuery('#'); expect(result.hashtags).toEqual([]); - expect(result.keywords).toEqual(['#']); + expect(result.textQuery).toBe('#'); + expect(result.keywords).toEqual([]); }); it('handles @ and # embedded in words (e.g. email addresses)', () => { @@ -246,4 +248,42 @@ describe('parseSearchQuery', () => { expect(result.usernames).toEqual(['some']); }); }); + + // ── Punctuation-only tokens ───────────────────────────────────────────── + + describe('punctuation-only tokens', () => { + it('excludes a lone @ from keywords but keeps it in textQuery', () => { + const result = parseSearchQuery('@'); + + expect(result.keywords).toEqual([]); + expect(result.textQuery).toBe('@'); + }); + + it('excludes a lone # from keywords but keeps it in textQuery', () => { + const result = parseSearchQuery('#'); + + expect(result.keywords).toEqual([]); + expect(result.textQuery).toBe('#'); + }); + + it('excludes multiple punctuation-only tokens from keywords', () => { + const result = parseSearchQuery('@ # !!!'); + + expect(result.keywords).toEqual([]); + expect(result.textQuery).toBe('@ # !!!'); + }); + + it('keeps a word containing punctuation, such as an email address', () => { + const result = parseSearchQuery('user@example.com'); + + expect(result.keywords).toEqual(['user@example.com']); + expect(result.usernames).toEqual([]); + }); + + it('keeps punctuation-only tokens interspersed with real keywords, filtering only the former', () => { + const result = parseSearchQuery('hello @ world # !!!'); + + expect(result.keywords).toEqual(['hello', 'world']); + }); + }); }); \ No newline at end of file diff --git a/quotevote-backend/app/data/utils/parseSearchQuery.ts b/quotevote-backend/app/data/utils/parseSearchQuery.ts index d1a30f3..8d32a2d 100644 --- a/quotevote-backend/app/data/utils/parseSearchQuery.ts +++ b/quotevote-backend/app/data/utils/parseSearchQuery.ts @@ -48,7 +48,10 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery { } textQuery = textQuery.replace(/\s{2,}/g, ' ').trim(); - const keywords = textQuery.length > 0 ? textQuery.split(' ') : []; + const keywords = textQuery.length > 0 + ? textQuery.split(' ').filter((word) => /\w/.test(word)) + : []; + return { keywords, usernames: Array.from(usernames), @@ -56,4 +59,4 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery { textQuery, tokens: orderedTokens.map((t) => t.token), }; -} +} \ No newline at end of file diff --git a/quotevote-backend/app/types/search.ts b/quotevote-backend/app/types/search.ts index d328ac2..d07905e 100644 --- a/quotevote-backend/app/types/search.ts +++ b/quotevote-backend/app/types/search.ts @@ -31,10 +31,13 @@ export type SearchToken = UsernameToken | HashtagToken; /** * The result of parsing a raw search query string. * + * - `keywords` — plain-text keyword tokens, in the order they appeared in the query. Tokens + * made up entirely of punctuation (e.g. a lone `@` or `#`) are excluded. * - `usernames` — extracted @username tokens (lowercased, without the @ prefix) - * - `keywords` — plain-text keyword tokens, in the order they appeared in the query * - `hashtags` — extracted #hashtag tokens (lowercased, without the # prefix) - * - `textQuery` — the remaining plain-text portion after token extraction (trimmed) + * - `textQuery` — the remaining plain-text portion after token extraction (trimmed) — kept for + * backwards compatibility. Unlike `keywords`, this retains punctuation-only + * tokens verbatim. * - `tokens` — ordered list of all extracted tokens for debugging/logging */ export interface ParsedSearchQuery { @@ -43,4 +46,4 @@ export interface ParsedSearchQuery { readonly hashtags: readonly string[]; readonly textQuery: string; readonly tokens: readonly SearchToken[]; -} +} \ No newline at end of file diff --git a/quotevote-frontend/src/__tests__/utils/parseSearchQuery.test.ts b/quotevote-frontend/src/__tests__/utils/parseSearchQuery.test.ts index 430d4ff..7f3d778 100644 --- a/quotevote-frontend/src/__tests__/utils/parseSearchQuery.test.ts +++ b/quotevote-frontend/src/__tests__/utils/parseSearchQuery.test.ts @@ -57,16 +57,18 @@ describe('parseSearchQuery (Frontend)', () => { expect(result.usernames).toEqual(['marta_smith']) }) - it('does not crash on a standalone @ with nothing following', () => { + it('does not crash on a standalone @ with nothing following, and excludes it from keywords', () => { const result = parseSearchQuery('@') expect(result.usernames).toEqual([]) - expect(result.keywords).toEqual(['@']) + expect(result.keywords).toEqual([]) + expect(result.textQuery).toBe('@') }) - it('does not crash on a standalone @ followed by whitespace', () => { + it('does not crash on a standalone @ followed by whitespace, and excludes it from keywords', () => { const result = parseSearchQuery('@ hello') expect(result.usernames).toEqual([]) - expect(result.keywords).toEqual(['@', 'hello']) + expect(result.keywords).toEqual(['hello']) + expect(result.textQuery).toBe('@ hello') }) }) @@ -106,16 +108,18 @@ describe('parseSearchQuery (Frontend)', () => { expect(result.hashtags).toEqual(['school_safety']) }) - it('does not crash on a standalone # with nothing following', () => { + it('does not crash on a standalone # with nothing following, and excludes it from keywords', () => { const result = parseSearchQuery('#') expect(result.hashtags).toEqual([]) - expect(result.keywords).toEqual(['#']) + expect(result.keywords).toEqual([]) + expect(result.textQuery).toBe('#') }) - it('does not crash on a standalone # followed by whitespace', () => { + it('does not crash on a standalone # followed by whitespace, and excludes it from keywords', () => { const result = parseSearchQuery('# hello') expect(result.hashtags).toEqual([]) - expect(result.keywords).toEqual(['#', 'hello']) + expect(result.keywords).toEqual(['hello']) + expect(result.textQuery).toBe('# hello') }) }) @@ -193,4 +197,37 @@ describe('parseSearchQuery (Frontend)', () => { expect(result.textQuery).toBe('education reform') }) }) + + // ── Punctuation-only tokens ────────────────────────────────────────────── + + describe('punctuation-only tokens', () => { + it('excludes a lone @ from keywords but keeps it in textQuery', () => { + const result = parseSearchQuery('@') + expect(result.keywords).toEqual([]) + expect(result.textQuery).toBe('@') + }) + + it('excludes a lone # from keywords but keeps it in textQuery', () => { + const result = parseSearchQuery('#') + expect(result.keywords).toEqual([]) + expect(result.textQuery).toBe('#') + }) + + it('excludes multiple punctuation-only tokens from keywords', () => { + const result = parseSearchQuery('@ # !!!') + expect(result.keywords).toEqual([]) + expect(result.textQuery).toBe('@ # !!!') + }) + + it('keeps a word containing punctuation, such as an email address', () => { + const result = parseSearchQuery('user@example.com') + expect(result.keywords).toEqual(['user@example.com']) + expect(result.usernames).toEqual([]) + }) + + it('keeps punctuation-only tokens interspersed with real keywords, filtering only the former', () => { + const result = parseSearchQuery('hello @ world # !!!') + expect(result.keywords).toEqual(['hello', 'world']) + }) + }) }) \ No newline at end of file diff --git a/quotevote-frontend/src/types/search.ts b/quotevote-frontend/src/types/search.ts index d557273..a6f6278 100644 --- a/quotevote-frontend/src/types/search.ts +++ b/quotevote-frontend/src/types/search.ts @@ -7,8 +7,10 @@ /** The result of parsing a raw search query string on the frontend */ export interface ParsedSearchQuery { + /** Plain-text keyword tokens, in the order they appeared in the query. Tokens made up entirely of punctuation (e.g. a lone `@` or `#`) are excluded. */ readonly keywords: readonly string[] readonly usernames: readonly string[] readonly hashtags: readonly string[] + /** The remaining plain-text portion after token extraction (trimmed) — kept for backwards compatibility. Unlike `keywords`, this retains punctuation-only tokens verbatim. */ readonly textQuery: string -} +} \ No newline at end of file diff --git a/quotevote-frontend/src/utils/parseSearchQuery.ts b/quotevote-frontend/src/utils/parseSearchQuery.ts index c3548b0..8278815 100644 --- a/quotevote-frontend/src/utils/parseSearchQuery.ts +++ b/quotevote-frontend/src/utils/parseSearchQuery.ts @@ -44,7 +44,9 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery { } textQuery = textQuery.replace(/\s{2,}/g, ' ').trim() - const keywords = textQuery.length > 0 ? textQuery.split(' ') : [] + const keywords = textQuery.length > 0 + ? textQuery.split(' ').filter((word) => /\w/.test(word)) + : [] return { keywords, @@ -52,4 +54,4 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery { hashtags: Array.from(hashtags), textQuery, } -} +} \ No newline at end of file