refactor(itinerary): extract reordering logic into pure functions
Extract all itinerary reordering logic from ItineraryTableViewController into ItineraryReorderingLogic.swift for testability. Key changes: - Add flattenDays, dayNumber, travelRow, simulateMove pure functions - Add calculateSortOrder with proper region classification (before/after games) - Add computeValidDestinationRowsProposed with simulation+validation pattern - Add coordinate space conversion helpers (proposedToOriginal, originalToProposed) - Fix DragZones coordinate space mismatch (was mixing proposed/original indices) - Add comprehensive documentation of coordinate space conventions Test coverage includes: - Row flattening order and semantic travel model - Sort order calculation for before/after games regions - Travel constraints validation - DragZones coordinate space correctness - Coordinate conversion helpers - Edge cases (empty days, multi-day trips) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
682
SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift
Normal file
682
SportsTimeTests/Features/Trip/ItinerarySemanticTravelTests.swift
Normal file
@@ -0,0 +1,682 @@
|
||||
//
|
||||
// ItinerarySemanticTravelTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
// Acceptance tests for semantic travel model in ItineraryReorderingLogic.
|
||||
// These tests verify the core invariants of the refactored logic.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import SportsTime
|
||||
|
||||
private typealias H = ItineraryTestHelpers
|
||||
private typealias Logic = ItineraryReorderingLogic
|
||||
|
||||
final class ItinerarySemanticTravelTests: XCTestCase {
|
||||
|
||||
private let testDate = H.testDate
|
||||
private let testTripId = H.testTripId
|
||||
|
||||
// MARK: - Acceptance Test A: No Travel Duplication
|
||||
|
||||
/// flattenDays must NOT duplicate travel that appears in both travelBefore AND items.
|
||||
/// Under semantic model, travelBefore is IGNORED entirely.
|
||||
func test_A_flattenDays_ignoresTravelBefore_noDuplication() {
|
||||
// Create a travel segment
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
// Create day with travel in BOTH travelBefore (legacy) AND items (semantic)
|
||||
let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2)
|
||||
let days = [
|
||||
ItineraryDayData(
|
||||
id: 1,
|
||||
dayNumber: 1,
|
||||
date: testDate,
|
||||
games: [],
|
||||
items: [],
|
||||
travelBefore: nil
|
||||
),
|
||||
ItineraryDayData(
|
||||
id: 2,
|
||||
dayNumber: 2,
|
||||
date: H.dayAfter(testDate),
|
||||
games: [],
|
||||
items: [travelItem], // Travel in items (semantic model)
|
||||
travelBefore: travel // Travel also in travelBefore (legacy)
|
||||
)
|
||||
]
|
||||
|
||||
// Provide sortOrder lookup for the travel
|
||||
let result = Logic.flattenDays(days) { segment in
|
||||
if segment.fromLocation.name == "Detroit" && segment.toLocation.name == "Chicago" {
|
||||
return 1.0 // sortOrder >= 0 means after games
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count travel rows
|
||||
let travelCount = result.filter { row in
|
||||
if case .travel = row { return true }
|
||||
return false
|
||||
}.count
|
||||
|
||||
XCTAssertEqual(travelCount, 1, "Travel should appear exactly ONCE (travelBefore is ignored)")
|
||||
}
|
||||
|
||||
/// When travelBefore exists but items is empty, travel should NOT appear at all.
|
||||
func test_A_flattenDays_travelBeforeOnly_notIncluded() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
let days = [
|
||||
ItineraryDayData(
|
||||
id: 1,
|
||||
dayNumber: 1,
|
||||
date: testDate,
|
||||
games: [],
|
||||
items: [],
|
||||
travelBefore: nil
|
||||
),
|
||||
ItineraryDayData(
|
||||
id: 2,
|
||||
dayNumber: 2,
|
||||
date: H.dayAfter(testDate),
|
||||
games: [],
|
||||
items: [], // No travel in items
|
||||
travelBefore: travel // Only in travelBefore (legacy)
|
||||
)
|
||||
]
|
||||
|
||||
let result = Logic.flattenDays(days) { _ in 1.0 }
|
||||
|
||||
let travelCount = result.filter { row in
|
||||
if case .travel = row { return true }
|
||||
return false
|
||||
}.count
|
||||
|
||||
XCTAssertEqual(travelCount, 0, "travelBefore should be completely ignored - no travel should appear")
|
||||
}
|
||||
|
||||
// MARK: - Acceptance Test B: dayForTravelAt Uses Backward Scan
|
||||
|
||||
/// dayForTravelAt must use backward scan (same as dayNumber), not forward scan.
|
||||
func test_B_dayForTravelAt_usesBackwardScan_matchesDayNumber() {
|
||||
// Build items: [day1, game1, travel, day2, game2]
|
||||
// Travel at row 2 should return day 1 (backward scan finds day1 header)
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
let game1 = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let game2 = H.makeRichGame(city: "Chicago", hour: 19, baseDate: H.dayAfter(testDate))
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.games([game1], dayNumber: 1),
|
||||
.travel(travel, dayNumber: 2), // This is what legacy would say
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate)),
|
||||
.games([game2], dayNumber: 2)
|
||||
]
|
||||
|
||||
let travelRow = 2
|
||||
|
||||
// Both functions should return the SAME value
|
||||
let dayNumberResult = Logic.dayNumber(in: items, forRow: travelRow)
|
||||
let dayForTravelResult = Logic.dayForTravelAt(row: travelRow, in: items)
|
||||
|
||||
XCTAssertEqual(dayNumberResult, dayForTravelResult,
|
||||
"dayForTravelAt must match dayNumber (backward scan)")
|
||||
XCTAssertEqual(dayForTravelResult, 1,
|
||||
"Travel at row 2 should belong to day 1 (backward scan finds day1 header)")
|
||||
}
|
||||
|
||||
/// Travel placed after day header (but no next header) uses backward scan.
|
||||
func test_B_dayForTravelAt_atEndOfList_usesBackwardScan() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate)),
|
||||
.travel(travel, dayNumber: 2) // At end of list
|
||||
]
|
||||
|
||||
let result = Logic.dayForTravelAt(row: 2, in: items)
|
||||
let dayNumberResult = Logic.dayNumber(in: items, forRow: 2)
|
||||
|
||||
XCTAssertEqual(result, dayNumberResult)
|
||||
XCTAssertEqual(result, 2, "Travel at row 2 after day2 header should belong to day 2")
|
||||
}
|
||||
|
||||
// MARK: - Acceptance Test C: Custom Item Valid Destinations Include "Above Games"
|
||||
|
||||
/// Custom items can be placed above games (with negative sortOrder).
|
||||
func test_C_customItemValidDestinations_includesAboveGames() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity")
|
||||
|
||||
// Row layout: [0: header, 1: games, 2: customItem]
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate), // Row 0
|
||||
.games([game], dayNumber: 1), // Row 1
|
||||
.customItem(customItem) // Row 2
|
||||
]
|
||||
|
||||
// Create constraints that allow custom items anywhere
|
||||
let constraints = ItineraryConstraints(tripDayCount: 1, items: [])
|
||||
|
||||
let validRows = Logic.computeValidDestinationRowsProposed(
|
||||
flatItems: items,
|
||||
sourceRow: 2, // Moving customItem from row 2
|
||||
dragged: .customItem(customItem),
|
||||
travelValidRanges: [:],
|
||||
constraints: constraints,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { segment in
|
||||
let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name)
|
||||
return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 0, kind: .travel(info))
|
||||
},
|
||||
findTravelSortOrder: { _ in nil }
|
||||
)
|
||||
|
||||
// After removing customItem at row 2, array is: [header, games] with count 2
|
||||
// Valid proposed rows are 1 (after header) and 2 (after games, but beyond count-1)
|
||||
// So proposedRow 1 should put item between header and games
|
||||
XCTAssertTrue(validRows.contains(1),
|
||||
"proposedRow 1 (insert between header and games) should be valid")
|
||||
}
|
||||
|
||||
/// Verify sortOrder is negative when custom item dropped above games.
|
||||
func test_C_customItemAboveGames_getsNegativeSortOrder() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 5.0, title: "Activity")
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.customItem(customItem), // Now at row 1 (moved above games)
|
||||
.games([game], dayNumber: 1) // Row 2
|
||||
]
|
||||
|
||||
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
|
||||
|
||||
XCTAssertLessThan(sortOrder, 0, "Custom item above games must have negative sortOrder")
|
||||
}
|
||||
|
||||
// MARK: - Acceptance Test D: Travel Edge-Day Respects SortOrder
|
||||
|
||||
/// Travel on departure game day is valid only if sortOrder > game's sortOrder.
|
||||
func test_D_travelOnDepartureGameDay_validOnlyAfterGame() {
|
||||
// Game at city A on day 3
|
||||
let gameA = H.makeGameItem(city: "CityA", day: 3)
|
||||
let gameB = H.makeGameItem(city: "CityB", day: 6)
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 7, items: [gameA, gameB])
|
||||
|
||||
let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 3, sortOrder: 50)
|
||||
|
||||
// Travel on day 3 BEFORE game (sortOrder = -50) should be INVALID
|
||||
XCTAssertFalse(
|
||||
constraints.isValidPosition(for: travelItem, day: 3, sortOrder: -50),
|
||||
"Travel before departure game should be invalid"
|
||||
)
|
||||
|
||||
// Travel on day 3 AFTER game (sortOrder = 150) should be VALID
|
||||
XCTAssertTrue(
|
||||
constraints.isValidPosition(for: travelItem, day: 3, sortOrder: 150),
|
||||
"Travel after departure game should be valid"
|
||||
)
|
||||
}
|
||||
|
||||
/// Travel on arrival game day is valid only if sortOrder < game's sortOrder.
|
||||
func test_D_travelOnArrivalGameDay_validOnlyBeforeGame() {
|
||||
let gameA = H.makeGameItem(city: "CityA", day: 3)
|
||||
let gameB = H.makeGameItem(city: "CityB", day: 6)
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 7, items: [gameA, gameB])
|
||||
|
||||
let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 6, sortOrder: 50)
|
||||
|
||||
// Travel on day 6 AFTER game (sortOrder = 150) should be INVALID
|
||||
XCTAssertFalse(
|
||||
constraints.isValidPosition(for: travelItem, day: 6, sortOrder: 150),
|
||||
"Travel after arrival game should be invalid"
|
||||
)
|
||||
|
||||
// Travel on day 6 BEFORE game (sortOrder = -50) should be VALID
|
||||
XCTAssertTrue(
|
||||
constraints.isValidPosition(for: travelItem, day: 6, sortOrder: -50),
|
||||
"Travel before arrival game should be valid"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Acceptance Test E: computeValidDestinationRowsProposed Matches Constraints
|
||||
|
||||
/// For each proposedRow, simulate → compute (day, sortOrder) → constraints.isValidPosition must match.
|
||||
func test_E_computeValidDestinationRows_matchesConstraintsValidation() {
|
||||
let gameA = H.makeRichGame(city: "CityA", hour: 19, baseDate: testDate)
|
||||
let gameBDate = Calendar.current.date(byAdding: .day, value: 3, to: testDate)!
|
||||
let gameB = H.makeRichGame(city: "CityB", hour: 19, baseDate: gameBDate)
|
||||
let travel = H.makeTravelSegment(from: "CityA", to: "CityB")
|
||||
|
||||
let day2Date = Calendar.current.date(byAdding: .day, value: 1, to: testDate)!
|
||||
let day3Date = Calendar.current.date(byAdding: .day, value: 2, to: testDate)!
|
||||
let day4Date = Calendar.current.date(byAdding: .day, value: 3, to: testDate)!
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.games([gameA], dayNumber: 1),
|
||||
.dayHeader(dayNumber: 2, date: day2Date),
|
||||
.travel(travel, dayNumber: 2), // Source row 3
|
||||
.dayHeader(dayNumber: 3, date: day3Date),
|
||||
.dayHeader(dayNumber: 4, date: day4Date),
|
||||
.games([gameB], dayNumber: 4)
|
||||
]
|
||||
|
||||
let gameItemA = H.makeGameItem(city: "CityA", day: 1)
|
||||
let gameItemB = H.makeGameItem(city: "CityB", day: 4)
|
||||
let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 2, sortOrder: 1.0)
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 4, items: [gameItemA, gameItemB])
|
||||
|
||||
let travelValidRanges = ["travel:citya->cityb": 1...4]
|
||||
|
||||
let validRows = Logic.computeValidDestinationRowsProposed(
|
||||
flatItems: items,
|
||||
sourceRow: 3,
|
||||
dragged: .travel(travel, dayNumber: 2),
|
||||
travelValidRanges: travelValidRanges,
|
||||
constraints: constraints,
|
||||
findTravelItem: { _ in travelItem },
|
||||
makeTravelItem: { _ in travelItem },
|
||||
findTravelSortOrder: { _ in 1.0 }
|
||||
)
|
||||
|
||||
// Manually verify each row
|
||||
for proposedRow in 1..<items.count {
|
||||
let simulated = Logic.simulateMove(original: items, sourceRow: 3, destinationProposedRow: proposedRow)
|
||||
guard simulated.didMove else { continue }
|
||||
|
||||
let destRow = simulated.destinationRowInNewArray
|
||||
|
||||
// Skip day headers
|
||||
if case .dayHeader = simulated.items[destRow] {
|
||||
XCTAssertFalse(validRows.contains(proposedRow),
|
||||
"Day header row \(proposedRow) should not be valid")
|
||||
continue
|
||||
}
|
||||
|
||||
let day = Logic.dayNumber(in: simulated.items, forRow: destRow)
|
||||
let sortOrder = Logic.calculateSortOrder(in: simulated.items, at: destRow) { _ in 1.0 }
|
||||
|
||||
let constraintSaysValid = constraints.isValidPosition(for: travelItem, day: day, sortOrder: sortOrder)
|
||||
let functionSaysValid = validRows.contains(proposedRow)
|
||||
|
||||
XCTAssertEqual(constraintSaysValid, functionSaysValid,
|
||||
"Row \(proposedRow) → day \(day), sortOrder \(sortOrder): " +
|
||||
"constraint says \(constraintSaysValid), function says \(functionSaysValid)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom item should also match constraints validation.
|
||||
func test_E_customItemValidDestinations_matchesConstraints() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Lunch")
|
||||
let day2Date = Calendar.current.date(byAdding: .day, value: 1, to: testDate)!
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.games([game], dayNumber: 1),
|
||||
.customItem(customItem), // Source row 2
|
||||
.dayHeader(dayNumber: 2, date: day2Date)
|
||||
]
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 2, items: [])
|
||||
|
||||
let validRows = Logic.computeValidDestinationRowsProposed(
|
||||
flatItems: items,
|
||||
sourceRow: 2,
|
||||
dragged: .customItem(customItem),
|
||||
travelValidRanges: [:],
|
||||
constraints: constraints,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { segment in
|
||||
let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name)
|
||||
return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 0, kind: .travel(info))
|
||||
},
|
||||
findTravelSortOrder: { _ in nil }
|
||||
)
|
||||
|
||||
// Verify each row matches constraint
|
||||
for proposedRow in 1..<items.count {
|
||||
let simulated = Logic.simulateMove(original: items, sourceRow: 2, destinationProposedRow: proposedRow)
|
||||
guard simulated.didMove else { continue }
|
||||
|
||||
let destRow = simulated.destinationRowInNewArray
|
||||
|
||||
// Skip day headers
|
||||
if case .dayHeader = simulated.items[destRow] {
|
||||
XCTAssertFalse(validRows.contains(proposedRow),
|
||||
"Day header row \(proposedRow) should not be valid")
|
||||
continue
|
||||
}
|
||||
|
||||
let day = Logic.dayNumber(in: simulated.items, forRow: destRow)
|
||||
let sortOrder = Logic.calculateSortOrder(in: simulated.items, at: destRow) { _ in nil }
|
||||
|
||||
let testItem = ItineraryItem(
|
||||
id: customItem.id,
|
||||
tripId: customItem.tripId,
|
||||
day: day,
|
||||
sortOrder: sortOrder,
|
||||
kind: customItem.kind
|
||||
)
|
||||
|
||||
let constraintSaysValid = constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder)
|
||||
let functionSaysValid = validRows.contains(proposedRow)
|
||||
|
||||
XCTAssertEqual(constraintSaysValid, functionSaysValid,
|
||||
"Custom item row \(proposedRow) → day \(day), sortOrder \(sortOrder): mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SimulateMove Bounds Safety
|
||||
|
||||
func test_simulateMove_negativeSourceRow_returnsOriginal() {
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate)
|
||||
]
|
||||
|
||||
let result = Logic.simulateMove(original: items, sourceRow: -1, destinationProposedRow: 0)
|
||||
|
||||
XCTAssertFalse(result.didMove, "Invalid sourceRow should not move")
|
||||
XCTAssertEqual(result.items.count, 1)
|
||||
}
|
||||
|
||||
func test_simulateMove_sourceRowOutOfBounds_returnsOriginal() {
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate)
|
||||
]
|
||||
|
||||
let result = Logic.simulateMove(original: items, sourceRow: 5, destinationProposedRow: 0)
|
||||
|
||||
XCTAssertFalse(result.didMove, "Out of bounds sourceRow should not move")
|
||||
XCTAssertEqual(result.items.count, 1)
|
||||
}
|
||||
|
||||
func test_simulateMove_validSourceRow_didMoveIsTrue() {
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
|
||||
]
|
||||
|
||||
let result = Logic.simulateMove(original: items, sourceRow: 0, destinationProposedRow: 1)
|
||||
|
||||
XCTAssertTrue(result.didMove, "Valid sourceRow should move")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: Travel Never Disappears
|
||||
|
||||
/// Travel in items must appear even if findTravelSortOrder returns nil.
|
||||
func test_flattenDays_travelAppearsEvenWithNilSortOrder() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
let travelItem = ItineraryRowItem.travel(travel, dayNumber: 1)
|
||||
|
||||
let days = [
|
||||
ItineraryDayData(
|
||||
id: 1,
|
||||
dayNumber: 1,
|
||||
date: testDate,
|
||||
games: [],
|
||||
items: [travelItem],
|
||||
travelBefore: nil
|
||||
)
|
||||
]
|
||||
|
||||
// Return nil for sortOrder lookup - should use default (1.0)
|
||||
let result = Logic.flattenDays(days) { _ in nil }
|
||||
|
||||
let travelCount = result.filter { row in
|
||||
if case .travel = row { return true }
|
||||
return false
|
||||
}.count
|
||||
|
||||
XCTAssertEqual(travelCount, 1, "Travel must appear even when sortOrder lookup returns nil")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: travelRow Semantic Lookup
|
||||
|
||||
/// travelRow must find travel by scanning day section, not by embedded dayNumber.
|
||||
func test_travelRow_usesSemanticDayLookup() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
// Travel has embedded dayNumber 99 (wrong), but is positioned after day 2 header
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate)),
|
||||
.travel(travel, dayNumber: 99) // Wrong embedded dayNumber
|
||||
]
|
||||
|
||||
// Semantic lookup should find travel in day 2 section
|
||||
let result = Logic.travelRow(in: items, forDay: 2)
|
||||
|
||||
XCTAssertEqual(result, 2, "travelRow should find travel in day 2 section by position, not embedded dayNumber")
|
||||
}
|
||||
|
||||
/// travelRow returns nil if no travel in that day section.
|
||||
func test_travelRow_returnsNilIfNoTravelInDaySection() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.travel(travel, dayNumber: 1), // Travel in day 1
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
|
||||
]
|
||||
|
||||
// Day 2 has no travel
|
||||
let result = Logic.travelRow(in: items, forDay: 2)
|
||||
|
||||
XCTAssertNil(result, "travelRow should return nil when no travel in day section")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: calculateSortOrder Region Correctness
|
||||
|
||||
/// Before-games region must always return negative sortOrder.
|
||||
func test_calculateSortOrder_beforeGamesRegion_alwaysNegative() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Morning")
|
||||
|
||||
// Item placed BEFORE games row
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate), // Row 0
|
||||
.customItem(customItem), // Row 1 - before games
|
||||
.games([game], dayNumber: 1) // Row 2
|
||||
]
|
||||
|
||||
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
|
||||
|
||||
XCTAssertLessThan(sortOrder, 0, "Item before games must have negative sortOrder")
|
||||
}
|
||||
|
||||
/// After-games region must always return non-negative sortOrder.
|
||||
func test_calculateSortOrder_afterGamesRegion_alwaysNonNegative() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Evening")
|
||||
|
||||
// Item placed AFTER games row
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.games([game], dayNumber: 1),
|
||||
.customItem(customItem) // Row 2 - after games
|
||||
]
|
||||
|
||||
let sortOrder = Logic.calculateSortOrder(in: items, at: 2) { _ in nil }
|
||||
|
||||
XCTAssertGreaterThanOrEqual(sortOrder, 0, "Item after games must have non-negative sortOrder")
|
||||
}
|
||||
|
||||
/// First item in before-games region gets proper negative sortOrder.
|
||||
func test_calculateSortOrder_firstItemBeforeGames_getsNegativeValue() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
// Create a custom item to place before games (simulating a moved item)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: -1.0, title: "Pre-game activity")
|
||||
|
||||
// This represents the state AFTER moving an item to row 1 (between header and games)
|
||||
// calculateSortOrder expects the moved item already in the array
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate), // Row 0
|
||||
.customItem(customItem), // Row 1 - the moved item (before games)
|
||||
.games([game], dayNumber: 1) // Row 2
|
||||
]
|
||||
|
||||
// Calculate sortOrder for the item at row 1
|
||||
let sortOrder = Logic.calculateSortOrder(in: items, at: 1) { _ in nil }
|
||||
|
||||
// With no other movable items in before-games region, should get -1.0
|
||||
XCTAssertLessThan(sortOrder, 0, "First item before games should get negative sortOrder")
|
||||
XCTAssertEqual(sortOrder, -1.0, "With no neighbors in before-games region, should return -1.0")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: Coordinate Space Conversion
|
||||
|
||||
/// proposedToOriginal converts correctly when proposed < sourceRow.
|
||||
func test_proposedToOriginal_belowSource_unchanged() {
|
||||
// Source at row 5, proposed is 3
|
||||
// After removing row 5, proposed 3 is still original 3
|
||||
let result = Logic.proposedToOriginal(3, sourceRow: 5)
|
||||
XCTAssertEqual(result, 3)
|
||||
}
|
||||
|
||||
/// proposedToOriginal converts correctly when proposed >= sourceRow.
|
||||
func test_proposedToOriginal_atOrAboveSource_addOne() {
|
||||
// Source at row 5, proposed is 5
|
||||
// After removing row 5, proposed 5 corresponds to original 6
|
||||
let result = Logic.proposedToOriginal(5, sourceRow: 5)
|
||||
XCTAssertEqual(result, 6)
|
||||
|
||||
// Source at row 5, proposed is 7
|
||||
let result2 = Logic.proposedToOriginal(7, sourceRow: 5)
|
||||
XCTAssertEqual(result2, 8)
|
||||
}
|
||||
|
||||
/// originalToProposed converts correctly.
|
||||
func test_originalToProposed_convertsCorrectly() {
|
||||
// Original 3 with source at 5 -> proposed 3 (below source)
|
||||
XCTAssertEqual(Logic.originalToProposed(3, sourceRow: 5), 3)
|
||||
|
||||
// Original 7 with source at 5 -> proposed 6 (above source)
|
||||
XCTAssertEqual(Logic.originalToProposed(7, sourceRow: 5), 6)
|
||||
|
||||
// Original 5 with source at 5 -> nil (is the source)
|
||||
XCTAssertNil(Logic.originalToProposed(5, sourceRow: 5))
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: DragZones Coordinate Space
|
||||
|
||||
/// DragZones validDropRows should be in ORIGINAL coordinate space.
|
||||
func test_dragZones_returnsOriginalCoordinates() {
|
||||
let game = H.makeRichGame(city: "Detroit", hour: 19, baseDate: testDate)
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity")
|
||||
|
||||
// [0: header, 1: games, 2: customItem]
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.games([game], dayNumber: 1),
|
||||
.customItem(customItem)
|
||||
]
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 1, items: [])
|
||||
|
||||
let zones = Logic.calculateCustomItemDragZones(
|
||||
item: customItem,
|
||||
sourceRow: 2,
|
||||
flatItems: items,
|
||||
constraints: constraints,
|
||||
findTravelSortOrder: { _ in nil }
|
||||
)
|
||||
|
||||
// Source row (2) should NOT be in invalidRowIndices (it's being dragged)
|
||||
XCTAssertFalse(zones.invalidRowIndices.contains(2),
|
||||
"Source row should not be in invalidRowIndices")
|
||||
|
||||
// Row 0 (header) should be invalid
|
||||
XCTAssertTrue(zones.invalidRowIndices.contains(0),
|
||||
"Day header should be invalid")
|
||||
|
||||
// validDropRows are in ORIGINAL coordinate space
|
||||
// After removing row 2, post-removal array is [header, games] with count 2
|
||||
// Proposed indices: 0 (before header - invalid), 1 (after header/games), 2 (append at end)
|
||||
// - Proposed 1 -> original 1 (below source 2)
|
||||
// - Proposed 2 -> original 3 (at/above source, so +1)
|
||||
// Note: original index 3 is valid for append operations (insert at end)
|
||||
for validRow in zones.validDropRows {
|
||||
// Valid rows must be <= items.count (allowing append at end which is items.count)
|
||||
XCTAssertLessThanOrEqual(validRow, items.count,
|
||||
"Valid drop rows must be valid indices for insertion (including append)")
|
||||
// Source row should not be included
|
||||
XCTAssertNotEqual(validRow, 2,
|
||||
"Valid drop rows should not include source row itself")
|
||||
}
|
||||
}
|
||||
|
||||
/// DragZones invalidRowIndices should not include sourceRow.
|
||||
func test_dragZones_excludesSourceRowFromInvalid() {
|
||||
let travel = H.makeTravelSegment(from: "Detroit", to: "Chicago")
|
||||
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.travel(travel, dayNumber: 1), // Source row 1
|
||||
.dayHeader(dayNumber: 2, date: H.dayAfter(testDate))
|
||||
]
|
||||
|
||||
let zones = Logic.calculateTravelDragZones(
|
||||
segment: travel,
|
||||
sourceRow: 1,
|
||||
flatItems: items,
|
||||
travelValidRanges: [:],
|
||||
constraints: nil,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { segment in
|
||||
let info = TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name)
|
||||
return ItineraryItem(tripId: testTripId, day: 1, sortOrder: 1.0, kind: .travel(info))
|
||||
},
|
||||
findTravelSortOrder: { _ in 1.0 }
|
||||
)
|
||||
|
||||
XCTAssertFalse(zones.invalidRowIndices.contains(1),
|
||||
"Source row (being dragged) should not be marked invalid")
|
||||
}
|
||||
|
||||
// MARK: - Audit Fix Tests: computeValidDestinationRowsProposed End Position
|
||||
|
||||
/// Verify that append-at-end is handled correctly.
|
||||
func test_computeValidDestinationRows_includesAppendAtEnd() {
|
||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Activity")
|
||||
|
||||
// [0: header, 1: customItem]
|
||||
let items: [ItineraryRowItem] = [
|
||||
.dayHeader(dayNumber: 1, date: testDate),
|
||||
.customItem(customItem)
|
||||
]
|
||||
|
||||
let constraints = ItineraryConstraints(tripDayCount: 1, items: [])
|
||||
|
||||
let validRows = Logic.computeValidDestinationRowsProposed(
|
||||
flatItems: items,
|
||||
sourceRow: 1,
|
||||
dragged: .customItem(customItem),
|
||||
travelValidRanges: [:],
|
||||
constraints: constraints,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { _ in fatalError() },
|
||||
findTravelSortOrder: { _ in nil }
|
||||
)
|
||||
|
||||
// After removing row 1, array is [header] with count 1
|
||||
// Valid proposed positions: 1 (after header, which is append-at-end)
|
||||
// maxProposed = items.count - 1 = 1
|
||||
// So we test 1...1 which includes the append position
|
||||
|
||||
// The proposed row 1 should be valid (append after header)
|
||||
// But wait - we need to check what simulateMove does:
|
||||
// simulateMove with count=1 (after removal), proposed=1 -> clampedDest=1 which is insert at end
|
||||
// This inserts AFTER the header, which is valid
|
||||
|
||||
XCTAssertTrue(validRows.contains(1),
|
||||
"Proposed row 1 (append after header) should be valid")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user