diff --git a/e2e/specs/03-feed/filter-search.spec.ts b/e2e/specs/03-feed/filter-search.spec.ts index 6973975..00bf4de 100644 --- a/e2e/specs/03-feed/filter-search.spec.ts +++ b/e2e/specs/03-feed/filter-search.spec.ts @@ -1,29 +1,68 @@ /** - * USER_JOURNEYS.md §8 — search and filter chips. Asserts the OR / AND - * combination rules described in the journey. + * USER_JOURNEYS.md §8 — grid-view search & filter chips. Verifies the OR / AND + * combination rules end-to-end by seeding uploads with known captions/uploaders, + * activating chips, and counting the resulting grid tiles. * - * Most of this test currently drives the UI; the data-seeding happens - * via API once a Node-side upload helper lands. For now we ship the - * structure and the UI assertions, marked with `test.fixme` where they - * depend on seeded data we can't yet create. + * (The pure combination logic is also unit-tested in + * frontend/src/lib/feed-filter.test.ts.) */ import { test, expect } from '../../fixtures/test'; +import { seedUpload } from '../../helpers/seed'; +import type { Page } from '@playwright/test'; + +/** Grid tiles render as buttons labelled "Upload anzeigen". */ +const tiles = (page: Page) => page.getByRole('button', { name: 'Upload anzeigen' }); + +async function switchToGrid(page: Page) { + await page.getByRole('button', { name: 'Rasteransicht' }).click(); +} + +/** Type a query into the grid search box and click the matching suggestion. */ +async function addFilter(page: Page, query: string, suggestionName: string | RegExp) { + const search = page.getByPlaceholder('Nutzer oder #Tag suchen…'); + await search.click(); + await search.fill(query); + await page.getByRole('button', { name: suggestionName }).click(); +} test.describe('Feed — filter & search', () => { - test.fixme('two hashtag chips combine with OR', async ({ page, guest, signIn }) => { - const h = await guest('Searcher'); - await signIn(page, h); - // TODO: seed 2 uploads with different hashtags, then activate two chips - // and assert both cards remain visible. - await page.goto('/feed'); - expect(true).toBe(true); + test('two hashtag chips combine with OR', async ({ page, guest, signIn }) => { + const a = await guest('OrUser'); + await seedUpload(a.jwt, { caption: 'pic #wedding' }); + await seedUpload(a.jwt, { caption: 'pic #party' }); + await seedUpload(a.jwt, { caption: 'pic #other' }); + + await signIn(page, a); // lands on /feed + await switchToGrid(page); + await expect(tiles(page)).toHaveCount(3); + + // One tag → only its card. + await addFilter(page, '#wedding', /wedding/i); + await expect(tiles(page)).toHaveCount(1); + + // Adding a second tag widens the result (OR), not narrows it. + await addFilter(page, '#party', /party/i); + await expect(tiles(page)).toHaveCount(2); }); - test.fixme('uploader chip + hashtag chip combines with AND', async ({ page, guest, signIn }) => { - const h = await guest('Searcher2'); - await signIn(page, h); - await page.goto('/feed'); - expect(true).toBe(true); + test('uploader chip + hashtag chip combines with AND', async ({ page, guest, signIn }) => { + const alice = await guest('AndAlice'); + const bob = await guest('AndBob'); + await seedUpload(alice.jwt, { caption: 'pic #wedding' }); // Alice + wedding + await seedUpload(bob.jwt, { caption: 'pic #wedding' }); // Bob + wedding + await seedUpload(alice.jwt, { caption: 'pic #party' }); // Alice + party + + await signIn(page, alice); // feed is event-wide → sees all three + await switchToGrid(page); + await expect(tiles(page)).toHaveCount(3); + + // Filter by uploader → Alice's two uploads. + await addFilter(page, 'AndAlice', 'AndAlice'); + await expect(tiles(page)).toHaveCount(2); + + // Add a tag → must satisfy BOTH (Alice AND #wedding) → just the one. + await addFilter(page, '#wedding', /wedding/i); + await expect(tiles(page)).toHaveCount(1); }); test('feed page renders without crashing for an authed user', async ({ page, guest, signIn }) => { diff --git a/frontend/src/lib/feed-filter.test.ts b/frontend/src/lib/feed-filter.test.ts new file mode 100644 index 0000000..26d7529 --- /dev/null +++ b/frontend/src/lib/feed-filter.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { filterUploads, type FeedFilter } from './feed-filter'; + +// filterUploads only reads `caption` and `uploader_name`, so a partial shape suffices. +const u = (id: string, uploader_name: string, caption: string | null) => + ({ id, uploader_name, caption }) as any; + +const uploads = [ + u('1', 'Alice', 'pic #wedding'), + u('2', 'Bob', 'party #party'), + u('3', 'Alice', 'more #wedding #party'), + u('4', 'Carol', 'no tags here'), + u('5', 'Bob', null), // no caption +]; + +const ids = (r: any[]) => r.map((x) => x.id); +const tag = (value: string): FeedFilter => ({ type: 'tag', value }); +const user = (value: string): FeedFilter => ({ type: 'user', value }); + +describe('filterUploads', () => { + it('no filters → returns all uploads unchanged', () => { + expect(filterUploads(uploads, [])).toBe(uploads); + }); + + it('a single tag matches uploads whose caption contains it', () => { + expect(ids(filterUploads(uploads, [tag('wedding')]))).toEqual(['1', '3']); + }); + + it('two tags combine with OR', () => { + expect(ids(filterUploads(uploads, [tag('wedding'), tag('party')]))).toEqual(['1', '2', '3']); + }); + + it('a single user matches only that uploader', () => { + expect(ids(filterUploads(uploads, [user('Alice')]))).toEqual(['1', '3']); + }); + + it('two users combine with OR', () => { + expect(ids(filterUploads(uploads, [user('Alice'), user('Bob')]))).toEqual(['1', '2', '3', '5']); + }); + + it('a user chip and a tag chip combine with AND', () => { + // Alice AND #wedding → only Alice's wedding uploads (not Bob's #wedding, not Alice's #party-only) + expect(ids(filterUploads(uploads, [user('Alice'), tag('wedding')]))).toEqual(['1', '3']); + }); + + it('AND excludes an uploader-match that lacks the tag', () => { + // Bob AND #wedding → none (Bob has #party and a null caption, no #wedding) + expect(filterUploads(uploads, [user('Bob'), tag('wedding')])).toHaveLength(0); + }); + + it('tag matching is case-insensitive against the caption', () => { + expect(filterUploads([u('9', 'X', 'PIC #WeDDing')], [tag('wedding')])).toHaveLength(1); + }); + + it('a null caption never matches a tag but can match a user', () => { + expect(filterUploads([u('5', 'Bob', null)], [tag('party')])).toHaveLength(0); + expect(filterUploads([u('5', 'Bob', null)], [user('Bob')])).toHaveLength(1); + }); +}); diff --git a/frontend/src/lib/feed-filter.ts b/frontend/src/lib/feed-filter.ts new file mode 100644 index 0000000..ebac89b --- /dev/null +++ b/frontend/src/lib/feed-filter.ts @@ -0,0 +1,35 @@ +// Grid-view feed filtering. Extracted from feed/+page.svelte so the OR/AND +// combination rules are unit-testable without mounting the page. + +import type { FeedUpload } from './types'; + +export interface FeedFilter { + type: 'tag' | 'user'; + value: string; +} + +/** + * Apply the active grid filters to a list of uploads. + * + * Combination rules (mirroring the chip UI): + * - Tags combine with **OR**: a card passes the tag group if its caption contains + * ANY selected `#tag`. + * - Users combine with **OR** within the user group (uploader is one of the + * selected names). + * - The tag group and the user group combine with **AND**: a card must satisfy + * both groups. An empty group is a pass-through. + * + * Tags are matched against the caption text (the autocomplete source), so `value` + * is expected lowercase (as produced by the tag suggestions). + */ +export function filterUploads(uploads: FeedUpload[], filters: FeedFilter[]): FeedUpload[] { + if (filters.length === 0) return uploads; + const tags = filters.filter((f) => f.type === 'tag').map((f) => f.value); + const users = filters.filter((f) => f.type === 'user').map((f) => f.value); + return uploads.filter((u) => { + const cap = (u.caption ?? '').toLowerCase(); + const passTag = !tags.length || tags.some((t) => cap.includes('#' + t)); + const passUser = !users.length || users.includes(u.uploader_name); + return passTag && passUser; + }); +} diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte index ddd48e2..e29a68c 100644 --- a/frontend/src/routes/feed/+page.svelte +++ b/frontend/src/routes/feed/+page.svelte @@ -15,6 +15,7 @@ import { toast, toastError } from '$lib/toast-store'; import { pullToRefresh } from '$lib/actions/pull-to-refresh'; import { vibrate } from '$lib/haptics'; + import { filterUploads } from '$lib/feed-filter'; import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types'; let uploads = $state([]); @@ -149,14 +150,7 @@ // ── Filtered uploads for grid view ─────────────────────────────────────── let displayUploads = $derived.by(() => { if (viewMode === 'list' || activeFilters.length === 0) return uploads; - const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value); - const users = activeFilters.filter((f) => f.type === 'user').map((f) => f.value); - return uploads.filter((u) => { - const cap = (u.caption ?? '').toLowerCase(); - const passTag = !tags.length || tags.some((t) => cap.includes('#' + t)); - const passUser = !users.length || users.includes(u.uploader_name); - return passTag && passUser; - }); + return filterUploads(uploads, activeFilters); }); // ─────────────────────────────────────────────────────────────────────────