Phase 2: Constraint Validation - Discovered ItineraryConstraints already implemented - 17 XCTest tests cover all CONS-* requirements - Integration with ItineraryTableViewController verified - Main work: migrate tests to Swift Testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
17 KiB
Phase 2: Constraint Validation - Research
Researched: 2026-01-18 Domain: Constraint validation for itinerary item positioning Confidence: HIGH
Summary
This research reveals that Phase 2 is largely already implemented. The codebase contains a complete ItineraryConstraints struct with 17 XCTest test cases covering all the constraint requirements from the phase description. The Phase 1 work (SortOrderProvider, Trip day derivation) provides the foundation, and ItineraryConstraints already validates game immutability, travel segment positioning, and custom item flexibility.
The main gap is migrating the tests from XCTest to Swift Testing (@Test, @Suite) to match the project's established patterns from Phase 1. The ItineraryTableViewController already integrates with ItineraryConstraints for drag-drop validation during Phase 4's UI work.
Primary recommendation: Verify existing implementation covers all CONS-* requirements, migrate tests to Swift Testing, and document the constraint API for Phase 4's UI integration.
Codebase Analysis
Key Files
| File | Purpose | Status |
|---|---|---|
SportsTime/Core/Models/Domain/ItineraryConstraints.swift |
Constraint validation struct | Complete |
SportsTimeTests/ItineraryConstraintsTests.swift |
17 XCTest test cases | Needs migration to Swift Testing |
SportsTime/Core/Models/Domain/SortOrderProvider.swift |
sortOrder calculation utilities | Complete (Phase 1) |
SportsTime/Core/Models/Domain/Trip.swift |
Day derivation methods | Complete (Phase 1) |
SportsTime/Core/Models/Domain/ItineraryItem.swift |
Unified itinerary item model | Complete |
SportsTime/Features/Trip/Views/ItineraryTableViewController.swift |
UITableView with drag-drop | Uses constraints |
Existing ItineraryConstraints API
// Source: SportsTime/Core/Models/Domain/ItineraryConstraints.swift
struct ItineraryConstraints {
let tripDayCount: Int
private let items: [ItineraryItem]
init(tripDayCount: Int, items: [ItineraryItem])
/// Check if a position is valid for an item
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool
/// Get the valid day range for a travel item
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>?
/// Get the games that act as barriers for visual highlighting
func barrierGames(for item: ItineraryItem) -> [ItineraryItem]
}
ItemKind Types
// Source: SportsTime/Core/Models/Domain/ItineraryItem.swift
enum ItemKind: Codable, Hashable {
case game(gameId: String) // CONS-01: Cannot be moved
case travel(TravelInfo) // CONS-02, CONS-03: Day range + ordering constraints
case custom(CustomInfo) // CONS-04: No constraints
}
Integration with Drag-Drop
The ItineraryTableViewController already creates and uses ItineraryConstraints:
// Source: ItineraryTableViewController.swift (lines 484-494)
func reloadData(
days: [ItineraryDayData],
travelValidRanges: [String: ClosedRange<Int>],
itineraryItems: [ItineraryItem] = []
) {
self.travelValidRanges = travelValidRanges
self.allItineraryItems = itineraryItems
self.tripDayCount = days.count
// Rebuild constraints with new data
self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems)
// ...
}
Constraint Requirements Mapping
CONS-01: Games cannot be moved (fixed by schedule)
Status: IMPLEMENTED
// ItineraryConstraints.isValidPosition()
case .game:
// Games are fixed, should never be moved
return false
Existing tests:
test_gameItem_cannotBeMoved()- Verifies games return false for any position
UI Integration (Phase 4):
ItineraryRowItem.isReorderablereturns false for.games- No drag handle appears on game rows
CONS-02: Travel segments constrained to valid day range
Status: IMPLEMENTED
// ItineraryConstraints.isValidTravelPosition()
let departureGameDays = gameDays(in: fromCity)
let arrivalGameDays = gameDays(in: toCity)
let minDay = departureGameDays.max() ?? 1 // After last from-city game
let maxDay = arrivalGameDays.min() ?? tripDayCount // Before first to-city game
guard day >= minDay && day <= maxDay else { return false }
Existing tests:
test_travel_validDayRange_simpleCase()- Chicago Day 1, Detroit Day 3 -> range 1...3test_travel_cannotGoOutsideValidDayRange()- Day before departure invalidtest_travel_validDayRange_returnsNil_whenConstraintsImpossible()- Reversed order returns nil
CONS-03: Travel must be after from-city games, before to-city games on same day
Status: IMPLEMENTED
// ItineraryConstraints.isValidTravelPosition()
if departureGameDays.contains(day) {
let maxGameSortOrder = games(in: fromCity)
.filter { $0.day == day }
.map { $0.sortOrder }
.max() ?? 0
if sortOrder <= maxGameSortOrder {
return false // Must be AFTER all departure games
}
}
if arrivalGameDays.contains(day) {
let minGameSortOrder = games(in: toCity)
.filter { $0.day == day }
.map { $0.sortOrder }
.min() ?? Double.greatestFiniteMagnitude
if sortOrder >= minGameSortOrder {
return false // Must be BEFORE all arrival games
}
}
Existing tests:
test_travel_mustBeAfterDepartureGames()- Before departure game invalidtest_travel_mustBeBeforeArrivalGames()- After arrival game invalidtest_travel_mustBeAfterAllDepartureGamesOnSameDay()- Between games invalidtest_travel_mustBeBeforeAllArrivalGamesOnSameDay()- Between games invalidtest_travel_canBeAnywhereOnRestDays()- No games = any position valid
CONS-04: Custom items have no constraints
Status: IMPLEMENTED
// ItineraryConstraints.isValidPosition()
case .custom:
// Custom items can go anywhere
return true
Existing tests:
test_customItem_canGoOnAnyDay()- Days 1-5 all validtest_customItem_canGoBeforeOrAfterGames()- Any sortOrder valid
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Swift Testing | Swift 5.10+ | Test framework | Project standard from Phase 1 |
| Foundation | Swift stdlib | Date/Calendar | Already used throughout |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| XCTest | iOS 26+ | Legacy tests | Being migrated away |
Architecture Patterns
Pattern 1: Pure Function Constraint Checking
What: ItineraryConstraints.isValidPosition() is a pure function - no side effects, deterministic
When to use: All constraint validation - fast, testable, no async complexity
Example:
// Source: ItineraryConstraints.swift
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
guard day >= 1 && day <= tripDayCount else { return false }
switch item.kind {
case .game:
return false
case .travel(let info):
return isValidTravelPosition(fromCity: info.fromCity, toCity: info.toCity, day: day, sortOrder: sortOrder)
case .custom:
return true
}
}
Pattern 2: Precomputed Valid Ranges
What: validDayRange(for:) computes the valid day range once at drag start
When to use: UI needs to quickly check many positions during drag
Example:
// Source: ItineraryTableViewController.swift
func calculateTravelDragZones(segment: TravelSegment) {
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
guard let validRange = travelValidRanges[travelId] else { ... }
// Pre-calculate ALL valid row indices
for (index, rowItem) in flatItems.enumerated() {
if validRange.contains(dayNum) {
validRows.append(index)
} else {
invalidRows.insert(index)
}
}
}
Pattern 3: City Extraction from Game ID
What: Game IDs encode city: game-CityName-xxxx
Why: Avoids needing to look up game details during constraint checking
Example:
// Source: ItineraryConstraints.swift
private func city(forGameId gameId: String) -> String? {
let components = gameId.components(separatedBy: "-")
guard components.count >= 2 else { return nil }
return components[1]
}
Anti-Patterns to Avoid
- Async constraint validation: Constraints must be synchronous for responsive drag feedback
- Row-index based constraints: Always use semantic (day, sortOrder), never row indices
- Checking constraints on drop only: Check at drag start for valid range, each position during drag
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Constraint validation | Custom per-item-type logic | ItineraryConstraints |
Already handles all cases |
| Day range calculation | Manual game day scanning | validDayRange(for:) |
Handles edge cases |
| City matching | String equality | city(forGameId:) helper |
Game ID format is stable |
| Position checking | Multiple conditions scattered | isValidPosition() |
Single entry point |
Common Pitfalls
Pitfall 1: Forgetting sortOrder Constraints on Same Day
What goes wrong: Travel placed on game day but ignoring sortOrder requirement
Why it happens: Day range looks valid, forget to check sortOrder against games
How to avoid: Always use isValidPosition() which checks both day AND sortOrder
Warning signs: Travel placed before departure game or after arrival game
Pitfall 2: City Name Case Sensitivity
What goes wrong: "Chicago" != "chicago" causing constraint checks to fail
Why it happens: TravelInfo stores display-case cities, game IDs may differ
How to avoid: The implementation already lowercases for comparison
Warning signs: Valid travel rejected because city match fails
Pitfall 3: Empty Game Days
What goes wrong: No games in a city means null/empty arrays
Why it happens: Some cities might have no games yet (planning in progress)
How to avoid: Implementation uses ?? 1 and ?? tripDayCount defaults
Warning signs: Constraint checks crash on cities with no games
Code Examples
Constraint Checking (Full Implementation)
// Source: SportsTime/Core/Models/Domain/ItineraryConstraints.swift
struct ItineraryConstraints {
let tripDayCount: Int
private let items: [ItineraryItem]
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
guard day >= 1 && day <= tripDayCount else { return false }
switch item.kind {
case .game:
return false
case .travel(let info):
return isValidTravelPosition(
fromCity: info.fromCity,
toCity: info.toCity,
day: day,
sortOrder: sortOrder
)
case .custom:
return true
}
}
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>? {
guard case .travel(let info) = item.kind else { return nil }
let departureGameDays = gameDays(in: info.fromCity)
let arrivalGameDays = gameDays(in: info.toCity)
let minDay = departureGameDays.max() ?? 1
let maxDay = arrivalGameDays.min() ?? tripDayCount
guard minDay <= maxDay else { return nil }
return minDay...maxDay
}
}
Test Pattern (Swift Testing)
// Pattern from Phase 1 tests - to be applied to constraint tests
@Suite("ItineraryConstraints")
struct ItineraryConstraintsTests {
@Test("game: cannot be moved to any position")
func game_cannotBeMoved() {
let constraints = makeConstraints(tripDays: 5, games: [gameItem])
#expect(constraints.isValidPosition(for: gameItem, day: 2, sortOrder: 100) == false)
#expect(constraints.isValidPosition(for: gameItem, day: 3, sortOrder: 100) == false)
}
@Test("travel: must be after departure games on same day")
func travel_mustBeAfterDepartureGames() {
let constraints = makeConstraints(tripDays: 3, games: [chicagoGame])
let travel = makeTravelItem(from: "Chicago", to: "Detroit")
// Before departure game - invalid
#expect(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50) == false)
// After departure game - valid
#expect(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150) == true)
}
@Test("custom: can go anywhere")
func custom_canGoAnywhere() {
let constraints = makeConstraints(tripDays: 5)
let custom = makeCustomItem()
for day in 1...5 {
#expect(constraints.isValidPosition(for: custom, day: day, sortOrder: 50) == true)
}
}
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Row-index validation | Semantic (day, sortOrder) validation | Phase 1 | Stable across reloads |
| XCTest framework | Swift Testing (@Test, @Suite) | Phase 1 | Modern, cleaner assertions |
| Constraint checking on drop | Precompute at drag start | Already implemented | Smoother drag UX |
Open Questions
Resolved by Research
-
Where does constraint validation live?
- Answer:
ItineraryConstraintsstruct in Domain models - Confidence: HIGH - already implemented and integrated
- Answer:
-
How will Phase 4 call it?
- Answer:
ItineraryTableViewControlleralready integrates viaself.constraints - Confidence: HIGH - verified in codebase
- Answer:
Minor Questions (Claude's Discretion per CONTEXT.md)
-
Visual styling for invalid zones?
- Current: Alpha 0.3 dimming, gold border on barrier games
- CONTEXT.md says: "Border highlight color for valid drop zones" is Claude's discretion
- Recommendation: Keep current implementation, refine in Phase 4 if needed
-
sortOrder precision exhaustion handling?
- Current:
SortOrderProvider.needsNormalization()andnormalize()exist - CONTEXT.md says: "Renormalize vs. block" is Claude's discretion
- Recommendation: Renormalize proactively when detected
- Current:
Test Strategy
Migration Required
The existing 17 XCTest tests need migration to Swift Testing:
| XCTest Method | Swift Testing Equivalent |
|---|---|
XCTestCase class |
@Suite struct |
func test_* |
@Test("description") func |
XCTAssertTrue(x) |
#expect(x == true) |
XCTAssertFalse(x) |
#expect(x == false) |
XCTAssertEqual(a, b) |
#expect(a == b) |
XCTAssertNil(x) |
#expect(x == nil) |
Test Categories to Verify
| Category | Current Count | Coverage |
|---|---|---|
| Game immutability (CONS-01) | 1 test | Complete |
| Travel day range (CONS-02) | 4 tests | Complete |
| Travel sortOrder constraints (CONS-03) | 4 tests | Complete |
| Custom item flexibility (CONS-04) | 2 tests | Complete |
| Edge cases | 1 test | Impossible constraints |
| Barrier games | 1 test | Visual highlighting |
Sources
Primary (HIGH confidence)
SportsTime/Core/Models/Domain/ItineraryConstraints.swift- Full implementation reviewedSportsTimeTests/ItineraryConstraintsTests.swift- 17 test cases reviewedSportsTime/Features/Trip/Views/ItineraryTableViewController.swift- Integration verified- Phase 1 research and summaries - Pattern consistency verified
Secondary (MEDIUM confidence)
- CONTEXT.md decisions - Visual styling details are Claude's discretion
- CLAUDE.md test patterns - Swift Testing is project standard
Metadata
Confidence breakdown:
- Constraint implementation: HIGH - Code reviewed, all CONS-* requirements met
- Test coverage: HIGH - 17 existing tests cover all requirements
- UI integration: HIGH - Already used in ItineraryTableViewController
- Migration path: HIGH - Clear XCTest -> Swift Testing mapping
Research date: 2026-01-18 Valid until: Indefinite (constraint logic is stable)
Recommendations for Planning
Phase 2 Scope (Refined)
Given that ItineraryConstraints is already implemented and tested:
- Verify existing tests cover all requirements - Compare against CONS-01 through CONS-04
- Migrate tests to Swift Testing - Match Phase 1 patterns
- Add any missing edge case tests - e.g., empty trip, single-day trip
- Document constraint API - For Phase 4 UI integration reference
What NOT to Build
- The constraint checking logic already exists and works
- The UI integration already exists in
ItineraryTableViewController - The visual feedback (dimming, barriers) already exists
Minimal Work Required
Phase 2 is essentially a verification and standardization phase, not a building phase:
- Verify implementation matches requirements
- Standardize tests to project patterns
- Document for downstream phases