182 lines
5.9 KiB
Swift
182 lines
5.9 KiB
Swift
// 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)
|
||
}
|
||
}
|