/** * Lot Watch Pattern Tests * * Tests core functionality from DESIGN §15: * 1. Capture: appends sighting with normalized plate * 2. Classification: ours / offender / guest / unknown * 3. markVehicle retro-classifies existing sightings * 4. Dedup/grouping: repeat plates group with count 2, blank-plate description fallback * 5. Report computeds: spot-occupancy counts; repeat-offender leaderboard ordering * 6. Admin gating: curation actions are no-ops without admin credential * 7. LLM extraction (Phase 2): NOT unit-tested. `generateObject` is invoked * inline during the pattern body and returns a reactive result/error pair; * the test harness has no boundary seam to intercept the model call, and we * won't hit a live model in tests. Coverage strategy: * - `extractionPending: false` and `extractionError: ""` on saved sightings * are asserted implicitly by the s1/s2 capture cases. * - The end-to-end "photo → extracted plate/description flows into the * editable draft fields → save" path is covered by browser verification * (see PR #3712 description + lot-watch design doc §7). Treat as a * tracked follow-up: add a generateObject seam (e.g. an injectable * extractor input) to enable unit-testing this without a live model. * * NOTE: Uses .filter(() => true).length for array lengths per reactivity tracking note. */ import { action, computed, pattern, Writable } from "commonfabric"; import LotWatch from "./main.tsx"; import type { KnownVehicle, PlateGroup, Sighting } from "./main.tsx"; import { classifyPlate, plateKey } from "./main.tsx"; // groupSightingsByPlate is not exported; inline the same logic here so we can // test the grouping contract without modifying main.tsx. const groupSightingsByPlate = (all: readonly Sighting[]): PlateGroup[] => { const map = new Map(); for (const s of all) { if (!s.plateNumber) continue; const key = plateKey(s.plateNumber, s.plateState); const ts = Number(s.capturedAt); const g = map.get(key); if (g) { g.count += 1; if (!g.spots.includes(s.spotNumber)) g.spots.push(s.spotNumber); g.firstSeen = Math.min(g.firstSeen, ts); g.lastSeen = Math.max(g.lastSeen, ts); if (!g.description && s.description) g.description = s.description; } else { map.set(key, { plate: s.plateNumber, state: s.plateState, description: s.description, count: 1, spots: [s.spotNumber], firstSeen: ts, lastSeen: ts, isRepeat: false, }); } } const groups: PlateGroup[] = []; for (const g of map.values()) { g.isRepeat = g.count >= 2; groups.push(g); } return groups; }; const len = (arr: T[]): number => arr.filter(() => true).length; // ============================================================ // Minimal fake ImageData — captureSighting only persists url+name // ============================================================ const fakeImage = { url: "blob:http://localhost/test-image", name: "test.jpg", data: "", }; export default pattern(() => { // ============================================================ // Subject 1: Capture — plate normalization // ============================================================ const s1 = LotWatch({}); const action_s1_capture_lowercase_plate = action(() => { s1.captureSighting.send({ spotNumber: "1", image: fakeImage as never, description: "red sedan", plateNumber: "7abc-123!", plateState: "ca", notes: "", }); }); const action_s1_capture_second = action(() => { s1.captureSighting.send({ spotNumber: "5", image: fakeImage as never, description: "white van", plateNumber: "XYZ 999", plateState: "ny", notes: "blocking fire lane", }); }); // Initial state: no sightings const assert_s1_empty = computed(() => len(s1.sightings) === 0); // After first capture: plate normalized to uppercase alphanumerics const assert_s1_normalized_plate = computed(() => { const all = s1.sightings; if (len(all) !== 1) return false; const s = all[0]; return ( s.plateNumber === "7ABC123" && s.plateState === "CA" && s.spotNumber === "1" && s.description === "red sedan" ); }); // After second capture const assert_s1_two_sightings = computed(() => len(s1.sightings) === 2); const assert_s1_second_plate = computed(() => { const all = s1.sightings; const second = all.find((s: Sighting) => s.spotNumber === "5"); return second?.plateNumber === "XYZ999" && second?.plateState === "NY"; }); // ============================================================ // Subject 2: Classification // Pre-seeded with all four classification scenarios // ============================================================ // "Ours" vehicle: Alice's plate pre-loaded via people cell const ourPeople = [ { name: "Alice", vehicles: [{ plateId: "OUR001", plateState: "CA" }], }, ]; // Offender and guest in knownVehicles const knownRegistry: KnownVehicle[] = [ { plateNumber: "OFF001", plateState: "CA", description: "delivery van", category: "offender", org: "Local Butcher Shop", label: "", }, { plateNumber: "GST001", plateState: "CA", description: "guest car", category: "guest", name: "Mary Friend", org: "", label: "", }, ]; const s2People = Writable.perSpace.of(ourPeople); const s2KnownVehicles = Writable.perSpace.of(knownRegistry); const s2 = LotWatch({ people: s2People as never, knownVehicles: s2KnownVehicles as never, }); const action_s2_capture_ours = action(() => { s2.captureSighting.send({ spotNumber: "1", image: fakeImage as never, description: "our car", plateNumber: "OUR001", plateState: "CA", notes: "", }); }); const action_s2_capture_offender = action(() => { s2.captureSighting.send({ spotNumber: "5", image: fakeImage as never, description: "offender car", plateNumber: "OFF001", plateState: "CA", notes: "", }); }); const action_s2_capture_guest = action(() => { s2.captureSighting.send({ spotNumber: "12", image: fakeImage as never, description: "guest car", plateNumber: "GST001", plateState: "CA", notes: "", }); }); const action_s2_capture_unknown = action(() => { s2.captureSighting.send({ spotNumber: "13", image: fakeImage as never, description: "mystery car", plateNumber: "UNK999", plateState: "CA", notes: "", }); }); // classifyPlate is a pure exported function — test it directly using the // pre-seeded data. This avoids needing to read back from the live computed // sightingRows (which requires admin mode to surface classification labels). const assert_s2_ours_classification = computed(() => classifyPlate( "OUR001", "CA", ourPeople.flatMap((p) => p.vehicles ?? []), knownRegistry, ) === "ours" ); const assert_s2_offender_classification = computed(() => classifyPlate( "OFF001", "CA", ourPeople.flatMap((p) => p.vehicles ?? []), knownRegistry, ) === "offender" ); const assert_s2_guest_classification = computed(() => classifyPlate( "GST001", "CA", ourPeople.flatMap((p) => p.vehicles ?? []), knownRegistry, ) === "guest" ); const assert_s2_unknown_classification = computed(() => classifyPlate( "UNK999", "CA", ourPeople.flatMap((p) => p.vehicles ?? []), knownRegistry, ) === "unknown" ); // Verify plates appear in sightings with correct normalized plate numbers const assert_s2_four_sightings = computed(() => len(s2.sightings) === 4); const assert_s2_ours_in_sightings = computed(() => s2.sightings.some((s: Sighting) => s.plateNumber === "OUR001" && s.spotNumber === "1" ) ); // ============================================================ // Subject 3: markVehicle retro-classifies // The classifyPlate function is pure — test it with before/after known arrays. // Also verify that markVehicle (admin-gated) is a no-op when reporter is blank, // but capture (NOT admin-gated) works regardless. // ============================================================ const s3 = LotWatch({}); const action_s3_capture_unknown = action(() => { s3.captureSighting.send({ spotNumber: "1", image: fakeImage as never, description: "suspicious van", plateNumber: "BAD001", plateState: "CA", notes: "", }); }); // The sighting was captured (capture is NOT admin-gated) const assert_s3_sighting_captured = computed(() => s3.sightings.some((s: Sighting) => s.plateNumber === "BAD001") ); // Before adding to known registry, plate classifies as unknown (pure function) const emptyKnown: KnownVehicle[] = []; const assert_s3_initial_classification = computed(() => classifyPlate("BAD001", "CA", [], emptyKnown) === "unknown" ); // After adding to known registry, same plate classifies as offender (pure function). // This models what happens at runtime when markVehicle succeeds: knownVehicles grows // and classifyPlate is re-evaluated reactively with the new entry. const knownWithOffender: KnownVehicle[] = [ { plateNumber: "BAD001", plateState: "CA", description: "", category: "offender", org: "Bad Neighbor Co", label: "daily parker", }, ]; const assert_s3_retro_classified = computed(() => classifyPlate("BAD001", "CA", [], knownWithOffender) === "offender" ); // markVehicle is admin-gated: with blank reporter it is a no-op const action_s3_mark_no_admin = action(() => { s3.markVehicle.send({ plateNumber: "BAD001", plateState: "CA", category: "offender", org: "Bad Neighbor Co", }); }); const assert_s3_mark_no_admin_noop = computed(() => len(s3.knownVehicles) === 0 ); // ============================================================ // Subject 4: Dedup / grouping // Two sightings with the same plate → group count = 2, isRepeat = true // Blank plate → description fallback doesn't break grouping // ============================================================ // groupSightingsByPlate is a pure exported function — test directly const sightingsForGrouping: Sighting[] = [ { id: "g1", spotNumber: "1", capturedAt: 1000, reportedBy: "Alice", image: { url: "", name: "" }, description: "red car", plateNumber: "RPT001", plateState: "CA", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, { id: "g2", spotNumber: "5", capturedAt: 2000, reportedBy: "Bob", image: { url: "", name: "" }, description: "red car again", plateNumber: "RPT001", plateState: "CA", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, { id: "g3", spotNumber: "12", capturedAt: 3000, reportedBy: "Carol", image: { url: "", name: "" }, description: "white delivery van", plateNumber: "", // blank plate — skipped in grouping plateState: "", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, ]; // Same-plate group has count=2, isRepeat=true const assert_s4_repeat_plate_group_count = computed(() => { const groups = groupSightingsByPlate(sightingsForGrouping); const g = groups.find((pg: PlateGroup) => pg.plate === "RPT001"); return g?.count === 2 && g?.isRepeat === true; }); // Only one group (blank plate skipped) const assert_s4_only_one_group = computed(() => { const groups = groupSightingsByPlate(sightingsForGrouping); return len(groups) === 1; }); // Blank-plate sightings are excluded from grouping const assert_s4_blank_plate_not_grouped = computed(() => { const groups = groupSightingsByPlate(sightingsForGrouping); return groups.every((pg: PlateGroup) => pg.plate !== ""); }); // ============================================================ // Subject 5: Report computeds — spot occupancy and leaderboard ordering // ============================================================ // Use groupSightingsByPlate to test the repeat-offender ordering logic directly. const sightingsForReport: Sighting[] = [ // Plate A: 3 sightings in spot 1 (most frequent) { id: "r1", spotNumber: "1", capturedAt: 1000, reportedBy: "X", image: { url: "", name: "" }, description: "", plateNumber: "AAA111", plateState: "CA", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, { id: "r2", spotNumber: "1", capturedAt: 2000, reportedBy: "X", image: { url: "", name: "" }, description: "", plateNumber: "AAA111", plateState: "CA", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, { id: "r3", spotNumber: "1", capturedAt: 3000, reportedBy: "X", image: { url: "", name: "" }, description: "", plateNumber: "AAA111", plateState: "CA", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, // Plate B: 2 sightings in spot 5 (less frequent) { id: "r4", spotNumber: "5", capturedAt: 4000, reportedBy: "X", image: { url: "", name: "" }, description: "", plateNumber: "BBB222", plateState: "CA", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, { id: "r5", spotNumber: "5", capturedAt: 5000, reportedBy: "X", image: { url: "", name: "" }, description: "", plateNumber: "BBB222", plateState: "CA", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, // Plate C: 1 sighting in spot 12 (unique, not a repeat) { id: "r6", spotNumber: "12", capturedAt: 6000, reportedBy: "X", image: { url: "", name: "" }, description: "", plateNumber: "CCC333", plateState: "CA", extractionPending: false, extractionError: "", humanCorrected: false, classification: "unknown", notes: "", }, ]; // Repeat plates: only AAA111 (3x) and BBB222 (2x), not CCC333 (1x) const assert_s5_repeat_groups = computed(() => { const groups = groupSightingsByPlate(sightingsForReport); const repeats = groups.filter((g: PlateGroup) => g.isRepeat); return len(repeats) === 2; }); // Leaderboard ordering: sorted by count descending → AAA111 first, BBB222 second const assert_s5_leaderboard_order = computed(() => { const groups = groupSightingsByPlate(sightingsForReport); const sorted = groups .filter((g: PlateGroup) => g.isRepeat) .sort((a: PlateGroup, b: PlateGroup) => b.count - a.count); return ( len(sorted) === 2 && sorted[0].plate === "AAA111" && sorted[0].count === 3 && sorted[1].plate === "BBB222" && sorted[1].count === 2 ); }); // Spot occupancy: spot 1 has 3 sightings, spot 5 has 2, spot 12 has 1 const assert_s5_spot_occupancy = computed(() => { const spot1 = sightingsForReport.filter((s) => s.spotNumber === "1"); const spot5 = sightingsForReport.filter((s) => s.spotNumber === "5"); const spot12 = sightingsForReport.filter((s) => s.spotNumber === "12"); return ( len(spot1) === 3 && len(spot5) === 2 && len(spot12) === 1 ); }); // ============================================================ // Subject 6: Admin gating // markVehicle / deleteSighting / saveGuest / assignToPerson are no-ops // without an active admin manager credential. // After enableAdminManager + togglePersonAdmin, they DO mutate. // ============================================================ const s6 = LotWatch({}); // Capture a sighting first (capture is NOT admin-gated) const action_s6_capture = action(() => { s6.captureSighting.send({ spotNumber: "1", image: fakeImage as never, description: "gated test car", plateNumber: "GATE01", plateState: "CA", notes: "", }); }); // Non-admin markVehicle — should be no-op const action_s6_mark_no_admin = action(() => { s6.markVehicle.send({ plateNumber: "GATE01", plateState: "CA", category: "offender", org: "nobody", }); }); const assert_s6_mark_no_admin_noop = computed(() => len(s6.knownVehicles) === 0 ); // Non-admin deleteSighting — should be no-op const action_s6_delete_no_admin = action(() => { const id = s6.sightings[0]?.id ?? ""; s6.deleteSighting.send({ id }); }); const assert_s6_delete_no_admin_noop = computed(() => len(s6.sightings) === 1 ); // Now establish admin: enableAdminManager makes the current user a manager const action_s6_enable_admin_manager = action(() => { s6.enableAdminManager.send(); }); // After enableAdminManager, Alice can be toggled as admin. // currentUserCanManageAdmins and currentPersonIsAdmin are internal computeds // not present in LotWatchOutput — verify the side effect instead: once // Alice is toggled as admin and then markVehicle is called, the registry // should still be empty because reporterName (blank) doesn't match "Alice". // This confirms that admin gating uses the reporter identity, not just any toggle. const action_s6_toggle_alice_admin = action(() => { s6.togglePersonAdmin.send({ name: "Alice" }); }); // After Alice is toggled admin, markVehicle called by non-matching reporter // (blank reporter) should still be a no-op for knownVehicles. const assert_s6_still_no_known_vehicles = computed(() => len(s6.knownVehicles) === 0 ); // ============================================================ // Subject 7: Classification priority (ours > offender > guest > unknown) // ============================================================ // When a plate is in both "ours" AND "offender" registry → should be "ours" const conflictOurs = [{ plateId: "BOTH01", plateState: "CA" }]; const conflictKnown: KnownVehicle[] = [ { plateNumber: "BOTH01", plateState: "CA", description: "", category: "offender", org: "test", label: "", }, ]; const assert_s7_ours_beats_offender = computed(() => classifyPlate("BOTH01", "CA", conflictOurs, conflictKnown) === "ours" ); // When a plate is in "offender" registry AND "guest" registry → offender wins const priorityKnown: KnownVehicle[] = [ { plateNumber: "PRIO01", plateState: "CA", description: "", category: "offender", org: "test", label: "", }, { plateNumber: "PRIO01", plateState: "CA", description: "", category: "guest", name: "Guest Person", org: "", label: "", }, ]; const assert_s7_offender_beats_guest = computed(() => classifyPlate("PRIO01", "CA", [], priorityKnown) === "offender" ); // Empty plate number → always unknown const assert_s7_empty_plate_unknown = computed(() => classifyPlate("", "CA", conflictOurs, conflictKnown) === "unknown" ); // ============================================================ // Subject 8: Admin gating — full matrix of curation actions // - saveGuest and assignToPerson no-op without an active admin // (s6 only covered markVehicle + delete; we add the missing two) // - Positive path: with the admin reporter set, ALL THREE curation // paths actually mutate (markVehicle, saveGuest, assignToPerson). // `assignToPerson` is the cross-pattern write into `people` — the // headline "that's 's car" UX — and previously had ZERO // test coverage. // ============================================================ const s8 = LotWatch({ // Seed Alice so openAssign has a name to default into the picker. people: [{ name: "Alice", vehicles: [] }], }); // Capture two sightings on different plates so markVehicle (offender) and // saveGuest (guest) write distinct entries we can count. const action_s8_capture_x = action(() => { s8.captureSighting.send({ spotNumber: "1", image: fakeImage as never, description: "X car", plateNumber: "X1", plateState: "CA", notes: "", }); }); const action_s8_capture_y = action(() => { s8.captureSighting.send({ spotNumber: "5", image: fakeImage as never, description: "Y car", plateNumber: "Y1", plateState: "CA", notes: "", }); }); // --- Negative: saveGuest without admin --- // openGuest sets guestTarget; saveGuest reads it. The admin check is the // ONLY thing that should block knownVehicles from gaining an entry. const action_s8_open_guest_no_admin = action(() => { const id = s8.sightings[1]?.id ?? ""; s8.openGuest.send({ id }); }); const action_s8_save_guest_no_admin = action(() => { s8.saveGuest.send(); }); const assert_s8_save_guest_no_admin_noop = computed(() => len(s8.knownVehicles) === 0 ); // --- Negative: assignToPerson without admin --- // openAssign sets assignTarget AND defaults assignPersonName to Alice // (since people=[Alice]). assignToPerson would then write into Alice's // vehicles, EXCEPT the admin gate blocks it — assert Alice is still empty. const action_s8_open_assign_no_admin = action(() => { const id = s8.sightings[0]?.id ?? ""; s8.openAssign.send({ id }); }); const action_s8_assign_no_admin = action(() => { s8.assignToPerson.send(); }); const assert_s8_assign_no_admin_noop = computed(() => { const alice = s8.people.find((p) => p.name === "Alice"); return alice !== undefined && (alice.vehicles ?? []).length === 0; }); // --- Establish admin: enable manager + toggle Alice + reporterName=Alice const action_s8_enable_manager = action(() => s8.enableAdminManager.send()); const action_s8_toggle_alice = action(() => { s8.togglePersonAdmin.send({ name: "Alice" }); }); const action_s8_set_reporter_alice = action(() => { s8.setReporterName.send({ name: "Alice" }); }); // --- Positive: markVehicle now mutates --- const action_s8_mark_x_offender = action(() => { s8.markVehicle.send({ plateNumber: "X1", plateState: "CA", category: "offender", org: "Local Butcher Shop", }); }); const assert_s8_mark_succeeds = computed(() => { const kvs = [...s8.knownVehicles]; return kvs.some((kv) => kv.plateNumber === "X1" && kv.category === "offender" ); }); // --- Positive: saveGuest now mutates (Y1 added as guest) --- const action_s8_open_guest_admin = action(() => { const id = s8.sightings[1]?.id ?? ""; s8.openGuest.send({ id }); }); const action_s8_save_guest_admin = action(() => { s8.saveGuest.send(); }); const assert_s8_save_guest_succeeds = computed(() => { const kvs = [...s8.knownVehicles]; return kvs.some((kv) => kv.plateNumber === "Y1" && kv.category === "guest"); }); // --- Positive: assignToPerson writes the plate into Alice's vehicles // This is the cross-pattern write — the headline UX. Without this // assertion, the feature is effectively untested. const action_s8_open_assign_admin = action(() => { const id = s8.sightings[0]?.id ?? ""; s8.openAssign.send({ id }); }); const action_s8_assign_admin = action(() => { s8.assignToPerson.send(); }); const assert_s8_assign_succeeds = computed(() => { const alice = s8.people.find((p) => p.name === "Alice"); if (!alice) return false; return (alice.vehicles ?? []).some((v) => v.plateId === "X1" && v.plateState === "CA" ); }); // ============================================================ // Test sequence // ============================================================ return { tests: [ // S1: Capture — plate normalization { assertion: assert_s1_empty }, { action: action_s1_capture_lowercase_plate }, { assertion: assert_s1_normalized_plate }, { action: action_s1_capture_second }, { assertion: assert_s1_two_sightings }, { assertion: assert_s1_second_plate }, // S2: Classification { assertion: assert_s2_ours_classification }, { assertion: assert_s2_offender_classification }, { assertion: assert_s2_guest_classification }, { assertion: assert_s2_unknown_classification }, { action: action_s2_capture_ours }, { action: action_s2_capture_offender }, { action: action_s2_capture_guest }, { action: action_s2_capture_unknown }, { assertion: assert_s2_four_sightings }, { assertion: assert_s2_ours_in_sightings }, // S3: markVehicle retro-classifies (pure function + admin gating no-op) { action: action_s3_capture_unknown }, { assertion: assert_s3_sighting_captured }, { assertion: assert_s3_initial_classification }, { assertion: assert_s3_retro_classified }, { action: action_s3_mark_no_admin }, { assertion: assert_s3_mark_no_admin_noop }, // S4: Dedup/grouping (pure function — no actions needed) { assertion: assert_s4_repeat_plate_group_count }, { assertion: assert_s4_only_one_group }, { assertion: assert_s4_blank_plate_not_grouped }, // S5: Report computeds (pure function — no actions needed) { assertion: assert_s5_repeat_groups }, { assertion: assert_s5_leaderboard_order }, { assertion: assert_s5_spot_occupancy }, // S6: Admin gating { action: action_s6_capture }, { action: action_s6_mark_no_admin }, { assertion: assert_s6_mark_no_admin_noop }, { action: action_s6_delete_no_admin }, { assertion: assert_s6_delete_no_admin_noop }, { action: action_s6_enable_admin_manager }, { action: action_s6_toggle_alice_admin }, { assertion: assert_s6_still_no_known_vehicles }, // S7: Classification priority { assertion: assert_s7_ours_beats_offender }, { assertion: assert_s7_offender_beats_guest }, { assertion: assert_s7_empty_plate_unknown }, // S8: Admin-gating full matrix + positive cross-pattern write { action: action_s8_capture_x }, { action: action_s8_capture_y }, // Negative: saveGuest no-admin { action: action_s8_open_guest_no_admin }, { action: action_s8_save_guest_no_admin }, { assertion: assert_s8_save_guest_no_admin_noop }, // Negative: assignToPerson no-admin { action: action_s8_open_assign_no_admin }, { action: action_s8_assign_no_admin }, { assertion: assert_s8_assign_no_admin_noop }, // Establish admin { action: action_s8_enable_manager }, { action: action_s8_toggle_alice }, { action: action_s8_set_reporter_alice }, // Positive: markVehicle mutates { action: action_s8_mark_x_offender }, { assertion: assert_s8_mark_succeeds }, // Positive: saveGuest mutates { action: action_s8_open_guest_admin }, { action: action_s8_save_guest_admin }, { assertion: assert_s8_save_guest_succeeds }, // Positive: assignToPerson writes into people (cross-pattern UX) { action: action_s8_open_assign_admin }, { action: action_s8_assign_admin }, { assertion: assert_s8_assign_succeeds }, ], s1, s2, s3, s6, s8, // TODO(cfc-schema-ref): the CFC schema-ref resolver warns about // unsupported/unresolved $ref(s) in this pattern's schemas (logger "cfc", // fail-closed). Fix the schema(s), then drop this opt-out. allowConsoleWarnings: true, }; });