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>
365 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|