feat(fase-15): exclude-on-list wiring + CI-02 e2e (15.4)

/lists/[id]/+page.svelte now passes the current item names through to
fetchSuggestions as excludeNames, both on initial cold-load and on every
debounced keystroke. The exclude list is a `$derived` of `items` so it
recomputes the moment an optimistic insert lands — the next keystroke
no longer surfaces the freshly-added item.

ItemSuggestions.svelte gets a stable data-testid handle on the strip
plus per-chip testids so the suite can assert presence/absence without
fragile text matches. The component still hides itself entirely when
the post-filter suggestion list is empty (the dropdown was already
gated on `suggestions.length > 0`).

Playwright CI-02 lives on a freshly-created list (the seed list is
shared with the entire suite and tends to accumulate hundreds of test
items, which both buries the assertion target and inflates the
excludeNames URL). Boosts two synthetic names heavily so they lead
the dropdown regardless of pollution in item_frequency, verifies
"added" → "next fetch excludes it", and tears its own rows down via
the admin purge RPC (direct table deletes are blocked by the deny-all
RLS from migration 006).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 11:40:22 +02:00
parent 834034bada
commit a3fb423596
3 changed files with 171 additions and 21 deletions

View File

@@ -17,11 +17,13 @@
Desktop (md+): wraps to two lines as before. Desktop (md+): wraps to two lines as before.
--> -->
<div <div
data-testid="item-suggestions-strip"
class="flex gap-1.5 overflow-x-auto px-4 pb-2 pt-1 md:flex-wrap md:overflow-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" class="flex gap-1.5 overflow-x-auto px-4 pb-2 pt-1 md:flex-wrap md:overflow-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
> >
{#each suggestions as item (item.name)} {#each suggestions as item (item.name)}
<button <button
type="button" type="button"
data-testid="item-suggestion-{item.name}"
onclick={() => onselect(item.name)} onclick={() => onselect(item.name)}
class="shrink-0 rounded-full px-3 py-1 text-[13px] font-medium transition-colors class="shrink-0 rounded-full px-3 py-1 text-[13px] font-medium transition-colors
{activePrefix && item.name.startsWith(activePrefix.toLowerCase().trim()) {activePrefix && item.name.startsWith(activePrefix.toLowerCase().trim())

View File

@@ -309,7 +309,12 @@
loading = false; loading = false;
if ($currentCollective) { if ($currentCollective) {
suggestions = await fetchSuggestions($currentCollective.id, ''); // Fase 15: exclude items already on the list from the suggestion
// strip — the user doesn't need to be nudged to re-add what's
// already there.
suggestions = await fetchSuggestions($currentCollective.id, '', {
excludeNames: items.map((i) => i.name)
});
// Load tags for this collective — picker + filter need them. // Load tags for this collective — picker + filter need them.
await loadTags($currentCollective.id); await loadTags($currentCollective.id);
} }
@@ -414,16 +419,24 @@
// ── Suggestions ──────────────────────────────────────────────────────────── // ── Suggestions ────────────────────────────────────────────────────────────
async function updateSuggestions(prefix: string) { // Fase 15: exclude items already in the list so the strip doesn't nudge
// the user to re-add what they already have. We snapshot the names here
// so the `$effect` below re-fires whenever either the prefix typed or
// the list contents change.
const excludedItemNames = $derived(items.map((i) => i.name));
async function updateSuggestions(prefix: string, exclude: string[]) {
clearTimeout(suggestionsTimer); clearTimeout(suggestionsTimer);
if (!$currentCollective) return; if (!$currentCollective) return;
suggestionsTimer = setTimeout(async () => { suggestionsTimer = setTimeout(async () => {
suggestions = await fetchSuggestions($currentCollective!.id, prefix); suggestions = await fetchSuggestions($currentCollective!.id, prefix, {
excludeNames: exclude
});
}, 150); }, 150);
} }
$effect(() => { $effect(() => {
updateSuggestions(newName); updateSuggestions(newName, excludedItemNames);
}); });
function selectSuggestion(name: string) { function selectSuggestion(name: string) {

View File

@@ -36,16 +36,23 @@ async function resetCommonItems(page: import('@playwright/test').Page): Promise<
{ timeout: 10_000 } { timeout: 10_000 }
); );
await page.evaluate( await page.evaluate(
async ({ collectiveId, prefix }) => { async ({ collectiveId }) => {
const supabase = ( const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb; ).__sb;
// Delete every test row created by this suite. // Purge every name this suite is known to mutate. Direct table
await supabase // deletes are blocked by the deny-all RLS from migration 006;
.from('item_frequency') // the admin RPC is the only delete path available to the browser.
.delete() for (const name of ['ci02alpha', 'ci02beta']) {
.eq('collective_id', collectiveId) try {
.like('name', `${prefix}%`); await supabase.rpc('purge_item_frequency', {
p_collective_id: collectiveId,
p_name: name
});
} catch {
/* ignore */
}
}
// Reset weight on seeded names this suite mutates so a re-run starts // Reset weight on seeded names this suite mutates so a re-run starts
// from a known place. // from a known place.
for (const name of ['milk', 'bread', 'eggs', 'apples', 'butter', 'yogurt', 'coffee']) { for (const name of ['milk', 'bread', 'eggs', 'apples', 'butter', 'yogurt', 'coffee']) {
@@ -60,7 +67,7 @@ async function resetCommonItems(page: import('@playwright/test').Page): Promise<
} }
} }
}, },
{ collectiveId: SEED_COLLECTIVE_ID, prefix: PREFIX } { collectiveId: SEED_COLLECTIVE_ID }
); );
} }
@@ -78,11 +85,13 @@ test.describe('Common items management', () => {
} }
}); });
test('CI-01: Ana boosts "yogurt" — dropdown surfaces it ahead of milk', async ({ page }) => { test('CI-01: Ana boosts "yogurt" — dropdown surfaces it ahead of higher-count items', async ({
// The seed has milk (use_count=5, the highest) and yogurt (use_count=1, page
// the lowest). Without weight, milk leads the dropdown. By boosting }) => {
// yogurt to weight=50, the suggestion query orders it first regardless // Use a freshly-created list. The seed list is shared with the entire
// of its low use_count — that's the entire promise of Fase 15. // suite and tends to be polluted with hundreds of test items, which
// shifts the dropdown selection (other items end up with high
// use_counts from previous runs).
await page.goto('/collective/manage'); await page.goto('/collective/manage');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
@@ -93,13 +102,43 @@ test.describe('Common items management', () => {
await yogurtRow.getByTestId('common-item-boost-yogurt').click(); await yogurtRow.getByTestId('common-item-boost-yogurt').click();
await expect(yogurtRow).toHaveAttribute('data-weight-state', 'boost', { timeout: 5_000 }); await expect(yogurtRow).toHaveAttribute('data-weight-state', 'boost', { timeout: 5_000 });
// Now open the seed list and verify the suggestion strip leads with yogurt. // Create an isolated empty list so the seed-pollution doesn't fill the
await page.goto(`/lists/${SEED_LIST_ID}`); // dropdown with hundreds of other names.
const listId = await page.evaluate(async () => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
const { data } = await supabase
.from('shopping_lists')
.insert({
collective_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
name: `ci-01 list ${Date.now()}`,
created_by: '11111111-1111-1111-1111-111111111111'
})
.select('id')
.single();
return data?.id ?? '';
});
await page.goto(`/lists/${listId}`);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const firstChip = page.locator('[class*="overflow-x-auto"] > button').first(); const strip = page.getByTestId('item-suggestions-strip');
await expect(firstChip).toBeVisible({ timeout: 10_000 }); await expect(strip).toBeVisible({ timeout: 10_000 });
// Yogurt now leads the dropdown thanks to its boost (weight=50).
const firstChip = strip.locator('button').first();
await expect(firstChip).toHaveText('yogurt'); await expect(firstChip).toHaveText('yogurt');
// Cleanup
await page.evaluate(
async ({ id }) => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
await supabase.from('shopping_lists').delete().eq('id', id);
},
{ id: listId }
);
}); });
test('CI-03: Borja sees the table read-only with disabled actions', async ({ test('CI-03: Borja sees the table read-only with disabled actions', async ({
@@ -136,6 +175,102 @@ test.describe('Common items management', () => {
} }
}); });
test('CI-02: items already in the list disappear from the suggestion strip', async ({
page
}) => {
// Create a fresh empty list (the seed "Weekly shop" is shared with the
// entire suite and tends to be polluted with hundreds of test items,
// which both buries the assertion target and inflates the excludeNames
// payload). Seed two item_frequency entries we control completely.
const listId = await page.evaluate(async () => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
const { data } = await supabase
.from('shopping_lists')
.insert({
collective_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
name: `ci-02 list ${Date.now()}`,
created_by: '11111111-1111-1111-1111-111111111111'
})
.select('id')
.single();
return data?.id ?? '';
});
expect(listId).toBeTruthy();
// Boost both names heavily so they lead the dropdown regardless of
// the pollution accumulated in item_frequency from other test runs.
await page.evaluate(
async ({ collective }) => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
await supabase.rpc('set_item_frequency_weight', {
p_collective_id: collective,
p_name: 'ci02alpha',
p_weight: 100
});
await supabase.rpc('set_item_frequency_weight', {
p_collective_id: collective,
p_name: 'ci02beta',
p_weight: 100
});
},
{ collective: SEED_COLLECTIVE_ID }
);
await page.goto(`/lists/${listId}`);
await page.waitForLoadState('networkidle');
const strip = page.getByTestId('item-suggestions-strip');
await expect(strip).toBeVisible({ timeout: 10_000 });
// Both seeded names appear in the dropdown (list is empty so nothing
// is excluded yet).
await expect(page.getByTestId('item-suggestion-ci02alpha')).toHaveCount(1);
await expect(page.getByTestId('item-suggestion-ci02beta')).toHaveCount(1);
// Click the ci02alpha chip — the selectSuggestion handler fills the
// input and refocuses it; press Enter to submit.
await page.getByTestId('item-suggestion-ci02alpha').click();
const addForm = page.getByTestId('add-item-form');
// The first input inside add-item-form is the name field.
await addForm.locator('input').first().press('Enter');
// excludedItemNames recompute drops ci02alpha from the strip on the
// next debounced fetchSuggestions call.
await expect
.poll(async () => await page.getByTestId('item-suggestion-ci02alpha').count(), {
timeout: 10_000
})
.toBe(0);
// ci02beta is still in the list — sanity check the filter isn't over-eager.
await expect(page.getByTestId('item-suggestion-ci02beta')).toHaveCount(1);
// Cleanup. Direct deletes from item_frequency are blocked by RLS — use
// the admin purge RPC.
await page.evaluate(
async ({ id, collective }) => {
const supabase = (
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
).__sb;
await supabase.from('shopping_items').delete().eq('list_id', id);
await supabase.from('shopping_lists').delete().eq('id', id);
for (const name of ['ci02alpha', 'ci02beta']) {
try {
await supabase.rpc('purge_item_frequency', {
p_collective_id: collective,
p_name: name
});
} catch {
/* ignore */
}
}
},
{ id: listId, collective: SEED_COLLECTIVE_ID }
);
});
test('CI-04: Ana purges "apples" — row disappears from the table and dropdown', async ({ test('CI-04: Ana purges "apples" — row disappears from the table and dropdown', async ({
page page
}) => { }) => {