import XCTest import RastrilloApple @testable import CalendarKit // B11 (polish-audit PLAN): a failed load used to run `events = []`, so // any blip — a dropped connection, a 500, going offline — blanked a // calendar that was on screen a second earlier, and the menu-bar panel // shares the model, so opening it offline blanked the window too. final class LoadFailureTests: XCTestCase { @MainActor func testFailedReloadKeepsLastGoodEventsAndReportsTheError() async throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") process.arguments = ["-u", "-c", """ import http.server, json failing=False event={'id':7,'calendar_id':1,'status':'confirmed','title':'Standup','location':'','notes':'','all_day':False, 'start_local':'2030-10-25T09:00','end_local':'2030-10-25T09:30','tz':'Europe/Dublin','start':1919232000,'end':1919233800, 'recurring':False,'writable':True,'calendar_name':'Personal','occurrence':'','day_start':'2030-10-25','day_end':'2030-10-25'} class Handler(http.server.BaseHTTPRequestHandler): def log_message(self,*args): pass def reply(self,code,value): data=json.dumps(value).encode(); self.send_response(code); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data) def do_GET(self): global failing if self.path=='/fail': failing=True; self.reply(200,{}); return if self.path=='/recover': failing=False; self.reply(200,{}); return if failing: self.reply(500,{'error':'fixture outage'}); return if self.path.startswith('/api/native/session'): self.reply(200,{'week_start':1,'zone':'Europe/Dublin','calendars':[{'id':1,'name':'Personal','hue':200,'writable':True}],'default_calendar_id':1}); return if self.path.startswith('/api/native/events'): self.reply(200,{'events':[event]}); return self.reply(404,{}) server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler) print(server.server_port,flush=True); server.serve_forever() """] let output = Pipe(); process.standardOutput = output; process.standardError = FileHandle.nullDevice try process.run() defer { process.terminate() } let data = output.fileHandleForReading.availableData let port = try XCTUnwrap(String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)) let client = NativeHTTP(origin: URL(string: "http://127.0.0.1:\(port)")!) let model = CalendarModel(zone: "Europe/Dublin", date: Date(timeIntervalSince1970: 1919232000)) // A first load that FAILS has nothing to keep: the error stands alone. _ = try await client.response("/fail") await model.load(client) XCTAssertFalse(model.error.isEmpty) XCTAssertFalse(model.hasLoaded) XCTAssertTrue(model.events.isEmpty) // It recovers, and the day shows its event. _ = try await client.response("/recover") await model.load(client) XCTAssertTrue(model.error.isEmpty) XCTAssertTrue(model.hasLoaded) XCTAssertEqual(model.events.map(\.title), ["Standup"]) XCTAssertEqual(model.calendars.map(\.name), ["Personal"]) // Then the server goes away. The refresh fails, the error is // reported, and what was on screen stays on screen. _ = try await client.response("/fail") await model.load(client) XCTAssertFalse(model.error.isEmpty, "a failed refresh must still say it failed") XCTAssertTrue(model.hasLoaded) XCTAssertFalse(model.loading) XCTAssertEqual(model.events.map(\.title), ["Standup"], "a failed refresh blanked the events on screen") XCTAssertEqual(model.calendars.map(\.name), ["Personal"]) XCTAssertEqual(model.dayEvents(model.date).map(\.title), ["Standup"]) // Signing out is the one thing that does clear it. model.clear() XCTAssertFalse(model.hasLoaded) XCTAssertTrue(model.events.isEmpty) } }