feat(timeline): merge bp.systolic + bp.diastolic into single entry

- TimelineEntry.from(indicators:) 批处理:找 bp.systolic 配对同 capturedAt
  (±5s)的 bp.diastolic,合并成 '血压 120/80 mmHg' 一行
- 未配对的 systolic 单独退回 from(indicator:)
- 非 bp.* series 不动
- ArchiveListView + HomeView 改用 from(indicators:) 批处理
- 6 个新测试覆盖配对/未配对/异常标记/非 bp 不动/不同时间不合并
This commit is contained in:
link2026
2026-05-26 07:50:00 +08:00
parent 0f38bf585b
commit e2fb631b96
4 changed files with 159 additions and 2 deletions

View File

@@ -0,0 +1,116 @@
import Testing
import SwiftData
import Foundation
@testable import
@MainActor
struct TimelineEntryBPMergeTests {
private func makeContext() throws -> ModelContext {
let schema = Schema([Indicator.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
return ModelContext(try ModelContainer(for: schema, configurations: [config]))
}
private func bp(sys: Int, dia: Int, at: Date, in ctx: ModelContext) -> (Indicator, Indicator) {
let s = Indicator(name: "收缩压", value: "\(sys)", unit: "mmHg",
range: "90-140", status: .normal,
capturedAt: at, pinned: true, seriesKey: "bp.systolic")
let d = Indicator(name: "舒张压", value: "\(dia)", unit: "mmHg",
range: "60-90", status: .normal,
capturedAt: at, pinned: true, seriesKey: "bp.diastolic")
ctx.insert(s); ctx.insert(d)
return (s, d)
}
@Test func bpPairAtSameTimeMergesIntoSingleEntry() throws {
let ctx = try makeContext()
let now = Date(timeIntervalSince1970: 1_716_000_000)
_ = bp(sys: 120, dia: 80, at: now, in: ctx)
try ctx.save()
let all = try ctx.fetch(FetchDescriptor<Indicator>())
let entries = TimelineEntry.from(indicators: all)
#expect(entries.count == 1)
let e = try #require(entries.first)
#expect(e.title == "血压")
#expect(e.trailing?.contains("120/80") == true)
#expect(e.trailingIsAlert == false)
}
@Test func bpPairWithAbnormalSystolicMarksAlert() throws {
let ctx = try makeContext()
let now = Date()
let (s, _) = bp(sys: 150, dia: 80, at: now, in: ctx)
s.statusRaw = IndicatorStatus.high.rawValue
try ctx.save()
let all = try ctx.fetch(FetchDescriptor<Indicator>())
let entries = TimelineEntry.from(indicators: all)
#expect(entries.count == 1)
#expect(entries.first?.trailingIsAlert == true)
#expect(entries.first?.trailing?.contains("") == true)
}
@Test func nonBPSeriesStayAsSeparateEntries() throws {
let ctx = try makeContext()
let now = Date()
let glu = Indicator(name: "空腹血糖", value: "5.4", unit: "mmol/L",
range: "3.9-6.1", status: .normal,
capturedAt: now, pinned: true, seriesKey: "glucose.fasting")
let weight = Indicator(name: "体重", value: "68", unit: "kg",
range: "", status: .normal,
capturedAt: now, pinned: true, seriesKey: "weight")
ctx.insert(glu); ctx.insert(weight)
try ctx.save()
let all = try ctx.fetch(FetchDescriptor<Indicator>())
let entries = TimelineEntry.from(indicators: all)
#expect(entries.count == 2)
#expect(entries.contains { $0.title == "空腹血糖" })
#expect(entries.contains { $0.title == "体重" })
}
@Test func bpAtDifferentTimesDoNotMerge() throws {
let ctx = try makeContext()
let t1 = Date(timeIntervalSince1970: 1_716_000_000)
let t2 = t1.addingTimeInterval(3600) // 1
_ = bp(sys: 120, dia: 80, at: t1, in: ctx)
_ = bp(sys: 130, dia: 85, at: t2, in: ctx)
try ctx.save()
let all = try ctx.fetch(FetchDescriptor<Indicator>())
let entries = TimelineEntry.from(indicators: all)
#expect(entries.count == 2)
}
@Test func unpairedSystolicFallsBackToSingleEntry() throws {
let ctx = try makeContext()
let s = Indicator(name: "收缩压", value: "120", unit: "mmHg",
range: "90-140", status: .normal,
capturedAt: .now, pinned: true, seriesKey: "bp.systolic")
ctx.insert(s)
try ctx.save()
let all = try ctx.fetch(FetchDescriptor<Indicator>())
let entries = TimelineEntry.from(indicators: all)
// diastolic , from(indicator:), ""
#expect(entries.count == 1)
#expect(entries.first?.title == "收缩压")
}
@Test func freeformIndicatorWithoutSeriesKeyShowsAsItself() throws {
let ctx = try makeContext()
let i = Indicator(name: "ALT", value: "32", unit: "U/L",
range: "9-50", status: .normal,
capturedAt: .now)
ctx.insert(i)
try ctx.save()
let all = try ctx.fetch(FetchDescriptor<Indicator>())
let entries = TimelineEntry.from(indicators: all)
#expect(entries.count == 1)
#expect(entries.first?.title == "ALT")
}
}