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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import XCTest
|
||||
@testable import BusyMirror
|
||||
|
||||
final class EventFiltersTests: XCTestCase {
|
||||
|
||||
// MARK: - isOutsideWorkHours
|
||||
|
||||
func testInsideWorkHours() {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
let date = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 10, minute: 0))!
|
||||
XCTAssertFalse(isOutsideWorkHours(date, calendar: calendar, startMinutes: 9 * 60, endMinutes: 17 * 60))
|
||||
}
|
||||
|
||||
func testOutsideWorkHoursBeforeStart() {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
let date = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 8, minute: 59))!
|
||||
XCTAssertTrue(isOutsideWorkHours(date, calendar: calendar, startMinutes: 9 * 60, endMinutes: 17 * 60))
|
||||
}
|
||||
|
||||
func testOutsideWorkHoursAtEndBoundary() {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
let date = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 17, minute: 0))!
|
||||
XCTAssertTrue(isOutsideWorkHours(date, calendar: calendar, startMinutes: 9 * 60, endMinutes: 17 * 60))
|
||||
}
|
||||
|
||||
func testOutsideWorkHoursInvalidRange() {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
let date = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 10, minute: 0))!
|
||||
// end <= start means "no enforcement"
|
||||
XCTAssertFalse(isOutsideWorkHours(date, calendar: calendar, startMinutes: 17 * 60, endMinutes: 9 * 60))
|
||||
}
|
||||
|
||||
// MARK: - shouldSkip
|
||||
|
||||
func testShouldSkipEmptyFilters() {
|
||||
XCTAssertFalse(shouldSkip(title: "Meeting", filters: [], titlePrefix: "🪞 "))
|
||||
}
|
||||
|
||||
func testShouldSkipMatchingRawTitle() {
|
||||
XCTAssertTrue(shouldSkip(title: "Standup", filters: ["standup"], titlePrefix: ""))
|
||||
}
|
||||
|
||||
func testShouldSkipMatchingStrippedTitle() {
|
||||
XCTAssertTrue(shouldSkip(title: "🪞 Standup", filters: ["standup"], titlePrefix: "🪞 "))
|
||||
}
|
||||
|
||||
func testShouldSkipCaseInsensitive() {
|
||||
XCTAssertTrue(shouldSkip(title: "STANDUP", filters: ["standup"], titlePrefix: ""))
|
||||
}
|
||||
|
||||
func testShouldSkipNoMatch() {
|
||||
XCTAssertFalse(shouldSkip(title: "Meeting", filters: ["standup"], titlePrefix: ""))
|
||||
}
|
||||
|
||||
func testShouldSkipNilTitle() {
|
||||
XCTAssertFalse(shouldSkip(title: nil, filters: ["standup"], titlePrefix: ""))
|
||||
}
|
||||
|
||||
func testShouldSkipMultipleFilters() {
|
||||
XCTAssertTrue(shouldSkip(title: "Lunch", filters: ["standup", "lunch"], titlePrefix: ""))
|
||||
}
|
||||
|
||||
// MARK: - shouldSkipOrganizer
|
||||
|
||||
func testShouldSkipOrganizerEmptyFilters() {
|
||||
XCTAssertFalse(shouldSkipOrganizer(organizerValues: ["alice@example.com"], filters: []))
|
||||
}
|
||||
|
||||
func testShouldSkipOrganizerEmptyValues() {
|
||||
XCTAssertFalse(shouldSkipOrganizer(organizerValues: [], filters: ["alice"]))
|
||||
}
|
||||
|
||||
func testShouldSkipOrganizerMatch() {
|
||||
XCTAssertTrue(shouldSkipOrganizer(organizerValues: ["Alice Smith", "alice@example.com"], filters: ["alice"]))
|
||||
}
|
||||
|
||||
func testShouldSkipOrganizerCaseInsensitive() {
|
||||
XCTAssertTrue(shouldSkipOrganizer(organizerValues: ["ALICE@EXAMPLE.COM"], filters: ["alice"]))
|
||||
}
|
||||
|
||||
func testShouldSkipOrganizerPartialMatch() {
|
||||
XCTAssertTrue(shouldSkipOrganizer(organizerValues: ["bob@corp.com"], filters: ["corp"]))
|
||||
}
|
||||
|
||||
func testShouldSkipOrganizerNoMatch() {
|
||||
XCTAssertFalse(shouldSkipOrganizer(organizerValues: ["charlie@example.com"], filters: ["alice"]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import XCTest
|
||||
@testable import BusyMirror
|
||||
|
||||
final class MirrorUtilsTests: XCTestCase {
|
||||
|
||||
// MARK: - stripPrefix
|
||||
|
||||
func testStripPrefixMatching() {
|
||||
XCTAssertEqual(stripPrefix("🪞 Meeting", prefix: "🪞 "), "Meeting")
|
||||
}
|
||||
|
||||
func testStripPrefixNoMatch() {
|
||||
XCTAssertEqual(stripPrefix("Meeting", prefix: "🪞 "), "Meeting")
|
||||
}
|
||||
|
||||
func testStripPrefixEmptyPrefix() {
|
||||
XCTAssertEqual(stripPrefix("Meeting", prefix: ""), "Meeting")
|
||||
}
|
||||
|
||||
func testStripPrefixNilTitle() {
|
||||
XCTAssertEqual(stripPrefix(nil, prefix: "🪞 "), "")
|
||||
}
|
||||
|
||||
// MARK: - mirrorURL encode/decode round-trip
|
||||
|
||||
func testMirrorURLEncodeDecodeRoundTrip() {
|
||||
let raw = "abc|123://"
|
||||
let encoded = mirrorURLComponentEncode(raw)
|
||||
let decoded = mirrorURLComponentDecode(Substring(encoded))
|
||||
XCTAssertEqual(decoded, raw)
|
||||
}
|
||||
|
||||
func testMirrorURLAllowedCharactersUnchanged() {
|
||||
let raw = "abcABC123-._~"
|
||||
XCTAssertEqual(mirrorURLComponentEncode(raw), raw)
|
||||
}
|
||||
|
||||
// MARK: - buildMirrorURL / parseMirrorURL
|
||||
|
||||
func testMirrorURLRoundTrip() {
|
||||
let calID = "ABC-123"
|
||||
let sourceID = "event-456"
|
||||
let occ = Date(timeIntervalSince1970: 1000)
|
||||
let start = Date(timeIntervalSince1970: 2000)
|
||||
let end = Date(timeIntervalSince1970: 3600)
|
||||
|
||||
let url = buildMirrorURL(
|
||||
targetCalID: calID,
|
||||
sourceCalID: calID,
|
||||
sourceStableID: sourceID,
|
||||
occurrence: occ,
|
||||
start: start,
|
||||
end: end
|
||||
)
|
||||
XCTAssertNotNil(url)
|
||||
XCTAssertTrue(url?.absoluteString.hasPrefix("mirror://x/") ?? false)
|
||||
|
||||
let parsed = parseMirrorURL(url)
|
||||
XCTAssertEqual(parsed.targetCalID, calID)
|
||||
XCTAssertEqual(parsed.sourceCalID, calID)
|
||||
XCTAssertEqual(parsed.sourceStableID, sourceID)
|
||||
XCTAssertEqual(parsed.occ?.timeIntervalSince1970, 1000)
|
||||
XCTAssertEqual(parsed.start?.timeIntervalSince1970, 2000)
|
||||
XCTAssertEqual(parsed.end?.timeIntervalSince1970, 3600)
|
||||
}
|
||||
|
||||
func testMirrorURLWithSpecialCharacters() {
|
||||
let calID = "cal|with/pipe"
|
||||
let url = buildMirrorURL(
|
||||
targetCalID: calID,
|
||||
sourceCalID: "src",
|
||||
sourceStableID: nil,
|
||||
occurrence: nil,
|
||||
start: Date(),
|
||||
end: Date()
|
||||
)
|
||||
XCTAssertNotNil(url)
|
||||
let parsed = parseMirrorURL(url)
|
||||
XCTAssertEqual(parsed.targetCalID, calID)
|
||||
}
|
||||
|
||||
func testParseMirrorURLInvalid() {
|
||||
let parsed = parseMirrorURL(URL(string: "https://example.com"))
|
||||
XCTAssertNil(parsed.targetCalID)
|
||||
XCTAssertNil(parsed.sourceCalID)
|
||||
}
|
||||
|
||||
func testParseMirrorURLMissingOptionalFields() {
|
||||
let url = URL(string: "mirror://x/tgt;src;;-;;")
|
||||
let parsed = parseMirrorURL(url)
|
||||
XCTAssertEqual(parsed.targetCalID, "tgt")
|
||||
XCTAssertEqual(parsed.sourceCalID, "src")
|
||||
XCTAssertNil(parsed.sourceStableID)
|
||||
XCTAssertNil(parsed.occ)
|
||||
XCTAssertNil(parsed.start)
|
||||
XCTAssertNil(parsed.end)
|
||||
}
|
||||
|
||||
// MARK: - isMirrorEvent
|
||||
|
||||
func testIsMirrorEventByURL() {
|
||||
XCTAssertTrue(isMirrorEvent(title: "Meeting", urlString: "mirror://x", prefix: "🪞 ", placeholder: "Busy"))
|
||||
}
|
||||
|
||||
func testIsMirrorEventByPrefix() {
|
||||
XCTAssertTrue(isMirrorEvent(title: "🪞 Meeting", urlString: nil, prefix: "🪞 ", placeholder: "Busy"))
|
||||
}
|
||||
|
||||
func testIsMirrorEventByPlaceholder() {
|
||||
XCTAssertTrue(isMirrorEvent(title: "Busy", urlString: nil, prefix: "", placeholder: "Busy"))
|
||||
}
|
||||
|
||||
func testIsMirrorEventByPrefixedPlaceholder() {
|
||||
XCTAssertTrue(isMirrorEvent(title: "🪞 Busy", urlString: nil, prefix: "🪞 ", placeholder: "Busy"))
|
||||
}
|
||||
|
||||
func testIsMirrorEventNegative() {
|
||||
XCTAssertFalse(isMirrorEvent(title: "Regular Meeting", urlString: nil, prefix: "🪞 ", placeholder: "Busy"))
|
||||
}
|
||||
|
||||
// MARK: - key generators
|
||||
|
||||
func testSourceOccurrenceKey() {
|
||||
let occ = Date(timeIntervalSince1970: 1234)
|
||||
let key = sourceOccurrenceKey(sourceCalID: "cal1", sourceStableID: "evt1", occurrence: occ)
|
||||
XCTAssertEqual(key, "cal1|evt1|1234.0")
|
||||
}
|
||||
|
||||
func testSourceOccurrenceKeyNoOccurrence() {
|
||||
let key = sourceOccurrenceKey(sourceCalID: "cal1", sourceStableID: "evt1", occurrence: nil)
|
||||
XCTAssertEqual(key, "cal1|evt1|-")
|
||||
}
|
||||
|
||||
func testMirrorRecordKey() {
|
||||
XCTAssertEqual(mirrorRecordKey(targetCalID: "t", sourceKey: "s"), "t|s")
|
||||
}
|
||||
|
||||
func testMirrorTimeKey() {
|
||||
let s = Date(timeIntervalSince1970: 100)
|
||||
let e = Date(timeIntervalSince1970: 200)
|
||||
XCTAssertEqual(mirrorTimeKey(start: s, end: e), "100.0|200.0")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user