Files
SportstimeAPI/.planning/phases/02-constraint-validation/02-RESEARCH.md
Trey t aae302cf5b docs(02): research constraint validation phase
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>
2026-01-18 14:44:51 -06:00

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.isReorderable returns 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...3
  • test_travel_cannotGoOutsideValidDayRange() - Day before departure invalid
  • test_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 invalid
  • test_travel_mustBeBeforeArrivalGames() - After arrival game invalid
  • test_travel_mustBeAfterAllDepartureGamesOnSameDay() - Between games invalid
  • test_travel_mustBeBeforeAllArrivalGamesOnSameDay() - Between games invalid
  • test_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 valid
  • test_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

  1. Where does constraint validation live?

    • Answer: ItineraryConstraints struct in Domain models
    • Confidence: HIGH - already implemented and integrated
  2. How will Phase 4 call it?

    • Answer: ItineraryTableViewController already integrates via self.constraints
    • Confidence: HIGH - verified in codebase

Minor Questions (Claude's Discretion per CONTEXT.md)

  1. 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
  2. sortOrder precision exhaustion handling?

    • Current: SortOrderProvider.needsNormalization() and normalize() exist
    • CONTEXT.md says: "Renormalize vs. block" is Claude's discretion
    • Recommendation: Renormalize proactively when detected

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 reviewed
  • SportsTimeTests/ItineraryConstraintsTests.swift - 17 test cases reviewed
  • SportsTime/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:

  1. Verify existing tests cover all requirements - Compare against CONS-01 through CONS-04
  2. Migrate tests to Swift Testing - Match Phase 1 patterns
  3. Add any missing edge case tests - e.g., empty trip, single-day trip
  4. 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