683 lines
28 KiB
Swift
683 lines
28 KiB
Swift
//
|
|
// 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 = TestClock.calendar.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 = TestClock.calendar.date(byAdding: .day, value: 1, to: testDate)!
|
|
let day3Date = TestClock.calendar.date(byAdding: .day, value: 2, to: testDate)!
|
|
let day4Date = TestClock.calendar.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:0: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 = TestClock.calendar.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")
|
|
}
|
|
}
|