Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
/.build
*/.docc-build
/.index-build
/Examples/.build
/Packages
Expand All @@ -8,6 +9,7 @@ xcuserdata/
DerivedData/
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
/.vscode
/.fleet
*.pyc
.swiftpm
vcpkg_installed/
Expand Down
2 changes: 1 addition & 1 deletion Examples/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,6 @@ let package = Package(
.executableTarget(
name: "WebViewExample",
dependencies: exampleDependencies
)
),
]
)
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ let package = Package(
dependencies: [
"SwiftCrossUI",
.target(name: "AppKitBackend", condition: .when(platforms: [.macOS])),
.target(name: "DefaultBackend"),
]
),
.target(
Expand Down
185 changes: 185 additions & 0 deletions Tests/SwiftCrossUITests/SwiftCrossUIBackendTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import Foundation
import Testing

@testable import SwiftCrossUI

#if canImport(AppKitBackend)
@testable import AppKitBackend
typealias ActiveBackend = AppKitBackend
#else
// Consider this the mock backend for code that is not currently specialized to one backend explicitly
@testable import DefaultBackend
typealias ActiveBackend = DefaultBackend
#endif

// TODO: Create mock backend so that this can be tested on all platforms. There's
// nothing AppKit-specific about it.
@Suite(
"Testing for Graphical Backends",
.tags(.interface, .backend)
)
struct BackendTests {
public struct BackendTestError: Error {
var message: String? = nil

// These are preconfigured instances for functions with `throws(BackendTests.BackendTestError)` in the signature
static let failedBitmapBack: Self = new("Failed to create bitmap backing")
static let failedTiffRep: Self = new("Failed to create tiff representation")
static let unknownIssue: Self = new()

var errorDescription: String? {
message ?? "Something when wrong during Backend Testing"
}

// Condensed Error Generator for abridging the initalizer
static func new(_ info: String? = nil) -> Self {
Self(message: info)
}
}

struct CounterView: View {
@State var count = 0

var body: some View {
VStack {
Button("Decrease") { count -= 1 }
Text("Count: 1")
Button("Increase") { count += 1 }
}.padding()
}
}

@Test(
"Ensures that `Publisher.observeAsUIUpdater(backend:)` throttles state update observations",
.tags(.observation, .state)
)
func throttledStateObservation() async {
class MyState: SwiftCrossUI.ObservableObject {
@SwiftCrossUI.Published
var count = 0
}

/// A thread-safe count.
actor Count {
var count: Int = 0

/// Allow for the contents of Count to be manipulated with an async function
func update(_ action: (Int) -> Int) async {
count = action(count)
}

/// Allow for the contents of Count to be aquired with an async function
func get() async -> Int {
count
}
}

// Number of mutations to perform
let mutationCount = 20
// Length of each fake state update
let updateDuration = 0.02
// Delay between observation-causing state mutations
let mutationGap = 0.01

let state = MyState()
let updateCount = Count()

let backend = await ActiveBackend()
let cancellable = state.didChange.observeAsUIUpdater(backend: backend) {
Task {
await updateCount.update { $0 + 1 }
}
// Simulate an update of duration `updateDuration` seconds
Thread.sleep(forTimeInterval: updateDuration)
}
_ = cancellable // Silence warning about cancellable being unused

let start = ProcessInfo.processInfo.systemUptime
for _ in 0..<mutationCount {
state.count += 1
try? await Task.sleep(for: .seconds(mutationGap))
}
let end = ProcessInfo.processInfo.systemUptime
let elapsed = end - start

let count = await updateCount.get()

// Compute percentage of main thread's time taken up by updates.
let ratio = Double(count) * updateDuration / elapsed
#expect(
ratio <= 0.85,
"""
Expected throttled updates to take under 85% of the main \
thread's time. Took \(Int(ratio * 100))%

Duration: \(elapsed) seconds
Updates: \(count)/\(mutationCount)
"""
)
}

#if canImport(AppKitBackend)

@Test(
"A Basic Layout Works properly",
tags(.layout)
)
@MainActor
func basicLayout() async throws {
let backend = ActiveBackend()
let window = backend.createWindow(withDefaultSize: SIMD2(200, 200))

// Idea taken from https://github.com/pointfreeco/swift-snapshot-testing/pull/533
// and implemented in AppKitBackend.
window.backingScaleFactorOverride = 1
window.colorSpace = .genericRGB

let environment = EnvironmentValues(backend: backend)
.with(\.window, window)
let viewGraph = ViewGraph(
for: CounterView(),
backend: backend,
environment: environment
)
backend.setChild(ofWindow: window, to: viewGraph.rootNode.widget.into())

let result = viewGraph.update(
proposedSize: SIMD2(200, 200),
environment: environment,
dryRun: false
)
let view: ActiveBackend.Widget = viewGraph.rootNode.widget.into()
backend.setSize(of: view, to: result.size.size)
backend.setSize(ofWindow: window, to: result.size.size)

#expect(
result.size == ViewSize(fixedSize: SIMD2(92, 96)),
"View update result mismatch"
)

#expect(
result.preferences.onOpenURL == nil,
"`onOpenURL` should be `nil` as it was not set"
)
}

