/// /** * Habit Tracker Pattern Tests * * Tests core functionality: * - Initial state * - Adding habits (with validation) * - Toggling habit completion * - Deleting habits * - Default values * * Run: deno task ct test packages/patterns/habit-tracker/habit-tracker.test.tsx --verbose * * NOTE: Uses .filter(() => true).length instead of .length directly due to * a reactivity tracking bug where direct .length access doesn't register * dependencies. See packages/patterns/gideon-tests/array-length-repro.test.tsx */ import { action, computed, pattern } from "commontools"; import HabitTracker from "./habit-tracker.tsx"; import type { Habit, HabitLog } from "./schemas.tsx"; // Helper to get array length with proper reactivity tracking const len = (arr: T[]): number => arr.filter(() => true).length; export default pattern(() => { const subject = HabitTracker({ habits: [], logs: [] }); // === Actions === const action_add_exercise = action(() => { subject.addHabit.send({ name: "Exercise", icon: "🏃" }); }); const action_add_read = action(() => { subject.addHabit.send({ name: "Read", icon: "📚" }); }); const action_add_empty_name = action(() => { subject.addHabit.send({ name: " ", icon: "❌" }); }); const action_add_with_default_icon = action(() => { subject.addHabit.send({ name: "Meditate", icon: "" }); }); const action_toggle_exercise = action(() => { subject.toggleHabit.send({ habitName: "Exercise" }); }); const action_toggle_nonexistent = action(() => { subject.toggleHabit.send({ habitName: "NonExistent" }); }); const action_delete_read = action(() => { subject.deleteHabit.send({ habit: { name: "Read", icon: "📚", color: "#3b82f6" }, }); }); const action_delete_nonexistent = action(() => { subject.deleteHabit.send({ habit: { name: "NonExistent", icon: "?", color: "#000" }, }); }); // === Assertions === // Initial state const assert_initial_no_habits = computed( () => len(subject.habits) === 0, ); const assert_initial_no_logs = computed( () => len(subject.logs) === 0, ); const assert_today_date_format = computed(() => { const dateRegex = /^\d{4}-\d{2}-\d{2}$/; return dateRegex.test(subject.todayDate); }); // After adding first habit const assert_one_habit = computed( () => len(subject.habits) === 1, ); const assert_exercise_name = computed( () => subject.habits[0]?.name === "Exercise", ); const assert_exercise_icon = computed( () => subject.habits[0]?.icon === "🏃", ); const assert_exercise_default_color = computed( () => subject.habits[0]?.color === "#3b82f6", ); // Empty name should not add habit const assert_still_one_habit_after_empty = computed( () => len(subject.habits) === 1, ); // Default icon when empty string provided const assert_two_habits = computed( () => len(subject.habits) === 2, ); const assert_meditate_default_icon = computed(() => { const meditate = subject.habits.find((h: Habit) => h.name === "Meditate"); return meditate?.icon === "✓"; }); // After toggling habit (creates log) const assert_one_log = computed( () => len(subject.logs) === 1, ); const assert_log_completed = computed(() => { const log = subject.logs.find((l: HabitLog) => l.habitName === "Exercise"); return log?.completed === true; }); const assert_log_habitName = computed( () => subject.logs[0]?.habitName === "Exercise", ); const assert_log_date_is_today = computed( () => subject.logs[0]?.date === subject.todayDate, ); // Toggling nonexistent habit should not create log const assert_still_one_log_after_nonexistent = computed( () => len(subject.logs) === 1, ); // After toggling again (uncompletes) const assert_log_uncompleted = computed(() => { const log = subject.logs.find((l: HabitLog) => l.habitName === "Exercise"); return log?.completed === false; }); const assert_still_one_log_after_toggle = computed( () => len(subject.logs) === 1, ); // After adding second habit const assert_three_habits = computed( () => len(subject.habits) === 3, ); const assert_read_exists = computed( () => subject.habits.some((h: Habit) => h.name === "Read"), ); // After deleting habit const assert_two_habits_after_delete = computed( () => len(subject.habits) === 2, ); const assert_read_deleted = computed( () => !subject.habits.some((h: Habit) => h.name === "Read"), ); const assert_exercise_remains = computed( () => subject.habits.some((h: Habit) => h.name === "Exercise"), ); // Deleting nonexistent should not change count const assert_still_two_habits = computed( () => len(subject.habits) === 2, ); return { tests: [ // Initial state { assertion: assert_initial_no_habits }, { assertion: assert_initial_no_logs }, { assertion: assert_today_date_format }, // Add first habit { action: action_add_exercise }, { assertion: assert_one_habit }, { assertion: assert_exercise_name }, { assertion: assert_exercise_icon }, { assertion: assert_exercise_default_color }, // Empty name rejected { action: action_add_empty_name }, { assertion: assert_still_one_habit_after_empty }, // Default icon applied { action: action_add_with_default_icon }, { assertion: assert_two_habits }, { assertion: assert_meditate_default_icon }, // Toggle creates log { action: action_toggle_exercise }, { assertion: assert_one_log }, { assertion: assert_log_completed }, { assertion: assert_log_habitName }, { assertion: assert_log_date_is_today }, // Toggle nonexistent habit { action: action_toggle_nonexistent }, { assertion: assert_still_one_log_after_nonexistent }, // Toggle again uncompletes { action: action_toggle_exercise }, { assertion: assert_still_one_log_after_toggle }, { assertion: assert_log_uncompleted }, // Add and delete habit { action: action_add_read }, { assertion: assert_three_habits }, { assertion: assert_read_exists }, { action: action_delete_read }, { assertion: assert_two_habits_after_delete }, { assertion: assert_read_deleted }, { assertion: assert_exercise_remains }, // Delete nonexistent habit { action: action_delete_nonexistent }, { assertion: assert_still_two_habits }, ], subject, }; });