Skip to content

Commit ff6f8a1

Browse files
committed
test: add unit test target with first 19 tests over the pure core
The repo had zero automated tests. This adds an app-hosted CineScreenTests target (project.yml + explicit scheme, make test) and covers the logic where the recent bug fixes live: - Spring/adaptive smoothing: convergence, the zero-smoothTime click collapse staying finite, blend endpoints + monotonicity. - RenderSnapshot: proximity-factor binary search, mouse-ups not collapsing smoothing, lag urgency, zoom ramp/identity, pan-table monotonicity, defensive section sorting, cursor interpolation. - Metadata: encode/decode round trip and back-compat decode of files missing the newer optional fields. - Zoom generation: click clustering, sorted non-overlapping output, bounds clamping, mouse-up filtering. - ProjectsLibrary: descriptor creation/collisions and the marker check that keeps arbitrary folders out of the (deletable) project list.
1 parent e4782b1 commit ff6f8a1

8 files changed

Lines changed: 378 additions & 0 deletions

File tree

Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ build: project
3232
-derivedDataPath $(BUILD_DIR)/derived \
3333
build
3434

35+
.PHONY: test
36+
test: project
37+
xcodebuild \
38+
-project $(PROJECT) \
39+
-scheme $(SCHEME) \
40+
-configuration $(CONFIG_DEBUG) \
41+
-derivedDataPath $(BUILD_DIR)/derived \
42+
test
43+
3544
.PHONY: build-release
3645
build-release: project
3746
xcodebuild \
@@ -100,6 +109,7 @@ help:
100109
@echo "Targets:"
101110
@echo " project — regenerate $(PROJECT) from project.yml"
102111
@echo " build — debug build"
112+
@echo " test — run unit tests"
103113
@echo " build-release — release build"
104114
@echo " archive — create xcarchive"
105115
@echo " export — export signed .app from archive"
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Foundation
2+
@testable import CineScreen
3+
4+
/// Shared builders for metadata fixtures used across the unit tests.
5+
enum Fixtures {
6+
static func metadata(
7+
width: Int = 2000,
8+
height: Int = 1000,
9+
duration: Double = 10_000,
10+
keyframes: [CursorKeyframe] = [],
11+
clicks: [ClickEvent] = [],
12+
sections: [ZoomSection] = [],
13+
zoomEnabled: Bool = true,
14+
webcamOffsetMs: Double? = nil
15+
) -> RecordingMetadata {
16+
RecordingMetadata(
17+
version: RecordingMetadata.currentVersion,
18+
video: VideoInfo(
19+
path: "/tmp/fixture.mp4",
20+
width: width,
21+
height: height,
22+
frameRate: 60,
23+
duration: duration
24+
),
25+
cursor: CursorTrack(
26+
keyframes: keyframes,
27+
segments: nil,
28+
config: CursorConfig(size: 96, shape: .arrow, motionBlur: nil, hideWhenStatic: nil)
29+
),
30+
zoom: ZoomTrack(
31+
sections: sections,
32+
config: ZoomConfig(
33+
enabled: zoomEnabled,
34+
level: 2.0,
35+
transitionSpeed: 300,
36+
padding: 0,
37+
followSpeed: 1.0,
38+
smoothness: nil,
39+
animationStyle: .mellow,
40+
deadZone: nil,
41+
motionBlur: nil,
42+
physics: nil,
43+
autoZoom: false
44+
)
45+
),
46+
clicks: clicks,
47+
effects: nil,
48+
trim: nil,
49+
webcamOffsetMs: webcamOffsetMs,
50+
createdAt: 0
51+
)
52+
}
53+
54+
static func keyframe(_ t: Double, _ x: Double, _ y: Double) -> CursorKeyframe {
55+
CursorKeyframe(timestamp: t, x: x, y: y, size: nil, shape: .arrow, easing: .linear)
56+
}
57+
58+
static func click(_ t: Double, x: Double = 0, y: Double = 0, action: ClickAction = .down) -> ClickEvent {
59+
ClickEvent(timestamp: t, x: x, y: y, button: .left, action: action)
60+
}
61+
62+
static func section(_ start: Double, _ end: Double, scale: Double = 2.0) -> ZoomSection {
63+
ZoomSection(startTime: start, endTime: end, scale: scale, centerX: 0, centerY: 0)
64+
}
65+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import XCTest
2+
@testable import CineScreen
3+
4+
final class MetadataTests: XCTestCase {
5+
func testRoundTripPreservesEverything() throws {
6+
let original = Fixtures.metadata(
7+
keyframes: [Fixtures.keyframe(0, 1, 2), Fixtures.keyframe(100, 3, 4)],
8+
clicks: [Fixtures.click(50)],
9+
sections: [Fixtures.section(10, 500, scale: 3)],
10+
webcamOffsetMs: 456.7
11+
)
12+
let decoded = try RecordingMetadata.decode(from: original.encode())
13+
XCTAssertEqual(decoded, original)
14+
}
15+
16+
/// Files written before the newer optional fields existed must still
17+
/// decode, with those fields nil.
18+
func testDecodingOldFileWithoutNewFieldsYieldsNil() throws {
19+
let original = Fixtures.metadata(webcamOffsetMs: 456.7)
20+
var json = try XCTUnwrap(
21+
JSONSerialization.jsonObject(with: original.encode()) as? [String: Any]
22+
)
23+
json.removeValue(forKey: "webcamOffsetMs")
24+
json.removeValue(forKey: "webcam")
25+
json.removeValue(forKey: "canvas")
26+
json.removeValue(forKey: "trim")
27+
let data = try JSONSerialization.data(withJSONObject: json)
28+
let decoded = try RecordingMetadata.decode(from: data)
29+
XCTAssertNil(decoded.webcamOffsetMs)
30+
XCTAssertNil(decoded.webcam)
31+
XCTAssertNil(decoded.canvas)
32+
XCTAssertNil(decoded.trim)
33+
}
34+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import XCTest
2+
@testable import CineScreen
3+
4+
final class ProjectsLibraryTests: XCTestCase {
5+
private var root: URL!
6+
7+
override func setUpWithError() throws {
8+
root = FileManager.default.temporaryDirectory
9+
.appendingPathComponent("CineScreenTests-\(UUID().uuidString)", isDirectory: true)
10+
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
11+
}
12+
13+
override func tearDownWithError() throws {
14+
try? FileManager.default.removeItem(at: root)
15+
}
16+
17+
@MainActor
18+
func testCreateNewWritesDescriptorAndAvoidsCollisions() throws {
19+
let a = try ProjectsLibrary.createNew(in: root, name: "Take")
20+
let b = try ProjectsLibrary.createNew(in: root, name: "Take")
21+
XCTAssertNotEqual(a.folderURL, b.folderURL)
22+
XCTAssertTrue(FileManager.default.fileExists(
23+
atPath: a.folderURL.appendingPathComponent(Project.projectFileName).path
24+
))
25+
}
26+
27+
/// The library must never present arbitrary folders as projects —
28+
/// Settings allows pointing it at any directory, and Delete moves listed
29+
/// folders to the Trash.
30+
@MainActor
31+
func testListingIgnoresFoldersWithoutCineScreenArtifacts() throws {
32+
_ = try ProjectsLibrary.createNew(in: root, name: "Real")
33+
let foreign = root.appendingPathComponent("Vacation Photos", isDirectory: true)
34+
try FileManager.default.createDirectory(at: foreign, withIntermediateDirectories: true)
35+
36+
let listed = ProjectsLibrary.projects(in: root)
37+
XCTAssertEqual(listed.count, 1)
38+
XCTAssertEqual(listed.first?.name, "Real")
39+
}
40+
41+
/// Pre-descriptor recordings (video only, no project.json) stay visible.
42+
@MainActor
43+
func testListingKeepsFoldersWithVideoButNoDescriptor() throws {
44+
let legacy = root.appendingPathComponent("Old Recording", isDirectory: true)
45+
try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: true)
46+
FileManager.default.createFile(
47+
atPath: legacy.appendingPathComponent(Project.videoFileName).path,
48+
contents: Data([0x00])
49+
)
50+
XCTAssertEqual(ProjectsLibrary.projects(in: root).count, 1)
51+
}
52+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import XCTest
2+
@testable import CineScreen
3+
4+
final class RenderSnapshotTests: XCTestCase {
5+
func testProximityFactorBinarySearch() {
6+
let times = [1000.0, 2000.0]
7+
XCTAssertEqual(RenderSnapshot.proximityFactor(to: times, at: 1000, window: 140), 0)
8+
XCTAssertEqual(RenderSnapshot.proximityFactor(to: times, at: 1070, window: 140), 0.5, accuracy: 1e-9)
9+
XCTAssertEqual(RenderSnapshot.proximityFactor(to: times, at: 1500, window: 140), 1)
10+
XCTAssertEqual(RenderSnapshot.proximityFactor(to: times, at: 500, window: 140), 1)
11+
XCTAssertEqual(RenderSnapshot.proximityFactor(to: times, at: 2010, window: 140), 10.0 / 140.0, accuracy: 1e-9)
12+
XCTAssertEqual(RenderSnapshot.proximityFactor(to: [], at: 0, window: 140), 1)
13+
}
14+
15+
/// The smoothing collapse keys off mouse-DOWNs only — an up (e.g. a drag
16+
/// release, often far from any ring) must not snap the sprite.
17+
func testMouseUpsDoNotCollapseSmoothing() {
18+
let metadata = Fixtures.metadata(
19+
keyframes: [Fixtures.keyframe(0, 100, 100), Fixtures.keyframe(5000, 100, 100)],
20+
clicks: [Fixtures.click(1000, action: .down), Fixtures.click(1600, action: .up)]
21+
)
22+
let snapshot = RenderSnapshot(metadata: metadata, zoomSections: [])
23+
XCTAssertEqual(snapshot.adaptiveCursorSmoothTime(atMilliseconds: 1000), 0, accuracy: 1e-9)
24+
XCTAssertEqual(
25+
snapshot.adaptiveCursorSmoothTime(atMilliseconds: 1600),
26+
CursorAnimationStyle.mellow.smoothTime,
27+
accuracy: 1e-9
28+
)
29+
}
30+
31+
/// A sprite trailing far behind a stationary pointer must tighten —
32+
/// the move-then-hover case, where speed alone reads as calm.
33+
func testLagUrgencyTightensSmoothTime() {
34+
let metadata = Fixtures.metadata(
35+
keyframes: [Fixtures.keyframe(0, 0, 0), Fixtures.keyframe(5000, 0, 0)]
36+
)
37+
let snapshot = RenderSnapshot(metadata: metadata, zoomSections: [])
38+
let calm = snapshot.adaptiveCursorSmoothTime(atMilliseconds: 2500, spriteAt: SIMD2(0, 0))
39+
let lagging = snapshot.adaptiveCursorSmoothTime(atMilliseconds: 2500, spriteAt: SIMD2(500, 0))
40+
XCTAssertLessThan(lagging, calm)
41+
XCTAssertEqual(lagging, CursorAnimationStyle.mellow.minSmoothTime, accuracy: 1e-9)
42+
}
43+
44+
func testZoomStateRampAndIdentity() {
45+
let metadata = Fixtures.metadata(
46+
keyframes: [Fixtures.keyframe(0, 1000, 500), Fixtures.keyframe(10_000, 1000, 500)],
47+
sections: [Fixtures.section(1000, 5000)]
48+
)
49+
let snapshot = RenderSnapshot(metadata: metadata, zoomSections: metadata.zoom.sections)
50+
XCTAssertEqual(snapshot.zoomState(atMilliseconds: 500).scale, 1.0)
51+
XCTAssertEqual(snapshot.zoomState(atMilliseconds: 1000).scale, 1.0, accuracy: 1e-4)
52+
XCTAssertEqual(snapshot.zoomState(atMilliseconds: 3000).scale, 2.0, accuracy: 1e-4)
53+
XCTAssertEqual(snapshot.zoomState(atMilliseconds: 6000).scale, 1.0)
54+
}
55+
56+
/// The pan lookup table is binary-searched by time — it must be
57+
/// monotonic no matter how many sections feed it.
58+
func testPanSamplesAreMonotonic() {
59+
let metadata = Fixtures.metadata(
60+
keyframes: stride(from: 0.0, through: 10_000, by: 50).map {
61+
Fixtures.keyframe($0, 500 + $0 / 10, 400)
62+
},
63+
sections: [Fixtures.section(1000, 4000), Fixtures.section(6000, 9000)]
64+
)
65+
let snapshot = RenderSnapshot(metadata: metadata, zoomSections: metadata.zoom.sections)
66+
XCTAssertFalse(snapshot.panSamples.isEmpty)
67+
for pair in zip(snapshot.panSamples, snapshot.panSamples.dropFirst()) {
68+
XCTAssertLessThanOrEqual(pair.0.t, pair.1.t)
69+
}
70+
}
71+
72+
/// Defensive sort: old metadata files may carry sections in drag order.
73+
func testInitSortsSections() {
74+
let metadata = Fixtures.metadata(
75+
sections: [Fixtures.section(6000, 9000), Fixtures.section(1000, 4000)]
76+
)
77+
let snapshot = RenderSnapshot(metadata: metadata, zoomSections: metadata.zoom.sections)
78+
XCTAssertEqual(snapshot.zoomSections.map(\.startTime), [1000, 6000])
79+
}
80+
81+
func testRawCursorPositionInterpolatesAndClamps() {
82+
let metadata = Fixtures.metadata(
83+
keyframes: [Fixtures.keyframe(0, 0, 0), Fixtures.keyframe(1000, 100, 200)]
84+
)
85+
let mid = RenderSnapshot.rawCursorPosition(atMilliseconds: 500, metadata: metadata)
86+
XCTAssertEqual(Double(mid?.x ?? -1), 50, accuracy: 0.001)
87+
XCTAssertEqual(Double(mid?.y ?? -1), 100, accuracy: 0.001)
88+
let past = RenderSnapshot.rawCursorPosition(atMilliseconds: 5000, metadata: metadata)
89+
XCTAssertEqual(Double(past?.x ?? -1), 100, accuracy: 0.001)
90+
XCTAssertNil(RenderSnapshot.rawCursorPosition(atMilliseconds: 0, metadata: Fixtures.metadata()))
91+
}
92+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import XCTest
2+
@testable import CineScreen
3+
4+
final class SpringTests: XCTestCase {
5+
func testSmoothDampConvergesToTarget() {
6+
var spring = SmoothPosition2D(x: 0, y: 0, smoothTime: 0.2)
7+
var pos = SIMD2<Double>(0, 0)
8+
for _ in 0..<300 {
9+
pos = spring.update(targetX: 100, targetY: 50, deltaTime: 1.0 / 60.0)
10+
}
11+
XCTAssertEqual(pos.x, 100, accuracy: 0.5)
12+
XCTAssertEqual(pos.y, 50, accuracy: 0.5)
13+
}
14+
15+
/// The click window collapses smoothTime to exactly 0 at a mouse-down —
16+
/// the update must stay finite (omega is clamped) and effectively snap.
17+
func testZeroSmoothTimeIsFiniteAndSnaps() {
18+
var spring = SmoothPosition2D(x: 0, y: 0, smoothTime: 0)
19+
let pos = spring.update(targetX: 500, targetY: 500, deltaTime: 1.0 / 60.0)
20+
XCTAssertTrue(pos.x.isFinite && pos.y.isFinite)
21+
XCTAssertEqual(pos.x, 500, accuracy: 1.0)
22+
XCTAssertEqual(pos.y, 500, accuracy: 1.0)
23+
}
24+
25+
func testAdaptiveBlendEndpointsAndMonotonicity() {
26+
let style = CursorAnimationStyle.mellow
27+
let w = 2000.0
28+
29+
// At rest: full cinematic glide. Flat out (speed or lag): fully tight.
30+
XCTAssertEqual(style.smoothTime(forSpeedPxPerSec: 0, videoWidth: w), style.smoothTime)
31+
XCTAssertEqual(style.smoothTime(forSpeedPxPerSec: 10 * w, videoWidth: w),
32+
style.minSmoothTime, accuracy: 1e-9)
33+
XCTAssertEqual(style.smoothTime(forSpeedPxPerSec: 0, lagPx: w, videoWidth: w),
34+
style.minSmoothTime, accuracy: 1e-9)
35+
36+
// Monotonically non-increasing in speed.
37+
var last = style.smoothTime(forSpeedPxPerSec: 0, videoWidth: w)
38+
for speed in stride(from: 0.0, through: 2.0 * w, by: w / 10) {
39+
let t = style.smoothTime(forSpeedPxPerSec: speed, videoWidth: w)
40+
XCTAssertLessThanOrEqual(t, last + 1e-12)
41+
last = t
42+
}
43+
44+
// Degenerate width falls back to the base time rather than dividing by 0.
45+
XCTAssertEqual(style.smoothTime(forSpeedPxPerSec: 1e9, videoWidth: 0), style.smoothTime)
46+
}
47+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import XCTest
2+
@testable import CineScreen
3+
4+
final class ZoomGenerationTests: XCTestCase {
5+
@MainActor
6+
func testClicksClusterWithinGap() {
7+
// 1000 and 2500 are within the 2s cluster gap; 8000 starts a new one.
8+
let clicks = [Fixtures.click(1000), Fixtures.click(2500), Fixtures.click(8000)]
9+
let sections = EditorViewModel.generateZoomSections(from: clicks, videoDuration: 20_000, scale: 2)
10+
XCTAssertEqual(sections.count, 2)
11+
XCTAssertEqual(sections[0].startTime, 700, accuracy: 1e-9) // 300ms preroll
12+
XCTAssertEqual(sections[0].endTime, 3700, accuracy: 1e-9) // 1200ms postroll
13+
XCTAssertEqual(sections[1].startTime, 7700, accuracy: 1e-9)
14+
}
15+
16+
@MainActor
17+
func testGeneratedSectionsAreSortedAndNonOverlapping() {
18+
let clicks = stride(from: 500.0, through: 30_000, by: 2500).map { Fixtures.click($0) }
19+
let sections = EditorViewModel.generateZoomSections(from: clicks, videoDuration: 32_000, scale: 2)
20+
XCTAssertFalse(sections.isEmpty)
21+
for pair in zip(sections, sections.dropFirst()) {
22+
XCTAssertLessThanOrEqual(pair.0.endTime, pair.1.startTime)
23+
}
24+
}
25+
26+
@MainActor
27+
func testMouseUpsAreIgnored() {
28+
let clicks = [Fixtures.click(1000, action: .up)]
29+
XCTAssertTrue(EditorViewModel.generateZoomSections(from: clicks, videoDuration: 10_000, scale: 2).isEmpty)
30+
}
31+
32+
@MainActor
33+
func testBoundsClampToVideo() {
34+
let clicks = [Fixtures.click(100), Fixtures.click(9_900)]
35+
let sections = EditorViewModel.generateZoomSections(from: clicks, videoDuration: 10_000, scale: 2)
36+
XCTAssertGreaterThanOrEqual(sections.first?.startTime ?? -1, 0)
37+
XCTAssertLessThanOrEqual(sections.last?.endTime ?? .infinity, 10_000)
38+
}
39+
}

0 commit comments

Comments
 (0)