Merge test/filter-search: grid filter OR/AND coverage (pure fn + unit + e2e)
This commit is contained in:
@@ -1,29 +1,68 @@
|
|||||||
/**
|
/**
|
||||||
* USER_JOURNEYS.md §8 — search and filter chips. Asserts the OR / AND
|
* USER_JOURNEYS.md §8 — grid-view search & filter chips. Verifies the OR / AND
|
||||||
* combination rules described in the journey.
|
* 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
|
* (The pure combination logic is also unit-tested in
|
||||||
* via API once a Node-side upload helper lands. For now we ship the
|
* frontend/src/lib/feed-filter.test.ts.)
|
||||||
* structure and the UI assertions, marked with `test.fixme` where they
|
|
||||||
* depend on seeded data we can't yet create.
|
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
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.describe('Feed — filter & search', () => {
|
||||||
test.fixme('two hashtag chips combine with OR', async ({ page, guest, signIn }) => {
|
test('two hashtag chips combine with OR', async ({ page, guest, signIn }) => {
|
||||||
const h = await guest('Searcher');
|
const a = await guest('OrUser');
|
||||||
await signIn(page, h);
|
await seedUpload(a.jwt, { caption: 'pic #wedding' });
|
||||||
// TODO: seed 2 uploads with different hashtags, then activate two chips
|
await seedUpload(a.jwt, { caption: 'pic #party' });
|
||||||
// and assert both cards remain visible.
|
await seedUpload(a.jwt, { caption: 'pic #other' });
|
||||||
await page.goto('/feed');
|
|
||||||
expect(true).toBe(true);
|
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 }) => {
|
test('uploader chip + hashtag chip combines with AND', async ({ page, guest, signIn }) => {
|
||||||
const h = await guest('Searcher2');
|
const alice = await guest('AndAlice');
|
||||||
await signIn(page, h);
|
const bob = await guest('AndBob');
|
||||||
await page.goto('/feed');
|
await seedUpload(alice.jwt, { caption: 'pic #wedding' }); // Alice + wedding
|
||||||
expect(true).toBe(true);
|
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 }) => {
|
test('feed page renders without crashing for an authed user', async ({ page, guest, signIn }) => {
|
||||||
|
|||||||
59
frontend/src/lib/feed-filter.test.ts
Normal file
59
frontend/src/lib/feed-filter.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
frontend/src/lib/feed-filter.ts
Normal file
35
frontend/src/lib/feed-filter.ts
Normal file
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import { toast, toastError } from '$lib/toast-store';
|
import { toast, toastError } from '$lib/toast-store';
|
||||||
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
|
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
|
||||||
import { vibrate } from '$lib/haptics';
|
import { vibrate } from '$lib/haptics';
|
||||||
|
import { filterUploads } from '$lib/feed-filter';
|
||||||
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
||||||
|
|
||||||
let uploads = $state<FeedUpload[]>([]);
|
let uploads = $state<FeedUpload[]>([]);
|
||||||
@@ -149,14 +150,7 @@
|
|||||||
// ── Filtered uploads for grid view ───────────────────────────────────────
|
// ── Filtered uploads for grid view ───────────────────────────────────────
|
||||||
let displayUploads = $derived.by(() => {
|
let displayUploads = $derived.by(() => {
|
||||||
if (viewMode === 'list' || activeFilters.length === 0) return uploads;
|
if (viewMode === 'list' || activeFilters.length === 0) return uploads;
|
||||||
const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value);
|
return filterUploads(uploads, activeFilters);
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user