Release 1.4.0

Fixes:
- Sandbox: add LaunchAgent temporary-exception entitlement
- Mirror URL: fix broken buildMirrorURL (URLComponents with ; separator)
- Cleanup: add bounds check to prevent crash on missing source
- State safety: pass MirrorConfig instead of mutating global @State
- KVC: remove misleading do-catch around setValue:forKey:
- Log cap: limit in-memory log to 2000 lines
- CLI: fix race with calendar loading
- launchCtl: separate stdout/stderr pipes

Features:
- Cancel button for long-running mirrors
- Progress indicator for multi-route runs (Route X of Y)
- Target event cache across routes

Code quality:
- Extract BlockMath, MirrorUtils, EventFilters, MirrorConfig
- Add 45 unit tests across 3 test files
- Refactor mergeGapMin to computed property
- Make log editor read-only

Build:
- Bump version to 1.4.0 (build 18)
- Add LSMinimumSystemVersion 15.5
This commit is contained in:
2026-05-27 11:00:18 +02:00
parent fe9e813583
commit f625ecc263
14 changed files with 1063 additions and 377 deletions
+158
View File
@@ -0,0 +1,158 @@
import XCTest
@testable import BusyMirror
final class BlockMathTests: XCTestCase {
private let d = Date(timeIntervalSince1970: 0)
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil) -> Block {
Block(
start: d.addingTimeInterval(TimeInterval(startMin * 60)),
end: d.addingTimeInterval(TimeInterval(endMin * 60)),
srcStableID: id,
label: nil,
notes: nil,
occurrence: nil
)
}
// MARK: - mergeBlocks
func testMergeBlocksNoGap() {
let blocks = [block(0, 10), block(10, 20)]
let merged = mergeBlocks(blocks, gapMinutes: 0)
XCTAssertEqual(merged.count, 1)
XCTAssertEqual(merged[0].start, blocks[0].start)
XCTAssertEqual(merged[0].end, blocks[1].end)
}
func testMergeBlocksWithGapUnderThreshold() {
let blocks = [block(0, 10), block(15, 20)]
let merged = mergeBlocks(blocks, gapMinutes: 10)
XCTAssertEqual(merged.count, 1)
XCTAssertEqual(merged[0].start, blocks[0].start)
XCTAssertEqual(merged[0].end, blocks[1].end)
}
func testMergeBlocksWithGapOverThreshold() {
let blocks = [block(0, 10), block(20, 30)]
let merged = mergeBlocks(blocks, gapMinutes: 5)
XCTAssertEqual(merged.count, 2)
}
func testMergeBlocksEmpty() {
XCTAssertTrue(mergeBlocks([], gapMinutes: 10).isEmpty)
}
func testMergeBlocksUnsortedInput() {
let blocks = [block(30, 40), block(0, 10), block(10, 20)]
let merged = mergeBlocks(blocks, gapMinutes: 0)
XCTAssertEqual(merged.count, 2)
XCTAssertEqual(merged[0].start, blocks[1].start)
XCTAssertEqual(merged[0].end, blocks[2].end)
XCTAssertEqual(merged[1].start, blocks[0].start)
XCTAssertEqual(merged[1].end, blocks[0].end)
}
// MARK: - coalesce
func testCoalesceOverlappingBlocks() {
let blocks = [block(0, 15), block(10, 20), block(25, 30)]
let result = coalesce(blocks)
XCTAssertEqual(result.count, 2)
XCTAssertEqual(result[0].start, blocks[0].start)
XCTAssertEqual(result[0].end, blocks[1].end)
XCTAssertEqual(result[1].start, blocks[2].start)
XCTAssertEqual(result[1].end, blocks[2].end)
}
// MARK: - fullyCovered
func testFullyCoveredExactMatch() {
let occupied = [block(0, 10)]
let b = block(0, 10)
XCTAssertTrue(fullyCovered(occupied, block: b, tolMin: 0))
}
func testFullyCoveredPartialOverlap() {
let occupied = [block(0, 5)]
let b = block(0, 10)
XCTAssertFalse(fullyCovered(occupied, block: b, tolMin: 0))
}
func testFullyCoveredWithTolerance() {
let occupied = [block(0, 10)]
let b = block(2, 8)
XCTAssertTrue(fullyCovered(occupied, block: b, tolMin: 5))
}
func testFullyCoveredMultipleSegments() {
let occupied = coalesce([block(0, 3), block(3, 10)])
let b = block(0, 10)
XCTAssertTrue(fullyCovered(occupied, block: b, tolMin: 0))
}
// MARK: - gapsWithin
func testGapsWithinNoOccupied() {
let b = block(0, 60)
let gaps = gapsWithin([], in: b)
XCTAssertEqual(gaps.count, 1)
XCTAssertEqual(gaps[0].start, b.start)
XCTAssertEqual(gaps[0].end, b.end)
}
func testGapsWithinSingleGap() {
let occupied = [block(0, 10), block(20, 30)]
let b = block(0, 30)
let gaps = gapsWithin(occupied, in: b)
XCTAssertEqual(gaps.count, 1)
XCTAssertEqual(gaps[0].start, occupied[0].end)
XCTAssertEqual(gaps[0].end, occupied[1].start)
}
func testGapsWithinMultipleGaps() {
let occupied = [block(5, 10), block(15, 20)]
let b = block(0, 30)
let gaps = gapsWithin(occupied, in: b)
XCTAssertEqual(gaps.count, 3)
XCTAssertEqual(gaps[0].start, b.start)
XCTAssertEqual(gaps[0].end, occupied[0].start)
XCTAssertEqual(gaps[1].start, occupied[0].end)
XCTAssertEqual(gaps[1].end, occupied[1].start)
XCTAssertEqual(gaps[2].start, occupied[1].end)
XCTAssertEqual(gaps[2].end, b.end)
}
func testGapsWithinExactFit() {
let occupied = [block(0, 10)]
let b = block(0, 10)
let gaps = gapsWithin(occupied, in: b)
XCTAssertTrue(gaps.isEmpty)
}
// MARK: - uniqueBlocks
func testUniqueBlocksByTime() {
let blocks = [block(0, 10), block(0, 10), block(10, 20)]
let result = uniqueBlocks(blocks, trackByID: false)
XCTAssertEqual(result.count, 2)
}
func testUniqueBlocksByID() {
let blocks = [
block(0, 10, id: "a"),
block(0, 10, id: "a"),
block(5, 15, id: "b")
]
let result = uniqueBlocks(blocks, trackByID: true)
XCTAssertEqual(result.count, 2)
}
func testUniqueBlocksByIDDifferentOccurrence() {
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d)
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600))
let result = uniqueBlocks([b1, b2], trackByID: true)
XCTAssertEqual(result.count, 2)
}
}