/// Helper function to be used in future tests
@MainActor
static func snapshotView(_ view: NSView) throws(BackendTestError) -> Data {
view.wantsLayer = true
view.layer?.backgroundColor = CGColor.white

guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else {
throw .failedBitmapBack
}

view.cacheDisplay(in: view.bounds, to: bitmap)

guard let data = bitmap.tiffRepresentation else {
throw .failedTiffRep
}

return data
}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/// Required Imports
import Foundation
import Testing

@testable import SwiftCrossUI

@Suite(
"Navigation Path Tests",
.tags(.interface, .navPath)
)
struct NavPathTests {
// NOTE: The Test ``CodableNavigationPath`` cannot be condensed into a parameterized test
// or a for loop without specifiying allowed Types individually. A Macro might be useful here
/// Makes sure that ``NavigationPath`` can retain data validating them with `compareComponents`.
@Test(
"Ensures that `NavigationPath` instances can be round tripped to JSON and back"
)
func codableNavigationPath() throws {
/// Specifies the values and types input into the `NavigationPath`
let typeValuePairs: [(type: any Codable.Type, value: any Codable)] =
[
(String.self, "a"),
(Int.self, 1),
([Int].self, [1, 2, 3]),
(Double.self, 5.0),
] as! [(any Codable.Type, any Codable)]

let Values: [any Codable] = typeValuePairs.map { $0.value }

let Types: [any Codable.Type] = typeValuePairs.map { $0.type }

var path = NavigationPath()
for value in Values {
path.append(value)
}

let components = path.path(destinationTypes: Types)

let encoded = try JSONEncoder().encode(path)
let decodedPath = try JSONDecoder().decode(NavigationPath.self, from: encoded)

let decodedComponents = decodedPath.path(destinationTypes: Types)

try #require(
decodedComponents.count == components.count,
"`decodedComponents` and `components` are inconsitently sized"
)

#expect(
Self.compareComponents(ofType: String.self, components[0], decodedComponents[0]),
"An Issue with Navigation path data retainment occured"
)
#expect(
Self.compareComponents(ofType: Int.self, components[1], decodedComponents[1]),
"An Issue with Navigation path data retainment occured"
)
#expect(
Self.compareComponents(ofType: [Int].self, components[2], decodedComponents[2]),
"An Issue with Navigation path data retainment occured"
)
#expect(
Self.compareComponents(ofType: Double.self, components[3], decodedComponents[3]),
"An Issue with Navigation path data retainment occured"
)
Comment on lines +49 to +64
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did consider using a loop but it would have been excessive and overkill as there is only one instance of each type

}

// Note: consider making compareComponents not require a type input
static func compareComponents<T>(
ofType type: T.Type, _ original: Any, _ decoded: Any
) -> Bool where T: Equatable {
guard
let original = original as? T,
let decoded = decoded as? T
else {
return false
}

return original == decoded
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/// Required Imports
import Foundation
import Testing

@testable import SwiftCrossUI

@Suite(
"State Tests",
.tags(.state)
)
struct StateTests {
@Test(
"Validates ObservableObject observation behaviour",
.tags(.observation)
)
func observableObjectObservation() {
class NestedState: SwiftCrossUI.ObservableObject {
@SwiftCrossUI.Published
var count = 0
}

class MyState: SwiftCrossUI.ObservableObject {
@SwiftCrossUI.Published
var count = 0
@SwiftCrossUI.Published
var publishedNestedState = NestedState()
var unpublishedNestedState = NestedState()
}

let state = MyState()
var observedChange = false
let cancellable = state.didChange.observe {
observedChange = true
}

// Ensures that published value type mutation triggers observation
observedChange = false
state.count += 1
#expect(observedChange, "Expected value type mutation to trigger observation")

// Ensure that published nested ObservableObject triggers observation
observedChange = false
state.publishedNestedState.count += 1
#expect(
observedChange,
"Expected nested published observable object mutation to trigger observation")

// Ensure that replacing published nested ObservableObject triggers observation
observedChange = false
state.publishedNestedState = NestedState()
#expect(
observedChange,
"Expected replacing nested published observable object to trigger observation")

// Ensure that replaced published nested ObservableObject triggers observation
observedChange = false
state.publishedNestedState.count += 1
#expect(
observedChange,
"Expected replaced nested published observable object mutation to trigger observation"
)

// Ensure that non-published nested ObservableObject doesn't trigger observation
observedChange = false
state.unpublishedNestedState.count += 1
#expect(
!observedChange,
"Expected nested unpublished observable object mutation to not trigger observation")

// Ensure that cancelling the observation prevents future observations
cancellable.cancel()
observedChange = false
state.count += 1
#expect(!observedChange, "Expected mutation not to trigger cancelled observation")
}
}
Loading