import Foundation @main enum UserCreatorChatDatePresentationCheck { static var assertions = 0 static func check(_ condition: Bool, _ message: String) { guard condition else { print("FAIL: \(message)"); exit(1) } assertions += 1 } static func checkTitles(for date: Date, calendar: Calendar, timeZone: TimeZone, expected: [String], label: String) { for (localeID, expectedTitle) in zip(["ko_KR", "en_US", "ja_JP"], expected) { let title = UserCreatorChatDatePresentation.title( for: date, locale: Locale(identifier: localeID), calendar: calendar, timeZone: timeZone ) check(title == expectedTitle, "\(label) \(localeID): expected '\(expectedTitle)', got '\(title)'") } } static func timestamp(_ value: String) -> Int64 { Int64(ISO8601DateFormatter().date(from: value)!.timeIntervalSince1970 * 1000) } static func serverMessage(_ id: Int, at value: String, type: String = "TEXT") -> UserCreatorChatDisplayMessage { UserCreatorChatDisplayMessage(message: UserCreatorChatMessageItem( messageId: id, messageType: type, mine: true, createdAt: timestamp(value), textMessage: type == "TEXT" ? "message \(id)" : nil, voiceMessageUrl: nil, senderId: 1, senderNickname: "sender", senderProfileImageUrl: nil )) } static func checkRows( _ messages: [UserCreatorChatDisplayMessage], calendar: Calendar, timeZone: TimeZone, expectedMessages: [UserCreatorChatDisplayMessage], separatorIndices: [Int], label: String ) { let rows = UserCreatorChatDatePresentation.rows(from: messages, calendar: calendar, timeZone: timeZone) let expectedDates: [Date?] = expectedMessages.enumerated().map { index, message in separatorIndices.contains(index) ? Date(timeIntervalSince1970: TimeInterval(message.createdAt) / 1000) : nil } check(rows.map(\.id) == expectedMessages.map(\.id), "\(label): preserve message IDs and order") check(rows.map(\.message) == expectedMessages, "\(label): preserve complete messages and status") check(rows.map(\.separatorDate) == expectedDates, "\(label): exact separator positions and timestamps") } static func checkDatedRows(calendar: Calendar, utc: TimeZone, seoul: TimeZone, losAngeles: TimeZone) { let first = serverMessage(1, at: "2026-10-16T10:00:00Z") let second = serverMessage(2, at: "2026-10-16T11:00:00Z") let third = serverMessage(3, at: "2026-10-16T12:00:00Z") let previous = serverMessage(4, at: "2026-10-15T23:00:00Z") let next = serverMessage(5, at: "2026-10-17T01:00:00Z") checkRows([], calendar: calendar, timeZone: utc, expectedMessages: [], separatorIndices: [], label: "empty") let singleRows = UserCreatorChatDatePresentation.rows(from: [first], calendar: calendar, timeZone: utc) check( singleRows.first?.separatorDate == Date(timeIntervalSince1970: TimeInterval(first.createdAt) / 1000), "single TEXT message must have its first separatorDate" ) checkRows([first], calendar: calendar, timeZone: utc, expectedMessages: [first], separatorIndices: [0], label: "single") checkRows([second, third], calendar: calendar, timeZone: utc, expectedMessages: [second, third], separatorIndices: [0], label: "before prepend") checkRows([first, second, third], calendar: calendar, timeZone: utc, expectedMessages: [first, second, third], separatorIndices: [0], label: "same day prepend moves first separator") checkRows([previous, first, second, third], calendar: calendar, timeZone: utc, expectedMessages: [previous, first, second, third], separatorIndices: [0, 1], label: "different day prepend") checkRows([previous, first, next], calendar: calendar, timeZone: utc, expectedMessages: [previous, first, next], separatorIndices: [0, 1, 2], label: "consecutive different days") let utcBoundary = [serverMessage(6, at: "2026-10-15T23:30:00Z"), serverMessage(7, at: "2026-10-16T00:30:00Z")] checkRows(utcBoundary, calendar: calendar, timeZone: seoul, expectedMessages: utcBoundary, separatorIndices: [0], label: "UTC midnight is same Seoul day with UTC calendar") checkRows(utcBoundary, calendar: calendar, timeZone: utc, expectedMessages: utcBoundary, separatorIndices: [0, 1], label: "same timestamps cross UTC day") let seoulBoundary = [serverMessage(8, at: "2026-10-16T14:30:00Z"), serverMessage(9, at: "2026-10-16T15:30:00Z")] checkRows(seoulBoundary, calendar: calendar, timeZone: seoul, expectedMessages: seoulBoundary, separatorIndices: [0, 1], label: "Seoul midnight within same UTC day") checkRows(seoulBoundary, calendar: calendar, timeZone: utc, expectedMessages: seoulBoundary, separatorIndices: [0], label: "same timestamps remain same UTC day") var mismatchedCalendar = calendar mismatchedCalendar.timeZone = seoul checkRows(seoulBoundary, calendar: mismatchedCalendar, timeZone: utc, expectedMessages: seoulBoundary, separatorIndices: [0], label: "explicit UTC overrides Seoul calendar") let repeatedHour = [serverMessage(10, at: "2026-11-01T08:30:00Z"), serverMessage(11, at: "2026-11-01T09:30:00Z")] let beforeMidnight = serverMessage(12, at: "2026-11-02T07:30:00Z") let midnight = serverMessage(13, at: "2026-11-02T08:00:00Z") checkRows(repeatedHour, calendar: calendar, timeZone: losAngeles, expectedMessages: repeatedHour, separatorIndices: [0], label: "LA DST repeated 01:30") let dstDay = repeatedHour + [beforeMidnight, midnight] checkRows(dstDay, calendar: calendar, timeZone: losAngeles, expectedMessages: dstDay, separatorIndices: [0, 3], label: "LA 25-hour day ends at local midnight") let earlierVoice = serverMessage(14, at: "2026-10-14T10:00:00Z", type: "VOICE") let betweenVoice = serverMessage(15, at: "2026-10-15T10:00:00Z", type: "VOICE") let laterVoice = serverMessage(16, at: "2026-10-18T10:00:00Z", type: "VOICE") checkRows([earlierVoice, betweenVoice, laterVoice], calendar: calendar, timeZone: utc, expectedMessages: [], separatorIndices: [], label: "VOICE-only has no orphan dates") checkRows([earlierVoice, first, betweenVoice, second, laterVoice], calendar: calendar, timeZone: utc, expectedMessages: [first, second], separatorIndices: [0], label: "mixed VOICE cannot split same TEXT day") checkRows([earlierVoice, first, betweenVoice, next, laterVoice], calendar: calendar, timeZone: utc, expectedMessages: [first, next], separatorIndices: [0, 1], label: "mixed VOICE preserves only displayed day boundaries") let pending = UserCreatorChatDisplayMessage(pendingText: "pending", requestId: "snapshot", createdAt: timestamp("2026-10-17T00:01:00Z")) var failed = pending failed.status = .failed checkRows([first, pending], calendar: calendar, timeZone: utc, expectedMessages: [first, pending], separatorIndices: [0, 1], label: "pending local date snapshot") checkRows([first, failed], calendar: calendar, timeZone: utc, expectedMessages: [first, failed], separatorIndices: [0, 1], label: "failed keeps pending date and ID") let pendingDates = UserCreatorChatDatePresentation.rows(from: [first, pending], calendar: calendar, timeZone: utc).map(\.separatorDate) let failedDates = UserCreatorChatDatePresentation.rows(from: [first, failed], calendar: calendar, timeZone: utc).map(\.separatorDate) check(pendingDates == failedDates, "pending-to-failed changes no separator timestamps") let serverSameDay = serverMessage(17, at: "2026-10-17T00:02:00Z") let serverChangedDay = serverMessage(17, at: "2026-10-16T23:59:00Z") checkRows([first, serverSameDay], calendar: calendar, timeZone: utc, expectedMessages: [first, serverSameDay], separatorIndices: [0, 1], label: "same day ACK replacement uses server timestamp and ID") checkRows([first, serverChangedDay], calendar: calendar, timeZone: utc, expectedMessages: [first, serverChangedDay], separatorIndices: [0], label: "changed day ACK removes empty pending date and duplicate destination date") checkRows([pending], calendar: calendar, timeZone: utc, expectedMessages: [pending], separatorIndices: [0], label: "only pending snapshot") checkRows([serverChangedDay], calendar: calendar, timeZone: utc, expectedMessages: [serverChangedDay], separatorIndices: [0], label: "only pending replaced leaves only server date") checkRows([first], calendar: calendar, timeZone: utc, expectedMessages: [first], separatorIndices: [0], label: "removed last day leaves no orphan") checkRows([], calendar: calendar, timeZone: utc, expectedMessages: [], separatorIndices: [], label: "removed all messages leaves no orphan") let equalTimestamp = serverMessage(18, at: "2026-10-16T10:00:00Z") checkRows([equalTimestamp, first, second], calendar: calendar, timeZone: utc, expectedMessages: [equalTimestamp, first, second], separatorIndices: [0], label: "equal timestamps keep supplied order and IDs") checkRows([next, first, second, previous, third], calendar: calendar, timeZone: utc, expectedMessages: [next, first, second, previous, third], separatorIndices: [0, 1, 3, 4], label: "only adjacent days compared without sorting") checkRows([first, first], calendar: calendar, timeZone: utc, expectedMessages: [first, first], separatorIndices: [0], label: "input is not deduplicated") let milliseconds = UserCreatorChatDisplayMessage(pendingText: "fraction", requestId: "milliseconds", createdAt: first.createdAt + 123) checkRows([milliseconds], calendar: calendar, timeZone: utc, expectedMessages: [milliseconds], separatorIndices: [0], label: "millisecond timestamp precision") } static func main() { let utc = TimeZone(secondsFromGMT: 0)! let seoul = TimeZone(identifier: "Asia/Seoul")! let losAngeles = TimeZone(identifier: "America/Los_Angeles")! var calendar = Calendar(identifier: .gregorian) calendar.timeZone = utc var seoulCalendar = calendar seoulCalendar.timeZone = seoul var losAngelesCalendar = calendar losAngelesCalendar.timeZone = losAngeles let fixtures: [(label: String, timestamp: String, calendar: Calendar, timeZone: TimeZone, expected: [String])] = [ ("Gregorian UTC fixture", "2026-10-16T12:00:00Z", calendar, utc, ["2026년 10월 16일 금요일", "Friday, October 16, 2026", "2026年10月16日 金曜日"]), ("previous year", "2025-10-16T12:00:00Z", calendar, utc, ["2025년 10월 16일 목요일", "Thursday, October 16, 2025", "2025年10月16日 木曜日"]), ("UTC boundary", "2026-10-16T15:30:00Z", calendar, utc, ["2026년 10월 16일 금요일", "Friday, October 16, 2026", "2026年10月16日 金曜日"]), ("Seoul boundary in every app locale", "2026-10-16T15:30:00Z", seoulCalendar, seoul, ["2026년 10월 17일 토요일", "Saturday, October 17, 2026", "2026年10月17日 土曜日"]), ("Los Angeles boundary in every app locale", "2026-10-16T15:30:00Z", losAngelesCalendar, losAngeles, ["2026년 10월 16일 금요일", "Friday, October 16, 2026", "2026年10月16日 金曜日"]), ("Los Angeles calendar normalized to Seoul", "2026-10-16T15:30:00Z", losAngelesCalendar, seoul, ["2026년 10월 17일 토요일", "Saturday, October 17, 2026", "2026年10月17日 土曜日"]), ("Seoul calendar normalized to Los Angeles", "2026-10-16T15:30:00Z", seoulCalendar, losAngeles, ["2026년 10월 16일 금요일", "Friday, October 16, 2026", "2026年10月16日 金曜日"]) ] let isoFormatter = ISO8601DateFormatter() for fixture in fixtures { checkTitles( for: isoFormatter.date(from: fixture.timestamp)!, calendar: fixture.calendar, timeZone: fixture.timeZone, expected: fixture.expected, label: fixture.label ) } let englishMonths = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] let englishWeekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] let koreanWeekdays = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"] let japaneseWeekdays = ["日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"] let today = Date() let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! for (label, date) in [("today without relative wording", today), ("yesterday without relative wording", yesterday)] { let components = calendar.dateComponents([.year, .month, .day, .weekday], from: date) let year = components.year! let month = components.month! let day = components.day! let weekday = components.weekday! - 1 checkTitles( for: date, calendar: calendar, timeZone: utc, expected: [ "\(year)년 \(month)월 \(day)일 \(koreanWeekdays[weekday])", "\(englishWeekdays[weekday]), \(englishMonths[month - 1]) \(day), \(year)", "\(year)年\(month)月\(day)日 \(japaneseWeekdays[weekday])" ], label: label ) } checkDatedRows(calendar: calendar, utc: utc, seoul: seoul, losAngeles: losAngeles) print("PASS: \(assertions) assertions") } }