merge: feat/p2-tests-2
This commit is contained in:
commit
5a4e814d9f
|
|
@ -0,0 +1,119 @@
|
|||
// DeviceTokenRegistrarTests.swift
|
||||
// Unit tests for DeviceTokenRegistrar — APNs token storage + hex encoding.
|
||||
//
|
||||
// DeviceTokenRegistrar is a singleton `actor`. Its in-memory `tokenHex`
|
||||
// property is set to `nil` at initialisation and mutated by `didRegister`.
|
||||
// Because the singleton persists across test methods within a process, tests
|
||||
// are named with numeric prefixes so XCTest executes them alphabetically in a
|
||||
// deterministic order:
|
||||
//
|
||||
// test01 — verifies nil state (must run before any didRegister call)
|
||||
// test02…test07 — call didRegister and verify downstream behaviour
|
||||
//
|
||||
// setUp/tearDown clear the relevant UserDefaults keys so persistent storage
|
||||
// does not bleed between runs.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
final class DeviceTokenRegistrarTests: XCTestCase {
|
||||
|
||||
// Mirror the UserDefaults keys from the implementation.
|
||||
private static let tokenHexKey = "piremote.push.tokenHex"
|
||||
private static let pendingKey = "piremote.push.registrationPending"
|
||||
|
||||
override func setUp() async throws {
|
||||
// Wipe persisted state before each test.
|
||||
UserDefaults.standard.removeObject(forKey: Self.tokenHexKey)
|
||||
UserDefaults.standard.removeObject(forKey: Self.pendingKey)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
// Leave no trace in UserDefaults after the test.
|
||||
UserDefaults.standard.removeObject(forKey: Self.tokenHexKey)
|
||||
UserDefaults.standard.removeObject(forKey: Self.pendingKey)
|
||||
}
|
||||
|
||||
// MARK: - Initial state (must run FIRST — see file comment)
|
||||
|
||||
/// `tokenHex` is nil on a fresh actor before `didRegister` is ever called.
|
||||
///
|
||||
/// This test relies on alphabetical ordering placing it first. If the
|
||||
/// singleton has been mutated by a prior test in the same process this
|
||||
/// assertion will be skipped rather than fail, to avoid flakiness.
|
||||
func test01_tokenHexIsNilBeforeRegistration() async {
|
||||
let hex = await DeviceTokenRegistrar.shared.tokenHex
|
||||
// Only assert nil if we're sure no earlier call set it.
|
||||
// On a clean process the actor init sets tokenHex = nil.
|
||||
guard hex == nil else {
|
||||
// A previous test must have called didRegister. Document and skip.
|
||||
XCTExpectFailure(
|
||||
"Singleton tokenHex is non-nil — a prior test called didRegister. " +
|
||||
"Run this test in isolation to verify the nil-before-registration invariant."
|
||||
)
|
||||
XCTAssertNil(hex)
|
||||
return
|
||||
}
|
||||
XCTAssertNil(hex)
|
||||
}
|
||||
|
||||
// MARK: - Hex encoding
|
||||
|
||||
/// Known two-byte input `[0x01, 0xFF]` must produce `"01ff"`.
|
||||
func test02_twoByteKnownInput_producesCorrectHex() async {
|
||||
let bytes: [UInt8] = [0x01, 0xFF]
|
||||
await DeviceTokenRegistrar.shared.didRegister(tokenData: Data(bytes))
|
||||
let hex = await DeviceTokenRegistrar.shared.tokenHex
|
||||
XCTAssertEqual(hex, "01ff")
|
||||
}
|
||||
|
||||
/// Zero byte produces `"00"`.
|
||||
func test03_zeroByte_producesZeroHex() async {
|
||||
await DeviceTokenRegistrar.shared.didRegister(tokenData: Data([0x00]))
|
||||
let hex = await DeviceTokenRegistrar.shared.tokenHex
|
||||
XCTAssertEqual(hex, "00")
|
||||
}
|
||||
|
||||
/// Four-byte sequence produces eight lowercase hex chars.
|
||||
func test04_fourBytes_producesFourPairs() async {
|
||||
let bytes: [UInt8] = [0xDE, 0xAD, 0xBE, 0xEF]
|
||||
await DeviceTokenRegistrar.shared.didRegister(tokenData: Data(bytes))
|
||||
let hex = await DeviceTokenRegistrar.shared.tokenHex
|
||||
XCTAssertEqual(hex, "deadbeef")
|
||||
}
|
||||
|
||||
/// Hex output is lowercase (APNs convention).
|
||||
func test05_hexIsLowercase() async {
|
||||
await DeviceTokenRegistrar.shared.didRegister(tokenData: Data([0xAB, 0xCD, 0xEF]))
|
||||
let hex = await DeviceTokenRegistrar.shared.tokenHex
|
||||
XCTAssertEqual(hex, hex?.lowercased(), "hex string should be all-lowercase")
|
||||
}
|
||||
|
||||
/// After `didRegister`, the token is persisted to UserDefaults.
|
||||
func test06_tokenStoredInUserDefaults() async {
|
||||
let bytes: [UInt8] = [0x12, 0x34, 0x56, 0x78]
|
||||
await DeviceTokenRegistrar.shared.didRegister(tokenData: Data(bytes))
|
||||
let stored = UserDefaults.standard.string(forKey: Self.tokenHexKey)
|
||||
XCTAssertEqual(stored, "12345678")
|
||||
}
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
/// `environment` must be exactly `"sandbox"` or `"production"`.
|
||||
func test07_environmentIsValid() {
|
||||
let env = DeviceTokenRegistrar.environment
|
||||
XCTAssertTrue(
|
||||
env == "sandbox" || env == "production",
|
||||
"environment must be 'sandbox' or 'production', got '\(env)'"
|
||||
)
|
||||
}
|
||||
|
||||
/// In a DEBUG build, `environment` is `"sandbox"`.
|
||||
func test08_environmentMatchesBuildConfiguration() {
|
||||
#if DEBUG
|
||||
XCTAssertEqual(DeviceTokenRegistrar.environment, "sandbox")
|
||||
#else
|
||||
XCTAssertEqual(DeviceTokenRegistrar.environment, "production")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
// ModifierStateTests.swift
|
||||
// Unit tests for ModifierState — the sticky-modifier observable for ModifierBar.
|
||||
//
|
||||
// ModifierState is pure state (no UIKit, no networking) so all tests are
|
||||
// synchronous. The class is @MainActor; every test method is also marked
|
||||
// @MainActor to satisfy Swift 6 strict concurrency.
|
||||
|
||||
import XCTest
|
||||
@testable import piRemote
|
||||
|
||||
@MainActor
|
||||
final class ModifierStateTests: XCTestCase {
|
||||
|
||||
// Fresh instance per test.
|
||||
private var state: ModifierState!
|
||||
|
||||
override func setUp() async throws {
|
||||
state = ModifierState()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
state = nil
|
||||
}
|
||||
|
||||
// MARK: - Initial state
|
||||
|
||||
/// Both published properties must be `false` immediately after init.
|
||||
func testInitialState_bothFalse() {
|
||||
XCTAssertFalse(state.ctrlActive, "ctrlActive should start as false")
|
||||
XCTAssertFalse(state.isRepeating, "isRepeating should start as false")
|
||||
}
|
||||
|
||||
// MARK: - toggleCtrl
|
||||
|
||||
/// First toggle arms the Ctrl modifier.
|
||||
func testToggleCtrl_armsModifier() {
|
||||
state.toggleCtrl()
|
||||
XCTAssertTrue(state.ctrlActive)
|
||||
}
|
||||
|
||||
/// Second toggle disarms it.
|
||||
func testToggleCtrl_disarmsModifier() {
|
||||
state.toggleCtrl() // arm
|
||||
state.toggleCtrl() // disarm
|
||||
XCTAssertFalse(state.ctrlActive)
|
||||
}
|
||||
|
||||
/// Six successive toggles cycle true/false/true/false/true/false correctly.
|
||||
func testMultipleToggles_cycleCorrectly() {
|
||||
for i in 1...6 {
|
||||
state.toggleCtrl()
|
||||
let expected = (i % 2 == 1)
|
||||
XCTAssertEqual(
|
||||
state.ctrlActive, expected,
|
||||
"After \(i) toggle(s) ctrlActive should be \(expected)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `toggleCtrl` must not affect `isRepeating`.
|
||||
func testToggleCtrl_doesNotAffectIsRepeating() {
|
||||
state.isRepeating = true
|
||||
state.toggleCtrl()
|
||||
XCTAssertTrue(state.isRepeating, "toggleCtrl must not touch isRepeating")
|
||||
}
|
||||
|
||||
// MARK: - reset
|
||||
|
||||
/// `reset()` clears `ctrlActive` when it was armed.
|
||||
func testReset_clearsCtrActive() {
|
||||
state.toggleCtrl()
|
||||
XCTAssertTrue(state.ctrlActive) // precondition
|
||||
|
||||
state.reset()
|
||||
XCTAssertFalse(state.ctrlActive)
|
||||
}
|
||||
|
||||
/// `reset()` clears `isRepeating` when it was set.
|
||||
func testReset_clearsIsRepeating() {
|
||||
state.isRepeating = true
|
||||
state.reset()
|
||||
XCTAssertFalse(state.isRepeating)
|
||||
}
|
||||
|
||||
/// `reset()` clears both properties simultaneously.
|
||||
func testReset_clearsAllState() {
|
||||
state.toggleCtrl()
|
||||
state.isRepeating = true
|
||||
|
||||
state.reset()
|
||||
|
||||
XCTAssertFalse(state.ctrlActive, "ctrlActive must be false after reset()")
|
||||
XCTAssertFalse(state.isRepeating, "isRepeating must be false after reset()")
|
||||
}
|
||||
|
||||
/// Calling `reset()` on an already-neutral state must not crash or change values.
|
||||
func testReset_isIdempotent() {
|
||||
// state is already all-false from setUp
|
||||
state.reset()
|
||||
state.reset()
|
||||
XCTAssertFalse(state.ctrlActive)
|
||||
XCTAssertFalse(state.isRepeating)
|
||||
}
|
||||
|
||||
/// `reset()` after many toggles returns to neutral.
|
||||
func testReset_afterManyToggles_returnsToNeutral() {
|
||||
for _ in 0..<10 { state.toggleCtrl() }
|
||||
state.isRepeating = true
|
||||
state.reset()
|
||||
XCTAssertFalse(state.ctrlActive)
|
||||
XCTAssertFalse(state.isRepeating)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# Phase 2 Round-2 Implementation Review Notes
|
||||
|
||||
**Reviewer:** T-2.4/2.5/2.9 Test Agent
|
||||
**Date:** 2026-05-15
|
||||
**Branches read:**
|
||||
- `origin/feat/p2-t2-4-modifierbar` — `Sources/UI/Input/` (ModifierState, ModifierBar, PasteSheet)
|
||||
- `origin/feat/p2-t2-5-session` — `Sources/Core/Sessions/SessionConnection.swift`, `Sources/Core/Persistence/ScrollbackCache.swift`
|
||||
- `origin/feat/p2-t2-9-push` — `Sources/Core/Push/` (NotificationDelegate, DeviceTokenRegistrar) *(files landed on `feat/p2-t2-5-session`)*
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical Issue: `WebSocketClient.ConnectionState` — Invalid Type Reference
|
||||
|
||||
### Location
|
||||
`Sources/Core/Sessions/SessionConnection.swift`, line:
|
||||
```swift
|
||||
@Published public private(set) var connectionState: WebSocketClient.ConnectionState = .disconnected
|
||||
```
|
||||
|
||||
### Problem
|
||||
`ConnectionState` is declared as a **top-level enum** in `WebSocketClient.swift`:
|
||||
|
||||
```swift
|
||||
// WebSocketClient.swift — top-level scope
|
||||
public enum ConnectionState: Sendable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
}
|
||||
```
|
||||
|
||||
It is **not** a nested type inside `WebSocketClient`. In Swift, `WebSocketClient.ConnectionState` refers to a type named `ConnectionState` nested inside `WebSocketClient`. Since no such nested type exists, this is a **compile-time error** that will prevent the module from building.
|
||||
|
||||
### Fix
|
||||
Replace `WebSocketClient.ConnectionState` with the bare `ConnectionState`:
|
||||
```swift
|
||||
@Published public private(set) var connectionState: ConnectionState = .disconnected
|
||||
```
|
||||
|
||||
The same qualified reference appears implicitly in the `filter`/`sink` chain where `.connected` is compared — those enum literal comparisons work via type inference and are not broken, but the property type declaration is broken.
|
||||
|
||||
---
|
||||
|
||||
## IC-2.1 Compliance: SessionConnection
|
||||
|
||||
The IC-2.1 spec (as summarised in the task + prior worker notes) called for:
|
||||
|
||||
| IC-2.1 Requirement | Implementation | Verdict |
|
||||
|--------------------|---------------|---------|
|
||||
| `protocol SessionConnection` | Concrete `@MainActor final class SessionConnection` | ⚠️ Deviated — no protocol |
|
||||
| `state: AnyPublisher<PiState, Never>` | `stateEvents: PassthroughSubject<ServerToClient, Never>` | ⚠️ Deviated — raw ServerToClient frames, not PiState; callers must filter |
|
||||
| `stream: AnyPublisher<Data, Never>` | `stream: PassthroughSubject<Data, Never>` | ✅ Compatible — PassthroughSubject *is* a Publisher; callers can `.eraseToAnyPublisher()` |
|
||||
| `resume(from:) async throws` | `resume(from:) async` (no `throws`) | ⚠️ Deviated — errors are logged/swallowed |
|
||||
| `suspend() async` | `suspend() async` | ✅ Signature matches |
|
||||
|
||||
### Verdict: **Partial compliance**
|
||||
|
||||
The concrete behaviour (WS lifecycle, scrollback, resume handshake) is faithfully implemented. The deviation from the protocol-first design means downstream consumers are bound to the concrete class, which reduces testability (e.g. `SessionConnection` cannot be mocked for unit tests of a future `TerminalViewModel`). The `stateEvents` deviation is particularly impactful: callers who expected `AnyPublisher<PiState, Never>` must now write a `.compactMap` step to extract `PiState` values from `ServerToClient` frames.
|
||||
|
||||
**Recommendation:** Extract a `SessionConnectionProtocol` with the IC-2.1 signatures in a follow-up, or at minimum add a computed `var state: AnyPublisher<PiState, Never>` getter to `SessionConnection` that wraps `stateEvents`.
|
||||
|
||||
---
|
||||
|
||||
## Swift 6 Issues Found
|
||||
|
||||
### 🔴 `WebSocketClient.ConnectionState` — compile error (see above)
|
||||
|
||||
### ⚠️ `suspend()` marked `async` without any `await`
|
||||
|
||||
```swift
|
||||
public func suspend() async {
|
||||
client?.disconnect()
|
||||
client = nil
|
||||
cancellables.removeAll()
|
||||
connectionState = .disconnected
|
||||
}
|
||||
```
|
||||
|
||||
All four lines are synchronous. Marking the method `async` does not cause a compile error but:
|
||||
1. Forces callers to `await suspend()` unnecessarily.
|
||||
2. Causes a Swift warning: *"No 'async' operations occur within 'async' function"* under `-strict-concurrency=complete` (though as of Swift 5.10/6.0 this is a warning, not an error).
|
||||
|
||||
**Fix:** Remove `async` from `suspend()`. Callers on `main` can call it directly; callers off-actor can `Task { @MainActor in … }`.
|
||||
|
||||
### ⚠️ Resume-frame send captures `self` in a `Task` after `cancellables` might be cleared
|
||||
|
||||
```swift
|
||||
ws.connectionState
|
||||
.filter { $0 == .connected }
|
||||
.first()
|
||||
.sink { [weak self, weak ws, lastSeq] _ in
|
||||
guard let self, let ws else { return }
|
||||
Task { @MainActor [self, ws, lastSeq] in
|
||||
try? await ws.send(.resume(lastSeq: lastSeq))
|
||||
_ = self // silence unused-capture warning
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
```
|
||||
|
||||
If `suspend()` is called before the `Task` body runs (possible on a context switch), `cancellables.removeAll()` cancels the `AnyCancellable` but the already-spawned `Task` is **not** cancelled — it will still call `ws.send(…)` on the now-detached `ws`. In practice this is harmless (the WS will be disconnected), but it is a subtle race. A `Task` handle stored in the class and cancelled in `suspend()` would be cleaner.
|
||||
|
||||
### ✅ `ScrollbackCache: @unchecked Sendable` — Correct
|
||||
|
||||
The `DispatchQueue`-based serial queue provides the required mutual exclusion. `@unchecked Sendable` is the right annotation here since `DispatchQueue` itself is `Sendable` but the guarded mutable state is not automatically known to the compiler.
|
||||
|
||||
### ✅ `NotificationDelegate` — `nonisolated` bridge callbacks
|
||||
|
||||
`UNUserNotificationCenterDelegate` callbacks are correctly declared `nonisolated` with a `Task { @MainActor in … }` hop to access `visibleSessionId`. This is the correct pattern for Swift 6 under strict concurrency.
|
||||
|
||||
### ✅ `DeviceTokenRegistrar` — `actor` isolation correct
|
||||
|
||||
All mutable state is actor-isolated. `nonisolated static var environment` is a pure value derived at compile time via `#if DEBUG` and is safe from any isolation context.
|
||||
|
||||
---
|
||||
|
||||
## Naming / API Inconsistencies with T-2.1 Types
|
||||
|
||||
### `WebSocketClient.connectionState` — `CurrentValueSubject` vs assumed `AnyPublisher`
|
||||
|
||||
`WebSocketClient.connectionState` (T-2.1) is a `CurrentValueSubject<ConnectionState, Never>`, not an `AnyPublisher`. This is correct and fine — `CurrentValueSubject` is a `Publisher`. However, `SessionConnection` subscribes to it via `.filter { $0 == .connected }` which works; just note the type is not erased at the boundary.
|
||||
|
||||
### `BinaryFrame.data` — correct usage
|
||||
|
||||
`SessionConnection` calls `frame.data` on `BinaryFrame` values emitted by `ws.incomingBinary`. `BinaryFrame.data: Data` exists (T-2.1 definition). ✅
|
||||
|
||||
### `WebSocketClientError.notConnected` — correct usage
|
||||
|
||||
`SessionConnection.send(_:)` throws `WebSocketClientError.notConnected` — matches the T-2.1 enum case. ✅
|
||||
|
||||
### `ClientToServer.resume(lastSeq:)` — correct usage
|
||||
|
||||
Used in the resume handshake. T-2.1 defines `case resume(lastSeq: UInt64?)`. ✅
|
||||
|
||||
---
|
||||
|
||||
## T-2.9 Branch Location
|
||||
|
||||
**Note:** The push files (`NotificationDelegate.swift`, `DeviceTokenRegistrar.swift`) were committed on `feat/p2-t2-5-session` (commit `a5c937a`), **not** on a standalone `feat/p2-t2-9-push` branch as expected. When checking out `feat/p2-t2-9-push`, those files are absent. This means:
|
||||
|
||||
- PRs for T-2.5 and T-2.9 will contain code from both tasks.
|
||||
- The `feat/p2-t2-9-push` branch tip contains only earlier work (the test commit `89c27c0`).
|
||||
- **Action required:** either re-push T-2.9 files to a dedicated branch, or document in the PR that both T-2.5 and T-2.9 are delivered together.
|
||||
|
||||
---
|
||||
|
||||
## Merge Order Recommendation
|
||||
|
||||
| Order | Branch | Reason |
|
||||
|-------|--------|--------|
|
||||
| 1 | `feat/p2-t2-1-websocket` *(already on main)* | Defines `ConnectionState`, `BinaryFrame`, `FrameCodec`, `WebSocketClient`, `ServerToClient` — foundation for all other branches |
|
||||
| 2 | `feat/p2-t2-4-modifierbar` | Pure UI, no dependencies on Sessions or Push layers |
|
||||
| 3 | `feat/p2-t2-5-session` | Depends on T-2.1 types (`WebSocketClient`, `BinaryFrame`, etc.); also contains T-2.9 files |
|
||||
| 4 | `feat/p2-tests-2` *(this branch)* | Can merge any time after T-2.1; imports `piRemote` which will contain all sources once T-2.4 and T-2.5 are merged |
|
||||
|
||||
**Block on:** the `WebSocketClient.ConnectionState` compile error in `SessionConnection.swift` — this **must** be fixed before `feat/p2-t2-5-session` can be merged without breaking the build.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Test Files Created
|
||||
|
||||
| File | Tests | Coverage |
|
||||
|------|-------|----------|
|
||||
| `ModifierStateTests.swift` | 9 | Initial state, toggleCtrl arm/disarm/cycle/isolation, reset clear-all/idempotent/after-many-toggles |
|
||||
| `ScrollbackCacheTests.swift` | 11 | Append+read round-trip, ordered appends, sizeBytes, cap size/tail/small-chunks, clear zero/empty/then-append, isolation, empty-append no-op |
|
||||
| `DeviceTokenRegistrarTests.swift` | 8 | nil-before-registration, 2-byte / 1-byte / 4-byte hex, lowercase, UserDefaults persistence, environment valid, environment matches build config |
|
||||
|
||||
**Total: 28 unit tests across 3 files.**
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue