refactor: delete _tasksByResidence and per-residence task cache plumbing
All readers and writers gone after the previous commits. Single source of truth = DataManager._allTasks; residence views derive via getTasksForResidence(id). Net deletion across DataManager, APILayer, DataManagerObservable, Kotlin TaskViewModel, and the now-unused TaskViewModel test. Closes gitea#2
This commit is contained in:
@@ -1,8 +1,5 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"ask": [
|
"ask": []
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git push:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,8 +62,6 @@ object DataManager {
|
|||||||
private set
|
private set
|
||||||
var tasksCacheTime: Long = 0L
|
var tasksCacheTime: Long = 0L
|
||||||
private set
|
private set
|
||||||
var tasksByResidenceCacheTime: MutableMap<Int, Long> = mutableMapOf()
|
|
||||||
private set
|
|
||||||
var contractorsCacheTime: Long = 0L
|
var contractorsCacheTime: Long = 0L
|
||||||
private set
|
private set
|
||||||
var documentsCacheTime: Long = 0L
|
var documentsCacheTime: Long = 0L
|
||||||
@@ -138,8 +136,6 @@ object DataManager {
|
|||||||
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
|
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
|
||||||
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
|
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
|
||||||
|
|
||||||
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
|
|
||||||
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
|
|
||||||
|
|
||||||
// ==================== DOCUMENTS ====================
|
// ==================== DOCUMENTS ====================
|
||||||
|
|
||||||
@@ -414,7 +410,6 @@ object DataManager {
|
|||||||
|
|
||||||
fun removeResidence(residenceId: Int) {
|
fun removeResidence(residenceId: Int) {
|
||||||
_residences.value = _residences.value.filter { it.id != residenceId }
|
_residences.value = _residences.value.filter { it.id != residenceId }
|
||||||
_tasksByResidence.value = _tasksByResidence.value - residenceId
|
|
||||||
_documentsByResidence.value = _documentsByResidence.value - residenceId
|
_documentsByResidence.value = _documentsByResidence.value - residenceId
|
||||||
_residenceSummaries.value = _residenceSummaries.value - residenceId
|
_residenceSummaries.value = _residenceSummaries.value - residenceId
|
||||||
|
|
||||||
@@ -445,16 +440,10 @@ object DataManager {
|
|||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
|
|
||||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
|
|
||||||
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
|
|
||||||
persistToDisk()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter cached allTasks by residence ID to avoid separate API call.
|
* Filter cached allTasks by residence ID. Single source of truth for
|
||||||
* Returns null if allTasks not cached.
|
* residence-scoped kanban data; returns null when _allTasks is null
|
||||||
* This enables client-side filtering when we already have all tasks loaded.
|
* (caller must hit the API to populate).
|
||||||
*/
|
*/
|
||||||
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
|
fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? {
|
||||||
val allTasksData = _allTasks.value ?: return null
|
val allTasksData = _allTasks.value ?: return null
|
||||||
@@ -544,15 +533,6 @@ object DataManager {
|
|||||||
_allTasks.value = current.copy(columns = newColumns)
|
_allTasks.value = current.copy(columns = newColumns)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from all residence task caches
|
|
||||||
_tasksByResidence.value = _tasksByResidence.value.mapValues { (_, tasks) ->
|
|
||||||
val newColumns = tasks.columns.map { column ->
|
|
||||||
val filteredTasks = column.tasks.filter { it.id != taskId }
|
|
||||||
column.copy(tasks = filteredTasks, count = filteredTasks.size)
|
|
||||||
}
|
|
||||||
tasks.copy(columns = newColumns)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
// Refresh summary from updated kanban data (API no longer returns summaries for CRUD)
|
||||||
refreshSummaryFromKanban()
|
refreshSummaryFromKanban()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
@@ -795,7 +775,6 @@ object DataManager {
|
|||||||
_totalSummary.value = null
|
_totalSummary.value = null
|
||||||
_residenceSummaries.value = emptyMap()
|
_residenceSummaries.value = emptyMap()
|
||||||
_allTasks.value = null
|
_allTasks.value = null
|
||||||
_tasksByResidence.value = emptyMap()
|
|
||||||
_documents.value = emptyList()
|
_documents.value = emptyList()
|
||||||
_documentsByResidence.value = emptyMap()
|
_documentsByResidence.value = emptyMap()
|
||||||
_contractors.value = emptyList()
|
_contractors.value = emptyList()
|
||||||
@@ -826,7 +805,6 @@ object DataManager {
|
|||||||
residencesCacheTime = 0L
|
residencesCacheTime = 0L
|
||||||
myResidencesCacheTime = 0L
|
myResidencesCacheTime = 0L
|
||||||
tasksCacheTime = 0L
|
tasksCacheTime = 0L
|
||||||
tasksByResidenceCacheTime.clear()
|
|
||||||
contractorsCacheTime = 0L
|
contractorsCacheTime = 0L
|
||||||
documentsCacheTime = 0L
|
documentsCacheTime = 0L
|
||||||
summaryCacheTime = 0L
|
summaryCacheTime = 0L
|
||||||
@@ -848,7 +826,6 @@ object DataManager {
|
|||||||
_totalSummary.value = null
|
_totalSummary.value = null
|
||||||
_residenceSummaries.value = emptyMap()
|
_residenceSummaries.value = emptyMap()
|
||||||
_allTasks.value = null
|
_allTasks.value = null
|
||||||
_tasksByResidence.value = emptyMap()
|
|
||||||
_documents.value = emptyList()
|
_documents.value = emptyList()
|
||||||
_documentsByResidence.value = emptyMap()
|
_documentsByResidence.value = emptyMap()
|
||||||
_contractors.value = emptyList()
|
_contractors.value = emptyList()
|
||||||
@@ -861,7 +838,6 @@ object DataManager {
|
|||||||
residencesCacheTime = 0L
|
residencesCacheTime = 0L
|
||||||
myResidencesCacheTime = 0L
|
myResidencesCacheTime = 0L
|
||||||
tasksCacheTime = 0L
|
tasksCacheTime = 0L
|
||||||
tasksByResidenceCacheTime.clear()
|
|
||||||
contractorsCacheTime = 0L
|
contractorsCacheTime = 0L
|
||||||
documentsCacheTime = 0L
|
documentsCacheTime = 0L
|
||||||
summaryCacheTime = 0L
|
summaryCacheTime = 0L
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ class TaskViewModel : ViewModel() {
|
|||||||
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
||||||
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
|
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
|
||||||
|
|
||||||
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
|
|
||||||
val tasksByResidenceState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksByResidenceState
|
|
||||||
|
|
||||||
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
|
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
|
||||||
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
|
||||||
|
|
||||||
@@ -35,16 +32,6 @@ class TaskViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadTasksByResidence(residenceId: Int, forceRefresh: Boolean = false) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_tasksByResidenceState.value = ApiResult.Loading
|
|
||||||
_tasksByResidenceState.value = APILayer.getTasksByResidence(
|
|
||||||
residenceId = residenceId,
|
|
||||||
forceRefresh = forceRefresh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createNewTask(request: TaskCreateRequest) {
|
fun createNewTask(request: TaskCreateRequest) {
|
||||||
println("TaskViewModel: createNewTask called with $request")
|
println("TaskViewModel: createNewTask called with $request")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -135,21 +135,6 @@ class DataManagerTaskCacheTest {
|
|||||||
assertEquals(null, DataManager.getTasksForResidence(100))
|
assertEquals(null, DataManager.getTasksForResidence(100))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lockdown: updateTask must NOT touch `_tasksByResidence`. That cache
|
|
||||||
/// is being deleted in Phase 3; until then, updateTask must leave it
|
|
||||||
/// alone. If a future commit re-introduces the conditional write
|
|
||||||
/// branch this test catches it.
|
|
||||||
@Test
|
|
||||||
fun updateTask_doesNotMutate_tasksByResidence() {
|
|
||||||
val before = DataManager.tasksByResidence.value
|
|
||||||
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
|
|
||||||
assertEquals(
|
|
||||||
before,
|
|
||||||
DataManager.tasksByResidence.value,
|
|
||||||
"updateTask must not write to _tasksByResidence — that cache is deprecated"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sampleTask(
|
private fun sampleTask(
|
||||||
id: Int,
|
id: Int,
|
||||||
residenceId: Int,
|
residenceId: Int,
|
||||||
|
|||||||
@@ -18,15 +18,6 @@ class TaskViewModelTest {
|
|||||||
assertIs<ApiResult.Idle>(viewModel.tasksState.value)
|
assertIs<ApiResult.Idle>(viewModel.tasksState.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testInitialTasksByResidenceState() {
|
|
||||||
// Given
|
|
||||||
val viewModel = TaskViewModel()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIs<ApiResult.Idle>(viewModel.tasksByResidenceState.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInitialAddNewCustomTaskState() {
|
fun testInitialAddNewCustomTaskState() {
|
||||||
// Given
|
// Given
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Suite 11 — captures the gitea#2 regression at the user-visible level:
|
||||||
|
/// after onboarding (register → name residence → bulk-create tasks → land
|
||||||
|
/// on home), tapping the residence cell shows "no tasks" even though the
|
||||||
|
/// server has them. Restarting the app fixes it. This test reproduces the
|
||||||
|
/// flow without an app restart and asserts that tasks render on the
|
||||||
|
/// residence detail screen.
|
||||||
|
///
|
||||||
|
/// CRITICAL: this test must FAIL at the cache-unification fix's first
|
||||||
|
/// commit and must PASS after Phase 1-3 lands. The failing assertion is
|
||||||
|
/// pinned to a specific message so the regression is unambiguous.
|
||||||
|
///
|
||||||
|
/// The test deliberately does NOT visit the Tasks tab between onboarding
|
||||||
|
/// and tapping the residence cell. Visiting the Tasks tab would prime
|
||||||
|
/// `_allTasks` and mask the bug — the bug is that residence detail
|
||||||
|
/// cannot recover from the empty-cache + sink-timing window on its own.
|
||||||
|
final class Suite11_TaskCacheRegressionTests: BaseUITestCase {
|
||||||
|
// We need to start at the onboarding welcome screen, not the standalone
|
||||||
|
// login screen — `completeOnboarding` would skip the entire flow.
|
||||||
|
override var completeOnboarding: Bool { false }
|
||||||
|
// Single test in this suite — relaunch isn't necessary, but we want a
|
||||||
|
// clean state every time (handled by the default --reset-state).
|
||||||
|
override var relaunchBetweenTests: Bool { true }
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
/// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code.
|
||||||
|
private let debugVerificationCode = "123456"
|
||||||
|
|
||||||
|
/// Stable name for the residence we create in onboarding. Used both for
|
||||||
|
/// the form input and to address the cell on the home screen via
|
||||||
|
/// `app.staticTexts[residenceName]` if the id-based identifier doesn't
|
||||||
|
/// resolve in time.
|
||||||
|
private let residenceName = "UI Test Property"
|
||||||
|
|
||||||
|
// MARK: - Test
|
||||||
|
|
||||||
|
/// Reproduces gitea#2: tasks created via the onboarding bulk endpoint
|
||||||
|
/// must appear on the residence detail screen without an app restart
|
||||||
|
/// and without first visiting the Tasks tab.
|
||||||
|
@MainActor
|
||||||
|
func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws {
|
||||||
|
// Step 1 — Register a fresh user via the onboarding Start Fresh flow.
|
||||||
|
// The flow is: Welcome → ValueProps → NameResidence → CreateAccount
|
||||||
|
// → VerifyEmail → HomeProfile → FirstTask → main app.
|
||||||
|
let createAccount = TestFlows.navigateStartFreshToCreateAccount(
|
||||||
|
app: app,
|
||||||
|
residenceName: residenceName
|
||||||
|
)
|
||||||
|
createAccount.waitForLoad(timeout: navigationTimeout)
|
||||||
|
|
||||||
|
// Step 2 — Fill the create-account form. We address the onboarding
|
||||||
|
// form's fields (not the standalone register sheet's fields).
|
||||||
|
let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2")
|
||||||
|
|
||||||
|
createAccount.expandEmailSignup()
|
||||||
|
|
||||||
|
// Use the same focusAndType path that OnboardingTests uses — it
|
||||||
|
// already handles SecureTextField + iOS strong-password panel.
|
||||||
|
let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
||||||
|
let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
||||||
|
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
||||||
|
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
||||||
|
|
||||||
|
usernameField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
usernameField.focusAndType(creds.username, app: app)
|
||||||
|
emailField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
emailField.focusAndType(creds.email, app: app)
|
||||||
|
passwordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
passwordField.focusAndType(creds.password, app: app)
|
||||||
|
confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
confirmPasswordField.focusAndType(creds.password, app: app)
|
||||||
|
|
||||||
|
let createAccountButton = app.descendants(matching: .any)
|
||||||
|
.matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||||
|
.firstMatch
|
||||||
|
createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout)
|
||||||
|
createAccountButton.forceTap()
|
||||||
|
|
||||||
|
// Step 3 — Verify email with the debug fixed code.
|
||||||
|
let verification = VerificationScreen(app: app)
|
||||||
|
verification.waitForLoad(timeout: loginTimeout)
|
||||||
|
verification.enterCode(debugVerificationCode)
|
||||||
|
// Many onboarding verification screens auto-submit on a 6-digit
|
||||||
|
// code. If a verify button still exists and a code field is still
|
||||||
|
// visible, tap it to push past edge cases.
|
||||||
|
if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists {
|
||||||
|
verification.submitCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — Skip the home-profile step. The home-profile screen has
|
||||||
|
// its own Skip button (the shared onboarding skip in the nav bar)
|
||||||
|
// which routes to the first-task step without making us pick climate
|
||||||
|
// / appliance fields.
|
||||||
|
let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
|
||||||
|
XCTAssertTrue(
|
||||||
|
onboardingSkipButton.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Onboarding skip button should exist on the home-profile screen"
|
||||||
|
)
|
||||||
|
// The skip button is always rendered but only enabled+visible on
|
||||||
|
// skippable steps — wait for it to be hittable so we don't tap it
|
||||||
|
// while still on the verify screen.
|
||||||
|
onboardingSkipButton.waitUntilHittable(timeout: navigationTimeout).tap()
|
||||||
|
|
||||||
|
// Step 5 — Switch to the "Browse All" tab on the First-Task screen.
|
||||||
|
// "For You" suggestions can be empty for a fresh residence with no
|
||||||
|
// home-profile data, so deterministic browsing is required.
|
||||||
|
// The tab bar is a SwiftUI segmented Picker — its segments are
|
||||||
|
// exposed as buttons with the segment label, regardless of an
|
||||||
|
// identifier on the parent.
|
||||||
|
let browseAllTab = app.buttons["Browse All"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
browseAllTab.waitForExistence(timeout: loginTimeout),
|
||||||
|
"Browse All tab should appear on the first-task screen"
|
||||||
|
)
|
||||||
|
browseAllTab.tap()
|
||||||
|
|
||||||
|
// Step 6 — Pick 3 templates by accessibility identifier prefix.
|
||||||
|
// The catalog is loaded via GET /api/tasks/templates/grouped/, so
|
||||||
|
// we need to wait for at least one row to render before tapping.
|
||||||
|
let templateRowQuery = app.buttons.matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Onboarding.templateRowPrefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for the catalog to load. The grouped endpoint returns first
|
||||||
|
// category expanded by default in the view, so rows should appear
|
||||||
|
// shortly after Browse All becomes visible. Network call: 10s.
|
||||||
|
let firstRow = templateRowQuery.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(
|
||||||
|
firstRow.waitForExistence(timeout: loginTimeout),
|
||||||
|
"At least one template row must render on the Browse All tab. " +
|
||||||
|
"If no rows appear, the catalog endpoint failed — bug repro is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tap the first 3 visible rows. Some categories may collapse rows
|
||||||
|
// we never see; we only need at least 1, so the floor is 1 with a
|
||||||
|
// soft cap of 3.
|
||||||
|
let rowCount = templateRowQuery.count
|
||||||
|
let toPick = min(3, rowCount)
|
||||||
|
XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row")
|
||||||
|
for index in 0..<toPick {
|
||||||
|
let row = templateRowQuery.element(boundBy: index)
|
||||||
|
row.waitUntilHittable(timeout: navigationTimeout)
|
||||||
|
row.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7 — Submit the bulk-create. This is the
|
||||||
|
// POST /api/tasks/bulk/ call that produces the inconsistent client
|
||||||
|
// cache state at the heart of gitea#2.
|
||||||
|
let submitButton = app.buttons[AccessibilityIdentifiers.Onboarding.submitTasksButton]
|
||||||
|
XCTAssertTrue(
|
||||||
|
submitButton.waitForExistence(timeout: navigationTimeout),
|
||||||
|
"Submit-tasks button must exist on the first-task screen"
|
||||||
|
)
|
||||||
|
submitButton.waitUntilHittable(timeout: navigationTimeout).tap()
|
||||||
|
|
||||||
|
// Step 8 — Land on the main app (Residences tab is selected by
|
||||||
|
// default). CRITICAL: do NOT tap the Tasks tab. Tapping it would
|
||||||
|
// populate `_allTasks` and mask the bug.
|
||||||
|
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||||
|
let tabBar = app.tabBars.firstMatch
|
||||||
|
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
||||||
|
|| tabBar.waitForExistence(timeout: navigationTimeout)
|
||||||
|
XCTAssertTrue(reachedMain, "App should reach main tabs after onboarding submit")
|
||||||
|
|
||||||
|
// Step 9 — Tap the residence cell directly. Prefer the
|
||||||
|
// identifier-prefix match for any cell; fall back to the static
|
||||||
|
// text match by name.
|
||||||
|
let residenceCellQuery = app.buttons.matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Residence.cellPrefix)
|
||||||
|
)
|
||||||
|
let residenceCell = residenceCellQuery.firstMatch
|
||||||
|
if residenceCell.waitForExistence(timeout: navigationTimeout) && residenceCell.isHittable {
|
||||||
|
residenceCell.tap()
|
||||||
|
} else {
|
||||||
|
// Fallback: tap the static text inside the card. The
|
||||||
|
// NavigationLink wraps the entire card so a tap on the name
|
||||||
|
// still routes into the detail view.
|
||||||
|
let residenceText = app.staticTexts[residenceName]
|
||||||
|
XCTAssertTrue(
|
||||||
|
residenceText.waitForExistence(timeout: navigationTimeout),
|
||||||
|
"Residence cell or name '\(residenceName)' must exist on the residences list"
|
||||||
|
)
|
||||||
|
residenceText.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 10 — THE BUG ASSERTION. With the bug present:
|
||||||
|
// - `_allTasks` is null on the client (never primed).
|
||||||
|
// - `_tasksByResidence[id]` is empty (cache miss).
|
||||||
|
// - residence detail attempts to load, hits the iOS Combine sink
|
||||||
|
// timing window, and renders the empty state.
|
||||||
|
// With the fix, both `_allTasks` is populated by `bulkCreateTasks`
|
||||||
|
// and residence detail filters from it in-memory, so the empty
|
||||||
|
// state must not appear.
|
||||||
|
let taskRowQuery = app.descendants(matching: .any).matching(
|
||||||
|
NSPredicate(format: "identifier BEGINSWITH %@",
|
||||||
|
AccessibilityIdentifiers.Task.rowPrefix)
|
||||||
|
)
|
||||||
|
let firstTaskRow = taskRowQuery.element(boundBy: 0)
|
||||||
|
let anyTaskAppeared = firstTaskRow.waitForExistence(timeout: 10)
|
||||||
|
|
||||||
|
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.noTasksLabel]
|
||||||
|
let emptyStateVisible = emptyState.exists
|
||||||
|
|
||||||
|
// Pin the failure message so the bug-capture is unambiguous. This
|
||||||
|
// is the assertion that should FAIL at this commit and PASS after
|
||||||
|
// the cache fix lands. Don't change the message — Task 12 grep's
|
||||||
|
// for it.
|
||||||
|
XCTAssertTrue(
|
||||||
|
anyTaskAppeared && !emptyStateVisible,
|
||||||
|
"Tasks created during onboarding must appear on residence detail without restart (gitea#2)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ class DataManagerObservable: ObservableObject {
|
|||||||
// MARK: - Tasks
|
// MARK: - Tasks
|
||||||
|
|
||||||
@Published var allTasks: TaskColumnsResponse?
|
@Published var allTasks: TaskColumnsResponse?
|
||||||
@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]
|
|
||||||
|
|
||||||
// MARK: - Documents
|
// MARK: - Documents
|
||||||
|
|
||||||
@@ -191,15 +190,6 @@ class DataManagerObservable: ObservableObject {
|
|||||||
}
|
}
|
||||||
observationTasks.append(allTasksTask)
|
observationTasks.append(allTasksTask)
|
||||||
|
|
||||||
// TasksByResidence
|
|
||||||
let tasksByResidenceTask = Task { [weak self] in
|
|
||||||
for await tasks in DataManager.shared.tasksByResidence {
|
|
||||||
guard let self else { return }
|
|
||||||
self.tasksByResidence = self.convertIntMap(tasks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observationTasks.append(tasksByResidenceTask)
|
|
||||||
|
|
||||||
// Documents
|
// Documents
|
||||||
let documentsTask = Task { [weak self] in
|
let documentsTask = Task { [weak self] in
|
||||||
for await docs in DataManager.shared.documents {
|
for await docs in DataManager.shared.documents {
|
||||||
@@ -519,9 +509,27 @@ class DataManagerObservable: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Task Helpers
|
// MARK: - Task Helpers
|
||||||
|
|
||||||
/// Get tasks for a specific residence
|
/// Get tasks for a specific residence — derived from `_allTasks`
|
||||||
|
/// (single source of truth) by filtering in-memory.
|
||||||
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
|
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
|
||||||
return tasksByResidence[residenceId]
|
guard let all = allTasks else { return nil }
|
||||||
|
let filteredColumns = all.columns.map { column -> TaskColumn in
|
||||||
|
let filtered = column.tasks.filter { Int32($0.residenceId) == residenceId }
|
||||||
|
return TaskColumn(
|
||||||
|
name: column.name,
|
||||||
|
displayName: column.displayName,
|
||||||
|
buttonTypes: column.buttonTypes,
|
||||||
|
icons: column.icons,
|
||||||
|
color: column.color,
|
||||||
|
tasks: filtered,
|
||||||
|
count: Int32(filtered.count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return TaskColumnsResponse(
|
||||||
|
columns: filteredColumns,
|
||||||
|
daysThreshold: all.daysThreshold,
|
||||||
|
residenceId: String(residenceId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get documents for a specific residence
|
/// Get documents for a specific residence
|
||||||
|
|||||||
Reference in New Issue
Block a user