diff --git a/AGENTS.md b/AGENTS.md index 3c901b9..fcd754e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,28 +1,35 @@ # AGENTS.md — Repository Shape -Broadway is a SwiftUI iOS + Mac Catalyst design system managed by **Tuist**. The Xcode project is not checked in — it is generated from `Project.swift`. +Broadway is a SwiftUI iOS + Mac Catalyst design system: **Swift Package Manager** supplies the shared libraries (`Package.swift`), and **Tuist** generates the Xcode workspace from `Project.swift` for the catalog app, test host, and unit test bundles that link those packages. ## Directory Layout ``` -BroadwayCatalog/ # Catalog app (Sources/, Resources/, Tests/) -BroadwayUI/ # Reusable UI component framework (Sources/, Tests/) -BroadwayCore/ # Foundational utilities framework (Sources/, Tests/) -BroadwayTestHost/ # Minimal test host app (Sources/) -BroadwayTesting/ # Shared test utilities framework (Sources/) -Project.swift # Tuist project manifest -Tuist.swift # Tuist global configuration -ide # Dev script (installs hooks, runs tuist generate) -swiftformat # Run SwiftFormat (--lint to check only) -sync-agents # Generate CLAUDE.md + .claude/skills from AGENTS.md +/ +├── .githooks/ # Git hooks (pre-commit SwiftFormat lint) +├── BroadwayCatalog/ # Catalog app (Sources/, Resources/, Tests/) +├── BroadwayUI/ # UI framework (Sources/, Tests/) +├── BroadwayCore/ # Core framework (Sources/, Tests/) +├── BroadwayTestHost/ # Minimal test host app (Sources/) +├── BroadwayTesting/ # Shared test utilities (Sources/) +├── Plans/ # Archived implementation plans +├── Package.swift # SPM: BroadwayCore, BroadwayUI, BroadwayTesting libraries +├── Project.swift # Tuist: apps, test host, and xctest targets using the local package +├── Tuist.swift # Tuist global configuration +├── ide # Dev script (installs hooks, runs tuist generate) +├── swiftformat # Run SwiftFormat (--lint to check only) +└── sync-agents # Generate CLAUDE.md + .claude/skills from AGENTS.md ``` +Key root files: `Package.swift` (library products), `Project.swift` (Tuist manifest), `Tuist.swift` (Tuist config), `.mise.toml` (tool versions), `.swiftformat` (style config), `ide` and `swiftformat` (dev scripts). + ## Build System -- **Tuist 4+** generates the Xcode project from `Project.swift`. The `.xcodeproj` and `Derived/` are git-ignored. +- **Swift Package Manager** (`Package.swift`) defines the **BroadwayCore**, **BroadwayUI**, and **BroadwayTesting** libraries (iOS 26 + Mac Catalyst). Library sources live under each module’s `Sources/` tree as today. +- **Tuist 4+** generates the Xcode workspace from `Project.swift`, wires a **local package** dependency (`.relativeToRoot(".")`), and defines **BroadwayCatalog**, **BroadwayTestHost**, **BroadwayCoreTests**, **BroadwayUITests**, and **BroadwayCatalogTests**. The `.xcodeproj` / `.xcworkspace` and `Derived/` are git-ignored. - Tuist and SwiftFormat are version-pinned via **mise** in `.mise.toml`. Run `mise install` to install them. - Run `./ide` to generate the project (or `./ide -i` to run `mise exec -- tuist install` first). -- Run `mise exec -- tuist test` to execute all tests, or `mise exec -- tuist test ` for a specific target. +- Run `mise exec -- tuist test` to execute all tests (scheme **Broadway-Workspace**), or `mise exec -- tuist test ` for a subset. Example schemes: **BroadwayCoreTests**, **BroadwayUITests**, **BroadwayCatalog**. There is no longer a generated **BroadwayCore** scheme (the core library is built as part of the Swift package). ## Formatting @@ -41,11 +48,16 @@ sync-agents # Generate CLAUDE.md + .claude/skills from AGENTS.md ## Dependency Graph ``` -BroadwayCatalog (app) --> BroadwayUI (framework) --> BroadwayCore (framework) -BroadwayTestHost (app) --> BroadwayUI --> BroadwayCore -BroadwayTesting (framework) --> BroadwayCore +Package.swift: + BroadwayUI (library) --> BroadwayCore (library) + BroadwayTesting (library) --> BroadwayCore (library) -All framework test targets use BroadwayTestHost and depend on BroadwayTesting. +Project.swift (Tuist): + BroadwayCatalog (app) --> package product BroadwayUI + BroadwayCatalogTests --> BroadwayCatalog, package BroadwayTesting + BroadwayCoreTests --> package BroadwayCore, package BroadwayTesting, BroadwayTestHost + BroadwayUITests --> package BroadwayUI, package BroadwayTesting, BroadwayTestHost + BroadwayTestHost (app) --> (no framework deps) ``` ## Key Conventions @@ -54,4 +66,5 @@ All framework test targets use BroadwayTestHost and depend on BroadwayTesting. - **Swift Testing** (`import Testing`) is used for unit tests, not XCTest. - Source files use `/Sources/**` globs; test files use `/Tests/**`. - Info.plist is auto-generated by Tuist via `infoPlist: .extendingDefault(with:)`. -- New targets or dependencies: edit `Project.swift`. +- New **library** code or package products: edit `Package.swift` (and folder layout under each module). +- New **app**, **test host**, or **xctest bundle** targets: edit `Project.swift`. diff --git a/BroadwayCatalog/Tests/BroadwayCatalogTests.swift b/BroadwayCatalog/Tests/BroadwayCatalogTests.swift index 92d4935..f7d8344 100644 --- a/BroadwayCatalog/Tests/BroadwayCatalogTests.swift +++ b/BroadwayCatalog/Tests/BroadwayCatalogTests.swift @@ -1,10 +1,4 @@ @testable import BroadwayCatalog import Testing -struct BroadwayCatalogTests { - @Test("App launches with ContentView") - func contentViewExists() { - let view = ContentView() - #expect(view != nil) - } -} +struct BroadwayCatalogTests {} diff --git a/BroadwayCore/Sources/BContext+UITraits.swift b/BroadwayCore/Sources/BContext+UITraits.swift index 4105eba..f862038 100644 --- a/BroadwayCore/Sources/BContext+UITraits.swift +++ b/BroadwayCore/Sources/BContext+UITraits.swift @@ -7,8 +7,8 @@ import UIKit /// Bridges ``BContext`` into UIKit's trait system so that it propagates /// automatically through the view-controller and view hierarchy. -struct BContextTrait: UITraitDefinition { - static let defaultValue = BContext() +public struct BContextTrait: UITraitDefinition { + public static let defaultValue = BContext() } extension UITraitCollection { diff --git a/BroadwayCore/Sources/BContext.swift b/BroadwayCore/Sources/BContext.swift index fdfc47f..4a4d7c8 100644 --- a/BroadwayCore/Sources/BContext.swift +++ b/BroadwayCore/Sources/BContext.swift @@ -12,26 +12,48 @@ import Foundation /// ``BStylesheets`` cache. Updating `traits` or `themes` replaces the /// stylesheet cache so subsequent lookups produce fresh instances. public struct BContext: Equatable, Sendable { - public init(traits: BTraits = .init(), themes: BThemes = .init()) { - self.traits = traits + public init( + traits: BTraits, + overrides: BTraits.Overrides = .init(), + themes: BThemes = .init(), + ) { + baseTraits = traits + traitOverrides = overrides self.themes = themes - stylesheets = BStylesheets(config: .init(traits: traits, themes: themes)) + stylesheets = BStylesheets( + traits: baseTraits.merging(with: traitOverrides), + themes: themes, + ) + } + + init() { + self.init(traits: BTraits()) + } + + public var traitOverrides: BTraits.Overrides { + didSet { + stylesheets.updateTraits(traits) + } } - /// The current trait values (accessibility, size class, etc.). public var traits: BTraits { + baseTraits.merging(with: traitOverrides) + } + + /// The current trait values (accessibility, size class, etc.). + public var baseTraits: BTraits { didSet { - stylesheets.traits = traits + stylesheets.updateTraits(traits) } } /// The current theme values. @CopyOnWrite public var themes: BThemes { didSet { - stylesheets.themes = themes + stylesheets.updateThemes(themes) } } /// Lazily-populated stylesheet cache, scoped to the current traits and themes. - @CopyOnWrite public private(set) var stylesheets: BStylesheets + @EquatableIgnored @CopyOnWrite public private(set) var stylesheets: BStylesheets } diff --git a/BroadwayCore/Sources/BStylesheets.swift b/BroadwayCore/Sources/BStylesheets.swift index 37a493b..ac74993 100644 --- a/BroadwayCore/Sources/BStylesheets.swift +++ b/BroadwayCore/Sources/BStylesheets.swift @@ -12,31 +12,25 @@ import Foundation /// first access and cached for subsequent lookups with the same key. public struct BStylesheets: Equatable, @unchecked Sendable { /// The current trait values (accessibility, size class, etc.). - public var traits: BTraits { - get { config.traits } - set { config.traits = newValue } - } + private var traits: BTraits /// The current theme values. - public var themes: BThemes { - get { config.themes } - set { config.themes = newValue } - } - - /// The inputs that determine stylesheet identity. Two `BStylesheets` - /// values are equal when their configurations match, regardless of - /// how much of the cache has been lazily populated. - var config: Config + private var themes: BThemes - struct Config: Equatable { - var traits: BTraits - var themes: BThemes + init(traits: BTraits, themes: BThemes) { + self.traits = traits + self.themes = themes } - // MARK: Equatable + /// Keeps the resolver’s trait key in sync with ``BContext/traits``. + /// `package` matches the monorepo `-package-name Broadway` set in Tuist (SE-0386). + package mutating func updateTraits(_ newTraits: BTraits) { + traits = newTraits + } - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.config == rhs.config + /// Keeps the resolver’s theme key in sync with ``BContext/themes``. + package mutating func updateThemes(_ newThemes: BThemes) { + themes = newThemes } // MARK: Lookup @@ -47,26 +41,30 @@ public struct BStylesheets: Equatable, @unchecked Sendable { /// a dependency cycle, or ``StylesheetError/creationFailed(type:underlying:)`` /// if the stylesheet's initializer throws. public func get(_: Stylesheet.Type) throws(StylesheetError) -> Stylesheet { - let key = Key(stylesheet: .init(Stylesheet.self), traits: traits, themes: themes) + let key = Key( + stylesheet: .init(Stylesheet.self), + traits: traits, + themes: themes, + ) guard let value = cache[key], let value = value.base as? Stylesheet else { let id = TypeIdentifier(Stylesheet.self) - let creating = _creating._unsafeUnderlyingValue + let creating = _creating.wrappedValue._unsafeUnderlyingValue if let cycleStart = creating.firstIndex(of: id) { let path = creating[cycleStart...].map(\.debugDescription) + [id.debugDescription] throw .cyclicDependency(path: path) } - _creating._unsafeUnderlyingValue.append(id) - defer { _creating._unsafeUnderlyingValue.removeLast() } + _creating.wrappedValue._unsafeUnderlyingValue.append(id) + defer { _creating.wrappedValue._unsafeUnderlyingValue.removeLast() } let context = SlicingContext(themes: themes, stylesheets: self) do { let new = try Stylesheet(context: context) - _cache._unsafeUnderlyingValue[key] = AnyEquatable(new) + _cache.wrappedValue._unsafeUnderlyingValue[key] = AnyEquatable(new) return new } catch let error as StylesheetError { throw error @@ -88,9 +86,9 @@ public struct BStylesheets: Equatable, @unchecked Sendable { // MARK: Cache // TODO: Eventually we should clear this out on memory warnings for sheets not accessed within 10(?) min - @CopyOnWrite private var cache: [Key: AnyEquatable] = [:] + @EquatableIgnored @CopyOnWrite private var cache: [Key: AnyEquatable] = [:] - @CopyOnWrite private var creating: [TypeIdentifier] = [] + @EquatableIgnored @CopyOnWrite private var creating: [TypeIdentifier] = [] /// Cache key combining the stylesheet type with the traits and themes /// that were active at creation time. diff --git a/BroadwayCore/Sources/BTraits.swift b/BroadwayCore/Sources/BTraits.swift deleted file mode 100644 index dbcee52..0000000 --- a/BroadwayCore/Sources/BTraits.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// BTraits.swift -// BroadwayCore -// -// Created by Kyle Van Essen on 3/3/26. -// - -import Foundation - -extension BTraits { - public var accessibility: BAccessibility { - get { self[BAccessibility.self] } - set { self[BAccessibility.self] = newValue } - } -} - -extension BAccessibility: BTraitsValue { - public static var defaultValue: Self { - .init() - } -} - -/// A type-keyed container of ``BTraitsValue`` conforming values -/// representing the current environment (accessibility, size classes, etc.). -/// -/// Traits are always present; accessing a type that hasn't been explicitly -/// set returns its ``BTraitsValue/defaultValue``. -public struct BTraits: Equatable, Hashable, @unchecked Sendable { - public init() {} - - /// Gets or sets the trait for the given type. Returns - /// ``BTraitsValue/defaultValue`` if no value has been set. - public subscript(_: Value.Type) -> Value { - get { - let id = TypeIdentifier(Value.self) - - guard let value = storage[id], let value = value.base as? Value else { - return Value.defaultValue - } - - return value - } - - set { - let id = TypeIdentifier(Value.self) - - storage[id] = AnyHashable(newValue) - } - } - - @CopyOnWrite private var storage: [TypeIdentifier: AnyHashable] = [:] -} - -/// A hashable value that can be stored in ``BTraits``. -/// -/// Conforming types provide a ``defaultValue`` that is returned when -/// the trait has not been explicitly set. -public protocol BTraitsValue: Hashable { - static var defaultValue: Self { get } -} diff --git a/BroadwayCore/Sources/EquatableIgnored.swift b/BroadwayCore/Sources/EquatableIgnored.swift index 4dd4654..40797c6 100644 --- a/BroadwayCore/Sources/EquatableIgnored.swift +++ b/BroadwayCore/Sources/EquatableIgnored.swift @@ -20,7 +20,7 @@ import Foundation /// } /// ``` /// In this example, `cache` will never affect the equality of two `Example` instances. -@propertyWrapper public struct EquatableIgnored: Equatable { +@propertyWrapper public struct EquatableIgnored: Equatable, Hashable, @unchecked Sendable { public var wrappedValue: Value public init(wrappedValue: Value) { @@ -30,4 +30,6 @@ import Foundation public static func == (_: Self, _: Self) -> Bool { true } + + public func hash(into _: inout Hasher) {} } diff --git a/BroadwayCore/Sources/Traits/BTraits+Overrides.swift b/BroadwayCore/Sources/Traits/BTraits+Overrides.swift new file mode 100644 index 0000000..91fc3b6 --- /dev/null +++ b/BroadwayCore/Sources/Traits/BTraits+Overrides.swift @@ -0,0 +1,46 @@ +// +// BTraits+Overrides.swift +// BroadwayCore +// +// Created by Kyle Van Essen on 3/31/26. +// + +import Foundation + +extension BTraits { + /// Allows overriding traits with specific values. + public struct Overrides: Equatable, Hashable, @unchecked Sendable { + public init() {} + + // MARK: Subscript + + /// Gets or sets the trait for the given type. Returns `nil` if no value has been set. + public subscript(_: Value.Type) -> Value? { + get { + let id = TypeIdentifier(Value.self) + + guard let value = storage[id], let value = value.base as? Value else { + return nil + } + + return value + } + + set { + let id = TypeIdentifier(Value.self) + + if let newValue { + storage[id] = AnyHashable(newValue) + } else { + storage[id] = nil + } + } + } + + @CopyOnWrite private var storage: [TypeIdentifier: AnyHashable] = [:] + + var values: [TypeIdentifier: AnyHashable] { + storage + } + } +} diff --git a/BroadwayCore/Sources/Traits/BTraits.swift b/BroadwayCore/Sources/Traits/BTraits.swift new file mode 100644 index 0000000..df2502b --- /dev/null +++ b/BroadwayCore/Sources/Traits/BTraits.swift @@ -0,0 +1,161 @@ +// +// BTraits.swift +// BroadwayCore +// +// Created by Kyle Van Essen on 3/3/26. +// + +import Foundation +import UIKit + +// MARK: - BTraits + +/// A type-keyed container of ``BTraitsValue`` conforming values +/// representing the current environment (accessibility, size classes, etc.). +/// +/// Create a traits container with ``system`` for the built-in set of +/// observed traits, or call ``register(_:)`` to add custom types. +/// Accessing a type that hasn't been explicitly set returns +/// its ``BTraitsValue/defaultValue``. +public struct BTraits: Equatable, Hashable, @unchecked Sendable { + init() {} + + /// A traits container pre-registered with all built-in system trait types. + @MainActor public static var system: BTraits { + var traits = BTraits() + + traits.register(BAccessibility.self) + traits.register(BMode.self) + traits.register(BContentSizeCategory.self) + + return traits + } + + // MARK: Subscript + + /// Gets or sets the trait for the given type. Returns + /// ``BTraitsValue/defaultValue`` if no value has been set. + public subscript(_: Value.Type) -> Value { + get { + let id = TypeIdentifier(Value.self) + + guard let value = storage[id], let value = value.base as? Value else { + return Value.defaultValue + } + + return value + } + + set { + let id = TypeIdentifier(Value.self) + + storage[id] = AnyHashable(newValue) + } + } + + // MARK: Applying Overrides + + public mutating func merge(with overrides: Overrides) { + for (id, value) in overrides.values { + storage[id] = value + } + } + + public func merging(with overrides: Overrides) -> BTraits { + var copy = self + copy.merge(with: overrides) + return copy + } + + // MARK: Registration + + /// Registers a ``BTraitsValue`` type so that ``readCurrentValues(from:)`` + /// and ``BTraitsObserver`` know about it. + @MainActor public mutating func register(_: V.Type) { + registrations.append(Registration( + id: TypeIdentifier(V.self), + readCurrentValue: { vc in AnyHashable(V.currentValue(from: vc)) }, + createObserver: { vc, onChange in + V.makeObserver(with: vc) { newValue in onChange(AnyHashable(newValue)) } + }, + )) + } + + /// Reads the live value for every registered trait type from the + /// given view controller hierarchy and stores it. + @MainActor public mutating func readCurrentValues( + from viewController: UIViewController, + ) { + for reg in registrations { + storage[reg.id] = reg.readCurrentValue(viewController) + } + } + + // MARK: Internal + + mutating func setValue(_ value: AnyHashable, for id: TypeIdentifier) { + storage[id] = value + } + + struct Registration: @unchecked Sendable { + let id: TypeIdentifier + let readCurrentValue: @MainActor @Sendable (UIViewController) -> AnyHashable + let createObserver: @MainActor @Sendable ( + UIViewController, + @escaping @MainActor @Sendable (AnyHashable) -> Void + ) -> any BTraitsValueObserver + } + + // MARK: Private + + @CopyOnWrite private var storage: [TypeIdentifier: AnyHashable] = [:] + @EquatableIgnored private(set) var registrations: [Registration] = [] +} + +// MARK: - BTraitsValue + +/// A hashable value that can be stored in ``BTraits``. +/// +/// Conforming types provide a ``defaultValue`` that is returned when +/// the trait has not been explicitly set. Types that need live system +/// observation override ``currentValue(from:)`` and +/// ``makeObserver(onChange:)`` to supply a snapshot and an observer. +public protocol BTraitsValue: Hashable { + associatedtype Observer: BTraitsValueObserver + + static var defaultValue: Self { get } + + /// Returns the current live value by reading from the view controller + /// hierarchy. Defaults to ``defaultValue``. + @MainActor static func currentValue(from viewController: UIViewController) -> Self + + @MainActor static func makeObserver( + with viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (Self) -> Void, + ) -> Observer +} + +extension BTraitsValue { + @MainActor public static func currentValue( + from _: UIViewController, + ) -> Self { + defaultValue + } +} + +// MARK: - BTraitsValueObserver + +/// The interface for an observer that monitors changes to +/// a single ``BTraitsValue`` type. Retained by ``BTraitsObserver``. +@MainActor public protocol BTraitsValueObserver: AnyObject { + func start() + func stop() +} + +/// A no-op observer type conformers can return from ``BTraitsValue/makeObserver`` +/// when a trait has no live system source to subscribe to. +@MainActor public final class NeverObserver: BTraitsValueObserver { + public init() {} + public func start() {} + public func stop() {} +} diff --git a/BroadwayCore/Sources/Traits/BTraitsObserver.swift b/BroadwayCore/Sources/Traits/BTraitsObserver.swift new file mode 100644 index 0000000..7e31cfb --- /dev/null +++ b/BroadwayCore/Sources/Traits/BTraitsObserver.swift @@ -0,0 +1,86 @@ +// +// BTraitsObserver.swift +// BroadwayCore +// + +import Foundation +import UIKit + +/// Observes all trait types registered in a ``BTraits`` instance, +/// maintaining a live snapshot and notifying the caller when any +/// trait changes. +/// +/// The observer reads its configuration (which types to observe) +/// from the ``BTraits`` value passed at init, reads the current +/// values from the view controller hierarchy, and creates +/// per-type observers via each type's ``BTraitsValue/makeObserver(onChange:)``. +@MainActor +public final class BTraitsObserver { + // MARK: Public + + /// The current aggregated traits. Updated automatically when + /// any observed trait value changes. + public private(set) var traits: BTraits + + // MARK: Initialization + + /// - Parameter traits: The ``BTraits`` container whose registrations + /// determine which types are observed. + /// - Parameter viewController: The view controller used to read + /// initial trait values via ``BTraitsValue/currentValue(from:)``. + /// - Parameter onChange: Called with the updated ``BTraits`` + /// whenever any observed trait value changes. + public init( + traits: BTraits, + from viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (BTraits) -> Void, + ) { + self.traits = traits + self.onChange = onChange + + self.traits.readCurrentValues(from: viewController) + + for reg in traits.registrations { + let regID = reg.id + + let observer = reg.createObserver(viewController) { [weak self] newValue in + guard let self else { return } + + let old = self.traits + self.traits.setValue(newValue, for: regID) + + if self.traits != old { + onChange(self.traits) + } + } + + observers.append(observer) + } + } + + // MARK: Lifecycle + + /// Starts all registered trait observers. Safe to call multiple times. + public func start() { + guard !started else { return } + started = true + for observer in observers { + observer.start() + } + } + + /// Stops all registered trait observers. Safe to call multiple times. + public func stop() { + guard started else { return } + started = false + for observer in observers { + observer.stop() + } + } + + // MARK: Private + + private let onChange: @MainActor @Sendable (BTraits) -> Void + private var observers: [any BTraitsValueObserver] = [] + private var started = false +} diff --git a/BroadwayCore/Sources/Traits/UIViewControllerTraitObserver.swift b/BroadwayCore/Sources/Traits/UIViewControllerTraitObserver.swift new file mode 100644 index 0000000..6ea39cf --- /dev/null +++ b/BroadwayCore/Sources/Traits/UIViewControllerTraitObserver.swift @@ -0,0 +1,45 @@ +// +// UIViewControllerTraitObserver.swift +// BroadwayCore +// + +import UIKit + +/// A reusable ``BTraitsValueObserver`` that monitors UIKit trait changes +/// on a specific view controller via `registerForTraitChanges`. +/// +/// Use this for ``BTraitsValue`` types whose values are derived from +/// `UITraitCollection` properties (e.g. user interface style, +/// preferred content size category). +@MainActor +public final class UIViewControllerTraitObserver: BTraitsValueObserver { + private weak var viewController: UIViewController? + private let uiTraits: [any UITraitDefinition.Type] + private let onChange: @MainActor @Sendable (UIViewController) -> Void + private var registration: (any UITraitChangeRegistration)? + + public init( + observing uiTraits: [any UITraitDefinition.Type], + on viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (UIViewController) -> Void, + ) { + self.viewController = viewController + self.uiTraits = uiTraits + self.onChange = onChange + } + + public func start() { + guard registration == nil, let viewController else { return } + + registration = viewController.registerForTraitChanges(uiTraits) { [weak self] (vc: UIViewController, _: UITraitCollection) in + self?.onChange(vc) + } + } + + public func stop() { + guard let registration, let viewController else { return } + + viewController.unregisterForTraitChanges(registration) + self.registration = nil + } +} diff --git a/BroadwayCore/Sources/BAccessibility.swift b/BroadwayCore/Sources/Traits/Values/BAccessibility.swift similarity index 91% rename from BroadwayCore/Sources/BAccessibility.swift rename to BroadwayCore/Sources/Traits/Values/BAccessibility.swift index 437103a..57d24fb 100644 --- a/BroadwayCore/Sources/BAccessibility.swift +++ b/BroadwayCore/Sources/Traits/Values/BAccessibility.swift @@ -8,6 +8,20 @@ import Foundation import UIKit +extension BTraits { + public var accessibility: BAccessibility { + get { self[BAccessibility.self] } + set { self[BAccessibility.self] = newValue } + } +} + +extension BTraits.Overrides { + public var accessibility: BAccessibility? { + get { self[BAccessibility.self] } + set { self[BAccessibility.self] = newValue } + } +} + /// A snapshot of the device's current accessibility settings. /// /// Each property mirrors a corresponding `UIAccessibility` class property. @@ -119,11 +133,30 @@ public struct BAccessibility: Equatable, Hashable, Sendable { } } +extension BAccessibility: BTraitsValue { + public static var defaultValue: Self { + .init() + } + + @MainActor public static func currentValue( + from _: UIViewController, + ) -> BAccessibility { + .current() + } + + @MainActor public static func makeObserver( + with _: UIViewController, + onChange: @MainActor @escaping @Sendable (BAccessibility) -> Void, + ) -> BAccessibility.Observer { + .init { _, new in onChange(new) } + } +} + extension BAccessibility { /// A provider which returns the current accessibility settings on the device. /// /// Instead of accessing `UIAccessibility.{...}` directly, utilize `BAccessibility.systemSettings`. - public protocol SettingsProvider: AnyObject { + public protocol SettingsProvider: AnyObject, Sendable { // MARK: Assistive Technologies var isVoiceOverRunning: Bool { get } @@ -175,31 +208,11 @@ extension BAccessibility { } extension BAccessibility { - /// Keeps a ``BContext``'s accessibility traits in sync with the device - /// by delivering change notifications as `(old, new)` pairs. - /// - /// Retain the returned ``Observer`` for the duration of observation. - /// Call ``Observer/start()`` to begin and ``Observer/stop()`` to pause. - /// - /// - Parameter onChange: Called with `(old, new)` values when a change is detected. - /// - Returns: An ``Observer`` that must be retained for the lifetime of observation. - @MainActor public static func observe( - on notificationCenter: NotificationCenter = .default, - with provider: any SettingsProvider = BAccessibility.systemSettings, - _ onChange: @MainActor @escaping @Sendable (BAccessibility, BAccessibility) -> Void, - ) -> Observer { - Observer( - notificationCenter: notificationCenter, - settingsProvider: provider, - onChange: onChange, - ) - } - /// Manages `NotificationCenter` registrations for system accessibility /// changes and reports diffs via a callback. /// Call ``start()`` and ``stop()`` to control the observation lifecycle. @MainActor - public final class Observer { + public final class Observer: BTraitsValueObserver { private let onChange: @MainActor @Sendable (BAccessibility, BAccessibility) -> Void private let notificationCenter: NotificationCenter @@ -210,8 +223,8 @@ extension BAccessibility { private var isObserving: Bool = false init( - notificationCenter: NotificationCenter, - settingsProvider: SettingsProvider, + notificationCenter: NotificationCenter = .default, + settingsProvider: any SettingsProvider = BAccessibility.systemSettings, onChange: @MainActor @escaping @Sendable (BAccessibility, BAccessibility) -> Void, ) { self.notificationCenter = notificationCenter @@ -287,7 +300,7 @@ extension BAccessibility { } extension BAccessibility { - private final class SystemSettingsProvider: SettingsProvider { + private final class SystemSettingsProvider: SettingsProvider, @unchecked Sendable { var isVoiceOverRunning: Bool { UIAccessibility.isVoiceOverRunning } diff --git a/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift new file mode 100644 index 0000000..b528abb --- /dev/null +++ b/BroadwayCore/Sources/Traits/Values/BTraits+Values.swift @@ -0,0 +1,126 @@ +// +// BTraits+Values.swift +// BroadwayCore +// +// Created by Kyle Van Essen on 3/31/26. +// + +import Foundation +import UIKit + +extension BTraits { + public var mode: BMode { + get { self[BMode.self] } + set { self[BMode.self] = newValue } + } + + public var contentSizeCategory: BContentSizeCategory { + get { self[BContentSizeCategory.self] } + set { self[BContentSizeCategory.self] = newValue } + } +} + +extension BTraits.Overrides { + public var mode: BMode? { + get { self[BMode.self] } + set { self[BMode.self] = newValue } + } + + public var contentSizeCategory: BContentSizeCategory? { + get { self[BContentSizeCategory.self] } + set { self[BContentSizeCategory.self] = newValue } + } +} + +public enum BMode: Equatable, Hashable, Sendable { + case dark + case light + + public static func from(_ style: UIUserInterfaceStyle) -> BMode { + switch style { + case .dark: + .dark + case .light, .unspecified: + .light + @unknown default: + .light + } + } +} + +extension BMode: BTraitsValue { + public static let defaultValue: BMode = .light + + @MainActor public static func currentValue(from viewController: UIViewController) -> BMode { + .from(viewController.traitCollection.userInterfaceStyle) + } + + @MainActor public static func makeObserver( + with viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (BMode) -> Void, + ) -> UIViewControllerTraitObserver { + UIViewControllerTraitObserver( + observing: [UITraitUserInterfaceStyle.self], + on: viewController, + ) { vc in + onChange(currentValue(from: vc)) + } + } +} + +public enum BContentSizeCategory: Equatable, Hashable, Comparable, Sendable { + case extraSmall + case small + case medium + case large + case extraLarge + case extraExtraLarge + case extraExtraExtraLarge + case accessibilityMedium + case accessibilityLarge + case accessibilityExtraLarge + case accessibilityExtraExtraLarge + case accessibilityExtraExtraExtraLarge + + public var isAccessibilitySize: Bool { + self >= Self.accessibilityMedium + } +} + +extension BContentSizeCategory: BTraitsValue { + public static let defaultValue: BContentSizeCategory = .large + + @MainActor public static func currentValue(from viewController: UIViewController) -> BContentSizeCategory { + .from(viewController.traitCollection.preferredContentSizeCategory) + } + + @MainActor public static func makeObserver( + with viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (BContentSizeCategory) -> Void, + ) -> UIViewControllerTraitObserver { + UIViewControllerTraitObserver( + observing: [UITraitPreferredContentSizeCategory.self], + on: viewController, + ) { vc in + onChange(currentValue(from: vc)) + } + } + + public static func from(_ uiCategory: UIContentSizeCategory) -> BContentSizeCategory { + switch uiCategory { + case .extraSmall: .extraSmall + case .small: .small + case .medium: .medium + case .large: .large + case .extraLarge: .extraLarge + case .extraExtraLarge: .extraExtraLarge + case .extraExtraExtraLarge: .extraExtraExtraLarge + case .accessibilityMedium: .accessibilityMedium + case .accessibilityLarge: .accessibilityLarge + case .accessibilityExtraLarge: .accessibilityExtraLarge + case .accessibilityExtraExtraLarge: .accessibilityExtraExtraLarge + case .accessibilityExtraExtraExtraLarge: .accessibilityExtraExtraExtraLarge + default: .large + } + } +} diff --git a/BroadwayCore/Tests/BAccessibilityTests.swift b/BroadwayCore/Tests/BAccessibilityTests.swift index 17489ab..b1cd110 100644 --- a/BroadwayCore/Tests/BAccessibilityTests.swift +++ b/BroadwayCore/Tests/BAccessibilityTests.swift @@ -120,7 +120,7 @@ struct BAccessibilityTests { // MARK: - Mock SettingsProvider -private final class MockSettingsProvider: BAccessibility.SettingsProvider { +private final class MockSettingsProvider: BAccessibility.SettingsProvider, @unchecked Sendable { var isVoiceOverRunning = false var isSwitchControlRunning = false var isAssistiveTouchRunning = false @@ -176,26 +176,18 @@ struct BAccessibilitySettingsProviderTests { // MARK: - Observer Tests @MainActor struct BAccessibilityObserverTests { - // MARK: - Factory - - @Test("observe returns an Observer") - func observeFactory() { - let observer = BAccessibility.observe { _, _ in } - _ = observer - } - // MARK: - Lifecycle @Test("start and stop complete without error") func startStop() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.start() observer.stop() } @Test("Calling start twice is safe") func doubleStart() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.start() observer.start() observer.stop() @@ -203,13 +195,13 @@ struct BAccessibilitySettingsProviderTests { @Test("Calling stop without start is safe") func stopWithoutStart() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.stop() } @Test("Calling stop twice is safe") func doubleStop() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.start() observer.stop() observer.stop() @@ -217,7 +209,7 @@ struct BAccessibilitySettingsProviderTests { @Test("Can restart after stopping") func restart() { - let observer = BAccessibility.observe { _, _ in } + let observer = BAccessibility.Observer { _, _ in } observer.start() observer.stop() observer.start() @@ -226,7 +218,7 @@ struct BAccessibilitySettingsProviderTests { @Test("Deallocation after start does not crash") func deallocAfterStart() { - var observer: BAccessibility.Observer? = BAccessibility.observe { _, _ in } + var observer: BAccessibility.Observer? = .init { _, _ in } observer?.start() observer = nil _ = observer @@ -239,7 +231,7 @@ struct BAccessibilitySettingsProviderTests { let center = NotificationCenter() var callCount = 0 - let observer = BAccessibility.observe(on: center) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center) { _, _ in callCount += 1 } observer.start() @@ -260,7 +252,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var callCount = 0 - let observer = BAccessibility.observe(on: center, with: mock) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { _, _ in callCount += 1 } observer.start() @@ -280,7 +272,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var callCount = 0 - let observer = BAccessibility.observe(on: center, with: mock) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { _, _ in callCount += 1 } observer.start() @@ -299,7 +291,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var received: (old: BAccessibility, new: BAccessibility)? - let observer = BAccessibility.observe(on: center, with: mock) { old, new in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { old, new in received = (old, new) } observer.start() @@ -319,7 +311,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var callCount = 0 - let observer = BAccessibility.observe(on: center, with: mock) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { _, _ in callCount += 1 } observer.start() @@ -337,7 +329,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var snapshots: [(old: BAccessibility, new: BAccessibility)] = [] - let observer = BAccessibility.observe(on: center, with: mock) { old, new in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { old, new in snapshots.append((old, new)) } observer.start() @@ -366,7 +358,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var callCount = 0 - let observer = BAccessibility.observe(on: center, with: mock) { _, _ in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { _, _ in callCount += 1 } observer.start() @@ -384,7 +376,7 @@ struct BAccessibilitySettingsProviderTests { let mock = MockSettingsProvider() var received: (old: BAccessibility, new: BAccessibility)? - let observer = BAccessibility.observe(on: center, with: mock) { old, new in + let observer = BAccessibility.Observer(notificationCenter: center, settingsProvider: mock) { old, new in received = (old, new) } diff --git a/BroadwayCore/Tests/BContextTests.swift b/BroadwayCore/Tests/BContextTests.swift index 91711c5..0ba99c4 100644 --- a/BroadwayCore/Tests/BContextTests.swift +++ b/BroadwayCore/Tests/BContextTests.swift @@ -156,7 +156,7 @@ struct BContextStylesheetTests { let before = try context.stylesheets.get(TestStylesheet.self) - context.traits.accessibility = BAccessibility(isVoiceOverRunning: true) + context.baseTraits.accessibility = BAccessibility(isVoiceOverRunning: true) let after = try context.stylesheets.get(TestStylesheet.self) @@ -206,7 +206,7 @@ struct BContextStylesheetTests { @Test("Circular stylesheet dependency throws cyclicDependency") func cycleThrows() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) #expect(throws: StylesheetError.self) { _ = try stylesheets.get(CycleA.self) @@ -215,7 +215,7 @@ struct BContextStylesheetTests { @Test("cyclicDependency error includes the dependency path") func cyclePath() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) let error = #expect(throws: StylesheetError.self) { _ = try stylesheets.get(CycleA.self) @@ -237,7 +237,7 @@ struct BContextStylesheetTests { @Test("Self-referencing stylesheet throws cyclicDependency") func selfCycle() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) #expect(throws: StylesheetError.self) { _ = try stylesheets.get(SelfCycle.self) @@ -246,7 +246,7 @@ struct BContextStylesheetTests { @Test("Three-node cycle throws cyclicDependency with full path") func threeNodeCycle() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) let error = #expect(throws: StylesheetError.self) { _ = try stylesheets.get(ThreeNodeCycleA.self) @@ -261,7 +261,7 @@ struct BContextStylesheetTests { @Test("Non-cycle error wraps in creationFailed") func nonCycleThrow() { - let stylesheets = BStylesheets(config: .init(traits: .init(), themes: .init())) + let stylesheets = BStylesheets(traits: .init(), themes: .init()) let error = #expect(throws: StylesheetError.self) { _ = try stylesheets.get(FailingStylesheet.self) @@ -276,10 +276,10 @@ struct BContextStylesheetTests { func setAndGet() throws { var themes = BThemes() themes[ColorTheme.self] = .dark - let source = BStylesheets(config: .init(traits: .init(), themes: themes)) + let source = BStylesheets(traits: .init(), themes: themes) let sheet = try source.get(TestStylesheet.self) - var target = BStylesheets(config: .init(traits: .init(), themes: themes)) + var target = BStylesheets(traits: .init(), themes: themes) target.set(sheet) let retrieved = try target.get(TestStylesheet.self) @@ -294,7 +294,7 @@ struct BContextStylesheetTests { _ = try context.stylesheets.get(CountingStylesheet.self) #expect(CountingStylesheet.initCount == 1) - context.traits.accessibility = BAccessibility(isVoiceOverRunning: true) + context.baseTraits.accessibility = BAccessibility(isVoiceOverRunning: true) _ = try context.stylesheets.get(CountingStylesheet.self) #expect(CountingStylesheet.initCount == 2) } @@ -311,7 +311,7 @@ struct BContextTests { @Test("Contexts with different traits are not equal") func traitInequality() { var a = BContext() - a.traits.accessibility = BAccessibility(isVoiceOverRunning: true) + a.baseTraits.accessibility = BAccessibility(isVoiceOverRunning: true) #expect(a != BContext()) } @@ -326,16 +326,29 @@ struct BContextTests { func traitPropagation() { var context = BContext() let accessibility = BAccessibility(isVoiceOverRunning: true) - context.traits.accessibility = accessibility - #expect(context.stylesheets.traits.accessibility == accessibility) + context.baseTraits.accessibility = accessibility + #expect(context.traits.accessibility == accessibility) } @Test("Theme mutation propagates to stylesheets config") - func themePropagation() { + func themePropagation() throws { var context = BContext() context.themes[ColorTheme.self] = .dark - let theme: ColorTheme = context.stylesheets.themes[ColorTheme.self] - #expect(theme == .dark) + let sheet = try context.stylesheets.get(TestStylesheet.self) + #expect(sheet.color == .dark) + } + + @Test("Stylesheets use merged traits when initializer passes non-empty overrides") + func stylesheetsTraitsMatchMergedAtInit() { + var base = BTraits() + base.accessibility = BAccessibility(isVoiceOverRunning: false) + + var overrides = BTraits.Overrides() + overrides.accessibility = BAccessibility(isVoiceOverRunning: true) + + let context = BContext(traits: base, overrides: overrides) + + #expect(context.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) } } diff --git a/BroadwayCore/Tests/BTraitsObserverTests.swift b/BroadwayCore/Tests/BTraitsObserverTests.swift new file mode 100644 index 0000000..ee43b44 --- /dev/null +++ b/BroadwayCore/Tests/BTraitsObserverTests.swift @@ -0,0 +1,226 @@ +@testable import BroadwayCore +import BroadwayTesting +import Testing +import UIKit + +@MainActor +private final class CountingStartSpy: BTraitsValueObserver { + static var lastInstance: CountingStartSpy? + + private(set) var startCount = 0 + + init() { + Self.lastInstance = self + } + + func start() { + startCount += 1 + } + + func stop() {} +} + +private struct StartCountingTrait: BTraitsValue, Hashable { + typealias Observer = CountingStartSpy + + @MainActor static var defaultValue: StartCountingTrait { + StartCountingTrait() + } + + @MainActor static func currentValue(from _: UIViewController) -> StartCountingTrait { + StartCountingTrait() + } + + @MainActor static func makeObserver( + with _: UIViewController, + onChange _: @MainActor @escaping @Sendable (StartCountingTrait) -> Void, + ) -> CountingStartSpy { + CountingStartSpy() + } +} + +private enum SeqTraitTest { + static let notificationCenter = NotificationCenter() + static let notificationName = Notification.Name("BroadwayTests.SeqTrait") +} + +private struct SeqTrait: BTraitsValue, Hashable { + typealias Observer = SeqTraitObserver + + var generation: Int + + @MainActor static var defaultValue: SeqTrait { + SeqTrait(generation: 0) + } + + @MainActor static func currentValue(from _: UIViewController) -> SeqTrait { + SeqTrait(generation: 0) + } + + @MainActor static func makeObserver( + with _: UIViewController, + onChange: @MainActor @escaping @Sendable (SeqTrait) -> Void, + ) -> SeqTraitObserver { + SeqTraitObserver(onChange: onChange) + } +} + +@MainActor +private final class SeqTraitObserver: BTraitsValueObserver { + private var token: NSObjectProtocol? + private var nextGeneration = 0 + private let onChange: @MainActor @Sendable (SeqTrait) -> Void + + init(onChange: @escaping @MainActor @Sendable (SeqTrait) -> Void) { + self.onChange = onChange + } + + func start() { + guard token == nil else { return } + let center = SeqTraitTest.notificationCenter + token = center.addObserver( + forName: SeqTraitTest.notificationName, + object: nil, + queue: nil, + ) { [weak self] _ in + guard let self else { return } + MainActor.assumeIsolated { + self.nextGeneration += 1 + self.onChange(SeqTrait(generation: self.nextGeneration)) + } + } + } + + func stop() { + if let token { + SeqTraitTest.notificationCenter.removeObserver(token) + } + token = nil + } +} + +@MainActor struct BTraitsObserverTests { + private func makeSystemObserver( + from viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (BTraits) -> Void = { _ in }, + ) -> BTraitsObserver { + BTraitsObserver(traits: .system, from: viewController, onChange: onChange) + } + + // MARK: - Initial Value + + @Test("Initial traits contain the live accessibility snapshot") + func initialAccessibility() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + #expect(observer.traits.accessibility == BAccessibility.current()) + } + } + + @Test("Initial traits are not equal to a default BTraits()") + func initialTraitsNotDefault() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + let defaultTraits = BTraits() + + #expect(observer.traits.accessibility == BAccessibility.current()) + #expect(defaultTraits.accessibility == BAccessibility()) + } + } + + // MARK: - Lifecycle + + @Test("start and stop complete without error") + func startStop() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + observer.start() + observer.stop() + } + } + + @Test("Coordinator start is idempotent for underlying observers") + func doubleStartForwardsStartOnceToObservers() throws { + var traits = BTraits() + traits.register(StartCountingTrait.self) + + let anchor = UIViewController() + try show(anchor) { hosted in + CountingStartSpy.lastInstance = nil + let observer = BTraitsObserver(traits: traits, from: hosted, onChange: { _ in }) + observer.start() + observer.start() + #expect(CountingStartSpy.lastInstance?.startCount == 1) + observer.stop() + } + } + + @Test("Calling stop without start is safe") + func stopWithoutStart() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + observer.stop() + } + } + + @Test("Calling stop twice is safe") + func doubleStop() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + observer.start() + observer.stop() + observer.stop() + } + } + + @Test("stop tears down underlying subscriptions so notifications no longer update traits") + func stopRemovesUnderlyingObservers() throws { + var traits = BTraits() + traits.register(SeqTrait.self) + + let anchor = UIViewController() + try show(anchor) { hosted in + var aggregateChangeCount = 0 + let observer = BTraitsObserver(traits: traits, from: hosted) { _ in + aggregateChangeCount += 1 + } + observer.start() + + SeqTraitTest.notificationCenter.post(name: SeqTraitTest.notificationName, object: nil) + #expect(aggregateChangeCount == 1) + + observer.stop() + + SeqTraitTest.notificationCenter.post(name: SeqTraitTest.notificationName, object: nil) + #expect(aggregateChangeCount == 1) + } + } + + @Test("Can restart after stopping") + func restart() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + let observer = makeSystemObserver(from: hosted) + observer.start() + observer.stop() + observer.start() + observer.stop() + } + } + + @Test("Deallocation after start does not crash") + func deallocAfterStart() throws { + let anchor = UIViewController() + try show(anchor) { hosted in + var observer: BTraitsObserver? = makeSystemObserver(from: hosted) + observer?.start() + observer = nil + _ = observer + } + } +} diff --git a/BroadwayCore/Tests/BTraitsTests.swift b/BroadwayCore/Tests/BTraitsTests.swift index 2bbb0e3..4ee4df7 100644 --- a/BroadwayCore/Tests/BTraitsTests.swift +++ b/BroadwayCore/Tests/BTraitsTests.swift @@ -1,11 +1,22 @@ @testable import BroadwayCore import Testing +import UIKit private struct ScaleFactor: BTraitsValue { + typealias Observer = NeverObserver + var value: Double + static var defaultValue: Self { ScaleFactor(value: 1.0) } + + @MainActor static func makeObserver( + with _: UIViewController, + onChange _: @MainActor @escaping @Sendable (ScaleFactor) -> Void, + ) -> NeverObserver { + NeverObserver() + } } struct BTraitsTests { diff --git a/BroadwayTesting/Sources/BroadwayTesting.swift b/BroadwayTesting/Sources/BroadwayTesting.swift deleted file mode 100644 index c38e2c5..0000000 --- a/BroadwayTesting/Sources/BroadwayTesting.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// BroadwayTesting.swift -// BroadwayTesting -// - -import BroadwayCore diff --git a/BroadwayTesting/Sources/XCTestCaseAdditions.swift b/BroadwayTesting/Sources/XCTestCaseAdditions.swift index dbfaecf..bbcfdee 100644 --- a/BroadwayTesting/Sources/XCTestCaseAdditions.swift +++ b/BroadwayTesting/Sources/XCTestCaseAdditions.swift @@ -2,116 +2,129 @@ // XCTestCaseAdditions.swift // BroadwayTesting // -// -import XCTest - -extension XCTestCase { - /// - /// Call this method to show a view controller in the test host application - /// during a unit test. The view controller will be the size of host application's device. - /// - /// After the test runs, the view controller will be removed from the view hierarchy. - /// - /// A test failure will occur if the host application does not exist, or does not have a root view controller. - /// - public func show( - vc viewController: ViewController, - loadAndPlaceView: Bool = true, - test: (ViewController) throws -> Void, - ) rethrows { - guard let rootVC = UIApplication.shared.delegate?.window??.rootViewController else { - XCTFail("Cannot present a view controller in a test host that does not have a root window.") - return - } +import UIKit - rootVC.view.window?.layer.speed = 4 - rootVC.addChild(viewController) - viewController.didMove(toParent: rootVC) +struct ShowError: Error, CustomStringConvertible { + var description: String - if loadAndPlaceView { - viewController.view.frame = rootVC.view.bounds - viewController.view.layoutIfNeeded() + init(_ description: String) { + self.description = description + } +} - rootVC.beginAppearanceTransition(true, animated: false) - rootVC.view.addSubview(viewController.view) - rootVC.endAppearanceTransition() - } +/// Shows a view controller in the test host application's window for the +/// duration of `perform`. The view controller is added as a child of +/// the host's root view controller and its view is placed in the window, +/// triggering the full UIKit appearance lifecycle (`viewIsAppearing`, etc.). +/// +/// After `perform` returns (or throws), the view controller is removed +/// from the hierarchy automatically. +@MainActor +public func show( + _ viewController: ViewController, + loadAndPlaceView: Bool = true, + perform test: (ViewController) throws -> Void, +) throws { + guard let rootVC = UIApplication.shared.delegate?.window??.rootViewController else { + throw ShowError("No root view controller in test host.") + } - defer { - if loadAndPlaceView { - viewController.beginAppearanceTransition(false, animated: false) - viewController.view.removeFromSuperview() - viewController.endAppearanceTransition() - } + rootVC.view.window?.layer.speed = 100 + rootVC.addChild(viewController) + viewController.didMove(toParent: rootVC) - viewController.willMove(toParent: nil) - viewController.removeFromParent() - rootVC.view.window?.layer.speed = 1 - } + if loadAndPlaceView { + viewController.view.frame = rootVC.view.bounds + rootVC.view.addSubview(viewController.view) + viewController.view.layoutIfNeeded() + } - try autoreleasepool { - try test(viewController) + defer { + if loadAndPlaceView { + viewController.view.removeFromSuperview() } + viewController.willMove(toParent: nil) + viewController.removeFromParent() + rootVC.view.window?.layer.speed = 1 } - public func waitFor(timeout: TimeInterval = 10.0, predicate: () -> Bool) { - let runloop = RunLoop.main - let timeout = Date(timeIntervalSinceNow: timeout) + try autoreleasepool { + try test(viewController) + } +} + +// MARK: - Run Loop Helpers - while Date() < timeout { - if predicate() { - return - } +@MainActor +public func waitFor(timeout: TimeInterval = 10.0, predicate: () -> Bool) throws { + let runloop = RunLoop.main + let deadline = Date(timeIntervalSinceNow: timeout) - runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) + while Date() < deadline { + if predicate() { + return } - XCTFail("waitUntil timed out waiting for a check to pass.") + runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) } - public func waitFor(timeout: TimeInterval = 10.0, block: (() -> Void) -> Void) { - var isDone = false + throw WaitError("waitFor timed out waiting for a check to pass.") +} - waitFor(timeout: timeout, predicate: { - block { isDone = true } - return isDone - }) - } +@MainActor +public func waitFor(timeout: TimeInterval = 10.0, block: (() -> Void) -> Void) throws { + var isDone = false - public func waitForOneRunloop() { - let runloop = RunLoop.main - runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) - } + try waitFor(timeout: timeout, predicate: { + block { isDone = true } + return isDone + }) +} - public func determineAverage(for seconds: TimeInterval, using block: () -> Void) { - let start = Date() +@MainActor +public func waitForOneRunloop() { + let runloop = RunLoop.main + runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) +} + +@MainActor +public func determineAverage(for seconds: TimeInterval, using block: () -> Void) { + let start = Date() + + var iterations = 0 + var lastUpdateDate = Date() - var iterations = 0 + repeat { + block() - var lastUpdateDate = Date() + iterations += 1 - repeat { - block() + if Date().timeIntervalSince(lastUpdateDate) >= 1 { + lastUpdateDate = Date() + print("Continuing Test: \(iterations) Iterations...") + } - iterations += 1 + } while Date() < start + seconds - if Date().timeIntervalSince(lastUpdateDate) >= 1 { - lastUpdateDate = Date() - print("Continuing Test: \(iterations) Iterations...") - } + let end = Date() - } while Date() < start + seconds + let duration = end.timeIntervalSince(start) + let average = duration / TimeInterval(iterations) - let end = Date() + print("Iterations: \(iterations), Average Time: \(average)") +} - let duration = end.timeIntervalSince(start) - let average = duration / TimeInterval(iterations) +struct WaitError: Error, CustomStringConvertible { + var description: String - print("Iterations: \(iterations), Average Time: \(average)") + init(_ description: String) { + self.description = description } } +// MARK: - UIView Helpers + extension UIView { public var recursiveDescription: String { value(forKey: "recursiveDescription") as! String diff --git a/BroadwayUI/Sources/BContext+SwiftUI.swift b/BroadwayUI/Sources/BContext+SwiftUI.swift new file mode 100644 index 0000000..11110c5 --- /dev/null +++ b/BroadwayUI/Sources/BContext+SwiftUI.swift @@ -0,0 +1,36 @@ +// +// BContext+SwiftUI.swift +// BroadwayUI +// + +import BroadwayCore +import SwiftUI +import UIKit + +// MARK: - UIKit ↔ SwiftUI Bridging + +/// Bridges ``BContext`` between UIKit's trait system and SwiftUI's +/// environment so that `@Environment(\.bContext)` automatically reads +/// from `UITraitCollection` and writes back via `traitOverrides`. +struct BContextEnvironmentKey: UITraitBridgedEnvironmentKey { + static var defaultValue: BContext { + BContextTrait.defaultValue + } + + static func read(from traitCollection: UITraitCollection) -> BContext { + traitCollection.bContext + } + + static func write(to mutableTraits: inout UIMutableTraits, value: BContext) { + mutableTraits.bContext = value + } +} + +extension EnvironmentValues { + /// The Broadway environment container, automatically bridged from the + /// nearest UIKit ancestor's `UITraitCollection`. + public var bContext: BContext { + get { self[BContextEnvironmentKey.self] } + set { self[BContextEnvironmentKey.self] = newValue } + } +} diff --git a/BroadwayUI/Sources/BRootViewController.swift b/BroadwayUI/Sources/BRootViewController.swift index bd5fdc2..3b64403 100644 --- a/BroadwayUI/Sources/BRootViewController.swift +++ b/BroadwayUI/Sources/BRootViewController.swift @@ -10,9 +10,14 @@ import UIKit /// A container view controller that owns the root ``BContext`` and /// propagates it to all descendants via a custom `UITraitCollection` trait. /// -/// `BRootViewController` manages the ``BAccessibility/Observer``, -/// keeping ``BContext/traits`` in sync with system accessibility changes -/// and re-publishing the updated context through `traitOverrides`. +/// `BRootViewController` manages a ``BTraitsObserver``, keeping +/// ``BContext/traits`` in sync with system changes and re-publishing +/// the updated context through `traitOverrides`. +/// +/// Child view controller creation, trait observation, and context setup +/// are deferred until the view controller enters a valid view hierarchy +/// (view loaded and in a window). This ensures traits can be read from +/// an actual hierarchy rather than from a detached controller. /// /// Use the designated initializer to wrap an arbitrary child view controller, /// or the convenience initializer to embed SwiftUI content directly. @@ -26,40 +31,33 @@ import UIKit public final class BRootViewController: UIViewController { // MARK: Public - /// The current context. Updated automatically when accessibility - /// settings change. Read this to inspect the live state. - public private(set) var context: BContext { + /// The current context, available after the view controller has been + /// added to a valid view hierarchy. `nil` before setup completes. + public private(set) var context: BContext? { didSet { - traitOverrides.bContext = context + if let context { + traitOverrides.bContext = context + } } } // MARK: Initialization - /// Creates a root container that embeds the given child view controller. - public init(child: UIViewController) { - context = BContext() - self.child = child + /// Creates a root container whose child view controller is produced + /// lazily by the given closure once the container enters a valid + /// view hierarchy. + public init(child: @escaping () -> UIViewController) { + makeChild = child super.init(nibName: nil, bundle: nil) - - addChild(child) - child.didMove(toParent: self) - - context.traits.accessibility = .current() - traitOverrides.bContext = context - - accessibilityObserver = BAccessibility.observe { [weak self] _, new in - guard let self else { return } - context.traits.accessibility = new - } } /// Creates a root container that hosts SwiftUI content. public convenience init( @ViewBuilder content: () -> some View, ) { - self.init(child: UIHostingController(rootView: content())) + let rootView = content() + self.init { UIHostingController(rootView: rootView) } } @available(*, unavailable) @@ -69,24 +67,48 @@ public final class BRootViewController: UIViewController { // MARK: Lifecycle - override public func viewDidLoad() { - super.viewDidLoad() - - child.view.frame = view.bounds - view.addSubview(child.view) + override public func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) - accessibilityObserver?.start() + setUpIfNeeded() } override public func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() - child.view.frame = view.bounds + child?.view.frame = view.bounds } // MARK: Private - private let child: UIViewController + private let makeChild: () -> UIViewController + + private var child: UIViewController? + private var traitsObserver: BTraitsObserver? + + private func setUpIfNeeded() { + guard child == nil else { return } - private var accessibilityObserver: BAccessibility.Observer? + // TODO: Walk up the tree and don't make an observer if we're nested in another root + // further up the tree. + + let observer = BTraitsObserver(traits: .system, from: self) { [weak self] traits in + self?.context?.baseTraits = traits + } + + traitsObserver = observer + + context = BContext(traits: observer.traits) + + observer.start() + + let child = makeChild() + self.child = child + + addChild(child) + child.didMove(toParent: self) + + child.view.frame = view.bounds + view.addSubview(child.view) + } } diff --git a/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift b/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift new file mode 100644 index 0000000..c554aaa --- /dev/null +++ b/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift @@ -0,0 +1,30 @@ +// +// BTraitOverrides+SwiftUI.swift +// BroadwayUI +// + +import BroadwayCore +import SwiftUI + +extension View { + /// Applies trait overrides to the ``BContext`` flowing through this + /// view's environment. The closure receives the current resolved + /// traits and a mutable ``BTraits/Overrides`` to modify. + public func bTraitOverrides( + _ apply: @escaping (BTraits, inout BTraits.Overrides) -> Void, + ) -> some View { + transformEnvironment(\.bContext) { context in + apply(context.traits, &context.traitOverrides) + } + } + + /// Overrides the ``BMode`` (light/dark) for this view's subtree. + public func bMode(_ mode: BMode) -> some View { + bTraitOverrides { _, overrides in overrides.mode = mode } + } + + /// Overrides the ``BContentSizeCategory`` for this view's subtree. + public func bContentSizeCategory(_ category: BContentSizeCategory) -> some View { + bTraitOverrides { _, overrides in overrides.contentSizeCategory = category } + } +} diff --git a/BroadwayUI/Sources/BTraitOverridesViewController.swift b/BroadwayUI/Sources/BTraitOverridesViewController.swift new file mode 100644 index 0000000..0f474f3 --- /dev/null +++ b/BroadwayUI/Sources/BTraitOverridesViewController.swift @@ -0,0 +1,119 @@ +// +// BTraitOverridesViewController.swift +// BroadwayUI +// + +import BroadwayCore +import UIKit + +/// A container view controller that applies ``BTraits/Overrides`` to the +/// inherited ``BContext`` before passing it to its child. +/// +/// Override values are refreshed when embedded (`didMove(toParent:)`) and when +/// inherited traits change, so the subtree keeps the correct merged context +/// before the child is created. +/// +/// Child creation is deferred until the view controller enters a valid view +/// hierarchy (`viewIsAppearing`), matching ``BRootViewController``'s lazy setup pattern. +public final class BTraitOverridesViewController: UIViewController { + // MARK: Public + + /// The child view controller, available after setup completes. + public private(set) var content: Content? + + public struct Context { + public let traits: BTraits + } + + // MARK: Initialization + + public init( + _ content: @escaping () -> Content, + overrides: @escaping (Context, inout BTraits.Overrides) -> Void, + ) { + makeContent = content + self.overrides = overrides + + super.init(nibName: nil, bundle: nil) + + registerForTraitChanges( + [BContextTrait.self], + action: #selector(onTraitsDidChange(vc:previous:)), + ) + } + + public convenience init( + set keyPath: WritableKeyPath, + to value: Value, + content: @escaping () -> Content, + ) { + self.init(content) { _, overrides in + overrides[keyPath: keyPath] = value + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + // MARK: Lifecycle + + override public func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + + if parent != nil { + applyOverrides() + } + } + + override public func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + + setUpIfNeeded() + } + + override public func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + content?.view.frame = view.bounds + } + + // MARK: Private + + private let makeContent: () -> Content + private let overrides: (Context, inout BTraits.Overrides) -> Void + + private func setUpIfNeeded() { + guard content == nil else { return } + + applyOverrides() + + let child = makeContent() + content = child + + addChild(child) + child.didMove(toParent: self) + + child.view.frame = view.bounds + view.addSubview(child.view) + } + + private func applyOverrides() { + // Prefer the parent's resolved traits: `traitCollection` on `self` can lag + // behind the ancestor chain for custom `UITraitDefinition`s during + // `viewIsAppearing`, so read the merged collection from `parent` when embedded. + var context = parent?.traitCollection.bContext ?? traitCollection.bContext + var current = context.traitOverrides + overrides(.init(traits: context.traits), ¤t) + context.traitOverrides = current + traitOverrides.bContext = context + } + + @objc private func onTraitsDidChange( + vc _: UIViewController, + previous _: UITraitCollection, + ) { + applyOverrides() + } +} diff --git a/BroadwayUI/Sources/BroadwayUI.swift b/BroadwayUI/Sources/BroadwayUI.swift deleted file mode 100644 index 0593951..0000000 --- a/BroadwayUI/Sources/BroadwayUI.swift +++ /dev/null @@ -1,3 +0,0 @@ -/// BroadwayUI is a SwiftUI component library providing reusable UI -/// building blocks for Broadway applications. -public enum BroadwayUI {} diff --git a/BroadwayUI/Tests/BRootViewControllerTests.swift b/BroadwayUI/Tests/BRootViewControllerTests.swift index 7d3407f..b43f12b 100644 --- a/BroadwayUI/Tests/BRootViewControllerTests.swift +++ b/BroadwayUI/Tests/BRootViewControllerTests.swift @@ -1,67 +1,122 @@ import BroadwayCore +import BroadwayTesting @testable import BroadwayUI import SwiftUI import Testing import UIKit @MainActor struct BRootViewControllerTests { - // MARK: - Initialization + // MARK: - Pre-Setup State + + @Test("Context is nil before entering a valid hierarchy") + func contextIsNilBeforeSetup() { + let vc = BRootViewController { UIViewController() } + #expect(vc.context == nil) + } + + // MARK: - Post-Setup State @Test("Context traits reflect the live accessibility snapshot") - func contextHasLiveAccessibility() { - let vc = BRootViewController(child: UIViewController()) - #expect(vc.context.traits.accessibility == BAccessibility.current()) + func contextHasLiveAccessibility() throws { + try show(BRootViewController { UIViewController() }) { vc in + #expect(vc.context?.traits.accessibility == BAccessibility.current()) + } } @Test("Convenience init wraps SwiftUI content") - func swiftUIConvenience() { - let vc = BRootViewController { - Text("Hello") + func swiftUIConvenience() throws { + try show(BRootViewController { Text("Hello") }) { vc in + #expect(vc.context?.traits.accessibility == BAccessibility.current()) } - #expect(vc.context.traits.accessibility == BAccessibility.current()) } // MARK: - Child Containment - @Test("Child is added to parent in init") - func childAddedInInit() { - let child = UIViewController() - let root = BRootViewController(child: child) - - #expect(child.parent === root) + @Test("Child is added after entering a valid hierarchy") + func childAddedAfterSetup() throws { + try show(BRootViewController { UIViewController() }) { root in + #expect(root.children.count == 1) + #expect(root.children.first?.parent === root) + } } - @Test("Child view is added as subview after loading") - func childViewEmbedded() { - let child = UIViewController() - let root = BRootViewController(child: child) - - root.loadViewIfNeeded() - - #expect(child.view.superview === root.view) + @Test("Child view is a subview after setup") + func childViewEmbedded() throws { + try show(BRootViewController { UIViewController() }) { root in + let child = root.children.first + #expect(child?.view.superview === root.view) + } } // MARK: - Trait Propagation - @Test("Context is written into traitOverrides at init") - func traitOverrideSet() { - let vc = BRootViewController(child: UIViewController()) - #expect(vc.traitOverrides.bContext == vc.context) + @Test("Context is written into traitOverrides after setup") + func traitOverrideSet() throws { + try show(BRootViewController { UIViewController() }) { vc in + #expect(vc.traitOverrides.bContext == vc.context) + } } - @Test("Default bContext on UITraitCollection equals BContext()") + @Test("Default bContext on UITraitCollection has default traits") func defaultTraitCollectionContext() { let tc = UITraitCollection() - #expect(tc.bContext == BContext()) + #expect(tc.bContext.traits.accessibility == BAccessibility()) + #expect(tc.bContext.themes == BThemes()) } @Test("Child inherits bContext after layout") - func childInheritsContext() { - let child = UIViewController() - let root = BRootViewController(child: child) - root.loadViewIfNeeded() - root.view.layoutIfNeeded() + func childInheritsContext() throws { + try show(BRootViewController { UIViewController() }) { root in + root.view.layoutIfNeeded() + let child = root.children.first + #expect(child?.traitCollection.bContext == root.context) + } + } - #expect(child.traitCollection.bContext == root.context) + @Test("Trait override container preserves inherited base traits and themes on screen") + func traitOverridesPreserveInheritedContextAtLeaf() throws { + try show(BRootViewController { + BTraitOverridesViewController(set: \.accessibility, to: BAccessibility(isVoiceOverRunning: true)) { + UIViewController() + } + }) { root in + root.view.layoutIfNeeded() + guard + let overridesVC = root.children.first as? BTraitOverridesViewController, + let leaf = overridesVC.content, + let rootContext = root.context + else { + Issue.record("Expected override container, leaf, and root context") + return + } + + let leafContext = leaf.traitCollection.bContext + #expect(leafContext.baseTraits == rootContext.baseTraits) + #expect(leafContext.themes == rootContext.themes) + #expect(leafContext.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) + } + } + + @Test("Trait override container publishes full inherited context to traitOverrides") + func traitOverridesPublishedContextPreservesBaseAndThemes() throws { + try show(BRootViewController { + BTraitOverridesViewController(set: \.accessibility, to: BAccessibility(isVoiceOverRunning: true)) { + UIViewController() + } + }) { root in + root.view.layoutIfNeeded() + guard + let overridesVC = root.children.first as? BTraitOverridesViewController, + let rootContext = root.context + else { + Issue.record("Expected override container and root context") + return + } + + let published = overridesVC.traitOverrides.bContext + #expect(published.baseTraits == rootContext.baseTraits) + #expect(published.themes == rootContext.themes) + #expect(published.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) + } } } diff --git a/BroadwayUI/Tests/BroadwayUITests.swift b/BroadwayUI/Tests/BroadwayUITests.swift deleted file mode 100644 index 09fc92f..0000000 --- a/BroadwayUI/Tests/BroadwayUITests.swift +++ /dev/null @@ -1,10 +0,0 @@ -@testable import BroadwayUI -import Testing - -struct BroadwayUITests { - @Test("BroadwayUI module is importable") - func moduleExists() { - // Verifies the framework builds and is importable. - #expect(true) - } -} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..bba3b35 --- /dev/null +++ b/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "Broadway", + platforms: [ + .iOS(.v26), + .macCatalyst(.v26), + ], + products: [ + .library(name: "BroadwayCore", targets: ["BroadwayCore"]), + .library(name: "BroadwayUI", targets: ["BroadwayUI"]), + .library(name: "BroadwayTesting", targets: ["BroadwayTesting"]), + ], + targets: [ + .target( + name: "BroadwayCore", + path: "BroadwayCore/Sources", + ), + .target( + name: "BroadwayUI", + dependencies: [ + .target(name: "BroadwayCore"), + ], + path: "BroadwayUI/Sources", + ), + .target( + name: "BroadwayTesting", + dependencies: [ + .target(name: "BroadwayCore"), + ], + path: "BroadwayTesting/Sources", + ), + ], +) diff --git a/Project.swift b/Project.swift index b41a557..5090814 100644 --- a/Project.swift +++ b/Project.swift @@ -3,35 +3,31 @@ import ProjectDescription let destinations: Destinations = [.iPhone, .iPad, .macCatalyst] let deployment: DeploymentTargets = .iOS("26.0") -func framework( - _ name: String, +/// Local Swift package (see root `Package.swift`) for BroadwayCore, BroadwayUI, and BroadwayTesting. +/// +/// The former Tuist `BroadwayTesting` framework target linked `.xctest`; the package library only +/// needs UIKit (`show`, etc.) and does not link XCTest, so that dependency is not carried over. +private let broadwayPackage = Package.local(path: .relativeToRoot(".")) + +func unitTests( + name: String, bundleIdSuffix: String, - dependencies: [TargetDependency] = [], -) -> [Target] { - [ - .target( - name: name, - destinations: destinations, - product: .framework, - bundleId: "com.broadway.\(bundleIdSuffix)", - deploymentTargets: deployment, - sources: ["\(name)/Sources/**"], - dependencies: dependencies, - ), - .target( - name: "\(name)Tests", - destinations: destinations, - product: .unitTests, - bundleId: "com.broadway.\(bundleIdSuffix).tests", - deploymentTargets: deployment, - sources: ["\(name)/Tests/**"], - dependencies: [ - .target(name: name), - .target(name: "BroadwayTesting"), - .target(name: "BroadwayTestHost"), - ], - ), - ] + productDependency: String, + sources: ProjectDescription.SourceFilesList, +) -> Target { + .target( + name: name, + destinations: destinations, + product: .unitTests, + bundleId: "com.broadway.\(bundleIdSuffix).tests", + deploymentTargets: deployment, + sources: sources, + dependencies: [ + .package(product: productDependency), + .package(product: "BroadwayTesting"), + .target(name: "BroadwayTestHost"), + ], + ) } let project = Project( @@ -40,6 +36,7 @@ let project = Project( defaultKnownRegions: ["en"], developmentRegion: "en", ), + packages: [broadwayPackage], targets: [ .target( name: "BroadwayCatalog", @@ -53,7 +50,9 @@ let project = Project( ]), sources: ["BroadwayCatalog/Sources/**"], resources: ["BroadwayCatalog/Resources/**"], - dependencies: [.target(name: "BroadwayUI")], + dependencies: [ + .package(product: "BroadwayUI"), + ], ), .target( name: "BroadwayCatalogTests", @@ -64,7 +63,7 @@ let project = Project( sources: ["BroadwayCatalog/Tests/**"], dependencies: [ .target(name: "BroadwayCatalog"), - .target(name: "BroadwayTesting"), + .package(product: "BroadwayTesting"), ], ), .target( @@ -79,19 +78,17 @@ let project = Project( sources: ["BroadwayTestHost/Sources/**"], dependencies: [], ), - .target( - name: "BroadwayTesting", - destinations: destinations, - product: .framework, - bundleId: "com.broadway.testing", - deploymentTargets: deployment, - sources: ["BroadwayTesting/Sources/**"], - dependencies: [ - .target(name: "BroadwayCore"), - .xctest, - ], + unitTests( + name: "BroadwayCoreTests", + bundleIdSuffix: "core", + productDependency: "BroadwayCore", + sources: ["BroadwayCore/Tests/**"], + ), + unitTests( + name: "BroadwayUITests", + bundleIdSuffix: "ui", + productDependency: "BroadwayUI", + sources: ["BroadwayUI/Tests/**"], ), - ] - + framework("BroadwayUI", bundleIdSuffix: "ui", dependencies: [.target(name: "BroadwayCore")]) - + framework("BroadwayCore", bundleIdSuffix: "core"), + ], ) diff --git a/README.md b/README.md index 88b2d76..2fbb344 100644 --- a/README.md +++ b/README.md @@ -39,22 +39,43 @@ The `./ide` script also configures a Git pre-commit hook that automatically form tuist test ``` -Or open the generated project in Xcode and run tests with **Cmd+U**. +Use `tuist test BroadwayCoreTests` or `tuist test BroadwayUITests` to run a single bundle. Core/UI **libraries** are defined in `Package.swift` (there is no `BroadwayCore` Xcode scheme—libraries build via the embedded Swift package). + +Or open the generated workspace in Xcode and run tests with **Cmd+U**. ## Project Structure ``` -BroadwayCatalog/ # Catalog app (Sources/, Resources/, Tests/) -BroadwayUI/ # Reusable UI component framework (Sources/, Tests/) -BroadwayCore/ # Foundational utilities framework (Sources/, Tests/) -BroadwayTestHost/ # Minimal test host app -BroadwayTesting/ # Shared test utilities framework -Project.swift # Tuist project manifest -ide # Dev setup script -swiftformat # Run SwiftFormat -sync-agents # Sync AI agent configuration across tools +/ +├── .githooks/ # Git hooks (pre-commit SwiftFormat) +├── BroadwayCatalog/ # Catalog app (Sources/, Resources/, Tests/) +├── BroadwayUI/ # UI framework (Sources/, Tests/) +├── BroadwayCore/ # Core framework (Sources/, Tests/) +├── BroadwayTestHost/ # Minimal test host app (Sources/) +├── BroadwayTesting/ # Shared test utilities (Sources/) +├── Plans/ # Archived implementation plans +├── Package.swift # SPM libraries (Core, UI, Testing) +├── Project.swift # Tuist: apps, test host, xctest bundles +├── Tuist.swift # Tuist global configuration +├── ide # Dev setup script +├── swiftformat # Run SwiftFormat +└── sync-agents # Sync AI agent configuration across tools ``` +## Targets + +**Swift package** (`Package.swift`): **BroadwayCore**, **BroadwayUI**, and **BroadwayTesting** library products. + +**Tuist / Xcode** (`Project.swift`): + +| Target | Product | Destinations | +|---|---|---| +| **BroadwayCatalog** | App | iOS, Mac Catalyst | +| **BroadwayCatalogTests** | Unit Tests | iOS, Mac Catalyst | +| **BroadwayCoreTests** | Unit Tests | iOS, Mac Catalyst | +| **BroadwayUITests** | Unit Tests | iOS, Mac Catalyst | +| **BroadwayTestHost** | App (test host) | iOS, Mac Catalyst | + ## AI Agent Skills External skills are managed via `sync-agents`. The manifest at `.agents/external-skills.json` tracks installed skills pinned to specific commits.