Include segment index in travel anchor IDs ("travel:INDEX:from->to")
so Follow Team trips visiting the same city pair multiple times get
unique, independently addressable travel segments. Prevents override
dictionary collisions and incorrect validDayRange lookups.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
193 lines
9.7 KiB
Swift
193 lines
9.7 KiB
Swift
//
|
|
// ItineraryRowFlatteningTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// Tests for row flattening order and ItineraryRowItem model.
|
|
//
|
|
|
|
import XCTest
|
|
@testable import SportsTime
|
|
|
|
private typealias H = ItineraryTestHelpers
|
|
|
|
final class ItineraryRowFlatteningTests: XCTestCase {
|
|
|
|
private let testDate = H.testDate
|
|
|
|
// MARK: - Row Flattening Order Tests
|
|
|
|
/// Verifies that rows are flattened in correct order under SEMANTIC TRAVEL MODEL:
|
|
/// 1. Day header
|
|
/// 2. Items with sortOrder < 0 (before games, including travel)
|
|
/// 3. Games
|
|
/// 4. Items with sortOrder >= 0 (after games, including travel)
|
|
///
|
|
/// NOTE: travelBefore is IGNORED - travel must be in items with sortOrder to appear.
|
|
func test_rowFlattening_correctOrder_semanticTravel() {
|
|
// Given: A day with travel in items (before games), games, and custom item (after games)
|
|
let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit")
|
|
let travelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: -1.0)
|
|
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
|
|
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Dinner")
|
|
|
|
let dayData = ItineraryDayData(
|
|
id: 1,
|
|
dayNumber: 1,
|
|
date: testDate,
|
|
games: games,
|
|
items: [.travel(travel, dayNumber: 1), .customItem(customItem)],
|
|
travelBefore: nil // travelBefore is IGNORED under semantic model
|
|
)
|
|
|
|
// When: Controller reloads with travel having negative sortOrder (before games)
|
|
let controller = ItineraryTableViewController(style: .plain)
|
|
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [travelItem, customItem])
|
|
|
|
// Then: Order should be: header, travel (before games), games, custom (after games)
|
|
let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0)
|
|
XCTAssertEqual(rowCount, 4, "Expected 4 rows: header, travel, games, custom item")
|
|
|
|
// Verify order by reorderability
|
|
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "Row 0 = Header (NOT reorderable)")
|
|
XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 1, section: 0)), "Row 1 = Travel (reorderable)")
|
|
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 2, section: 0)), "Row 2 = Games (NOT reorderable)")
|
|
XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 3, section: 0)), "Row 3 = Custom item (reorderable)")
|
|
}
|
|
|
|
func test_rowFlattening_itemsBeforeGames_negativeSortOrder() {
|
|
// Given: Custom items with negative sortOrder should appear BEFORE games
|
|
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
|
|
let beforeItem = H.makeCustomItem(day: 1, sortOrder: -1.0, title: "Morning coffee")
|
|
let afterItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Dinner")
|
|
|
|
let dayData = ItineraryDayData(
|
|
id: 1,
|
|
dayNumber: 1,
|
|
date: testDate,
|
|
games: games,
|
|
items: [.customItem(beforeItem), .customItem(afterItem)],
|
|
travelBefore: nil
|
|
)
|
|
|
|
let controller = ItineraryTableViewController(style: .plain)
|
|
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [beforeItem, afterItem])
|
|
|
|
// Then: Order should be header, beforeItem, games, afterItem
|
|
let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0)
|
|
XCTAssertEqual(rowCount, 4, "Expected 4 rows: header, before-item, games, after-item")
|
|
|
|
// Verify the before-games item appears at row 1 (after header at row 0)
|
|
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "Row 0 should be header (not reorderable)")
|
|
XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 1, section: 0)), "Row 1 should be before-item (reorderable)")
|
|
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 2, section: 0)), "Row 2 should be games (not reorderable)")
|
|
XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 3, section: 0)), "Row 3 should be after-item (reorderable)")
|
|
}
|
|
|
|
func test_rowFlattening_multipleItemsSortedBySortOrder() {
|
|
// Given: Multiple custom items should be sorted by sortOrder
|
|
let item1 = H.makeCustomItem(day: 1, sortOrder: 3.0, title: "Third")
|
|
let item2 = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "First")
|
|
let item3 = H.makeCustomItem(day: 1, sortOrder: 2.0, title: "Second")
|
|
|
|
let dayData = ItineraryDayData(
|
|
id: 1,
|
|
dayNumber: 1,
|
|
date: testDate,
|
|
games: [],
|
|
items: [.customItem(item1), .customItem(item2), .customItem(item3)],
|
|
travelBefore: nil
|
|
)
|
|
|
|
let controller = ItineraryTableViewController(style: .plain)
|
|
controller.reloadData(days: [dayData], travelValidRanges: [:], itineraryItems: [item1, item2, item3])
|
|
|
|
// Then: Items should appear in sortOrder: First (1.0), Second (2.0), Third (3.0)
|
|
let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0)
|
|
XCTAssertEqual(rowCount, 4, "Expected 4 rows: header + 3 items")
|
|
}
|
|
|
|
// MARK: - Day Number Calculation Tests
|
|
|
|
func test_dayNumber_firstDayHeader_returnsDay1() {
|
|
// Given: A simple 3-day trip
|
|
let days = H.makeDays(count: 3)
|
|
let controller = ItineraryTableViewController(style: .plain)
|
|
controller.reloadData(days: days, travelValidRanges: [:])
|
|
|
|
// The first row should be Day 1 header
|
|
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "First row should be header")
|
|
}
|
|
|
|
func test_dayNumber_rowAfterHeader_belongsToSameDay() {
|
|
// Given: A day with games
|
|
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
|
|
let dayData = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: games, items: [], travelBefore: nil)
|
|
|
|
let controller = ItineraryTableViewController(style: .plain)
|
|
controller.reloadData(days: [dayData], travelValidRanges: [:])
|
|
|
|
// Row 0 = header (Day 1), Row 1 = games (belongs to Day 1)
|
|
XCTAssertEqual(controller.tableView(controller.tableView, numberOfRowsInSection: 0), 2)
|
|
}
|
|
|
|
func test_dayNumber_travelRow_belongsToItsDay() {
|
|
// Given: Travel in Day 2's items (semantic model - travelBefore is ignored)
|
|
let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit")
|
|
let travelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 1.0)
|
|
let day1 = ItineraryDayData(id: 1, dayNumber: 1, date: testDate, games: [], items: [], travelBefore: nil)
|
|
let day2 = ItineraryDayData(
|
|
id: 2,
|
|
dayNumber: 2,
|
|
date: H.dayAfter(testDate),
|
|
games: [],
|
|
items: [.travel(travel, dayNumber: 2)],
|
|
travelBefore: nil // travelBefore is IGNORED under semantic model
|
|
)
|
|
|
|
let controller = ItineraryTableViewController(style: .plain)
|
|
controller.reloadData(days: [day1, day2], travelValidRanges: [:], itineraryItems: [travelItem])
|
|
|
|
// Row order: Day1 header, Day2 header, travel (in day 2's after-games region)
|
|
let rowCount = controller.tableView(controller.tableView, numberOfRowsInSection: 0)
|
|
XCTAssertEqual(rowCount, 3, "Expected: Day1 header, Day2 header, travel")
|
|
|
|
// Travel is reorderable and belongs to Day 2 (positioned after Day 2 header)
|
|
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 0, section: 0)), "Day 1 header")
|
|
XCTAssertFalse(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 1, section: 0)), "Day 2 header")
|
|
XCTAssertTrue(controller.tableView(controller.tableView, canMoveRowAt: IndexPath(row: 2, section: 0)), "Travel is reorderable")
|
|
}
|
|
|
|
// MARK: - ItineraryRowItem Tests
|
|
|
|
func test_itineraryRowItem_dayHeader_hasCorrectId() {
|
|
let item = ItineraryRowItem.dayHeader(dayNumber: 3, date: testDate)
|
|
XCTAssertEqual(item.id, "day:3")
|
|
}
|
|
|
|
func test_itineraryRowItem_games_hasCorrectId() {
|
|
let games = [H.makeRichGame(city: "Detroit", hour: 19)]
|
|
let item = ItineraryRowItem.games(games, dayNumber: 2)
|
|
XCTAssertEqual(item.id, "games:2")
|
|
}
|
|
|
|
func test_itineraryRowItem_travel_hasStableId() {
|
|
let segment = H.makeTravelSegment(from: "Chicago", to: "Detroit")
|
|
let item = ItineraryRowItem.travel(segment, dayNumber: 1)
|
|
XCTAssertTrue(item.id.hasPrefix("travel:"), "Travel ID should start with 'travel:'")
|
|
XCTAssertTrue(item.id.contains(segment.id.uuidString), "Travel ID should contain segment UUID")
|
|
}
|
|
|
|
func test_itineraryRowItem_customItem_hasUuidId() {
|
|
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "Test")
|
|
let item = ItineraryRowItem.customItem(customItem)
|
|
XCTAssertTrue(item.id.hasPrefix("item:"), "Custom item ID should start with 'item:'")
|
|
}
|
|
|
|
func test_itineraryRowItem_reorderability() {
|
|
XCTAssertFalse(ItineraryRowItem.dayHeader(dayNumber: 1, date: testDate).isReorderable)
|
|
XCTAssertFalse(ItineraryRowItem.games([], dayNumber: 1).isReorderable)
|
|
XCTAssertTrue(ItineraryRowItem.travel(H.makeTravelSegment(from: "A", to: "B"), dayNumber: 1).isReorderable)
|
|
XCTAssertTrue(ItineraryRowItem.customItem(H.makeCustomItem(day: 1, sortOrder: 1.0, title: "X")).isReorderable)
|
|
}
|
|
}
|