release(ios): 发布 0.2.1 并重构图标与界面细节

- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
chick
2026-09-14 16:30:39 +08:00
parent 27687868ef
commit 3607eddc9f
34 changed files with 2656 additions and 1213 deletions
@@ -11,7 +11,8 @@ final class AIThemeTests: XCTestCase {
XCTAssertEqual(AITheme.toolbarButtonHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.segmentedControlHeight, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.buttonRadius, 16, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 44, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonSize, 34, accuracy: 0.1)
XCTAssertEqual(AITheme.iconButtonRadius, 11, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonMinWidth, 96, accuracy: 0.1)
XCTAssertEqual(AITheme.toolbarButtonWidth, 104, accuracy: 0.1)
XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 104, accuracy: 0.1)
@@ -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.2.0")
XCTAssertEqual(AppMetadata.buildVersion, "3")
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.1")
XCTAssertEqual(AppMetadata.buildVersion, "6")
}
}
@@ -4,12 +4,18 @@ import XCTest
@MainActor
final class FleetViewModelTests: XCTestCase {
private func transientPreferences() -> UserDefaults {
let suite = "FleetViewModelTests.\(UUID().uuidString)"
return UserDefaults(suiteName: suite)!
}
func testRefreshSortsByNameAndReportsStatus() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore()
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Zulu",
@@ -36,7 +42,8 @@ final class FleetViewModelTests: XCTestCase {
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore()
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(name: "Home", origin: URL(string: "http://192.168.1.70:3000")!, tags: ["home"]))
try await model.add(InstanceInput(name: "Lab", origin: URL(string: "http://192.168.1.71:3000")!, tags: []))
@@ -48,14 +55,80 @@ final class FleetViewModelTests: XCTestCase {
XCTAssertEqual(model.groupedRows.flatMap(\.rows).map(\.instance.name), ["Home"])
model.searchText = ""
model.groupMode = .tags
XCTAssertEqual(model.groupedRows.map(\.title), ["home", "未打标签"])
XCTAssertEqual(model.groupedRows.map(\.rows.count), [1, 1])
model.clearTagFilters()
model.toggleTagFilter("Home")
XCTAssertEqual(model.allTags, ["home"])
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
XCTAssertEqual(model.countForTagFilter("home"), 1)
model.clearTagFilters()
model.groupMode = .status
XCTAssertEqual(model.groupedRows.map(\.title), ["在线"])
}
func testTagFiltersCombineWithAndSemanticsAndPruneStaleSelections() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: transientPreferences()
)
try await model.add(InstanceInput(
name: "Both",
origin: URL(string: "http://192.168.1.90:3000")!,
tags: ["primary", "field"]
))
try await model.add(InstanceInput(
name: "Single",
origin: URL(string: "http://192.168.1.91:3000")!,
tags: ["primary"]
))
await model.refresh()
model.toggleTagFilter("primary")
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both", "Single"])
model.toggleTagFilter("FIELD")
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both"])
model.toggleTagFilter("field")
model.toggleTagFilter("primary")
XCTAssertTrue(model.selectedTagFilters.isEmpty)
model.toggleTagFilter("vanished")
XCTAssertTrue(model.isTagFilterActive("vanished"))
await model.refresh()
XCTAssertTrue(model.selectedTagFilters.isEmpty, "已不存在的标签筛选应当在刷新后自动清理")
}
func testTagFilterSelectionSurvivesAcrossInstances() async throws {
let suite = "FleetTagFilterTests.\(UUID().uuidString)"
let repository = MemoryInstanceRepository()
let first = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: UserDefaults(suiteName: suite)!
)
try await first.add(InstanceInput(
name: "Relay",
origin: URL(string: "http://192.168.1.92:3000")!,
tags: ["relay"]
))
await first.refresh()
first.toggleTagFilter("relay")
let second = FleetViewModel(
repository: repository,
probe: ConnectionProbe(client: AlwaysOnlineClient()),
credentials: NoopCredentialStore(),
preferences: UserDefaults(suiteName: suite)!
)
XCTAssertTrue(second.selectedTagFilters.contains("relay"))
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
}
func testGroupModeKeepsUngroupedInstancesVisible() async throws {
let repository = MemoryInstanceRepository()
let model = FleetViewModel(
@@ -15,7 +15,8 @@ final class MessageServiceTests: XCTestCase {
XCTAssertEqual(client.routes.first, .smsList(limit: 2, offset: 0, direction: nil))
XCTAssertEqual(page.messages.count, 1)
let message = try XCTUnwrap(page.messages.first)
XCTAssertEqual(message.id, "42")
XCTAssertEqual(message.upstreamID, "42")
XCTAssertEqual(message.id, "\(instance.id.uuidString)#42")
XCTAssertEqual(message.instanceID, instance.id)
XCTAssertEqual(message.instanceName, "Message Node")
XCTAssertEqual(message.phoneNumber, "13800138000")
@@ -11,10 +11,10 @@ final class SettingsViewModelTests: XCTestCase {
await model.refresh()
XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.0")
XCTAssertEqual(model.runtime?.buildVersion, "3")
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.1")
XCTAssertEqual(model.runtime?.buildVersion, "6")
XCTAssertEqual(model.runtime?.platform, "iOS")
XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false)
XCTAssertTrue(model.runtime?.systemVersion.hasPrefix("iOS") ?? false)
XCTAssertNil(model.errorMessage)
}
@@ -0,0 +1,50 @@
import XCTest
@testable import MultiSimAdmin
final class TimestampTextTests: XCTestCase {
private let calendar = Calendar(identifier: .gregorian)
func testTodayOnlyShowsClockTime() {
let output = TimestampText.short("2026-09-14 08:05:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "08:05")
}
func testSameYearShowsMonthAndTime() {
let output = TimestampText.short("2026-03-02 08:05:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "03-02 08:05")
}
func testOtherYearFallsBackToDate() {
let output = TimestampText.short("2025-12-31 23:59:00", now: date(2026, 9, 14, 20, 0))
XCTAssertEqual(output, "2025-12-31")
}
func testParsesEpochMilliseconds() {
XCTAssertEqual(TimestampText.date(from: "1767484800000"), Date(timeIntervalSince1970: 1_767_484_800))
}
func testParsesEpochSeconds() {
XCTAssertEqual(TimestampText.date(from: "1767484800"), Date(timeIntervalSince1970: 1_767_484_800))
}
func testZuluTimestampIsReadAsUTC() {
let iso = try? XCTUnwrap(TimestampText.date(from: "2026-01-04T00:00:00Z"))
XCTAssertEqual(iso, Date(timeIntervalSince1970: 1_767_484_800))
}
func testUnparseableTextIsKeptAsIs() {
XCTAssertEqual(TimestampText.short(" 刚刚 "), "刚刚")
XCTAssertEqual(TimestampText.short(""), "")
}
private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date {
var components = DateComponents()
components.year = year
components.month = month
components.day = day
components.hour = hour
components.minute = minute
return calendar.date(from: components)!
}
}