// ScrollbackCacheTests.swift // Unit tests for ScrollbackCache — the rolling 5 MB on-disk ANSI cache. // // Each test creates its own ScrollbackCache with a unique UUID session ID so // instances never share the same file. All test files are deleted in tearDown. // // Tests are synchronous because ScrollbackCache's public API is // synchronous (guarded internally by a serial DispatchQueue). import XCTest @testable import piRemote final class ScrollbackCacheTests: XCTestCase { // Track session IDs created so tearDown can clean every one up. private var sessionIds: [String] = [] override func setUp() { super.setUp() sessionIds = [] } override func tearDown() { // Remove every test cache file, regardless of test outcome. for id in sessionIds { ScrollbackCache(sessionId: id).clear() } sessionIds.removeAll() super.tearDown() } // Convenience: create a cache for a fresh UUID session and track it. private func makeCache() -> ScrollbackCache { let id = UUID().uuidString sessionIds.append(id) return ScrollbackCache(sessionId: id) } // MARK: - Append + Read round-trip /// A single `append` followed by `read` returns exactly the same bytes. func testAppendAndRead_roundTrip() { let cache = makeCache() let data = Data("hello world".utf8) cache.append(data) XCTAssertEqual(cache.read(), data) } /// Multiple successive appends are concatenated in order. func testMultipleAppends_areOrdered() { let cache = makeCache() cache.append(Data("foo".utf8)) cache.append(Data("bar".utf8)) XCTAssertEqual(cache.read(), Data("foobar".utf8)) } /// `sizeBytes` reflects the total bytes written. func testSizeBytes_reflectsAppend() { let cache = makeCache() let data = Data(repeating: 0xAB, count: 1_024) cache.append(data) XCTAssertEqual(cache.sizeBytes, 1_024) } // MARK: - Cap enforcement private let fiveMB = 5 * 1024 * 1024 /// Writing > 5 MB of data keeps the on-disk size at or below 5 MB. func testCap_sizeStaysWithinFiveMB() { let cache = makeCache() // Write three 2 MB chunks (6 MB total) — must trim. let chunk = Data(repeating: 0xCC, count: 2 * 1_024 * 1_024) cache.append(chunk) cache.append(chunk) cache.append(chunk) XCTAssertLessThanOrEqual( cache.sizeBytes, fiveMB, "sizeBytes (\(cache.sizeBytes)) should be ≤ 5 MB after overflow" ) } /// After overflow, the last bytes in the file come from the newest chunk (oldest dropped). func testCap_dropsOldestBytes() { let cache = makeCache() // 3 MB of 'A' then 3 MB of 'B' = 6 MB total; trim must keep the tail. let chunkA = Data(repeating: 0x41, count: 3 * 1_024 * 1_024) // 'A' let chunkB = Data(repeating: 0x42, count: 3 * 1_024 * 1_024) // 'B' cache.append(chunkA) cache.append(chunkB) let result = cache.read() XCTAssertFalse(result.isEmpty, "result must not be empty after overflow") XCTAssertLessThanOrEqual(result.count, fiveMB) // The tail of the retained data must be the newest bytes ('B'). XCTAssertEqual(result.last, 0x42, "last byte should be from the newest chunk") // The very first byte of 'A'-only content should have been trimmed. XCTAssertEqual(result.first, 0x41, "some 'A' bytes may remain at the head, but 'A'-only prefix was trimmed") } /// `read()` count stays ≤ 5 MB even after many small appends that accumulate. func testCap_manySmallAppends() { let cache = makeCache() // 1 024 appends × 6 KB = ~6 MB let chunk = Data(repeating: 0x55, count: 6 * 1_024) for _ in 0..<1_024 { cache.append(chunk) } XCTAssertLessThanOrEqual(cache.read().count, fiveMB) } // MARK: - clear /// After `clear()`, `sizeBytes` is 0. func testClear_zeroesSizeBytes() { let cache = makeCache() cache.append(Data(repeating: 0x00, count: 1_024)) XCTAssertGreaterThan(cache.sizeBytes, 0) // precondition cache.clear() XCTAssertEqual(cache.sizeBytes, 0) } /// After `clear()`, `read()` returns empty Data. func testClear_emptiesData() { let cache = makeCache() cache.append(Data("test".utf8)) cache.clear() XCTAssertTrue(cache.read().isEmpty) } /// Appending after `clear()` works as if the cache were freshly created. func testClear_thenAppend_worksCorrectly() { let cache = makeCache() cache.append(Data("old".utf8)) cache.clear() cache.append(Data("new".utf8)) XCTAssertEqual(cache.read(), Data("new".utf8)) XCTAssertEqual(cache.sizeBytes, 3) } // MARK: - Isolation between instances /// Two caches with different session IDs do not share state. func testTwoInstances_doNotInterfere() { let cacheA = makeCache() let cacheB = makeCache() cacheA.append(Data("alpha".utf8)) cacheB.append(Data("beta".utf8)) XCTAssertEqual(cacheA.read(), Data("alpha".utf8)) XCTAssertEqual(cacheB.read(), Data("beta".utf8)) } /// Clearing one cache does not affect another. func testClearOneCache_doesNotAffectOther() { let cacheA = makeCache() let cacheB = makeCache() cacheA.append(Data("kept".utf8)) cacheB.append(Data("cleared".utf8)) cacheB.clear() XCTAssertEqual(cacheA.read(), Data("kept".utf8)) XCTAssertTrue(cacheB.read().isEmpty) } // MARK: - Edge cases /// Appending empty Data is a no-op (no file created, size stays 0). func testAppendEmptyData_isNoOp() { let cache = makeCache() cache.append(Data()) XCTAssertEqual(cache.sizeBytes, 0) XCTAssertTrue(cache.read().isEmpty) } }