// // ItineraryRowFlatteningTests.swift // SportsTimeTests // // Tests for row flattening order and ItineraryRowItem model. // import XCTest @testable import SportsTime private typealias H = ItineraryTestHelpers @MainActor 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) } }