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:
alexandrev-tibco
2026-09-13 22:05:31 +02:00
parent 167b736aad
commit 464fbb2bad
38 changed files with 1981 additions and 0 deletions
@@ -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%")
}
}