App de Apple Watch + 3 complicaciones (build 88) #48
App de watchOS de solo consulta: resumen de cartera (total, cambio del mes y mayores posiciones), objetivos con su progreso y estado del check-in con la racha. Toda la edición sigue en el iPhone. Complicaciones (accessoryCircular / Rectangular / Inline / Corner): valor total de la cartera, progreso del objetivo más cercano a cumplirse y racha + estado del check-in. Datos: los App Groups no se comparten entre iOS y watchOS, así que el reloj no puede leer el store de CoreData que lee el widget de iOS. En su lugar, WatchSyncService construye un WatchPortfolioSnapshot ligero y lo envía como application context de WatchConnectivity, enganchado a CoreDataStack.refreshWidgetData() — el mismo punto que refresca el widget de iOS, así que cualquier repositorio que toque datos refresca el reloj. WatchDataStore lo cachea en el App Group del watch (WatchSnapshotCache) y recarga las timelines; la extensión de complicaciones solo lee ese caché. Proyecto: dos targets nuevos (watchOS app + widget extension) con grupos sincronizados de Xcode 16; Shared/ pertenece a la app de watch y se comparte con la app de iOS y el widget mediante exception sets, el mismo mecanismo que ya usaba el widget de iOS para el modelo de CoreData. Scheme compartido PortfolioJournalWatch. Localización propia por target en los 7 idiomas. Verificado: compilan los tres targets, la app de watch queda embebida en Watch/ con la extensión en PlugIns/, y en el par de simuladores iPhone 17 Pro Max + Apple Watch Series 11 el reloj recibe y muestra el snapshot enviado por el iPhone. 10 tests nuevos del payload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import XCTest
|
||||
@testable import PortfolioJournal
|
||||
|
||||
/// The watch payload travels between processes and devices, so its encoding and
|
||||
/// the formatting the complications rely on are worth pinning down.
|
||||
final class WatchPortfolioSnapshotTests: XCTestCase {
|
||||
|
||||
private func makeSnapshot(
|
||||
goals: [WatchPortfolioSnapshot.Goal] = [],
|
||||
totalValue: Double = 1_000
|
||||
) -> WatchPortfolioSnapshot {
|
||||
WatchPortfolioSnapshot(
|
||||
generatedAt: Date(timeIntervalSince1970: 1_770_000_000),
|
||||
currencyCode: "EUR",
|
||||
totalValue: totalValue,
|
||||
monthChange: 250,
|
||||
monthChangePercent: 0.025,
|
||||
sources: [],
|
||||
goals: goals,
|
||||
currentStreak: 3,
|
||||
bestStreak: 9,
|
||||
checkInDone: true,
|
||||
checkInMonthLabel: "August 2026"
|
||||
)
|
||||
}
|
||||
|
||||
private func makeGoal(name: String, current: Double, target: Double) -> WatchPortfolioSnapshot.Goal {
|
||||
WatchPortfolioSnapshot.Goal(
|
||||
id: UUID(),
|
||||
name: name,
|
||||
categoryName: "Cash",
|
||||
categoryIcon: "banknote.fill",
|
||||
categoryColorHex: "#6B7280",
|
||||
currentValue: current,
|
||||
targetValue: target
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
func testApplicationContextRoundTrip() throws {
|
||||
let snapshot = makeSnapshot(goals: [makeGoal(name: "Cash", current: 40_000, target: 100_000)])
|
||||
|
||||
let context = try XCTUnwrap(snapshot.applicationContext())
|
||||
let decoded = try XCTUnwrap(WatchPortfolioSnapshot.from(applicationContext: context))
|
||||
|
||||
XCTAssertEqual(decoded, snapshot)
|
||||
}
|
||||
|
||||
func testApplicationContextIgnoresUnrelatedPayload() {
|
||||
XCTAssertNil(WatchPortfolioSnapshot.from(applicationContext: ["somethingElse": 1]))
|
||||
}
|
||||
|
||||
func testEmptySnapshotReportsNoData() {
|
||||
XCTAssertFalse(WatchPortfolioSnapshot.empty.hasData)
|
||||
XCTAssertTrue(makeSnapshot().hasData)
|
||||
}
|
||||
|
||||
// MARK: - Goals
|
||||
|
||||
func testProgressIsClampedBetweenZeroAndOne() {
|
||||
XCTAssertEqual(makeGoal(name: "Over", current: 150, target: 100).progress, 1)
|
||||
XCTAssertEqual(makeGoal(name: "Negative", current: -50, target: 100).progress, 0)
|
||||
XCTAssertEqual(makeGoal(name: "Half", current: 50, target: 100).progress, 0.5)
|
||||
}
|
||||
|
||||
func testProgressWithoutTargetIsZero() {
|
||||
XCTAssertEqual(makeGoal(name: "No target", current: 50, target: 0).progress, 0)
|
||||
}
|
||||
|
||||
/// The single-goal complication must skip goals already reached.
|
||||
func testFeaturedGoalSkipsAchievedGoals() {
|
||||
let achieved = makeGoal(name: "Done", current: 100, target: 100)
|
||||
let open = makeGoal(name: "Cash buffer", current: 40, target: 100)
|
||||
let snapshot = makeSnapshot(goals: [achieved, open])
|
||||
|
||||
XCTAssertEqual(snapshot.featuredGoal?.name, "Cash buffer")
|
||||
}
|
||||
|
||||
func testFeaturedGoalFallsBackToFirstWhenAllAchieved() {
|
||||
let first = makeGoal(name: "First", current: 100, target: 100)
|
||||
let second = makeGoal(name: "Second", current: 200, target: 200)
|
||||
|
||||
XCTAssertEqual(makeSnapshot(goals: [first, second]).featuredGoal?.name, "First")
|
||||
}
|
||||
|
||||
func testFeaturedGoalIsNilWithoutGoals() {
|
||||
XCTAssertNil(makeSnapshot().featuredGoal)
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
func testCompactFormatShortensLargeAmounts() {
|
||||
func compact(_ value: Double) -> String {
|
||||
WatchPortfolioSnapshot.format(value, currencyCode: "EUR", compact: true)
|
||||
}
|
||||
|
||||
XCTAssertTrue(compact(750).contains("750"))
|
||||
XCTAssertTrue(compact(1_500).contains("1.5K"))
|
||||
XCTAssertTrue(compact(25_000).contains("25K"))
|
||||
XCTAssertTrue(compact(2_000_000).contains("2.0M"))
|
||||
XCTAssertTrue(compact(-1_500).hasPrefix("-"))
|
||||
}
|
||||
|
||||
func testMonthChangeCarriesExplicitSign() {
|
||||
let positive = makeSnapshot()
|
||||
XCTAssertTrue(positive.formattedMonthChange().hasPrefix("+"))
|
||||
XCTAssertEqual(positive.formattedMonthChangePercent, "+2.5%")
|
||||
|
||||
let negative = WatchPortfolioSnapshot(
|
||||
generatedAt: Date(),
|
||||
currencyCode: "EUR",
|
||||
totalValue: 1_000,
|
||||
monthChange: -250,
|
||||
monthChangePercent: -0.025,
|
||||
sources: [],
|
||||
goals: [],
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
checkInDone: false,
|
||||
checkInMonthLabel: ""
|
||||
)
|
||||
XCTAssertTrue(negative.formattedMonthChange().hasPrefix("-"))
|
||||
XCTAssertEqual(negative.formattedMonthChangePercent, "-2.5%")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user