/// /** * Test Pattern: Store Mapper * * Tests the store mapper functionality: * - Initial state * - Adding aisles * - Aisle descriptions * - Loading default departments * - Department location assignment * - Adding item corrections * - Outline generation * - AI photo import: addExtractedAisle with valid products * - AI photo import: addExtractedAisle with null products (graceful handling) * - AI photo import: addExtractedAisle duplicate prevention * - AI photo import: mergeExtractedAisle product merging * * Run: deno task ct test packages/patterns/store-mapper.test.tsx --verbose */ import { computed, handler, pattern, Writable } from "commontools"; import StoreMapper from "./store-mapper.tsx"; interface Aisle { name: string; description: string; } type WallPosition = | "front-left" | "front-center" | "front-right" | "back-left" | "back-center" | "back-right" | "left-front" | "left-center" | "left-back" | "right-front" | "right-center" | "right-back" | "unassigned" | "not-in-store" | "in-center-aisle"; interface Department { name: string; icon: string; location: WallPosition; description: string; } interface ItemLocation { itemName: string; correctAisle: string; incorrectAisle: string; timestamp: number; } // Handler to set aisles const setAisles = handler; data: Aisle[] }>( (_event, { aisles, data }) => { aisles.set([...data]); }, ); // Handler to set departments const setDepts = handler< void, { departments: Writable; data: Department[] } >( (_event, { departments, data }) => { departments.set([...data]); }, ); // Handler to set item corrections const setCorrections = handler< void, { itemLocations: Writable; data: ItemLocation[] } >( (_event, { itemLocations, data }) => { itemLocations.set([...data]); }, ); // Handler to set store name const setStoreName = handler< void, { storeName: Writable; name: string } >( (_event, { storeName, name }) => { storeName.set(name); }, ); // Types for AI photo import testing interface ExtractedAisle { name: string; products: string[] | null; } // Handler to simulate addExtractedAisle (mirrors store-mapper.tsx implementation) const addExtractedAisle = handler< void, { aisles: Writable; extracted: ExtractedAisle } >((_event, { aisles, extracted }) => { const current = aisles.get() || []; const exists = current.some( (a: Aisle) => a.name.toLowerCase() === extracted.name.toLowerCase(), ); if (!exists) { aisles.push({ name: extracted.name, description: (extracted.products || []).map((p: string) => `- ${p}`).join( "\n", ), }); } }); // Handler to simulate mergeExtractedAisle (mirrors store-mapper.tsx implementation) const mergeExtractedAisle = handler< void, { aisles: Writable; extracted: ExtractedAisle } >((_event, { aisles, extracted }) => { const current = aisles.get() || []; const idx = current.findIndex( (a: Aisle) => a.name.toLowerCase() === extracted.name.toLowerCase(), ); if (idx >= 0) { const existing = current[idx]; const existingItems = (existing.description || "") .split("\n") .map((l) => l.replace(/^-\s*/, "").trim().toLowerCase()) .filter(Boolean); const newProducts = (extracted.products || []).filter( (p) => !existingItems.includes(p.toLowerCase()), ); if (newProducts.length > 0) { const newDesc = existing.description ? existing.description + "\n" + newProducts.map((p) => `- ${p}`).join("\n") : newProducts.map((p) => `- ${p}`).join("\n"); aisles.set( current.toSpliced(idx, 1, { ...existing, description: newDesc }), ); } } }); export default pattern(() => { // Create writable cells that we control const storeNameCell = Writable.of("Test Store"); const aislesCell = Writable.of([]); const departmentsCell = Writable.of([]); const entrancesCell = Writable.of<{ position: WallPosition }[]>([]); const itemLocationsCell = Writable.of([]); // Instantiate the store mapper pattern const store = StoreMapper({ storeName: storeNameCell, aisles: aislesCell, departments: departmentsCell, entrances: entrancesCell, itemLocations: itemLocationsCell, }); // ========================================================================== // Actions // ========================================================================== const action_add_aisle_1 = setAisles({ aisles: aislesCell, data: [{ name: "1", description: "" }], }); const action_add_aisle_2 = setAisles({ aisles: aislesCell, data: [ { name: "1", description: "" }, { name: "2", description: "" }, ], }); const action_add_aisle_5 = setAisles({ aisles: aislesCell, data: [ { name: "1", description: "" }, { name: "2", description: "" }, { name: "5", description: "" }, ], }); const action_set_aisle_1_description = setAisles({ aisles: aislesCell, data: [ { name: "1", description: "Dairy & Eggs" }, { name: "2", description: "" }, { name: "5", description: "" }, ], }); const action_load_departments = setDepts({ departments: departmentsCell, data: [ { name: "Bakery", icon: "🥖", location: "unassigned", description: "" }, { name: "Deli", icon: "🥪", location: "unassigned", description: "" }, { name: "Produce", icon: "🥬", location: "unassigned", description: "" }, ], }); const action_assign_bakery = setDepts({ departments: departmentsCell, data: [ { name: "Bakery", icon: "🥖", location: "front-left", description: "" }, { name: "Deli", icon: "🥪", location: "unassigned", description: "" }, { name: "Produce", icon: "🥬", location: "unassigned", description: "" }, ], }); const action_add_coffee_correction = setCorrections({ itemLocations: itemLocationsCell, data: [ { itemName: "Coffee", correctAisle: "Aisle 5", incorrectAisle: "", timestamp: Date.now(), }, ], }); const action_change_store_name = setStoreName({ storeName: storeNameCell, name: "My Grocery Store", }); // ========================================================================== // Assertions - use store's computed values where available // ========================================================================== // Initial state - use computed count values for reliable reactive array length checks const assert_initial_store_name = computed(() => String(store.storeName) === "Test Store" ); const assert_initial_no_aisles = computed(() => Number(store.aisleCount) === 0 ); // With auto-populated default departments (7 total) const assert_initial_default_departments = computed(() => Number(store.deptCount) === 7 ); const assert_initial_no_corrections = computed(() => Number(store.correctionCount) === 0 ); // After adding aisles - use computed aisleCount for reliable reactive array length checks const assert_one_aisle = computed(() => Number(store.aisleCount) === 1); const assert_two_aisles = computed(() => Number(store.aisleCount) === 2); const assert_three_aisles = computed(() => Number(store.aisleCount) === 3); const assert_first_aisle_is_1 = computed(() => store.aisles[0]?.name === "1"); // After setting description const assert_aisle_1_has_description = computed(() => store.aisles[0]?.description === "Dairy & Eggs" ); // After loading departments - use computed deptCount for reliable reactive array length checks const assert_three_departments = computed(() => Number(store.deptCount) === 3 ); const assert_bakery_exists = computed(() => store.departments[0]?.name === "Bakery" ); const assert_bakery_unassigned = computed(() => store.departments[0]?.location === "unassigned" ); // After assigning location const assert_bakery_assigned = computed(() => store.departments[0]?.location === "front-left" ); // After adding correction - use computed correctionCount for reliable reactive array length checks const assert_one_correction = computed(() => Number(store.correctionCount) === 1 ); const assert_coffee_correction = computed(() => store.itemLocations[0]?.itemName === "Coffee" && store.itemLocations[0]?.correctAisle === "Aisle 5" ); // Outline generation const assert_outline_contains_aisle_1 = computed(() => String(store.outline).includes("# Aisle 1") ); const assert_outline_contains_description = computed(() => String(store.outline).includes("Dairy & Eggs") ); const assert_outline_contains_bakery = computed(() => String(store.outline).includes("# Bakery") ); const assert_outline_contains_coffee = computed(() => String(store.outline).includes("Coffee") ); // Store name change const assert_store_name_changed = computed(() => String(store.storeName) === "My Grocery Store" ); // ========================================================================== // AI Photo Import Handler Actions // ========================================================================== // Test 9: addExtractedAisle with valid products const action_add_extracted_aisle_8 = addExtractedAisle({ aisles: aislesCell, extracted: { name: "8", products: ["Bread", "Cereal", "Coffee"] }, }); // Test 10: addExtractedAisle with null products (should not crash) const action_add_extracted_aisle_9_null_products = addExtractedAisle({ aisles: aislesCell, extracted: { name: "9", products: null }, }); // Test 11: addExtractedAisle duplicate (should not add - aisle "8" already exists) const action_add_extracted_aisle_8_duplicate = addExtractedAisle({ aisles: aislesCell, extracted: { name: "8", products: ["Snacks", "Chips"] }, }); // Test 12: mergeExtractedAisle - merge new products into existing aisle 8 const action_merge_extracted_aisle_8 = mergeExtractedAisle({ aisles: aislesCell, extracted: { name: "8", products: ["Tea", "Coffee", "Sugar"] }, // Coffee is duplicate, Tea and Sugar are new }); // ========================================================================== // AI Photo Import Handler Assertions // ========================================================================== // After adding extracted aisle 8 const assert_four_aisles = computed(() => Number(store.aisleCount) === 4); const assert_aisle_8_exists = computed(() => store.aisles.some((a: Aisle) => a.name === "8") ); const assert_aisle_8_has_products = computed(() => { const aisle8 = store.aisles.find((a: Aisle) => a.name === "8"); return aisle8 && String(aisle8.description).includes("Bread") && String(aisle8.description).includes("Cereal") && String(aisle8.description).includes("Coffee"); }); // After adding extracted aisle 9 with null products const assert_five_aisles = computed(() => Number(store.aisleCount) === 5); const assert_aisle_9_exists = computed(() => store.aisles.some((a: Aisle) => a.name === "9") ); const assert_aisle_9_empty_description = computed(() => { const aisle9 = store.aisles.find((a: Aisle) => a.name === "9"); return aisle9 && String(aisle9.description) === ""; }); // After attempting to add duplicate aisle 8 (count should remain 5) const assert_still_five_aisles = computed(() => Number(store.aisleCount) === 5 ); const assert_aisle_8_unchanged = computed(() => { const aisle8 = store.aisles.find((a: Aisle) => a.name === "8"); // Description should NOT contain Snacks or Chips from the duplicate attempt return aisle8 && !String(aisle8.description).includes("Snacks") && !String(aisle8.description).includes("Chips"); }); // After merging into aisle 8 const assert_aisle_8_merged = computed(() => { const aisle8 = store.aisles.find((a: Aisle) => a.name === "8"); // Should have original items plus Tea and Sugar, but Coffee only once return aisle8 && String(aisle8.description).includes("Bread") && String(aisle8.description).includes("Tea") && String(aisle8.description).includes("Sugar"); }); const assert_aisle_8_no_duplicate_coffee = computed(() => { const aisle8 = store.aisles.find((a: Aisle) => a.name === "8"); if (!aisle8) return false; // Count occurrences of "Coffee" in description const matches = String(aisle8.description).match(/Coffee/g); return matches && matches.length === 1; }); // ========================================================================== // Test Sequence // ========================================================================== return { tests: [ // === Test 1: Initial state === { assertion: assert_initial_store_name }, { assertion: assert_initial_no_aisles }, { assertion: assert_initial_default_departments }, { assertion: assert_initial_no_corrections }, // === Test 2: Add aisles === { action: action_add_aisle_1 }, { assertion: assert_one_aisle }, { assertion: assert_first_aisle_is_1 }, { action: action_add_aisle_2 }, { assertion: assert_two_aisles }, { action: action_add_aisle_5 }, { assertion: assert_three_aisles }, // === Test 3: Set aisle description === { action: action_set_aisle_1_description }, { assertion: assert_aisle_1_has_description }, // === Test 4: Load departments === { action: action_load_departments }, { assertion: assert_three_departments }, { assertion: assert_bakery_exists }, { assertion: assert_bakery_unassigned }, // === Test 5: Assign department location === { action: action_assign_bakery }, { assertion: assert_bakery_assigned }, // === Test 6: Add item correction === { action: action_add_coffee_correction }, { assertion: assert_one_correction }, { assertion: assert_coffee_correction }, // === Test 7: Verify outline generation === { assertion: assert_outline_contains_aisle_1 }, { assertion: assert_outline_contains_description }, { assertion: assert_outline_contains_bakery }, { assertion: assert_outline_contains_coffee }, // === Test 8: Store name change === { action: action_change_store_name }, { assertion: assert_store_name_changed }, // === Test 9: Add extracted aisle with valid products === { action: action_add_extracted_aisle_8 }, { assertion: assert_four_aisles }, { assertion: assert_aisle_8_exists }, { assertion: assert_aisle_8_has_products }, // === Test 10: Add extracted aisle with null products (should not crash) === { action: action_add_extracted_aisle_9_null_products }, { assertion: assert_five_aisles }, { assertion: assert_aisle_9_exists }, { assertion: assert_aisle_9_empty_description }, // === Test 11: Add duplicate extracted aisle (should not add) === { action: action_add_extracted_aisle_8_duplicate }, { assertion: assert_still_five_aisles }, { assertion: assert_aisle_8_unchanged }, // === Test 12: Merge products into existing aisle === { action: action_merge_extracted_aisle_8 }, { assertion: assert_aisle_8_merged }, { assertion: assert_aisle_8_no_duplicate_coffee }, ], store, }; });