Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Sources/UI/Settings/HomeRootAlertPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

/// One of the Home surface's three independent alert states.
///
/// They are presented through a single `.alert(item:)` because several legacy
/// `.alert(item:)` stacked on one SwiftUI view shadow all but the last.
enum HomeRootAlertSlot: String, Equatable {
case deleteConfirmation
case deleteFailure
case audioRetention
}

/// Which of the three Home alert states are currently set.
struct HomeRootAlertStates: Equatable {
var hasDeleteConfirmation: Bool
var hasDeleteFailure: Bool
var hasAudioRetention: Bool
}

/// Routing for the single Home alert presenter. Kept Foundation-pure so the
/// priority and dismissal rules can be unit-tested without SwiftUI.
enum HomeRootAlertPolicy {
/// The alert that should present, in priority order, when more than one
/// state is set. Only one is set in the common case; the order keeps the
/// shared presenter deterministic.
///
/// This is also the slot the dismissal path must clear, and *only* that
/// slot: a confirm action can raise a follow-up alert (for example a delete
/// failure) before SwiftUI writes nil to dismiss the confirmation. Because
/// the confirmation outranks the failure here, dismissal clears the
/// confirmation and the freshly-set failure survives to present next. If
/// dismissal cleared every state instead, that failure would be wiped before
/// it could appear.
static func activeSlot(_ states: HomeRootAlertStates) -> HomeRootAlertSlot? {
if states.hasDeleteConfirmation { return .deleteConfirmation }
if states.hasDeleteFailure { return .deleteFailure }
if states.hasAudioRetention { return .audioRetention }
return nil
}
}
51 changes: 35 additions & 16 deletions Sources/UI/Settings/TranscriptedSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1246,26 +1246,42 @@ struct TranscriptedSettingsView: View {
}
}

/// Whichever Home alert state is currently set, in presentation priority order.
/// At most one is non-nil at a time in practice.
/// Snapshot of which Home alert states are currently set, fed to
/// `HomeRootAlertPolicy` so presentation priority and dismissal stay in sync.
private var rootAlertStates: HomeRootAlertStates {
HomeRootAlertStates(
hasDeleteConfirmation: homeDeleteConfirmation != nil,
hasDeleteFailure: homeDeleteFailure != nil,
hasAudioRetention: pendingAudioRetentionWindow != nil
)
}

/// Whichever Home alert should present, in `HomeRootAlertPolicy` priority order.
private var activeRootAlert: RootAlert? {
if let confirmation = homeDeleteConfirmation { return .deleteConfirmation(confirmation) }
if let failure = homeDeleteFailure { return .deleteFailure(failure) }
if let window = pendingAudioRetentionWindow { return .audioRetention(window) }
return nil
switch HomeRootAlertPolicy.activeSlot(rootAlertStates) {
case .deleteConfirmation: return homeDeleteConfirmation.map(RootAlert.deleteConfirmation)
case .deleteFailure: return homeDeleteFailure.map(RootAlert.deleteFailure)
case .audioRetention: return pendingAudioRetentionWindow.map(RootAlert.audioRetention)
case .none: return nil
}
}

