464fbb2bad
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
127 lines
4.4 KiB
Swift
127 lines
4.4 KiB
Swift
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%")
|
|
}
|
|
}
|