///
/**
* 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 },
],
};
});