Files
honeyDueKMP/iosApp/HoneyDueTests/DataManagerExtendedTests.swift
Trey t 9ececfa48a Wire onboarding task suggestions to backend, delete hardcoded catalog
Both "For You" and "Browse All" tabs are now fully server-driven on
iOS and Android. No on-device task list, no client-side scoring rules.
When the API fails the screen shows error + Retry + Skip so onboarding
can still complete on a flaky network.

Shared (KMM)
- TaskCreateRequest + TaskResponse carry templateId
- New BulkCreateTasksRequest/Response, TaskApi.bulkCreateTasks,
  APILayer.bulkCreateTasks (updates DataManager + TotalSummary)
- OnboardingViewModel: templatesGroupedState + loadTemplatesGrouped;
  createTasks(residenceId, requests) posts once via the bulk path
- Deleted regional-template plumbing: APILayer.getRegionalTemplates,
  OnboardingViewModel.loadRegionalTemplates, TaskTemplateApi.
  getTemplatesByRegion, TaskTemplate.regionId/regionName
- 5 new AnalyticsEvents constants for the onboarding funnel

Android (Compose)
- OnboardingFirstTaskContent rewritten against the server catalog;
  ~70 lines of hardcoded taskCategories gone. Loading / Error / Empty
  panes with Retry + Skip buttons. Category icons derived from name
  keywords, colours from a 5-value palette keyed by category id
- Browse selection carries template.id into the bulk request so
  task_template_id is populated server-side

iOS (SwiftUI)
- New OnboardingTasksViewModel (@MainActor ObservableObject) wrapping
  APILayer.shared for suggestions / grouped / bulk-submit with
  loading + error state (mirrors the TaskViewModel.swift pattern)
- OnboardingFirstTaskView rewritten: buildForYouSuggestions (130 lines)
  and fallbackCategories (68 lines) deleted; both tabs show the same
  error+skip UX as Android; ForYouSuggestion/SuggestionRelevance gone
- 5 new AnalyticsEvent cases with identical PostHog event names to
  the Kotlin constants so cross-platform funnels join cleanly
- Existing TaskCreateRequest / TaskResponse call sites in TaskCard,
  TasksSection, TaskFormView updated for the new templateId parameter

Docs
- CLAUDE.md gains an "Onboarding task suggestions (server-driven)"
  subsection covering the data flow, key files on both platforms,
  and the KotlinInt(int: template.id) wrapping requirement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:25:01 -05:00

365 lines
13 KiB
Swift

