/// /** * Test Pattern: Folksonomy Aggregator * * Tests correctness of the folksonomy aggregator under load: * - Basic event posting via stream * - Multi-scope isolation * - Preferential attachment (sort order by count) * - Remove handling (count decrements, never negative) * - Large batch loading (500 events) * - Invalid event handling (graceful rejection) * - Dense scope (100 unique tags in 1 scope) * * Run: deno task ct test packages/patterns/experimental/folksonomy-aggregator.test.tsx --verbose * * Note: Uses module-scoped handlers with explicit state parameters * instead of action() closures to avoid "reactive reference outside * reactive context" errors when accessing reactive proxy objects. */ import { Cell, computed, handler, pattern, Stream } from "commontools"; import AggregatorPattern from "./folksonomy-aggregator.tsx"; export interface TagEvent { scope: string; tag: string; action: "add" | "use" | "remove"; timestamp: number; } export interface CommunityTagSuggestion { tag: string; count: number; } // ============================================================================ // Test Data Generators // ============================================================================ /** Multi-scope events: scope-a gets alpha(5) + beta(3), scope-b gets gamma(4) + delta(2) */ function makeMultiScopeEvents(): TagEvent[] { const events: TagEvent[] = []; for (let i = 0; i < 5; i++) { events.push({ scope: "scope-a", tag: "alpha", action: "add", timestamp: i, }); } for (let i = 0; i < 3; i++) { events.push({ scope: "scope-a", tag: "beta", action: "add", timestamp: 10 + i, }); } for (let i = 0; i < 4; i++) { events.push({ scope: "scope-b", tag: "gamma", action: "add", timestamp: 20 + i, }); } for (let i = 0; i < 2; i++) { events.push({ scope: "scope-b", tag: "delta", action: "add", timestamp: 30 + i, }); } return events; } /** Preferential attachment: popular(10) vs rare(1) in same scope */ function makePreferentialEvents(): TagEvent[] { const events: TagEvent[] = []; for (let i = 0; i < 10; i++) { events.push({ scope: "scope-p", tag: "popular", action: "add", timestamp: i, }); } events.push({ scope: "scope-p", tag: "rare", action: "add", timestamp: 100 }); return events; } /** Remove handling: add removable(5), remove removable(3), add permanent(1) */ function makeRemoveEvents(): TagEvent[] { const events: TagEvent[] = []; for (let i = 0; i < 5; i++) { events.push({ scope: "scope-r", tag: "removable", action: "add", timestamp: i, }); } for (let i = 0; i < 3; i++) { events.push({ scope: "scope-r", tag: "removable", action: "remove", timestamp: 10 + i, }); } events.push({ scope: "scope-r", tag: "permanent", action: "add", timestamp: 20, }); return events; } const BATCH_SCOPES = [ "recipe-tracker", "meal-planner", "cookbook", "food-blog", "grocery-list", "restaurant-reviews", "wine-journal", "kitchen-inventory", "diet-log", "cooking-class", ]; const BATCH_TAGS = [ "vegetarian", "quick", "easy", "italian", "mexican", "breakfast", "dessert", "healthy", "spicy", "gluten-free", "vegan", "comfort-food", "budget", "meal-prep", "one-pot", "grilled", "baked", "seasonal", "holiday", "snack", ]; const DENSE_TAGS = [ "vegetarian", "quick", "easy", "italian", "mexican", "breakfast", "dessert", "healthy", "spicy", "gluten-free", "vegan", "comfort-food", "budget", "meal-prep", "one-pot", "grilled", "baked", "seasonal", "holiday", "snack", "appetizer", "soup", "salad", "pasta", "seafood", "chicken", "beef", "pork", "tofu", "rice", "noodles", "curry", "stir-fry", "slow-cooker", "instant-pot", "fermented", "pickled", "smoked", "roasted", "steamed", "raw", "frozen", "organic", "local", "farm-to-table", "keto", "paleo", "mediterranean", "asian", "french", "indian", "thai", "japanese", "korean", "middle-eastern", "african", "caribbean", "southern", "tex-mex", "fusion", "street-food", "brunch", "lunch", "dinner", "party", "potluck", "date-night", "kids", "beginner", "advanced", "under-30-min", "under-15-min", "overnight", "no-cook", "five-ingredient", "dairy-free", "nut-free", "egg-free", "soy-free", "low-sodium", "high-protein", "low-carb", "high-fiber", "antioxidant", "probiotic", "immune-boost", "energy", "recovery", "chocolate", "vanilla", "cinnamon", "garlic", "lemon", "ginger", "basil", "cilantro", "mint", "rosemary", "thyme", "oregano", "cumin", "turmeric", ]; /** Large batch: 500 events across 10 scopes and 20 tags */ function makeLargeBatchEvents(): TagEvent[] { const events: TagEvent[] = []; for (let i = 0; i < 500; i++) { events.push({ scope: BATCH_SCOPES[i % 10], tag: BATCH_TAGS[(i * 7) % 20], // Prime multiplier for even distribution action: "add", timestamp: i, }); } return events; } /** Dense scope: 100 unique tags in 1 scope, each with (index % 3 + 1) events */ function makeDenseEvents(): TagEvent[] { const events: TagEvent[] = []; for (let t = 0; t < 100; t++) { const count = (t % 3) + 1; // 1, 2, or 3 events per tag for (let i = 0; i < count; i++) { events.push({ scope: "cookbook", tag: DENSE_TAGS[t], action: "add", timestamp: t * 10 + i, }); } } return events; } // ============================================================================ // Module-scope Handlers // ============================================================================ /** Set events cell directly (for bulk loading and resetting) */ const setEventsHandler = handler< void, { eventsCell: Cell; newEvents: TagEvent[] } >((_event, { eventsCell, newEvents }) => { eventsCell.set([...newEvents]); }); /** Post a single event via the aggregator's postEvent stream */ const postEventHandler = handler< void, { stream: Stream; event: TagEvent } >((_event, { stream, event }) => { stream.send(event); }); // ============================================================================ // Test Pattern // ============================================================================ export default pattern(() => { // Instantiate aggregator with a writable events cell const eventsCell = Cell.of([]); const aggregator = AggregatorPattern({ events: eventsCell }); // ======================================================================== // Actions // ======================================================================== // --- Test 1: Basic correctness --- const action_post_basic_event = postEventHandler({ stream: aggregator.postEvent, event: { scope: "basic", tag: "typescript", action: "add", timestamp: 1 }, }); // --- Test 2: Multi-scope isolation --- const action_load_multiscope = setEventsHandler({ eventsCell, newEvents: makeMultiScopeEvents(), }); // --- Test 3: Preferential attachment --- const action_load_preferential = setEventsHandler({ eventsCell, newEvents: makePreferentialEvents(), }); // --- Test 4: Remove handling --- const action_load_remove = setEventsHandler({ eventsCell, newEvents: makeRemoveEvents(), }); // --- Test 5: Large batch --- const action_load_large_batch = setEventsHandler({ eventsCell, newEvents: makeLargeBatchEvents(), }); // --- Test 6: Invalid events --- const action_reset = setEventsHandler({ eventsCell, newEvents: [], }); const action_post_invalid_empty_scope = postEventHandler({ stream: aggregator.postEvent, event: { scope: "", tag: "orphan", action: "add", timestamp: 1 }, }); const action_post_invalid_empty_tag = postEventHandler({ stream: aggregator.postEvent, event: { scope: "invalid-test", tag: "", action: "add", timestamp: 2 }, }); const action_post_valid_after_invalid = postEventHandler({ stream: aggregator.postEvent, event: { scope: "recovery", tag: "works", action: "add", timestamp: 3 }, }); // --- Test 7: Dense scope --- const action_load_dense = setEventsHandler({ eventsCell, newEvents: makeDenseEvents(), }); // ======================================================================== // Assertions // ======================================================================== // --- Initial state --- const assert_initial_empty = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; // Either no keys or all values are empty arrays for (const scopeSuggs of Object.values(suggs)) { if (scopeSuggs.length > 0) return false; } return true; }); // --- Test 1: Basic correctness --- const assert_basic_has_typescript = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; const basicSuggs = suggs["basic"] || []; return basicSuggs.some( (s) => s.tag === "typescript" && s.count === 1, ); }); // --- Test 2: Multi-scope isolation --- const assert_scope_a_has_alpha = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; return (suggs["scope-a"] || []).some((s) => s.tag === "alpha"); }); const assert_scope_a_has_beta = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; return (suggs["scope-a"] || []).some((s) => s.tag === "beta"); }); const assert_scope_b_has_gamma = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; return (suggs["scope-b"] || []).some((s) => s.tag === "gamma"); }); const assert_scope_a_no_gamma = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; return !(suggs["scope-a"] || []).some((s) => s.tag === "gamma"); }); const assert_scope_b_no_alpha = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; return !(suggs["scope-b"] || []).some((s) => s.tag === "alpha"); }); const assert_scope_a_alpha_count_5 = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; const alpha = (suggs["scope-a"] || []).find((s) => s.tag === "alpha"); return alpha?.count === 5; }); // --- Test 3: Preferential attachment --- const assert_popular_first = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; const pSuggs = suggs["scope-p"] || []; return pSuggs.length >= 2 && pSuggs[0].tag === "popular"; }); const assert_popular_count_higher = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; const pSuggs = suggs["scope-p"] || []; const popular = pSuggs.find((s) => s.tag === "popular"); const rare = pSuggs.find((s) => s.tag === "rare"); return (popular?.count || 0) > (rare?.count || 0); }); // --- Test 4: Remove handling --- const assert_removable_count_2 = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; const removable = (suggs["scope-r"] || []).find( (s) => s.tag === "removable", ); return removable?.count === 2; // 5 adds - 3 removes = 2 }); const assert_permanent_count_1 = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; const permanent = (suggs["scope-r"] || []).find( (s) => s.tag === "permanent", ); return permanent?.count === 1; }); // --- Test 5: Large batch --- const assert_batch_multiple_scopes = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; let scopeCount = 0; for (const key of Object.keys(suggs)) { if (BATCH_SCOPES.includes(key) && suggs[key].length > 0) { scopeCount++; } } return scopeCount >= 5; // Should have all 10 batch scopes populated }); const assert_batch_has_tags = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; const first = suggs["recipe-tracker"] || []; return first.length > 0; // first scope should have suggestions }); // --- Test 6: Invalid events --- const assert_empty_after_invalid = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; for (const scopeSuggs of Object.values(suggs)) { if (scopeSuggs.length > 0) return false; } return true; }); const assert_recovery_after_invalid = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; return (suggs["recovery"] || []).some( (s) => s.tag === "works" && s.count === 1, ); }); // --- Test 7: Dense scope --- const assert_dense_100_tags = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; return (suggs["cookbook"] || []).length === 100; }); const assert_dense_sorted = computed(() => { const suggs = (aggregator.suggestions || {}) as Record< string, CommunityTagSuggestion[] >; const denseSuggs = suggs["cookbook"] || []; if (denseSuggs.length < 2) return false; // Verify descending sort by count for (let i = 0; i < denseSuggs.length - 1; i++) { if (denseSuggs[i].count < denseSuggs[i + 1].count) return false; } return true; }); // ======================================================================== // Test Sequence // ======================================================================== return { tests: [ // === Initial state: empty === { assertion: assert_initial_empty }, // === Test 1: Basic correctness === // Post 1 event via stream, verify suggestions contain it { action: action_post_basic_event }, { assertion: assert_basic_has_typescript }, // === Test 2: Multi-scope isolation === // Set events across 2 scopes, verify isolation { action: action_load_multiscope }, { assertion: assert_scope_a_has_alpha }, { assertion: assert_scope_a_has_beta }, { assertion: assert_scope_b_has_gamma }, { assertion: assert_scope_a_no_gamma }, { assertion: assert_scope_b_no_alpha }, { assertion: assert_scope_a_alpha_count_5 }, // === Test 3: Preferential attachment === // popular(10) should sort before rare(1) { action: action_load_preferential }, { assertion: assert_popular_first }, { assertion: assert_popular_count_higher }, // === Test 4: Remove handling === // 5 adds - 3 removes = count 2, permanent stays at 1 { action: action_load_remove }, { assertion: assert_removable_count_2 }, { assertion: assert_permanent_count_1 }, // === Test 5: Large batch === // 500 events across 10 scopes { action: action_load_large_batch }, { assertion: assert_batch_multiple_scopes }, { assertion: assert_batch_has_tags }, // === Test 6: Invalid events === // Reset, post invalid events, verify rejection, then verify recovery { action: action_reset }, { action: action_post_invalid_empty_scope }, { action: action_post_invalid_empty_tag }, { assertion: assert_empty_after_invalid }, { action: action_post_valid_after_invalid }, { assertion: assert_recovery_after_invalid }, // === Test 7: Dense scope === // 100 unique tags in 1 scope, all present and sorted { action: action_load_dense }, { assertion: assert_dense_100_tags }, { assertion: assert_dense_sorted }, ], }; });