diff --git a/.github/workflows/add-release-assets.yml b/.github/workflows/add-release-assets.yml index 828c62de..f5b86f1d 100644 --- a/.github/workflows/add-release-assets.yml +++ b/.github/workflows/add-release-assets.yml @@ -10,9 +10,9 @@ env: jobs: build: - runs-on: macos-13 + runs-on: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Generate API reference doc run: | swift package --allow-writing-to-directory $DOC_ARCH \ diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 600cba13..68fae3dd 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -11,9 +11,9 @@ env: jobs: build: - runs-on: macos-13 + runs-on: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Generate API reference doc run: | swift package --allow-writing-to-directory $OUTPUT_PATH \ diff --git a/.github/workflows/swift-integration.yml b/.github/workflows/swift-integration.yml index cd099ff4..4fadfa28 100644 --- a/.github/workflows/swift-integration.yml +++ b/.github/workflows/swift-integration.yml @@ -20,5 +20,3 @@ jobs: - run: docker-compose -f docker/docker-compose-ci.yml up --build -d - name: Run tests run: swift test --enable-code-coverage -v --filter YorkieIntegrationTests - - diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 8550fadb..fa3f3f4d 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -8,12 +8,14 @@ on: jobs: build: - runs-on: macos-13 + runs-on: macos-14 steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable - - uses: actions/checkout@v3 + xcode-version: '15.3' + - uses: actions/checkout@v4 + - name: SwiftLint install + run: brew install swiftlint - name: SwiftLint run: swiftlint lint --strict - name: SwiftFormat @@ -23,7 +25,7 @@ jobs: - name: Prepare Code Coverage run: xcrun llvm-cov export -format="lcov" .build/debug/YorkiePackageTests.xctest/Contents/MacOS/YorkiePackageTests -instr-profile .build/debug/codecov/default.profdata > lcov.info - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: file: lcov.info env: diff --git a/Sources/API/Converter.swift b/Sources/API/Converter.swift index c3fa1e5f..71b2414f 100644 --- a/Sources/API/Converter.swift +++ b/Sources/API/Converter.swift @@ -431,6 +431,7 @@ extension Converter { treeStyleOperation.attributes.forEach { key, value in pbTreeStyleOperation.attributes[key] = value } + pbTreeStyleOperation.attributesToRemove = treeStyleOperation.attributesToRemove pbTreeStyleOperation.executedAt = toTimeTicket(treeStyleOperation.executedAt) pbOperation.treeStyle = pbTreeStyleOperation } else { @@ -510,7 +511,8 @@ extension Converter { return TreeStyleOperation(parentCreatedAt: fromTimeTicket(pbTreeStyleOperation.parentCreatedAt), fromPos: fromTreePos(pbTreeStyleOperation.from), toPos: fromTreePos(pbTreeStyleOperation.to), - attributes: pbTreeStyleOperation.attributes, + attributes: pbTreeStyleOperation.attributes, + attributesToRemove: pbTreeStyleOperation.attributesToRemove, executedAt: fromTimeTicket(pbTreeStyleOperation.executedAt)) } else { throw YorkieError.unimplemented(message: "unimplemented operation \(pbOperation)") diff --git a/Sources/API/V1/yorkie/v1/resources.pb.swift b/Sources/API/V1/yorkie/v1/resources.pb.swift index b35f05ff..14cfac82 100644 --- a/Sources/API/V1/yorkie/v1/resources.pb.swift +++ b/Sources/API/V1/yorkie/v1/resources.pb.swift @@ -952,6 +952,11 @@ struct Yorkie_V1_Operation { /// Clears the value of `executedAt`. Subsequent reads from it will return its default value. mutating func clearExecutedAt() {_uniqueStorage()._executedAt = nil} + var attributesToRemove: [String] { + get {return _storage._attributesToRemove} + set {_uniqueStorage()._attributesToRemove = newValue} + } + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -2533,7 +2538,15 @@ extension Yorkie_V1_Operation.Set: SwiftProtobuf.Message, SwiftProtobuf._Message var _value: Yorkie_V1_JSONElementSimple? = nil var _executedAt: Yorkie_V1_TimeTicket? = nil - static let defaultInstance = _StorageClass() + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} @@ -2625,7 +2638,15 @@ extension Yorkie_V1_Operation.Add: SwiftProtobuf.Message, SwiftProtobuf._Message var _value: Yorkie_V1_JSONElementSimple? = nil var _executedAt: Yorkie_V1_TimeTicket? = nil - static let defaultInstance = _StorageClass() + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} @@ -2825,7 +2846,15 @@ extension Yorkie_V1_Operation.Edit: SwiftProtobuf.Message, SwiftProtobuf._Messag var _executedAt: Yorkie_V1_TimeTicket? = nil var _attributes: Dictionary = [:] - static let defaultInstance = _StorageClass() + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} @@ -2993,7 +3022,15 @@ extension Yorkie_V1_Operation.Style: SwiftProtobuf.Message, SwiftProtobuf._Messa var _executedAt: Yorkie_V1_TimeTicket? = nil var _createdAtMapByActor: Dictionary = [:] - static let defaultInstance = _StorageClass() + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} @@ -3095,7 +3132,15 @@ extension Yorkie_V1_Operation.Increase: SwiftProtobuf.Message, SwiftProtobuf._Me var _value: Yorkie_V1_JSONElementSimple? = nil var _executedAt: Yorkie_V1_TimeTicket? = nil - static let defaultInstance = _StorageClass() + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} @@ -3187,7 +3232,15 @@ extension Yorkie_V1_Operation.TreeEdit: SwiftProtobuf.Message, SwiftProtobuf._Me var _splitLevel: Int32 = 0 var _executedAt: Yorkie_V1_TimeTicket? = nil - static let defaultInstance = _StorageClass() + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} @@ -3290,6 +3343,7 @@ extension Yorkie_V1_Operation.TreeStyle: SwiftProtobuf.Message, SwiftProtobuf._M 3: .same(proto: "to"), 4: .same(proto: "attributes"), 5: .standard(proto: "executed_at"), + 6: .standard(proto: "attributes_to_remove"), ] fileprivate class _StorageClass { @@ -3298,8 +3352,17 @@ extension Yorkie_V1_Operation.TreeStyle: SwiftProtobuf.Message, SwiftProtobuf._M var _to: Yorkie_V1_TreePos? = nil var _attributes: Dictionary = [:] var _executedAt: Yorkie_V1_TimeTicket? = nil - - static let defaultInstance = _StorageClass() + var _attributesToRemove: [String] = [] + + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} @@ -3309,6 +3372,7 @@ extension Yorkie_V1_Operation.TreeStyle: SwiftProtobuf.Message, SwiftProtobuf._M _to = source._to _attributes = source._attributes _executedAt = source._executedAt + _attributesToRemove = source._attributesToRemove } } @@ -3332,6 +3396,7 @@ extension Yorkie_V1_Operation.TreeStyle: SwiftProtobuf.Message, SwiftProtobuf._M case 3: try { try decoder.decodeSingularMessageField(value: &_storage._to) }() case 4: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &_storage._attributes) }() case 5: try { try decoder.decodeSingularMessageField(value: &_storage._executedAt) }() + case 6: try { try decoder.decodeRepeatedStringField(value: &_storage._attributesToRemove) }() default: break } } @@ -3359,6 +3424,9 @@ extension Yorkie_V1_Operation.TreeStyle: SwiftProtobuf.Message, SwiftProtobuf._M try { if let v = _storage._executedAt { try visitor.visitSingularMessageField(value: v, fieldNumber: 5) } }() + if !_storage._attributesToRemove.isEmpty { + try visitor.visitRepeatedStringField(value: _storage._attributesToRemove, fieldNumber: 6) + } } try unknownFields.traverse(visitor: &visitor) } @@ -3373,6 +3441,7 @@ extension Yorkie_V1_Operation.TreeStyle: SwiftProtobuf.Message, SwiftProtobuf._M if _storage._to != rhs_storage._to {return false} if _storage._attributes != rhs_storage._attributes {return false} if _storage._executedAt != rhs_storage._executedAt {return false} + if _storage._attributesToRemove != rhs_storage._attributesToRemove {return false} return true } if !storagesAreEqual {return false} @@ -3973,7 +4042,15 @@ extension Yorkie_V1_RGANode: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem var _next: Yorkie_V1_RGANode? = nil var _element: Yorkie_V1_JSONElement? = nil - static let defaultInstance = _StorageClass() + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} @@ -4205,7 +4282,15 @@ extension Yorkie_V1_TreeNode: SwiftProtobuf.Message, SwiftProtobuf._MessageImple var _depth: Int32 = 0 var _attributes: Dictionary = [:] - static let defaultInstance = _StorageClass() + #if swift(>=5.10) + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + #else + static let defaultInstance = _StorageClass() + #endif private init() {} diff --git a/Sources/API/V1/yorkie/v1/resources.proto b/Sources/API/V1/yorkie/v1/resources.proto index b98bd59b..1b186a94 100644 --- a/Sources/API/V1/yorkie/v1/resources.proto +++ b/Sources/API/V1/yorkie/v1/resources.proto @@ -133,6 +133,7 @@ message Operation { TreePos to = 3; map attributes = 4; TimeTicket executed_at = 5; + repeated string attributes_to_remove = 6; } oneof body { diff --git a/Sources/Document/CRDT/CRDTTree.swift b/Sources/Document/CRDT/CRDTTree.swift index 1566b31b..ce24ba53 100644 --- a/Sources/Document/CRDT/CRDTTree.swift +++ b/Sources/Document/CRDT/CRDTTree.swift @@ -35,11 +35,13 @@ struct TreeNodeForTest: Codable { enum TreeChangeType { case content case style + case removeStyle } enum TreeChangeValue { case nodes([CRDTTreeNode]) case attributes([String: String]) + case attributesToRemove([String]) } /** @@ -571,28 +573,50 @@ class CRDTTree: CRDTGCElement { */ @discardableResult func style(_ range: TreePosRange, _ attributes: [String: String]?, _ editedAt: TimeTicket) throws -> [TreeChange] { + try self.performChangeStyle(range, attributes, nil, editedAt) + } + + /** + * `removeStyle` removes the given attributes of the given range. + */ + @discardableResult + func removeStyle(_ range: TreePosRange, _ attributesToRemove: [String], _ editedAt: TimeTicket) throws -> [TreeChange] { + try self.performChangeStyle(range, nil, attributesToRemove, editedAt) + } + + private func performChangeStyle(_ range: TreePosRange, _ attributes: [String: String]?, _ attributesToRemove: [String]?, _ editedAt: TimeTicket) throws -> [TreeChange] { let (fromParent, fromLeft) = try self.findNodesAndSplitText(range.0, editedAt) let (toParent, toLeft) = try self.findNodesAndSplitText(range.1, editedAt) var changes: [TreeChange] = [] - var value: TreeChangeValue? + let value: TreeChangeValue? + let type: TreeChangeType if let attributes { value = .attributes(attributes) + type = .style + } else if let attributesToRemove { + value = .attributesToRemove(attributesToRemove) + type = .removeStyle + } else { + fatalError() } try self.traverseInPosRange(fromParent, fromLeft, toParent, toLeft) { token, _ in let (node, _) = token - if node.isRemoved == false, node.isText == false, let attributes { + if node.isRemoved == false, node.isText == false { if node.attrs == nil { node.attrs = RHT() } - for (key, value) in attributes { + for (key, value) in attributes ?? [:] { node.attrs?.set(key: key, value: value, executedAt: editedAt) } + for key in attributesToRemove ?? [] { + node.attrs?.remove(key: key, executedAt: editedAt) + } try changes.append(TreeChange(actor: editedAt.actorID, - type: .style, + type: type, from: self.toIndex(fromParent, fromLeft), to: self.toIndex(toParent, toLeft), fromPath: self.toPath(fromParent, fromLeft), diff --git a/Sources/Document/CRDT/RHT.swift b/Sources/Document/CRDT/RHT.swift index 9cda5367..d9140a68 100644 --- a/Sources/Document/CRDT/RHT.swift +++ b/Sources/Document/CRDT/RHT.swift @@ -23,6 +23,7 @@ struct RHTNode { var key: String var value: String var updatedAt: TimeTicket + var isRemoved: Bool } /** @@ -31,26 +32,62 @@ struct RHTNode { */ class RHT { private var nodeMapByKey = [String: RHTNode]() + private var numberOfRemovedElement: Int = 0 /** * `set` sets the value of the given key. */ func set(key: String, value: String, executedAt: TimeTicket) { - let previous = self.nodeMapByKey[key] + if let prev = nodeMapByKey[key] { + if executedAt.after(prev.updatedAt) { + if !prev.isRemoved { + self.numberOfRemovedElement -= 1 + } + let node = RHTNode(key: key, value: value, updatedAt: executedAt, isRemoved: false) + self.nodeMapByKey[key] = node + } + } else { + let node = RHTNode(key: key, value: value, updatedAt: executedAt, isRemoved: false) + self.nodeMapByKey[key] = node + } + } + + /** + * `remove` removes the Element of the given key. + */ + @discardableResult + func remove(key: String, executedAt: TimeTicket) -> String { + guard let prev = self.nodeMapByKey[key] else { + self.numberOfRemovedElement += 1 + let node = RHTNode(key: key, value: "", updatedAt: executedAt, isRemoved: true) + self.nodeMapByKey[key] = node + + return "" + } + + if executedAt.after(prev.updatedAt) { + let alreadyRemoved = prev.isRemoved + if !alreadyRemoved { + self.numberOfRemovedElement += 1 + } + let node = RHTNode(key: key, value: prev.value, updatedAt: executedAt, isRemoved: true) + self.nodeMapByKey[key] = node - if let previous, executedAt.after(previous.updatedAt) == false { - return + if alreadyRemoved { + return "" + } + + return prev.value } - let node = RHTNode(key: key, value: value, updatedAt: executedAt) - self.nodeMapByKey[key] = node + return "" } /** * `has` returns whether the element exists of the given key or not. */ func has(key: String) -> Bool { - return self.nodeMapByKey[key] != nil + !(self.nodeMapByKey[key]?.isRemoved ?? true) } /** @@ -86,7 +123,7 @@ class RHT { result.append("\"\(key)\":\"\(node.value.escaped())\"") } - return result.isEmpty ? "" : "{\(result.joined(separator: ","))}" + return result.isEmpty ? "{}" : "{\(result.joined(separator: ","))}" } /** @@ -100,7 +137,7 @@ class RHT { let sortedKeys = self.nodeMapByKey.keys.sorted() let xmlAttributes = sortedKeys.compactMap { key in - if let value = self.nodeMapByKey[key] { + if let value = self.nodeMapByKey[key], value.isRemoved == false { return "\(key)=\"\(value.value)\"" } else { return nil @@ -114,7 +151,7 @@ class RHT { * `size` returns the size of RHT */ public var size: Int { - self.nodeMapByKey.count + self.nodeMapByKey.count - self.numberOfRemovedElement } /** @@ -122,7 +159,7 @@ class RHT { */ func toObject() -> [String: (value: String, updatedAt: TimeTicket)] { var result = [String: (String, TimeTicket)]() - for (key, node) in self.nodeMapByKey { + for (key, node) in self.nodeMapByKey.filter({ _, node in !node.isRemoved }) { result[key] = (node.value, node.updatedAt) } diff --git a/Sources/Document/Json/JSONTree.swift b/Sources/Document/Json/JSONTree.swift index f9e6e6eb..442b8f83 100644 --- a/Sources/Document/Json/JSONTree.swift +++ b/Sources/Document/Json/JSONTree.swift @@ -336,6 +336,7 @@ public class JSONTree { fromPos: fromPos, toPos: toPos, attributes: stringAttrs, + attributesToRemove: [], executedAt: ticket) ) } @@ -370,6 +371,34 @@ public class JSONTree { fromPos: fromPos, toPos: toPos, attributes: stringAttrs, + attributesToRemove: [], + executedAt: ticket) + ) + } + + /** + * `removeStyle` removes the attributes to the elements of the given range. + */ + public func removeStyle(_ fromIdx: Int, _ toIdx: Int, _ attributesToRemove: [String]) throws { + guard let context, let tree else { + throw YorkieError.unexpected(message: "it is not initialized yet") + } + + if fromIdx > toIdx { + throw YorkieError.unexpected(message: "from should be less than or equal to to") + } + + let fromPos = try tree.findPos(fromIdx) + let toPos = try tree.findPos(toIdx) + let ticket = context.issueTimeTicket + + try tree.removeStyle((fromPos, toPos), attributesToRemove, ticket) + + context.push(operation: TreeStyleOperation(parentCreatedAt: tree.createdAt, + fromPos: fromPos, + toPos: toPos, + attributes: [:], + attributesToRemove: attributesToRemove, executedAt: ticket) ) } diff --git a/Sources/Document/Operation/TreeSytleOperation.swift b/Sources/Document/Operation/TreeSytleOperation.swift index 711096d5..fe8f366e 100644 --- a/Sources/Document/Operation/TreeSytleOperation.swift +++ b/Sources/Document/Operation/TreeSytleOperation.swift @@ -36,12 +36,14 @@ class TreeStyleOperation: Operation { * `attributes` returns the content of Edit. */ let attributes: [String: String] + let attributesToRemove: [String] - init(parentCreatedAt: TimeTicket, fromPos: CRDTTreePos, toPos: CRDTTreePos, attributes: [String: String], executedAt: TimeTicket) { + init(parentCreatedAt: TimeTicket, fromPos: CRDTTreePos, toPos: CRDTTreePos, attributes: [String: String], attributesToRemove: [String], executedAt: TimeTicket) { self.parentCreatedAt = parentCreatedAt self.fromPos = fromPos self.toPos = toPos self.attributes = attributes + self.attributesToRemove = attributesToRemove self.executedAt = executedAt } @@ -60,7 +62,13 @@ class TreeStyleOperation: Operation { fatalError("fail to execute, only Tree can execute edit") } - let changes = try tree.style((self.fromPos, self.toPos), self.attributes, self.executedAt) + let changes: [TreeChange] + + if self.attributes.isEmpty == false { + changes = try tree.style((self.fromPos, self.toPos), self.attributes, self.executedAt) + } else { + changes = try tree.removeStyle((self.fromPos, self.toPos), self.attributesToRemove, self.executedAt) + } guard let path = try? root.createPath(createdAt: parentCreatedAt) else { throw YorkieError.unexpected(message: "fail to get path") diff --git a/Sources/Document/Time/TimeTicket.swift b/Sources/Document/Time/TimeTicket.swift index b4cd4352..126e8c1f 100644 --- a/Sources/Document/Time/TimeTicket.swift +++ b/Sources/Document/Time/TimeTicket.swift @@ -28,7 +28,8 @@ public struct TimeTicket: Comparable { } public static let initial = TimeTicket(lamport: 0, delimiter: Values.initialDelimiter, actorID: ActorIDs.initial) - static let max = TimeTicket(lamport: Values.maxLamport, delimiter: Values.maxDelemiter, actorID: ActorIDs.max) + public static let next = TimeTicket(lamport: 1, delimiter: Values.initialDelimiter + 1, actorID: ActorIDs.initial) + public static let max = TimeTicket(lamport: Values.maxLamport, delimiter: Values.maxDelemiter, actorID: ActorIDs.max) /** * `lamport` returns the lamport int64. diff --git a/Tests/Integration/ClientIntegrationTests.swift b/Tests/Integration/ClientIntegrationTests.swift index f377afd4..8a0a4454 100644 --- a/Tests/Integration/ClientIntegrationTests.swift +++ b/Tests/Integration/ClientIntegrationTests.swift @@ -219,11 +219,17 @@ final class ClientIntegrationTests: XCTestCase { StreamConnectionStatus.disconnected ] + let exp = self.expectation(description: "exp") + c1.eventStream.sink { event in switch event { case let event as StreamConnectionStatusChangedEvent: XCTAssertEqual(event.value, c1ExpectedValues[c1NumberOfEvents]) c1NumberOfEvents += 1 + + if c1NumberOfEvents == c1ExpectedValues.count { + exp.fulfill() + } default: break } @@ -238,6 +244,8 @@ final class ClientIntegrationTests: XCTestCase { try await c1.detach(d1) try await c1.deactivate() + + await fulfillment(of: [exp], timeout: 10) } func test_should_apply_previous_changes_when_resuming_document() async throws { @@ -517,7 +525,7 @@ final class ClientIntegrationTests: XCTestCase { // but a response has not yet been received. try await c2.sync() - await fulfillment(of: [expect3]) + await fulfillment(of: [expect3], timeout: 10) try await d2.update { root, _ in try (root.t as? JSONTree)?.edit(2, 2, JSONTreeTextNode(value: "b")) diff --git a/Tests/Integration/TreeIntegrationTests.swift b/Tests/Integration/TreeIntegrationTests.swift index a4051311..c144bc32 100644 --- a/Tests/Integration/TreeIntegrationTests.swift +++ b/Tests/Integration/TreeIntegrationTests.swift @@ -1012,6 +1012,41 @@ final class TreeIntegrationStyleTests: XCTestCase { } } + func test_can_sync_its_content_with_remove_style() async throws { + try await withTwoClientsAndDocuments(self.description) { c1, d1, c2, d2 in + try await d1.update { root, _ in + root.t = JSONTree(initialRoot: + JSONTreeElementNode(type: "doc", + children: [JSONTreeElementNode(type: "p", + children: [JSONTreeTextNode(value: "hello")], + attributes: ["italic": "true"])]) + ) + } + + try await c1.sync() + try await c2.sync() + + var d1XML = await(d1.getRoot().t as? JSONTree)?.toXML() + var d2XML = await(d2.getRoot().t as? JSONTree)?.toXML() + + XCTAssertEqual(d1XML, /* html */ "

hello

") + XCTAssertEqual(d2XML, /* html */ "

hello

") + + try await d1.update { root, _ in + try (root.t as? JSONTree)?.removeStyle(0, 1, ["italic"]) + } + + try await c1.sync() + try await c2.sync() + + d1XML = await(d1.getRoot().t as? JSONTree)?.toXML() + d2XML = await(d2.getRoot().t as? JSONTree)?.toXML() + + XCTAssertEqual(d1XML, /* html */ "

hello

") + XCTAssertEqual(d2XML, /* html */ "

hello

") + } + } + func test_should_return_correct_range_path_within_doc_subscribe() async throws { try await withTwoClientsAndDocuments(self.description) { c1, d1, c2, d2 in try await d1.update { root, _ in diff --git a/Tests/Unit/Document/CRDT/RHTTests.swift b/Tests/Unit/Document/CRDT/RHTTests.swift index 5bca9563..260f110c 100644 --- a/Tests/Unit/Document/CRDT/RHTTests.swift +++ b/Tests/Unit/Document/CRDT/RHTTests.swift @@ -25,7 +25,7 @@ class RHTTests: XCTestCase { let rht = RHT() - XCTAssertEqual(rht.toJSON(), "") + XCTAssertEqual(rht.toJSON(), "{}") rht.set(key: testKey, value: testValue, executedAt: TimeTicket.initial) @@ -36,6 +36,24 @@ class RHTTests: XCTestCase { XCTAssertEqual(notExistsValue, nil) } + func test_should_handle_remove() throws { + let testKey = "test-key" + let testValue = "test-value" + + let rht = RHT() + + XCTAssertEqual(rht.toJSON(), "{}") + rht.set(key: testKey, value: testValue, executedAt: TimeTicket.initial) + + let actualValue = try rht.get(key: testKey) + XCTAssertEqual(actualValue, testValue) + XCTAssertEqual(rht.size, 1) + + rht.remove(key: testKey, executedAt: TimeTicket.next) + XCTAssertEqual(rht.has(key: testKey), false) + XCTAssertEqual(rht.size, 0) + } + func test_should_not_set_when_same_key_exsits_and_updatedAt_is_bigger() { let actorId = "actorId-1" let testKey = "test-key" @@ -43,7 +61,7 @@ class RHTTests: XCTestCase { let rht = RHT() - XCTAssertEqual(rht.toJSON(), "") + XCTAssertEqual(rht.toJSON(), "{}") rht.set(key: testKey, value: testValue, @@ -66,7 +84,7 @@ class RHTTests: XCTestCase { let rht = RHT() // Check if a rht object is constructed well. - XCTAssertEqual(rht.toJSON(), "") + XCTAssertEqual(rht.toJSON(), "{}") let notExistsValue = try? rht.get(key: notExistsKey) XCTAssertEqual(notExistsValue, nil) @@ -79,7 +97,7 @@ class RHTTests: XCTestCase { let rht = RHT() // Check if a rht object is constructed well. - XCTAssertEqual(rht.toJSON(), "") + XCTAssertEqual(rht.toJSON(), "{}") rht.set(key: testKey, value: testValue, executedAt: TimeTicket.initial) diff --git a/Tests/Unit/Document/JSONTextTest.swift b/Tests/Unit/Document/JSONTextTest.swift index f6b66339..36bc1d4c 100644 --- a/Tests/Unit/Document/JSONTextTest.swift +++ b/Tests/Unit/Document/JSONTextTest.swift @@ -132,7 +132,7 @@ final class JSONTextTest: XCTestCase { (root.text as? JSONText)?.edit(cmd.from, cmd.to, cmd.content) } - await fulfillment(of: [commands[index].exp], timeout: 1_000_000_000) + await fulfillment(of: [commands[index].exp], timeout: 2) let text = await(doc.getRoot()["text"] as? JSONText)?.toString XCTAssertEqual(view.toString, text)