//
// DataManagerExtendedTests.swift
// honeyDueTests
//
// Extended unit tests covering TASK-005, TASK-012, THEME-001, TCOMP-003, and QA-002.
//
// IMPORTANT: DataManager-mutating suites are nested inside the DataLayerTests
// serialized parent (defined in DataLayerTests.swift) via extension, so ALL
// DataManager tests serialize together and avoid concurrent state interference.
//
import Testing
import Foundation
@testable import honeyDue
import ComposeApp
// MARK: - Extension of DataLayerTests (serialized parent in DataLayerTests.swift)
extension DataLayerTests {
// MARK: - TASK-005: Template Search Tests
@Suite struct TemplateSearchTests {
// Helper to build a TaskTemplate with the fields that searchTaskTemplates inspects.
// categoryId and frequencyId are nullable Int in Kotlin; pass nil directly.
private func makeTemplate(
id: Int32,
title: String,
description: String = "",
tags: [String] = []
) -> TaskTemplate {
TaskTemplate(
id: id,
title: title,
description: description,
categoryId: nil,
category: nil,
frequencyId: nil,
frequency: nil,
iconIos: "",
iconAndroid: "",
tags: tags,
displayOrder: 0,
isActive: true
)
}
@Test func searchWithSingleCharReturnsEmpty() {
DataManager.shared.clear()
DataManager.shared.setTaskTemplates(templates: [
makeTemplate(id: 1, title: "Appliance check")
])
let results = DataManager.shared.searchTaskTemplates(query: "A")
#expect(results.isEmpty)
DataManager.shared.clear()
}
@Test func searchWithTwoCharsMatchesTitles() {
DataManager.shared.clear()
DataManager.shared.setTaskTemplates(templates: [
makeTemplate(id: 1, title: "Plumbing repair"),
makeTemplate(id: 2, title: "HVAC inspection"),
makeTemplate(id: 3, title: "Lawn care")
])
let results = DataManager.shared.searchTaskTemplates(query: "Pl")
#expect(results.count == 1)
#expect(results.first?.title == "Plumbing repair")
DataManager.shared.clear()
}
@Test func searchIsCaseInsensitive() {
DataManager.shared.clear()
DataManager.shared.setTaskTemplates(templates: [
makeTemplate(id: 1, title: "Plumbing repair"),
makeTemplate(id: 2, title: "Electrical panel check")
])
let results = DataManager.shared.searchTaskTemplates(query: "plumbing")
#expect(results.count == 1)
#expect(results.first?.title == "Plumbing repair")
DataManager.shared.clear()
}
@Test func searchMatchesDescription() {
DataManager.shared.clear()
DataManager.shared.setTaskTemplates(templates: [
makeTemplate(id: 1, title: "Pipe maintenance", description: "Fix water leaks and pipe corrosion"),
makeTemplate(id: 2, title: "Roof inspection", description: "Check shingles and gutters")
])
let results = DataManager.shared.searchTaskTemplates(query: "water")
#expect(results.count == 1)
#expect(results.first?.title == "Pipe maintenance")
DataManager.shared.clear()
}
@Test func searchMatchesTags() {
DataManager.shared.clear()
DataManager.shared.setTaskTemplates(templates: [
makeTemplate(id: 1, title: "Air filter replacement", tags: ["hvac", "filter", "air quality"]),
makeTemplate(id: 2, title: "Lawn mowing", tags: ["landscaping", "outdoor"])
])
let results = DataManager.shared.searchTaskTemplates(query: "hvac")
#expect(results.count == 1)
#expect(results.first?.title == "Air filter replacement")
DataManager.shared.clear()
}
@Test func searchReturnsMaxTenResults() {
DataManager.shared.clear()
// Create 15 templates all matching "maintenance"
var templates: [TaskTemplate] = []
for i in 1...15 {
templates.append(makeTemplate(id: Int32(i), title: "maintenance task \(i)"))
}
DataManager.shared.setTaskTemplates(templates: templates)
let results = DataManager.shared.searchTaskTemplates(query: "maintenance")
#expect(results.count == 10)
DataManager.shared.clear()
}
@Test func searchNoMatchReturnsEmpty() {
DataManager.shared.clear()
DataManager.shared.setTaskTemplates(templates: [
makeTemplate(id: 1, title: "Plumbing repair"),
makeTemplate(id: 2, title: "Roof inspection")
])
let results = DataManager.shared.searchTaskTemplates(query: "xyz")
#expect(results.isEmpty)
DataManager.shared.clear()
}
@Test func emptyQueryReturnsEmpty() {
DataManager.shared.clear()
DataManager.shared.setTaskTemplates(templates: [
makeTemplate(id: 1, title: "Plumbing repair")
])
let results = DataManager.shared.searchTaskTemplates(query: "")
#expect(results.isEmpty)
DataManager.shared.clear()
}
}
// MARK: - TASK-012: Remove Task Cache Updates
@Suite struct RemoveTaskTests {
@Test func allTasksIsNilAfterClear() {
DataManager.shared.clear()
let value = DataManager.shared.allTasks.value
#expect(value == nil)
}
@Test func removeTaskOnNilAllTasksIsNoOp() {
// When allTasks is nil, removeTask should not crash and allTasks remains nil.
DataManager.shared.clear()
DataManager.shared.removeTask(taskId: 42)
let value = DataManager.shared.allTasks.value
#expect(value == nil)
DataManager.shared.clear()
}
@Test func tasksByResidenceIsEmptyAfterClear() {
// After clear, tasksByResidence is empty removeTask has no residence caches to update.
// Calling removeTask with no tasks cached should be a no-op (no crash, allTasks stays nil).
DataManager.shared.clear()
// removeTask with empty caches must not throw or crash
DataManager.shared.removeTask(taskId: 999)
// allTasks should remain nil after a no-op removeTask on empty state
#expect(DataManager.shared.allTasks.value == nil)
}
// NOTE: Full integration coverage for removeTask (removing from kanban columns and
// residence caches) is exercised in the UI test suite (TaskIntegrationTests) where
// real TaskColumnsResponse objects are constructed through the API layer.
// Constructing TaskColumnsResponse directly from Swift requires bridging complex
// Kotlin generics (Map<String,String> for icons) that are impractical in unit tests.
}
// MARK: - THEME-001: Theme Persistence Tests
@Suite struct ThemePersistenceTests {
@Test func defaultThemeIdIsDefault() {
// Explicitly set to "default" first since clear() does NOT reset themeId.
DataManager.shared.setThemeId(id: "default")
DataManager.shared.clear()
let themeId = DataManager.shared.themeId.value as! String
#expect(themeId == "default")
}
@Test func setThemeIdUpdatesValue() {
DataManager.shared.clear()
DataManager.shared.setThemeId(id: "ocean")
let themeId = DataManager.shared.themeId.value as! String
#expect(themeId == "ocean")
// Restore to default so other tests are unaffected
DataManager.shared.setThemeId(id: "default")
}
@Test func setThemeIdToMultipleThemes() {
DataManager.shared.clear()
DataManager.shared.setThemeId(id: "forest")
let intermediate = DataManager.shared.themeId.value as! String
#expect(intermediate == "forest")
DataManager.shared.setThemeId(id: "midnight")
let final_ = DataManager.shared.themeId.value as! String
#expect(final_ == "midnight")
// Restore
DataManager.shared.setThemeId(id: "default")
}
@Test func clearDoesNotResetTheme() {
// Per CLAUDE.md architecture, clear() does NOT reset themeId.
// The Kotlin source confirms _themeId is absent from the clear() method.
DataManager.shared.setThemeId(id: "ocean")
DataManager.shared.clear()
// Theme should be preserved after clear verify it is still a non-empty string.
let themeId = DataManager.shared.themeId.value as! String
#expect(!themeId.isEmpty)
// Restore
DataManager.shared.setThemeId(id: "default")
}
@Test func themeIdIsPreservedAsOceanAfterClear() {
// Stronger assertion: the exact value set before clear() survives clear().
DataManager.shared.setThemeId(id: "ocean")
DataManager.shared.clear()
let themeId = DataManager.shared.themeId.value as! String
#expect(themeId == "ocean")
// Restore
DataManager.shared.setThemeId(id: "default")
}
}
}
// MARK: - TCOMP-003: Task Completion Form Validation
// These tests use only ValidationHelpers (pure Swift, no DataManager mutation).
struct TaskCompletionValidationTests {
@Test func completedByFieldRequired() {
let result = ValidationHelpers.validateRequired("", fieldName: "Completed by")
#expect(!result.isValid)
#expect(result.errorMessage == "Completed by is required")
}
@Test func completedByFieldWithValuePasses() {
let result = ValidationHelpers.validateRequired("Trey Tartt", fieldName: "Completed by")
#expect(result.isValid)
#expect(result.errorMessage == nil)
}
@Test func completedByFieldWhitespaceOnlyFails() {
let result = ValidationHelpers.validateRequired(" ", fieldName: "Completed by")
#expect(!result.isValid)
#expect(result.errorMessage == "Completed by is required")
}
}
// MARK: - QA-002: JSON Unknown Fields Resilience
// Swift's JSONDecoder ignores unknown keys by default these tests confirm that behaviour
// holds for WidgetDataManager.WidgetTask, which uses a custom CodingKeys enum.
struct JSONUnknownFieldsResilienceTests {
@Test func widgetTaskDecodesWithExtraFields() throws {
let json = """
{
"id": 1,
"title": "Test",
"description": null,
"priority": "high",
"in_progress": false,
"due_date": null,
"category": "test",
"residence_name": "Home",
"is_overdue": false,
"is_due_within_7_days": false,
"is_due_8_to_30_days": false,
"unknown_field": "should be ignored",
"another_unknown": 42
}
""".data(using: .utf8)!
let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json)
#expect(task.id == 1)
#expect(task.title == "Test")
#expect(task.priority == "high")
#expect(task.isOverdue == false)
#expect(task.isDueWithin7Days == false)
#expect(task.isDue8To30Days == false)
#expect(task.residenceName == "Home")
}
@Test func widgetTaskIgnoresUnknownNestedObjects() throws {
let json = """
{
"id": 99,
"title": "Nested unknown test",
"description": "Some description",
"priority": "low",
"in_progress": true,
"due_date": "2026-03-01",
"category": "plumbing",
"residence_name": null,
"is_overdue": true,
"is_due_within_7_days": false,
"is_due_8_to_30_days": false,
"unknown_nested": {
"key1": "value1",
"key2": 123,
"key3": true
},
"unknown_array": [1, 2, 3]
}
""".data(using: .utf8)!
let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json)
#expect(task.id == 99)
#expect(task.title == "Nested unknown test")
#expect(task.description == "Some description")
#expect(task.inProgress == true)
#expect(task.dueDate == "2026-03-01")
#expect(task.category == "plumbing")
#expect(task.residenceName == nil)
#expect(task.isOverdue == true)
}
}