Files
honeyDueKMP/iosApp/HoneyDueTests/WidgetResidenceFilterTests.swift
Trey T 498e6b8064
Android UI Tests / ui-tests (pull_request) Has been cancelled
feat(widget): per-residence widget configuration (iOS, gitea#6)
Users with multiple residences can now pick which one a given home-
screen widget shows tasks for. Pinning two widgets — one per house —
lets each surface tasks for only that residence; users who keep the
configuration untouched continue to see all residences (the previous
default), so single-home users see no behavioural change.

Implementation (iOS only — Android Glance follow-up is scoped in the
issue):

* `ConfigurationAppIntent` (HoneyDue widget extension) gains an
  optional `@Parameter` of type `WidgetResidenceEntity`. `AppIntents`
  renders it as a residence picker in the widget edit sheet.
* `WidgetResidenceEntity` + `WidgetResidenceEntityQuery` resolve the
  user's residences from a new `widget_residences.json` sidecar in the
  App Group container (avoids a network call at config time).
* `WidgetDataManager.saveResidences(from:)` writes that sidecar from
  the main app whenever `DataManagerObservable.myResidences` updates.
  Logout clears it along with the rest of the widget cache.
* `WidgetDataManager.WidgetTask` + the widget extension's
  `CacheManager.CustomTask` both gain an optional `residence_id`
  field. Optional so older app builds that wrote pre-#6 widget cache
  continue to decode — those tasks pass through the filter for
  unscoped widgets and are hidden from scoped ones (safer than
  guessing).
* `CacheManager.getUpcomingTasks(forResidenceId:)` and the pure
  helper `WidgetDataManager.filterTasks(_:forResidenceId:)` apply the
  filter. `Provider.timeline` / `snapshot` read
  `configuration.residence?.intId` and pass it through.

Tests: new `WidgetResidenceFilterTests` (HoneyDueTests target, 5
cases) cover nil-passthrough, matching-id, no-match, missing-residence
on a task, and order preservation. All five green.

No Android changes in this commit — Glance widgets need a separate
configuration activity and an actionStartActivity wiring that's
non-trivial; tracking as a follow-up in the same issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:14:58 -05:00

91 lines
3.3 KiB
Swift

import XCTest
@testable import honeyDue
/// Tests for the per-residence widget filter added in gitea#6.
///
/// `WidgetDataManager.filterTasks(_:forResidenceId:)` is the pure
/// function the widget timeline provider calls when a configuration
/// intent has a residence selected. These tests guarantee the contract
/// stays stable: nil pass-through, matching id only matching tasks,
/// no match empty, missing residenceId on a task never leaks into
/// a residence-scoped widget.
final class WidgetResidenceFilterTests: XCTestCase {
private func makeTask(
id: Int,
residenceId: Int? = nil
) -> WidgetDataManager.WidgetTask {
WidgetDataManager.WidgetTask(
id: id,
title: "Task \(id)",
description: nil,
priority: nil,
inProgress: false,
dueDate: nil,
category: nil,
residenceId: residenceId,
residenceName: nil,
isOverdue: false,
isDueWithin7Days: false,
isDue8To30Days: false
)
}
func testNilResidenceReturnsAllTasks() {
// "All residences" config widget passes nil, gets every task.
let tasks = [
makeTask(id: 1, residenceId: 10),
makeTask(id: 2, residenceId: 20),
makeTask(id: 3, residenceId: nil),
]
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: nil)
XCTAssertEqual(result.map(\.id), [1, 2, 3])
}
func testMatchingResidenceKeepsOnlyMatchingTasks() {
let tasks = [
makeTask(id: 1, residenceId: 10),
makeTask(id: 2, residenceId: 20),
makeTask(id: 3, residenceId: 10),
makeTask(id: 4, residenceId: 30),
]
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10)
XCTAssertEqual(result.map(\.id), [1, 3])
}
func testUnknownResidenceReturnsEmpty() {
let tasks = [
makeTask(id: 1, residenceId: 10),
makeTask(id: 2, residenceId: 20),
]
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 999)
XCTAssertTrue(result.isEmpty)
}
func testNilResidenceIdOnTaskDoesNotMatchScopedConfiguration() {
// A task written by an older app build (no `residence_id` in JSON)
// must NOT leak into a residence-scoped widget we'd rather hide
// it than misattribute it to the wrong home.
let tasks = [
makeTask(id: 1, residenceId: 10),
makeTask(id: 2, residenceId: nil),
]
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10)
XCTAssertEqual(result.map(\.id), [1])
}
func testFilterPreservesInputOrder() {
// The filter is a pure subset op no sorting side effects.
// Timeline provider relies on this so its sort step (overdue
// first, then by due date) operates on already-filtered tasks.
let tasks = [
makeTask(id: 5, residenceId: 1),
makeTask(id: 3, residenceId: 1),
makeTask(id: 7, residenceId: 1),
makeTask(id: 1, residenceId: 2),
]
let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 1)
XCTAssertEqual(result.map(\.id), [5, 3, 7])
}
}