/// Binds the single alert presenter to the three underlying states. Dismissal
/// clears all of them (only one is ever set), so call sites keep setting their
/// own `@State` directly.
/// Binds the single alert presenter to the three underlying states. On
/// dismissal it clears only the alert being dismissed, not all three: a
/// confirm action can set a follow-up alert (e.g. a delete failure) before
/// SwiftUI writes nil, and clearing everything would wipe it before it can
/// present. Call sites keep setting their own `@State` directly.
private var rootAlertBinding: Binding<RootAlert?> {
Binding(
get: { activeRootAlert },
set: { newValue in
guard newValue == nil else { return }
homeDeleteConfirmation = nil
homeDeleteFailure = nil
pendingAudioRetentionWindow = nil
switch activeRootAlert {
case .deleteConfirmation: homeDeleteConfirmation = nil
case .deleteFailure: homeDeleteFailure = nil
case .audioRetention: pendingAudioRetentionWindow = nil
case .none: break
}
}
)
}
Expand Down Expand Up @@ -1401,10 +1417,13 @@ struct TranscriptedSettingsView: View {

private func presentHomeActionFailure(title: String, message: String) {
NSSound.beep()
homeDeleteFailure = HomeDeleteFailure(
title: title,
message: message
)
// Defer to the next runloop turn so a failure raised synchronously inside
// an alert's confirm action lands after that alert finishes dismissing.
// SwiftUI won't present a second alert during the first one's dismissal,
// and the shared binding clears the dismissed alert on the same turn.
DispatchQueue.main.async {
homeDeleteFailure = HomeDeleteFailure(title: title, message: message)
}
}

private func retryFailedMeeting(_ item: MeetingSessionController.FailedMeetingItem) {
Expand Down
1 change: 1 addition & 0 deletions Tests/FastTests.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ MeetingDurationFormatterTests.swift:testMeetingDurationFormatter
LiveTranscriptPlainTextRendererTests.swift:testLiveTranscriptPlainTextRenderer
MeetingStartFailureClassifierTests.swift:testMeetingStartFailureClassifier
HomePresentationTests.swift:testHomePresentation
HomeRootAlertPolicyTests.swift:testHomeRootAlertPolicy
STTRouterPolicyTests.swift:testSTTRouterPolicy
ContextCaptureEnginePolicyTests.swift:testContextCaptureEnginePolicy
JSONLWriterTests.swift:testJSONLWriter
Expand Down
67 changes: 67 additions & 0 deletions Tests/HomeRootAlertPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Foundation

func testHomeRootAlertPolicy() {
func states(
confirmation: Bool = false,
failure: Bool = false,
retention: Bool = false
) -> HomeRootAlertStates {
HomeRootAlertStates(
hasDeleteConfirmation: confirmation,
hasDeleteFailure: failure,
hasAudioRetention: retention
)
}

runSuite("HomeRootAlertPolicy presents nothing when no state is set") {
assertNil(
HomeRootAlertPolicy.activeSlot(states()),
"no alert should present when none of the three states is set"
)
}

runSuite("HomeRootAlertPolicy presents whichever single state is set") {
assertEqual(
HomeRootAlertPolicy.activeSlot(states(confirmation: true)),
.deleteConfirmation,
"a set delete confirmation should present"
)
assertEqual(
HomeRootAlertPolicy.activeSlot(states(failure: true)),
.deleteFailure,
"a set delete failure should present"
)
assertEqual(
HomeRootAlertPolicy.activeSlot(states(retention: true)),
.audioRetention,
"a set audio-retention prompt should present"
)
}

runSuite("HomeRootAlertPolicy dismisses the confirmation first so a follow-up failure survives") {
// Regression guard: a confirm action (e.g. dictation delete throws, or a
// failed-meeting delete returns false) sets homeDeleteFailure *before*
// SwiftUI writes nil to dismiss the confirmation. The confirmation
// outranks the failure, so dismissal clears the confirmation and the
// failure remains set to present next. If the binding cleared all three
// states instead, the failure alert would never appear.
assertEqual(
HomeRootAlertPolicy.activeSlot(states(confirmation: true, failure: true)),
.deleteConfirmation,
"with both a confirmation and a freshly-raised failure set, the confirmation is dismissed first and the failure is left to present"
)
}

runSuite("HomeRootAlertPolicy keeps a deterministic order across all states") {
assertEqual(
HomeRootAlertPolicy.activeSlot(states(confirmation: true, failure: true, retention: true)),
.deleteConfirmation,
"confirmation outranks failure and audio retention"
)
assertEqual(
HomeRootAlertPolicy.activeSlot(states(failure: true, retention: true)),
.deleteFailure,
"failure outranks audio retention"
)
}
}
10 changes: 10 additions & 0 deletions Tests/UIAutomationSurfaceContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ func testUIAutomationSurfaceContract() {
|| settingsSource.contains(".alert(item: $pendingAudioRetentionWindow)"),
"Home alerts must not be re-stacked as separate `.alert(item:)` modifiers — stacked legacy alerts shadow all but the last"
)
// The shared binding must dismiss only the active alert via
// HomeRootAlertPolicy, never clear all three. A confirm action can raise
// a follow-up failure alert before SwiftUI writes nil; clearing
// everything would wipe it before it presents. (HomeRootAlertPolicyTests
// covers the priority/dismissal behavior directly.)
assertTrue(
settingsSource.contains("HomeRootAlertPolicy.activeSlot")
&& settingsSource.contains("switch activeRootAlert"),
"the shared alert binding should clear only the dismissed alert through HomeRootAlertPolicy, not reset all three states"
)

// Row-interaction affordances from fix/home-row-actions, which have no
// behavioral coverage in the fast suite (it greps source, never runs the
Expand Down
1 change: 1 addition & 0 deletions scripts/entrypoints/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ APP_SOURCES=(
"Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift"
"Sources/UI/Settings/SettingsContentLayoutPolicy.swift"
"Sources/UI/Settings/HomeDeleteConfirmationPolicy.swift"
"Sources/UI/Settings/HomeRootAlertPolicy.swift"
"Sources/UI/Settings/HomeCanvasGreeting.swift"
"Sources/UI/Shared/HomeMeetingDeletion.swift"
"Sources/UI/Shared/HomeMeetingRowActionTargets.swift"
Expand Down
Loading