feat(ios): simplify navigation and ship 0.2.0

Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
This commit is contained in:
chick
2026-09-12 17:10:02 +08:00
parent 20632be86a
commit e01a105645
25 changed files with 318 additions and 1393 deletions
@@ -6,7 +6,7 @@ final class AppMetadataTests: XCTestCase {
func testMetadataUsesIPAFoundationContract() {
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
XCTAssertEqual(AppMetadata.marketingVersion, "0.1.0")
XCTAssertEqual(AppMetadata.buildVersion, "1")
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.0")
XCTAssertEqual(AppMetadata.buildVersion, "2")
}
}
@@ -14,13 +14,9 @@ final class LogCenterTests: XCTestCase {
let snapshot = try await service.refresh([instance])
XCTAssertEqual(snapshot.runtime.count, 2)
XCTAssertEqual(snapshot.runtime.first?.id, "delivery-\(instance.id.uuidString)-log-2")
XCTAssertEqual(snapshot.runtime.first?.level, .error)
XCTAssertEqual(snapshot.runtime.first?.instanceName, instance.name)
XCTAssertEqual(snapshot.runtime.first?.source, .delivery)
XCTAssertEqual(snapshot.counts[.delivery], 2)
XCTAssertEqual(store.runtimeStorage.count, 2)
XCTAssertEqual(snapshot.runtime.count, 0)
XCTAssertEqual(snapshot.counts[.delivery], 0)
XCTAssertEqual(store.runtimeStorage.count, 0)
XCTAssertEqual(snapshot.connections.count, 1)
XCTAssertEqual(snapshot.connections.first?.outcome, .success)
@@ -43,7 +39,6 @@ final class LogCenterTests: XCTestCase {
let store = MemoryLogStore()
let service = LogCenterService(
client: LogCenterTestClient(
failingRoutes: [.notificationLogs(limit: 50, offset: 0)]
),
sessions: InstanceSessionStore(),
store: store
@@ -52,14 +47,11 @@ final class LogCenterTests: XCTestCase {
let snapshot = try await service.refresh([instance])
XCTAssertEqual(snapshot.runtime.count, 1)
XCTAssertEqual(snapshot.runtime.first?.level, .warning)
XCTAssertEqual(snapshot.runtime.first?.message, "通知日志未同步:需要登录")
XCTAssertEqual(snapshot.runtime.count, 0)
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.state, .online)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .requiresLogin }.count, 1)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 2)
XCTAssertEqual(store.runtimeStorage.count, 1)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 3)
XCTAssertEqual(store.runtimeStorage.count, 0)
XCTAssertEqual(store.connectionStorage.count, 1)
}
@@ -79,7 +71,7 @@ final class LogCenterTests: XCTestCase {
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
XCTAssertEqual(diagnostic.state, .offline)
XCTAssertEqual(diagnostic.healthStatus, "超时")
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 3)
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 2)
}
@MainActor
@@ -105,7 +97,7 @@ final class LogCenterTests: XCTestCase {
await model.refresh()
XCTAssertNotNil(model.snapshot)
XCTAssertEqual(model.filteredRuntime.count, 2)
XCTAssertEqual(model.filteredRuntime.count, 0)
XCTAssertEqual(model.filteredDiagnostics.count, 1)
XCTAssertEqual(model.offlineCount, 0)
@@ -113,8 +105,8 @@ final class LogCenterTests: XCTestCase {
model.searchText = "offline"
model.selectedLevel = .error
XCTAssertEqual(model.filteredRuntime.count, 1)
XCTAssertEqual(model.filteredRuntime.first?.message, "实例进入离线状态")
XCTAssertEqual(model.filteredRuntime.count, 0)
XCTAssertNil(model.filteredRuntime.first)
let removed = await model.pruneConnections(olderThanDays: 7)
XCTAssertEqual(removed, 0)
@@ -203,17 +195,6 @@ private struct LogCenterTestClient: SimAdminClientProtocol {
)
case .connectivity:
return Self.response(#"{"status":"ok","data":{"connected":true}}"#)
case .notificationLogs:
return Self.response(
"""
{"status":"ok","data":{"total":2,"logs":[
{"id":"log-1","event_type":"device_offline","status":"success",
"message":"实例恢复在线","created_at":"2026-09-08T10:01:00Z"},
{"id":"log-2","event_type":"device_offline","status":"failed",
"message":"实例进入离线状态","created_at":"2026-09-08T10:02:00Z"}
]}}
"""
)
default:
return UpstreamResponse(status: 404, data: Data("{}".utf8))
}
@@ -1,163 +0,0 @@
import XCTest
@testable import MultiSimAdmin
final class NotificationServiceTests: XCTestCase {
func testAggregatesInstanceConfigLogsAndQueue() async throws {
let client = NotificationTestClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let instance = Self.instance()
let overview = try await service.overview([instance])
XCTAssertEqual(client.routes, [
.notificationConfig,
.notificationLogs(limit: 50, offset: 0),
.notificationQueue(limit: 100)
])
XCTAssertEqual(overview.config.channelCount, 2)
XCTAssertEqual(overview.config.channelEnabled, 1)
XCTAssertEqual(overview.config.ruleCount, 1)
XCTAssertEqual(overview.config.ruleEnabled, 1)
XCTAssertEqual(overview.config.channelTypes.first?.type, "webhook")
XCTAssertEqual(overview.logs.total, 3)
XCTAssertEqual(overview.logs.success, 2)
XCTAssertEqual(overview.logs.failed, 1)
XCTAssertEqual(overview.queue.total, 2)
XCTAssertEqual(overview.queue.pending, 1)
XCTAssertEqual(overview.queue.failed, 1)
XCTAssertEqual(overview.queue.items.first?.id, "q-1")
XCTAssertEqual(overview.queue.items.first?.instanceID, instance.id)
XCTAssertEqual(overview.devices.first?.state, .ready)
}
func testQueueActionsValidateIDsAndUseUpstreamRoutes() async throws {
let client = NotificationTestClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let instance = Self.instance()
try await service.retryQueue(instance, id: "q_1-a.2")
try await service.deleteQueueItem(instance, id: "q_1-a.2")
XCTAssertEqual(client.routes.suffix(2), [
.notificationRetryQueue(id: "q_1-a.2"),
.notificationDeleteQueue(id: "q_1-a.2")
])
do {
try await service.retryQueue(instance, id: "bad id")
XCTFail("Invalid queue ID should fail.")
} catch {
XCTAssertEqual(error as? NotificationServiceError, .invalidInput)
}
}
func testReturnsStatefulOverviewWhenEveryInstanceIsUnauthorized() async throws {
let service = NotificationService(
client: UnauthorizedNotificationClient(),
sessions: InstanceSessionStore()
)
let instance = Self.instance()
let overview = try await service.overview([instance])
XCTAssertEqual(overview.devices, [NotificationDevice(
id: instance.id,
name: instance.name,
state: .unauthorized
)])
XCTAssertEqual(overview.config.channelCount, 0)
XCTAssertEqual(overview.logs.total, 0)
XCTAssertEqual(overview.queue.total, 0)
}
func testAggregatesChannelTypeSummariesInFirstSeenOrder() {
let first = NotificationConfigSummary(
channelCount: 2,
channelEnabled: 1,
ruleCount: 0,
ruleEnabled: 0,
channelTypes: [
NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1),
NotificationChannelTypeSummary(type: "bark", total: 1, enabled: 0)
]
)
let second = NotificationConfigSummary(
channelCount: 2,
channelEnabled: 2,
ruleCount: 0,
ruleEnabled: 0,
channelTypes: [
NotificationChannelTypeSummary(type: "telegram", total: 1, enabled: 1),
NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1)
]
)
let result = NotificationService.aggregateConfigs([first, second])
XCTAssertEqual(result.channelTypes.map(\.type), ["webhook", "bark", "telegram"])
let webhook = result.channelTypes.first { $0.type == "webhook" }
XCTAssertEqual(webhook, NotificationChannelTypeSummary(type: "webhook", total: 2, enabled: 2))
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
}
private struct UnauthorizedNotificationClient: SimAdminClientProtocol {
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
throw UpstreamError.unauthorized
}
}
private final class NotificationTestClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
switch route {
case .notificationConfig:
let json = """
{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true},
{"type":"bark","enabled":false}],"rules":[{"enabled":true}]}}
"""
return Self.response(json)
case .notificationLogs(let limit, let offset):
XCTAssertEqual(limit, 50)
XCTAssertEqual(offset, 0)
let json = """
{"status":"ok","data":{"total":3,"logs":[
{"id":"l-1","event_type":"device_offline","status":"success","created_at":"2026-09-08T10:00:00Z"},
{"id":"l-2","event_type":"low_signal","status":"success","created_at":"2026-09-08T10:01:00Z"},
{"id":"l-3","event_type":"device_offline","status":"failed","created_at":"2026-09-08T10:02:00Z"}
]}}
"""
return Self.response(json)
case .notificationQueue(let limit):
XCTAssertEqual(limit, 100)
let json = """
{"status":"ok","data":{"total":2,"items":[
{"id":"q-1","event_type":"device_offline","status":"pending","created_at":"2026-09-08T10:00:00Z"},
{"id":"q-2","event_type":"low_signal","status":"failed","created_at":"2026-09-08T10:01:00Z"}
]}}
"""
return Self.response(json)
case .notificationRetryQueue, .notificationDeleteQueue:
return Self.response(#"{"status":"ok"}"#)
default:
return UpstreamResponse(status: 404, data: Data("{}".utf8))
}
}
private static func response(_ json: String) -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data(json.utf8))
}
}
@@ -1,91 +0,0 @@
import XCTest
@testable import MultiSimAdmin
@MainActor
final class NotificationsViewModelTests: XCTestCase {
func testRefreshLoadsInstancesAndAggregatedOverview() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
XCTAssertEqual(model.instances.count, 1)
XCTAssertEqual(model.overview?.devices.first?.state, .ready)
XCTAssertEqual(model.overview?.queue.items.first?.id, "queue-1")
XCTAssertNil(model.errorMessage)
}
func testRetryQueueTargetsOwningInstance() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "Notification Node",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
await model.refresh()
let item = try XCTUnwrap(model.overview?.queue.items.first)
await model.retryQueue(item)
XCTAssertEqual(client.routes.last, .notificationRetryQueue(id: "queue-1"))
}
func testRetryAllTargetsEveryInstance() async throws {
let repository = MemoryInstanceRepository()
let client = NotificationViewModelClient()
let service = NotificationService(client: client, sessions: InstanceSessionStore())
let model = NotificationsViewModel(repository: repository, service: service)
_ = try await repository.create(InstanceInput(
name: "First",
origin: URL(string: "http://192.168.1.20:3000")!,
tags: [],
password: nil
))
_ = try await repository.create(InstanceInput(
name: "Second",
origin: URL(string: "http://192.168.1.21:3000")!,
tags: [],
password: nil
))
await model.refresh()
await model.retryAll()
XCTAssertEqual(client.routes.filter { $0 == .notificationRetryAllQueue }.count, 2)
}
}
private final class NotificationViewModelClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
switch route {
case .notificationConfig:
return Self.response(#"{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true}]}}"#)
case .notificationLogs:
return Self.response(#"{"status":"ok","data":{"total":1,"logs":[{"id":"log-1","event_type":"system","status":"success","created_at":"2026-09-08T10:00:00Z"}]}}"#)
case .notificationQueue:
return Self.response(#"{"status":"ok","data":{"total":1,"items":[{"id":"queue-1","event_type":"system","status":"failed","created_at":"2026-09-08T10:00:00Z"}]}}"#)
default:
return Self.response(#"{"status":"ok"}"#)
}
}
private static func response(_ json: String) -> UpstreamResponse {
UpstreamResponse(status: 200, data: Data(json.utf8))
}
}
@@ -11,8 +11,8 @@ final class SettingsViewModelTests: XCTestCase {
await model.refresh()
XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin")
XCTAssertEqual(model.runtime?.marketingVersion, "0.1.0")
XCTAssertEqual(model.runtime?.buildVersion, "1")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.0")
XCTAssertEqual(model.runtime?.buildVersion, "2")
XCTAssertEqual(model.runtime?.platform, "iOS")
XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false)
@@ -0,0 +1,26 @@
import XCTest
@testable import MultiSimAdmin
final class ThemeModeTests: XCTestCase {
@MainActor
func testLoadsPersistedThemeMode() throws {
let defaults = try XCTUnwrap(UserDefaults(suiteName: "ThemeModeTests-\(UUID().uuidString)"))
defaults.set(ThemeMode.dark.rawValue, forKey: "aiThemeMode")
XCTAssertEqual(ThemeStore(defaults: defaults).mode, .dark)
}
@MainActor
func testSelectionPersistsThemeMode() throws {
let suiteName = "ThemeModeTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
let store = ThemeStore(defaults: defaults)
store.set(.light)
store.set(.system)
XCTAssertEqual(ThemeMode(rawValue: defaults.string(forKey: "aiThemeMode") ?? ""), .system)
XCTAssertEqual(store.mode, .system)
}
}