From a981dd2ae2fc0548449d610c14cb10423eb471ad Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 15 Oct 2025 03:29:35 +0900 Subject: [PATCH] Patch --- .claude/settings.local.json | 5 +- Sources/StateGraph/StateGraph.swift | 19 +- Sources/StateGraph/StorageAbstraction.swift | 86 +++++++- Tests/StateGraphTests/NodeObserveTests.swift | 23 +- .../StoredComparatorTests.swift | 200 ++++++++++++++++++ 5 files changed, 299 insertions(+), 34 deletions(-) create mode 100644 Tests/StateGraphTests/StoredComparatorTests.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7a871c5..1737c28 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,8 +12,9 @@ "Bash(git commit:*)", "Bash(gh pr create:*)", "mcp__swiftlens__swift_search_pattern", - "mcp__swiftlens__swift_get_symbols_overview" + "mcp__swiftlens__swift_get_symbols_overview", + "mcp__swiftlens__swift_analyze_files" ], "deny": [] } -} \ No newline at end of file +} diff --git a/Sources/StateGraph/StateGraph.swift b/Sources/StateGraph/StateGraph.swift index 0861ef7..5e9e46b 100644 --- a/Sources/StateGraph/StateGraph.swift +++ b/Sources/StateGraph/StateGraph.swift @@ -20,7 +20,6 @@ import Foundation.NSLock public typealias Stored = _Stored> extension _Stored where S == InMemoryStorage { - /// 便利な初期化メソッド(wrappedValue指定) public convenience init( _ file: StaticString = #fileID, _ line: UInt = #line, @@ -37,6 +36,24 @@ extension _Stored where S == InMemoryStorage { storage: storage ) } + + /// Equatable Filter Available + public convenience init( + _ file: StaticString = #fileID, + _ line: UInt = #line, + _ column: UInt = #column, + name: StaticString? = nil, + wrappedValue: Value + ) where Value : Equatable { + let storage = InMemoryStorage(initialValue: wrappedValue) + self.init( + file, + line, + column, + name: name, + storage: storage + ) + } } public protocol ComputedDescriptor: Sendable { diff --git a/Sources/StateGraph/StorageAbstraction.swift b/Sources/StateGraph/StorageAbstraction.swift index 49b3e99..41f6338 100644 --- a/Sources/StateGraph/StorageAbstraction.swift +++ b/Sources/StateGraph/StorageAbstraction.swift @@ -179,21 +179,22 @@ public final class _Stored>: Node, Observable, CustomDe return storage.value } set { + + lock.lock() + + if let comparator { + guard comparator.isEqual(storage.value, newValue) == false else { + lock.unlock() + return + } + } #if canImport(Observation) if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { observationRegistrar.willSet(PointerKeyPathRoot.shared, keyPath: _keyPath(self)) } - - defer { - if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { - observationRegistrar.didSet(PointerKeyPathRoot.shared, keyPath: _keyPath(self)) - } - } #endif - lock.lock() - storage.value = newValue let _outgoingEdges = outgoingEdges @@ -204,9 +205,17 @@ public final class _Stored>: Node, Observable, CustomDe edge.isPending = true edge.to.potentiallyDirty = true } + +#if canImport(Observation) + if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { + observationRegistrar.didSet(PointerKeyPathRoot.shared, keyPath: _keyPath(self)) + } +#endif } } + private let comparator: (any _Comparator)? + private func notifyStorageUpdated() { #if canImport(Observation) if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { @@ -240,14 +249,48 @@ public final class _Stored>: Node, Observable, CustomDe nonisolated(unsafe) public var outgoingEdges: ContiguousArray = [] + + public convenience init( + _ file: StaticString = #fileID, + _ line: UInt = #line, + _ column: UInt = #column, + name: StaticString? = nil, + storage: consuming S + ) { + self.init( + file, + line, + column, + name: name, + storage: storage, + comparator: nil + ) + } - - public init( + public convenience init( _ file: StaticString = #fileID, _ line: UInt = #line, _ column: UInt = #column, name: StaticString? = nil, storage: consuming S + ) where Value : Equatable { + self.init( + file, + line, + column, + name: name, + storage: storage, + comparator: Comparator.init() + ) + } + + private init( + _ file: StaticString = #fileID, + _ line: UInt = #line, + _ column: UInt = #column, + name: StaticString? = nil, + storage: consuming S, + comparator: (any _Comparator)? ) { self.info = .init( name: name, @@ -255,6 +298,7 @@ public final class _Stored>: Node, Observable, CustomDe ) self.lock = .init() self.storage = storage + self.comparator = comparator self.storage.loaded(context: .init(onStorageUpdated: { [weak self] in self?.notifyStorageUpdated() @@ -301,4 +345,24 @@ public final class _Stored>: Node, Observable, CustomDe return result } -} +} + +protocol _Comparator: Sendable { + associatedtype Value + func isEqual(_ lhs: Value, _ rhs: Value) -> Bool +} + +private struct Comparator: _Comparator, Sendable { + + init() { + + } + + @_specialize(where Value == Int) + @_specialize(where Value == String) + @_specialize(where Value == Bool) + func isEqual(_ lhs: Value, _ rhs: Value) -> Bool { + lhs == rhs + } + +} diff --git a/Tests/StateGraphTests/NodeObserveTests.swift b/Tests/StateGraphTests/NodeObserveTests.swift index 67f1a3d..32eda45 100644 --- a/Tests/StateGraphTests/NodeObserveTests.swift +++ b/Tests/StateGraphTests/NodeObserveTests.swift @@ -51,12 +51,7 @@ struct NodeObserveTests { node.wrappedValue = 2 try await Task.sleep(for: .milliseconds(100)) #expect(results.withLock { $0 == [0, 1, 2] }) - - // Even with the same value, change notification is sent - node.wrappedValue = 2 - try await Task.sleep(for: .milliseconds(100)) - #expect(results.withLock { $0 == [0, 1, 2, 2] }) - + task.cancel() } @@ -89,13 +84,7 @@ struct NodeObserveTests { node.wrappedValue = TestStruct(value: 2) try await Task.sleep(for: .milliseconds(100)) #expect(results.withLock { $0 == [TestStruct(value: 0), TestStruct(value: 1), TestStruct(value: 2)] }) - - // Even with the same value, change notification is sent - node.wrappedValue = TestStruct(value: 2) - try await Task.sleep(for: .milliseconds(100)) - - #expect(results.withLock { $0 == [TestStruct(value: 0), TestStruct(value: 1), TestStruct(value: 2), TestStruct(value: 2)] }) - + task.cancel() } @@ -135,13 +124,7 @@ struct NodeObserveTests { try await Task.sleep(for: .milliseconds(100)) #expect(results1.withLock { $0 == [0, 1, 2] }) #expect(results2.withLock { $0 == [0, 1, 2] }) - - // Even with the same value, change notification is sent - node.wrappedValue = 2 - try await Task.sleep(for: .milliseconds(100)) - #expect(results1.withLock { $0 == [0, 1, 2, 2] }) - #expect(results2.withLock { $0 == [0, 1, 2, 2] }) - + task1.cancel() task2.cancel() } diff --git a/Tests/StateGraphTests/StoredComparatorTests.swift b/Tests/StateGraphTests/StoredComparatorTests.swift new file mode 100644 index 0000000..ab46978 --- /dev/null +++ b/Tests/StateGraphTests/StoredComparatorTests.swift @@ -0,0 +1,200 @@ +import Observation +import Testing + +@testable import StateGraph + +@Suite +struct StoredComparatorTests { + + @Test + func observation_willSet_didSet_not_called_for_same_value() async { + // Given: A model with GraphStored property + final class Model: Sendable { + @GraphStored var count: Int = 0 + } + + let model = Model() + + // When: Observing changes and setting the same value + await confirmation(expectedCount: 0) { c in + withObservationTracking { + _ = model.count + } onChange: { + c.confirm() + } + + model.count = 0 // Same value - should NOT trigger onChange + + try? await Task.sleep(for: .milliseconds(50)) + } + } + + @Test + func observation_willSet_didSet_called_for_different_value() async { + // Given: A model with GraphStored property + final class Model: Sendable { + @GraphStored var count: Int = 0 + } + + let model = Model() + + // When: Observing changes and setting a different value + await confirmation(expectedCount: 1) { c in + withObservationTracking { + _ = model.count + } onChange: { + Task { + #expect(model.count == 1) + c.confirm() + } + } + + model.count = 1 // Different value - should trigger onChange + + try? await Task.sleep(for: .milliseconds(50)) + } + } + + @Test + func multiple_same_value_updates_no_notifications() async { + // Given: A model with GraphStored property + final class Model: Sendable { + @GraphStored var value: String = "initial" + } + + let model = Model() + + // When: Setting same value multiple times + await confirmation(expectedCount: 0) { c in + withObservationTracking { + _ = model.value + } onChange: { + c.confirm() + } + + model.value = "initial" + model.value = "initial" + model.value = "initial" + + try? await Task.sleep(for: .milliseconds(50)) + } + } + + @Test + func state_graph_tracking_no_notification_for_same_value() async { + // Given: A model with GraphStored property + final class Model: Sendable { + @GraphStored var count: Int = 10 + } + + let model = Model() + + // When: Using StateGraph's tracking and setting same value + await confirmation(expectedCount: 0) { c in + withStateGraphTracking { + _ = model.count + } didChange: { + c.confirm() + } + + model.count = 10 // Same value - should NOT trigger + + try? await Task.sleep(for: .milliseconds(100)) + } + } + + @Test + func computed_node_not_invalidated_for_same_stored_value() async { + // Given: Stored node and computed node + let stored = Stored(name: "stored", wrappedValue: 5) + + let computeCount = OSAllocatedUnfairLock(initialState: 0) + let computed = Computed(name: "computed") { _ in + computeCount.withLock { $0 += 1 } + return stored.wrappedValue * 2 + } + + // Trigger initial computation + #expect(computed.wrappedValue == 10) + #expect(computeCount.withLock { $0 } == 1) + + // When: Set the same value + stored.wrappedValue = 5 + + // Then: Computed should not be marked as dirty and not recompute + #expect(computed.wrappedValue == 10) + #expect(computeCount.withLock { $0 } == 1, "Computed node should not recompute for same stored value") + } + + @Test + func onChange_callback_not_invoked_for_same_value() async { + // Given: Stored node with onChange callback + let stored = Stored(name: "stored", wrappedValue: 100) + + var changeCount = 0 + + let cancellable = withGraphTracking { + stored.onChange { newValue in + changeCount += 1 + } + } + + // Initial state + #expect(changeCount == 1) + + // When: Set the same value multiple times + stored.wrappedValue = 100 + stored.wrappedValue = 100 + + try? await Task.sleep(for: .milliseconds(50)) + + // Then: onChange should not be invoked + #expect(changeCount == 1, "onChange should not be called for same value") + + // When: Set a different value + stored.wrappedValue = 200 + + try? await Task.sleep(for: .milliseconds(50)) + + // Then: onChange should be invoked + #expect(changeCount == 2, "onChange should be called once for different value") + + withExtendedLifetime(cancellable, {}) + } + + @Test + func equatable_custom_struct_comparison() async { + // Given: Custom Equatable struct + struct Point: Equatable { + let x: Int + let y: Int + } + + let stored = Stored(name: "point", wrappedValue: Point(x: 10, y: 20)) + + let computeCount = OSAllocatedUnfairLock(initialState: 0) + let computed = Computed(name: "computed") { _ in + computeCount.withLock { $0 += 1 } + return stored.wrappedValue.x + stored.wrappedValue.y + } + + // Trigger initial computation + #expect(computed.wrappedValue == 30) + #expect(computeCount.withLock { $0 } == 1) + + // When: Set equivalent struct (same values) + stored.wrappedValue = Point(x: 10, y: 20) + + // Then: Should not recompute + #expect(computed.wrappedValue == 30) + #expect(computeCount.withLock { $0 } == 1, "Same struct values should not trigger recomputation") + + // When: Set different struct + stored.wrappedValue = Point(x: 15, y: 25) + + // Then: Should recompute + #expect(computed.wrappedValue == 40) + #expect(computeCount.withLock { $0 } == 2, "Different struct values should trigger recomputation") + } + +}