test: cover grid filter OR/AND (extract pure fn + unit + real e2e)

The two filter-search cases were test.fixme placeholders (expect(true).toBe(true)),
so the OR/AND chip rules were untested.

- Extract the grid-filter predicate from feed/+page.svelte into a pure
  filterUploads(uploads, filters) in $lib/feed-filter (behavior-preserving) and
  unit-test it (9 cases): tag OR, user OR, tag+user AND, AND-excludes-partial,
  case-insensitive caption match, null caption.
- Replace the e2e fixmes with real tests that seed known captions/uploaders, switch
  to grid view, activate chips via the search suggestions, and count grid tiles:
  OR widens 1→2, AND narrows 2→1.

Frontend unit: 27 passing. filter-search e2e: 3 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-01 20:08:28 +02:00
parent 6ed0aaf0d7
commit 74849c8d50
4 changed files with 153 additions and 26 deletions

View 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);
});
});

View 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;
});
}

View File

@@ -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<FeedUpload[]>([]);
@@ -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);
});
// ─────────────────────────────────────────────────────────────────────────