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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user