From eb4600e361c2ff76178218626e6aaaf8c2e45ff4 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 13:47:58 +0000 Subject: [PATCH 1/3] Fixed publishers being completed on override reset --- .../ToggleManager+Overrides.swift | 18 ++++++++++++++---- Sources/ToggleManager/ToggleManager.swift | 7 +++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Sources/ToggleManager/ToggleManager+Overrides.swift b/Sources/ToggleManager/ToggleManager+Overrides.swift index 38e7df5..3e8fcc6 100644 --- a/Sources/ToggleManager/ToggleManager+Overrides.swift +++ b/Sources/ToggleManager/ToggleManager+Overrides.swift @@ -18,14 +18,24 @@ extension ToggleManager { queue.sync(flags: .barrier) { guard let mutableValueProvider = mutableValueProvider else { return [] } let variables = mutableValueProvider.variables + log("Deleting all overrides.") + mutableValueProvider.deleteAll() + + // Clear cache for all variables that had overrides + for variable in variables { + cache[variable] = nil + } + + // Send updated values to existing subjects for variable in variables { DispatchQueue.main.async { - self.subjectsRefs[variable]?.send(completion: .finished) - self.subjectsRefs[variable] = nil + if let subject = self.subjectsRefs[variable] { + let newValue = self.value(for: variable) + subject.send(newValue) + } } } - log("Deleting all overrides.") - mutableValueProvider.deleteAll() + hasOverrides = false return variables } diff --git a/Sources/ToggleManager/ToggleManager.swift b/Sources/ToggleManager/ToggleManager.swift index 1499c69..63fa0f8 100644 --- a/Sources/ToggleManager/ToggleManager.swift +++ b/Sources/ToggleManager/ToggleManager.swift @@ -160,8 +160,11 @@ extension ToggleManager { self.cache[variable] = nil mutableValueProvider.delete(variable) DispatchQueue.main.async { - self.subjectsRefs[variable]?.send(completion: .finished) - self.subjectsRefs[variable] = nil + // Send the new default value to the subject + if let subject = self.subjectsRefs[variable] { + let newValue = self.value(for: variable) + subject.send(newValue) + } self.hasOverrides = !mutableValueProvider.variables.isEmpty } } From c0ba47ce538327b51e534ddd0a2caf3239f3a594 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 13:51:26 +0000 Subject: [PATCH 2/3] Added unit tests --- .../ToggleManager+PublishingTests.swift | 120 +++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/Tests/Suites/ToggleManager/ToggleManager+PublishingTests.swift b/Tests/Suites/ToggleManager/ToggleManager+PublishingTests.swift index 76e1d41..b27ea51 100644 --- a/Tests/Suites/ToggleManager/ToggleManager+PublishingTests.swift +++ b/Tests/Suites/ToggleManager/ToggleManager+PublishingTests.swift @@ -2,16 +2,21 @@ import XCTest import Combine -import Toggles +@testable import Toggles class ToggleManager_PublishingTests: XCTestCase { private var cancellables: Set = [] + private let datasourceUrl = Bundle.toggles.url(forResource: "TestDatasource", withExtension: "json")! + + override func tearDown() { + cancellables.removeAll() + super.tearDown() + } func test_publishers() throws { - let url = Bundle.toggles.url(forResource: "TestDatasource", withExtension: "json")! let inMemoryProvider = InMemoryValueProvider() - let manager = try! ToggleManager(mutableValueProvider: inMemoryProvider, datasourceUrl: url) + let manager = try ToggleManager(mutableValueProvider: inMemoryProvider, datasourceUrl: datasourceUrl) let valueExpectation = self.expectation(description: #function) let cachedPublisherValueExpectation = self.expectation(description: #function) @@ -55,4 +60,113 @@ class ToggleManager_PublishingTests: XCTestCase { wait(for: [valueExpectation, cachedPublisherValueExpectation], timeout: 5.0) } + + func test_publisherRemainsActiveAfterRemoveOverrides() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "integer_toggle" + + var receivedValues: [Value] = [] + let expectation = XCTestExpectation(description: "Receive updated values") + expectation.expectedFulfillmentCount = 3 + + manager.publisher(for: variable) + .sink { value in + receivedValues.append(value) + expectation.fulfill() + } + .store(in: &cancellables) + + manager.set(.int(999), for: variable) + manager.removeOverrides() + manager.reactToConfigurationChanges() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(receivedValues.count, 3) + XCTAssertEqual(receivedValues[0], .int(42)) + XCTAssertEqual(receivedValues[1], .int(999)) + XCTAssertEqual(receivedValues[2], .int(42)) + } + + func test_publisherRemainsActiveAfterDelete() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "string_toggle" + + var receivedValues: [Value] = [] + let expectation = XCTestExpectation(description: "Receive updated values") + expectation.expectedFulfillmentCount = 3 + + manager.publisher(for: variable) + .sink { value in + receivedValues.append(value) + expectation.fulfill() + } + .store(in: &cancellables) + + manager.set(.string("overridden"), for: variable) + manager.delete(variable) + manager.reactToConfigurationChanges() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(receivedValues.count, 3) + XCTAssertEqual(receivedValues[0], .string("Hello World")) + XCTAssertEqual(receivedValues[1], .string("overridden")) + XCTAssertEqual(receivedValues[2], .string("Hello World")) + } + + func test_subjectReferencesPreservedAfterRemoveOverrides() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "integer_toggle" + + _ = manager.publisher(for: variable) + XCTAssertNotNil(manager.subjectsRefs[variable]) + + manager.set(.int(999), for: variable) + manager.removeOverrides() + + XCTAssertNotNil(manager.subjectsRefs[variable], "Subject should still exist after removeOverrides()") + } + + func test_subjectReferencesPreservedAfterDelete() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "integer_toggle" + + _ = manager.publisher(for: variable) + XCTAssertNotNil(manager.subjectsRefs[variable]) + + manager.set(.int(888), for: variable) + manager.delete(variable) + + XCTAssertNotNil(manager.subjectsRefs[variable], "Subject should still exist after delete()") + } + + func test_toggleObservableRemainsActiveAfterRemoveOverrides() throws { + let manager = try ToggleManager(mutableValueProvider: InMemoryValueProvider(), datasourceUrl: datasourceUrl) + let variable = "boolean_toggle" + + let observable = ToggleObservable(manager: manager, variable: variable) + + var receivedValues: [Bool?] = [] + let expectation = XCTestExpectation(description: "Receive updated bool values") + expectation.expectedFulfillmentCount = 3 + + observable.$boolValue + .sink { boolValue in + receivedValues.append(boolValue) + expectation.fulfill() + } + .store(in: &cancellables) + + manager.set(.bool(false), for: variable) + manager.removeOverrides() + manager.reactToConfigurationChanges() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(receivedValues.count, 3) + XCTAssertEqual(receivedValues[0], true) + XCTAssertEqual(receivedValues[1], false) + XCTAssertEqual(receivedValues[2], true) + } } From e8cf3628196dbfac4c2cbb9f67f592cbc1f1ff34 Mon Sep 17 00:00:00 2001 From: Victor Sarda Date: Fri, 5 Dec 2025 13:54:04 +0000 Subject: [PATCH 3/3] Refactored `ToggleObservablesView` to add interactive UI elements --- .../TogglesDemo.xcodeproj/project.pbxproj | 2 + .../xcshareddata/swiftpm/Package.resolved | 14 +- .../Sources/Views/ToggleObservablesView.swift | 242 +++++++++++++++--- 3 files changed, 221 insertions(+), 37 deletions(-) diff --git a/TogglesDemo/TogglesDemo.xcodeproj/project.pbxproj b/TogglesDemo/TogglesDemo.xcodeproj/project.pbxproj index 5a89f7c..02c083a 100644 --- a/TogglesDemo/TogglesDemo.xcodeproj/project.pbxproj +++ b/TogglesDemo/TogglesDemo.xcodeproj/project.pbxproj @@ -379,6 +379,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -408,6 +409,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/TogglesDemo/TogglesDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TogglesDemo/TogglesDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 528fbc1..098fc07 100644 --- a/TogglesDemo/TogglesDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TogglesDemo/TogglesDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,24 @@ { + "originHash" : "c101c21cc202f49ddd2132f6b2405dac874253175a15e72bb116e8e24e9889c3", "pins" : [ { "identity" : "swift-docc-plugin", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } } ], - "version" : 2 + "version" : 3 } diff --git a/TogglesDemo/TogglesDemo/Sources/Views/ToggleObservablesView.swift b/TogglesDemo/TogglesDemo/Sources/Views/ToggleObservablesView.swift index 187907f..db89e58 100644 --- a/TogglesDemo/TogglesDemo/Sources/Views/ToggleObservablesView.swift +++ b/TogglesDemo/TogglesDemo/Sources/Views/ToggleObservablesView.swift @@ -6,10 +6,7 @@ import Toggles @MainActor struct ToggleObservablesView: View { - let message = """ -The values shown below are taken via ToggleObservables. -The view will show the values updating when overrides or new configurations are loaded. -""" + let manager: ToggleManager @ObservedObject var booleanObservable: ToggleObservable @ObservedObject var intObservable: ToggleObservable @@ -17,8 +14,12 @@ The view will show the values updating when overrides or new configurations are @ObservedObject var stringObservable: ToggleObservable @ObservedObject var secureObservable: ToggleObservable @ObservedObject var objectObservable: ToggleObservable + + @State private var stringValue: String = "" + @FocusState private var isStringFieldFocused: Bool init(manager: ToggleManager) { + self.manager = manager self.booleanObservable = ToggleObservable(manager: manager, variable: ToggleVariables.booleanToggle) self.intObservable = ToggleObservable(manager: manager, variable: ToggleVariables.integerToggle) self.numericObservable = ToggleObservable(manager: manager, variable: ToggleVariables.numericToggle) @@ -26,45 +27,216 @@ The view will show the values updating when overrides or new configurations are self.secureObservable = ToggleObservable(manager: manager, variable: ToggleVariables.encryptedToggle) self.objectObservable = ToggleObservable(manager: manager, variable: ToggleVariables.objectToggle) } - + var body: some View { - VStack(spacing: 10) { - Image(systemName: "slowmo") - .resizable() - .scaledToFit() - .frame(width: 60, height: 60) - Text("ToggleObservables showcase") - .font(.title) - .padding() - Text(message) - .padding() - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("\(ToggleVariables.booleanToggle):") - Text(String(booleanObservable.boolValue!)) + List { + // Header section + Section { + VStack(spacing: 8) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 40)) + .foregroundStyle(.blue) + Text("Live Reactive Updates") + .font(.headline) + Text("Values update automatically when overrides or configurations change.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + // Boolean Toggle + Section { + HStack(spacing: 12) { + Image(systemName: "switch.2") + .font(.title3) + .foregroundStyle(.green) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.booleanToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(String(booleanObservable.boolValue ?? false)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() + + SwiftUI.Toggle("", isOn: Binding( + get: { booleanObservable.boolValue ?? false }, + set: { manager.set(.bool($0), for: ToggleVariables.booleanToggle) } + )) + .labelsHidden() + .tint(.green) + } + .padding(.vertical, 4) + } + + // Integer Toggle with Stepper + Section { + HStack(spacing: 12) { + Image(systemName: "number") + .font(.title3) + .foregroundStyle(.blue) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.integerToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(String(intObservable.intValue ?? 0)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() + + Stepper("", value: Binding( + get: { intObservable.intValue ?? 0 }, + set: { manager.set(.int($0), for: ToggleVariables.integerToggle) } + )) + .labelsHidden() + } + .padding(.vertical, 4) + } + + // Numeric Toggle with Stepper + Section { + HStack(spacing: 12) { + Image(systemName: "function") + .font(.title3) + .foregroundStyle(.purple) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.numericToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(String(format: "%.2f", numericObservable.numberValue ?? 0.0)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() + + Stepper("", value: Binding( + get: { numericObservable.numberValue ?? 0.0 }, + set: { manager.set(.number($0), for: ToggleVariables.numericToggle) } + ), step: 0.5) + .labelsHidden() } - HStack { - Text("\(ToggleVariables.integerToggle):") - Text(String(intObservable.intValue!)) + .padding(.vertical, 4) + } + + // String Toggle with TextField + Section { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: "textformat") + .font(.title3) + .foregroundStyle(.orange) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.stringToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(stringObservable.stringValue ?? "") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + } + + HStack { + TextField("Enter value", text: $stringValue) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(.roundedBorder) + .focused($isStringFieldFocused) + .onSubmit { + manager.set(.string(stringValue), for: ToggleVariables.stringToggle) + } +#if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() +#endif + + Button { + manager.set(.string(stringValue), for: ToggleVariables.stringToggle) + isStringFieldFocused = false + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } } - HStack { - Text("\(ToggleVariables.numericToggle):") - Text(String(numericObservable.numberValue!)) + .padding(.vertical, 4) + .onAppear { + stringValue = stringObservable.stringValue ?? "" } - HStack { - Text("\(ToggleVariables.stringToggle):") - Text(stringObservable.stringValue!) + .onChange(of: stringObservable.stringValue) { _, newValue in + if !isStringFieldFocused { + stringValue = newValue ?? "" + } } - HStack { - Text("\(ToggleVariables.encryptedToggle):") - Text(secureObservable.secureValue!) + } + + // Read-only toggles + Section { + HStack(spacing: 12) { + Image(systemName: "lock.fill") + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.encryptedToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(secureObservable.secureValue ?? "") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() } - HStack { - Text("\(ToggleVariables.objectToggle):") - Text(objectObservable.objectValue?.description ?? "unknown") + .padding(.vertical, 4) + + HStack(spacing: 12) { + Image(systemName: "curlybraces") + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(ToggleVariables.objectToggle) + .font(.subheadline) + .fontWeight(.medium) + Text(objectObservable.objectValue?.description ?? "unknown") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + Spacer() } - }.padding(.horizontal, 24) + .padding(.vertical, 4) + } header: { + Text("Read-only") + } } +#if os(iOS) + .listStyle(.insetGrouped) +#endif + .navigationTitle("Observables") } }