diff --git a/.github/workflows/swift-integration.yml b/.github/workflows/swift-integration.yml index ecec2012..30def2b7 100644 --- a/.github/workflows/swift-integration.yml +++ b/.github/workflows/swift-integration.yml @@ -14,8 +14,10 @@ jobs: with: xcode-version: latest-stable - uses: actions/checkout@v4 + - name: Update Brew + run: brew update - name: Setup yorkie server - run: brew install yorkie + run: brew reinstall yorkie - name: Run yorkie server run: yorkie server & - name: Run tests diff --git a/Sources/API/Converter.swift b/Sources/API/Converter.swift index 7a8c1fd0..000dd9c5 100644 --- a/Sources/API/Converter.swift +++ b/Sources/API/Converter.swift @@ -1005,10 +1005,10 @@ extension Converter { static func fromTreeNode(_ pbTreeNode: PbTreeNode) -> CRDTTreeNode { let id = fromTreeNodeID(pbTreeNode.id) let node = CRDTTreeNode(id: id, type: pbTreeNode.type) - + if node.isText { node.value = pbTreeNode.value as NSString - } else { + } else if !pbTreeNode.attributes.isEmpty { node.attrs = RHT() pbTreeNode.attributes.forEach { key, value in diff --git a/Sources/Document/CRDT/CRDTTree.swift b/Sources/Document/CRDT/CRDTTree.swift index 6eae2c56..75d91b50 100644 --- a/Sources/Document/CRDT/CRDTTree.swift +++ b/Sources/Document/CRDT/CRDTTree.swift @@ -98,12 +98,12 @@ extension CRDTTreePos { } /** - * `toTreeNodes` converts the pos to parent and left sibling nodes. + * `toTreeNodePair` converts the pos to parent and left sibling nodes. * If the position points to the middle of a node, then the left sibling node * is the node that contains the position. Otherwise, the left sibling node is * the node that is located at the left of the position. */ - func toTreeNodes(tree: CRDTTree) throws -> (CRDTTreeNode, CRDTTreeNode) { + func toTreeNodePair(tree: CRDTTree) throws -> TreeNodePair { let parentID = self.parentID let leftSiblingID = self.leftSiblingID let parentNode = tree.findFloorNode(parentID) @@ -232,6 +232,12 @@ public struct CRDTTreeNodeIDStruct: Codable { */ typealias TreePosRange = (CRDTTreePos, CRDTTreePos) +/** + * `TreeNodePair` represents a pair of CRDTTreeNode. It represents the position + * of the node in the tree with the left and parent nodes. + */ +typealias TreeNodePair = (CRDTTreeNode, CRDTTreeNode) + /** * `TreePosStructRange` represents a pair of CRDTTreePosStruct. */ @@ -597,9 +603,9 @@ class CRDTTree: CRDTElement { * This is different from `TreePos` which is a position of the tree in the * physical perspective. */ - func findNodesAndSplitText(_ pos: CRDTTreePos, _ editedAt: TimeTicket? = nil) throws -> (CRDTTreeNode, CRDTTreeNode) { + func findNodesAndSplitText(_ pos: CRDTTreePos, _ editedAt: TimeTicket? = nil) throws -> TreeNodePair { // 01. Find the parent and left sibling node of the given position. - let (parent, leftSibling) = try pos.toTreeNodes(tree: self) + let (parent, leftSibling) = try pos.toTreeNodePair(tree: self) var leftNode = leftSibling // 02. Determine whether the position is left-most and the exact parent @@ -636,7 +642,7 @@ class CRDTTree: CRDTElement { * `style` applies the given attributes of the given range. */ @discardableResult - func style(_ range: TreePosRange, _ attributes: [String: String]?, _ editedAt: TimeTicket, _ maxCreatedAtMapByActor: [String: TimeTicket]?) throws -> ([String: TimeTicket], [GCPair], [TreeChange]) { + func style(_ range: TreePosRange, _ attributes: [String: String]?, _ editedAt: TimeTicket, _ maxCreatedAtMapByActor: [String: TimeTicket]? = nil) throws -> ([String: TimeTicket], [GCPair], [TreeChange]) { let (fromParent, fromLeft) = try self.findNodesAndSplitText(range.0, editedAt) let (toParent, toLeft) = try self.findNodesAndSplitText(range.1, editedAt) @@ -646,10 +652,10 @@ class CRDTTree: CRDTElement { try self.traverseInPosRange(fromParent, fromLeft, toParent, toLeft) { token, _ in let (node, _) = token let actorID = node.createdAt.actorID - var maxCreatedAt: TimeTicket? = maxCreatedAtMapByActor != nil ? maxCreatedAtMapByActor?[actorID] ?? TimeTicket.initial : TimeTicket.max + let maxCreatedAt = maxCreatedAtMapByActor != nil ? maxCreatedAtMapByActor![actorID] ?? TimeTicket.initial : TimeTicket.max - if node.canStyle(editedAt, maxCreatedAt!), !node.isText, let attributes { - maxCreatedAt = createdAtMapByActor[actorID] + if node.canStyle(editedAt, maxCreatedAt), !node.isText, let attributes { + let maxCreatedAt = createdAtMapByActor[actorID] let createdAt = node.createdAt if maxCreatedAt == nil || createdAt.after(maxCreatedAt!) { createdAtMapByActor[actorID] = createdAt @@ -666,13 +672,16 @@ class CRDTTree: CRDTElement { } } + let parentOfNode = node.parent! + let previousNode = node.prevSibling ?? node.parent! + if !affectedAttrs.isEmpty { try changes.append(TreeChange(actor: editedAt.actorID, type: .style, - from: self.toIndex(fromParent, fromLeft), - to: self.toIndex(toParent, toLeft), - fromPath: self.toPath(fromParent, fromLeft), - toPath: self.toPath(toParent, toLeft), + from: self.toIndex(parentOfNode, previousNode), + to: self.toIndex(node, node), + fromPath: self.toPath(parentOfNode, previousNode), + toPath: self.toPath(node, node), value: TreeChangeValue.attributes(affectedAttrs), splitLevel: 0) // dummy value. ) @@ -690,16 +699,26 @@ class CRDTTree: CRDTElement { /** * `removeStyle` removes the given attributes of the given range. */ - func removeStyle(_ range: TreePosRange, _ attributesToRemove: [String], _ editedAt: TimeTicket) throws -> ([GCPair], [TreeChange]) { + func removeStyle(_ range: TreePosRange, _ attributesToRemove: [String], _ editedAt: TimeTicket, _ maxCreatedAtMapByActor: [String: TimeTicket]? = nil) throws -> ([String: TimeTicket], [GCPair], [TreeChange]) { let (fromParent, fromLeft) = try self.findNodesAndSplitText(range.0, editedAt) let (toParent, toLeft) = try self.findNodesAndSplitText(range.1, editedAt) var changes: [TreeChange] = [] + var createdAtMapByActor = [String: TimeTicket]() var pairs = [GCPair]() let value = TreeChangeValue.attributesToRemove(attributesToRemove) try self.traverseInPosRange(fromParent, fromLeft, toParent, toLeft) { token, _ in let (node, _) = token - if node.isRemoved == false, node.isText == false { + let actorID = node.createdAt.actorID + let maxCreatedAt = maxCreatedAtMapByActor != nil ? maxCreatedAtMapByActor![actorID] ?? TimeTicket.initial : TimeTicket.max + + if node.canStyle(editedAt, maxCreatedAt), !attributesToRemove.isEmpty { + let maxCreatedAt = createdAtMapByActor[actorID] + let createdAt = node.createdAt + if maxCreatedAt == nil || createdAt.after(maxCreatedAt!) { + createdAtMapByActor[actorID] = createdAt + } + if node.attrs == nil { node.attrs = RHT() } @@ -710,19 +729,22 @@ class CRDTTree: CRDTElement { } } + let parentOfNode = node.parent! + let previousNode = node.prevSibling ?? node.parent! + try changes.append(TreeChange(actor: editedAt.actorID, type: .removeStyle, - from: self.toIndex(fromParent, fromLeft), - to: self.toIndex(toParent, toLeft), - fromPath: self.toPath(fromParent, fromLeft), - toPath: self.toPath(toParent, toLeft), + from: self.toIndex(parentOfNode, previousNode), + to: self.toIndex(node, node), + fromPath: self.toPath(parentOfNode, previousNode), + toPath: self.toPath(node, node), value: value, splitLevel: 0) // dummy value. ) } } - return (pairs, changes) + return (createdAtMapByActor, pairs, changes) } /** @@ -730,7 +752,7 @@ class CRDTTree: CRDTElement { * If the content is undefined, the range will be removed. */ @discardableResult - func edit(_ range: TreePosRange, _ contents: [CRDTTreeNode]?, _ splitLevel: Int32, _ editedAt: TimeTicket, _ maxCreatedAtMapByActor: [String: TimeTicket] = [:], _ issueTimeTicket: () -> TimeTicket) throws -> ([TreeChange], [GCPair], [String: TimeTicket]) { + func edit(_ range: TreePosRange, _ contents: [CRDTTreeNode]?, _ splitLevel: Int32, _ editedAt: TimeTicket, _ issueTimeTicket: () -> TimeTicket, _ maxCreatedAtMapByActor: [String: TimeTicket]? = nil) throws -> ([TreeChange], [GCPair], [String: TimeTicket]) { // 01. find nodes from the given range and split nodes. let (fromParent, fromLeft) = try self.findNodesAndSplitText(range.0, editedAt) let (toParent, toLeft) = try self.findNodesAndSplitText(range.1, editedAt) @@ -760,7 +782,7 @@ class CRDTTree: CRDTElement { let actorID = node.createdAt.actorID - let maxCreatedAt = maxCreatedAtMapByActor.isEmpty == false ? maxCreatedAtMapByActor[actorID] ?? TimeTicket.initial : TimeTicket.max + let maxCreatedAt = maxCreatedAtMapByActor != nil ? maxCreatedAtMapByActor![actorID] ?? TimeTicket.initial : TimeTicket.max // NOTE(sejongk): If the node is removable or its parent is going to // be removed, then this node should be removed. @@ -886,7 +908,7 @@ class CRDTTree: CRDTElement { func editT(_ range: (Int, Int), _ contents: [CRDTTreeNode]?, _ splitLevel: Int32, _ editedAt: TimeTicket, _ issueTimeTicket: () -> TimeTicket) throws { let fromPos = try self.findPos(range.0) let toPos = try self.findPos(range.1) - try self.edit((fromPos, toPos), contents, splitLevel, editedAt, [:], issueTimeTicket) + try self.edit((fromPos, toPos), contents, splitLevel, editedAt, issueTimeTicket) } /** diff --git a/Sources/Document/CRDT/RHT.swift b/Sources/Document/CRDT/RHT.swift index ab5d5f92..d806d073 100644 --- a/Sources/Document/CRDT/RHT.swift +++ b/Sources/Document/CRDT/RHT.swift @@ -132,7 +132,7 @@ class RHT { * `get` returns the value of the given key. */ func get(key: String) throws -> String { - guard let node = self.nodeMapByKey[key] else { + guard self.has(key: key), let node = self.nodeMapByKey[key] else { let log = "can't find the given node with: \(key)" Logger.critical(log) throw YorkieError.unexpected(message: log) diff --git a/Sources/Document/Json/JSONTree.swift b/Sources/Document/Json/JSONTree.swift index faaa181e..d9cb2e18 100644 --- a/Sources/Document/Json/JSONTree.swift +++ b/Sources/Document/Json/JSONTree.swift @@ -384,7 +384,7 @@ public class JSONTree { let ticket = context.issueTimeTicket - let (maxCreationMapByActor, pairs, _) = try tree.style((fromPos, toPos), stringAttrs, ticket, nil) + let (maxCreationMapByActor, pairs, _) = try tree.style((fromPos, toPos), stringAttrs, ticket) context.push(operation: TreeStyleOperation(parentCreatedAt: tree.createdAt, fromPos: fromPos, @@ -442,7 +442,7 @@ public class JSONTree { let ticket = context.issueTimeTicket - let (pairs, _) = try tree.removeStyle((fromPos, toPos), attributesToRemove, ticket) + let (maxCreationMapByActor, pairs, _) = try tree.removeStyle((fromPos, toPos), attributesToRemove, ticket) for pair in pairs { self.context?.registerGCPair(pair) @@ -451,7 +451,7 @@ public class JSONTree { context.push(operation: TreeStyleOperation(parentCreatedAt: tree.createdAt, fromPos: fromPos, toPos: toPos, - maxCreatedAtMapByActor: [:], + maxCreatedAtMapByActor: maxCreationMapByActor, attributes: [:], attributesToRemove: attributesToRemove, executedAt: ticket) diff --git a/Sources/Document/Operation/Operation.swift b/Sources/Document/Operation/Operation.swift index b0e1cf46..647183e1 100644 --- a/Sources/Document/Operation/Operation.swift +++ b/Sources/Document/Operation/Operation.swift @@ -194,13 +194,31 @@ public struct TreeEditOpInfo: OperationInfo { } } +public enum TreeStyleOpValue: Equatable { + case attributes([String: Any]) + case attributesToRemove([String]) + + public static func == (lhs: TreeStyleOpValue, rhs: TreeStyleOpValue) -> Bool { + switch (lhs, rhs) { + case (.attributes(let lhsAttributes), .attributes(let rhsAttributes)): + // Assuming [String: Any] comparison based on keys and string descriptions of values + return NSDictionary(dictionary: lhsAttributes).isEqual(to: rhsAttributes) + case (.attributesToRemove(let lhsAttributesToRemove), .attributesToRemove(let rhsAttributesToRemove)): + return lhsAttributesToRemove == rhsAttributesToRemove + default: + return false + } + } +} + public struct TreeStyleOpInfo: OperationInfo { public let type: OperationInfoType = .treeStyle public let path: String public let from: Int public let to: Int public let fromPath: [Int] - public let value: [String: Any] + public let toPath: [Int] + public let value: TreeStyleOpValue? public static func == (lhs: TreeStyleOpInfo, rhs: TreeStyleOpInfo) -> Bool { if lhs.type != rhs.type { @@ -218,6 +236,9 @@ public struct TreeStyleOpInfo: OperationInfo { if lhs.fromPath != rhs.fromPath { return false } + if lhs.toPath != rhs.toPath { + return false + } if !(lhs.value == rhs.value) { return false diff --git a/Sources/Document/Operation/TreeEditOperation.swift b/Sources/Document/Operation/TreeEditOperation.swift index 7b858b91..fd80396d 100644 --- a/Sources/Document/Operation/TreeEditOperation.swift +++ b/Sources/Document/Operation/TreeEditOperation.swift @@ -71,7 +71,7 @@ struct TreeEditOperation: Operation { * Therefore, it is possible to simulate later timeTickets using `editedAt` and the length of `contents`. * This logic might be unclear; consider refactoring for multi-level concurrent editing in the Tree implementation. */ - let (changes, pairs, _) = try tree.edit((self.fromPos, self.toPos), self.contents?.compactMap { $0.deepcopy() }, self.splitLevel, editedAt, self.maxCreatedAtMapByActor) { + let (changes, pairs, _) = try tree.edit((self.fromPos, self.toPos), self.contents?.compactMap { $0.deepcopy() }, self.splitLevel, editedAt, { var delimiter = editedAt.delimiter if let contents { delimiter += UInt32(contents.count) @@ -81,7 +81,7 @@ struct TreeEditOperation: Operation { editedAt = TimeTicket(lamport: editedAt.lamport, delimiter: delimiter, actorID: editedAt.actorID) return editedAt - } + }, self.maxCreatedAtMapByActor) for pair in pairs { root.registerGCPair(pair) diff --git a/Sources/Document/Operation/TreeSytleOperation.swift b/Sources/Document/Operation/TreeSytleOperation.swift index 5a85c412..ae9ed21e 100644 --- a/Sources/Document/Operation/TreeSytleOperation.swift +++ b/Sources/Document/Operation/TreeSytleOperation.swift @@ -70,7 +70,7 @@ class TreeStyleOperation: Operation { if self.attributes.isEmpty == false { (_, pairs, changes) = try tree.style((self.fromPos, self.toPos), self.attributes, self.executedAt, self.maxCreatedAtMapByActor) } else { - (pairs, changes) = try tree.removeStyle((self.fromPos, self.toPos), self.attributesToRemove, self.executedAt) + (_, pairs, changes) = try tree.removeStyle((self.fromPos, self.toPos), self.attributesToRemove, self.executedAt, self.maxCreatedAtMapByActor) } guard let path = try? root.createPath(createdAt: parentCreatedAt) else { @@ -82,11 +82,14 @@ class TreeStyleOperation: Operation { } return changes.compactMap { change in - let attributes: [String: Any] = { - if case .attributes(let attributes) = change.value { - return attributes.toJSONObejct - } else { - return [:] + let values: TreeStyleOpValue? = { + switch change.value { + case .attributes(let attributes): + return .attributes(attributes.toJSONObejct) + case .attributesToRemove(let keys): + return .attributesToRemove(keys) + default: + return nil } }() @@ -95,7 +98,8 @@ class TreeStyleOperation: Operation { from: change.from, to: change.to, fromPath: change.fromPath, - value: attributes + toPath: change.toPath, + value: values ) } } diff --git a/Sources/Util/IndexTree.swift b/Sources/Util/IndexTree.swift index 7fac482e..d813ad5d 100644 --- a/Sources/Util/IndexTree.swift +++ b/Sources/Util/IndexTree.swift @@ -208,6 +208,21 @@ extension IndexTreeNode { return parent.children[safe: offset + 1] } + /** + * `prevSibling` returns the previous sibling of the node. + */ + var prevSibling: Self? { + guard let parent else { + return nil + } + + guard let offset = try? parent.findOffset(node: self) else { + return nil + } + + return parent.children[safe: offset - 1] + } + /** * `splitText` splits the given node at the given offset. */ diff --git a/Tests/Integration/IntegrationHelper.swift b/Tests/Integration/IntegrationHelper.swift index 846a7ee4..d87fdd01 100644 --- a/Tests/Integration/IntegrationHelper.swift +++ b/Tests/Integration/IntegrationHelper.swift @@ -85,8 +85,9 @@ struct TreeEditOpInfoForDebug: OperationInfoForDebug { struct TreeStyleOpInfoForDebug: OperationInfoForDebug { let from: Int? let to: Int? - let value: [String: Codable]? + let value: TreeStyleOpValue? let fromPath: [Int]? + let toPath: [Int]? func compare(_ operation: TreeStyleOpInfo) { if let value = from { @@ -96,16 +97,14 @@ struct TreeStyleOpInfoForDebug: OperationInfoForDebug { XCTAssertEqual(value, operation.to) } if let value = value { - XCTAssertEqual(value.count, operation.value.count) - if operation.value.count - 1 >= 0 { - for key in operation.value.keys { - XCTAssertEqual(value[key]?.toJSONString, convertToJSONString(operation.value[key] ?? NSNull())) - } - } + XCTAssertEqual(value, operation.value) } if let value = fromPath { XCTAssertEqual(value, operation.fromPath) } + if let value = toPath { + XCTAssertEqual(value, operation.toPath) + } } } diff --git a/Tests/Integration/TreeConcurrencyTests.swift b/Tests/Integration/TreeConcurrencyTests.swift index de6429cc..99cfabd5 100644 --- a/Tests/Integration/TreeConcurrencyTests.swift +++ b/Tests/Integration/TreeConcurrencyTests.swift @@ -301,12 +301,11 @@ final class TreeConcurrencyTests: XCTestCase { DispatchQueue.global().async { Task { let result = try await self.runTest(initialState: initialState.deepcopy(), initialXML: initialXML, ranges: ranges, op1: op1, op2: op2, desc: desc) - print("====== before d1: \(result.before.0)") print("====== before d2: \(result.before.1)") print("====== after d1: \(result.after.0)") print("====== after d2: \(result.after.1)") - XCTAssertEqual(result.after.0, result.after.1) + XCTAssertEqual(result.after.0, result.after.1, desc) exp.fulfill() } @@ -609,7 +608,7 @@ final class TreeConcurrencyTests: XCTestCase { ) let initialXML = "

a

b

c

" - let content = JSONTreeElementNode(type: "p", children: [JSONTreeTextNode(value: "d")], attributes: ["italic": true]) + let content = JSONTreeElementNode(type: "p", children: [JSONTreeTextNode(value: "d")], attributes: ["italic": true, "color": "blue"]) let rangesArr = [ // equal:

b

-

b

@@ -679,7 +678,7 @@ final class TreeConcurrencyTests: XCTestCase { .styleRemove, "color", "", - "remove-bold" + "remove-color" ), StyleOperationType( .rangeAll, diff --git a/Tests/Integration/TreeIntegrationTests.swift b/Tests/Integration/TreeIntegrationTests.swift index a9b9d0a1..606a8ef1 100644 --- a/Tests/Integration/TreeIntegrationTests.swift +++ b/Tests/Integration/TreeIntegrationTests.swift @@ -3722,6 +3722,104 @@ final class TreeIntegrationTreeChangeGeneration: XCTestCase { } } + func test_concurrent_insert_and_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: []) + ]) + ) + } + + 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 */ "

") + XCTAssertEqual(d1XML, d2XML) + + await subscribeDocs(d1, + d2, + [TreeStyleOpInfoForDebug(from: 0, to: 1, value: .attributes(["key": "a"]), fromPath: [0], toPath: [0, 0]), + TreeStyleOpInfoForDebug(from: 0, to: 1, value: .attributes(["key": "a"]), fromPath: [0], toPath: [0, 0]), + TreeEditOpInfoForDebug(from: 0, to: 0, value: [JSONTreeElementNode(type: "p")], fromPath: [0], toPath: [0])], + [TreeEditOpInfoForDebug(from: 0, to: 0, value: [JSONTreeElementNode(type: "p")], fromPath: [0], toPath: [0]), + TreeStyleOpInfoForDebug(from: 2, to: 3, value: .attributes(["key": "a"]), fromPath: [1], toPath: [1, 0]), + TreeStyleOpInfoForDebug(from: 2, to: 3, value: .attributes(["key": "a"]), fromPath: [1], toPath: [1, 0])]) + + try await d1.update { root, _ in + try (root.t as? JSONTree)?.style(0, 1, ["key": "a"]) + } + try await d1.update { root, _ in + try (root.t as? JSONTree)?.style(0, 1, ["key": "a"]) + } + try await d2.update { root, _ in + try (root.t as? JSONTree)?.edit(0, 0, JSONTreeElementNode(type: "p")) + } + + try await c1.sync() + try await c2.sync() + try await c1.sync() + + d1XML = await(d1.getRoot().t as? JSONTree)?.toXML() + d2XML = await(d2.getRoot().t as? JSONTree)?.toXML() + XCTAssertEqual(d1XML, /* html */ "

") + XCTAssertEqual(d1XML, d2XML) + } + } + + func test_concurrent_insert_and_removeStyle() 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: [], attributes: ["key": "a"]) + ]) + ) + } + + 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 */ "

") + XCTAssertEqual(d1XML, d2XML) + + await subscribeDocs(d1, + d2, + [TreeStyleOpInfoForDebug(from: 0, to: 1, value: .attributesToRemove(["key"]), fromPath: [0], toPath: [0, 0]), + TreeStyleOpInfoForDebug(from: 0, to: 1, value: .attributesToRemove(["key"]), fromPath: [0], toPath: [0, 0]), + TreeEditOpInfoForDebug(from: 0, to: 0, value: [JSONTreeElementNode(type: "p")], fromPath: [0], toPath: [0])], + [TreeEditOpInfoForDebug(from: 0, to: 0, value: [JSONTreeElementNode(type: "p")], fromPath: [0], toPath: [0]), + TreeStyleOpInfoForDebug(from: 2, to: 3, value: .attributesToRemove(["key"]), fromPath: [1], toPath: [1, 0]), + TreeStyleOpInfoForDebug(from: 2, to: 3, value: .attributesToRemove(["key"]), fromPath: [1], toPath: [1, 0])]) + + try await d1.update { root, _ in + try (root.t as? JSONTree)?.removeStyle(0, 1, ["key"]) + } + try await d1.update { root, _ in + try (root.t as? JSONTree)?.removeStyle(0, 1, ["key"]) + } + try await d2.update { root, _ in + try (root.t as? JSONTree)?.edit(0, 0, JSONTreeElementNode(type: "p")) + } + + try await c1.sync() + try await c2.sync() + try await c1.sync() + + d1XML = await(d1.getRoot().t as? JSONTree)?.toXML() + d2XML = await(d2.getRoot().t as? JSONTree)?.toXML() + XCTAssertEqual(d1XML, /* html */ "

") + XCTAssertEqual(d1XML, d2XML) + } + } + func test_concurrent_delete_and_style() async throws { try await withTwoClientsAndDocuments(self.description) { c1, d1, c2, d2 in try await d1.update { root, _ in @@ -3742,7 +3840,7 @@ final class TreeIntegrationTreeChangeGeneration: XCTestCase { await subscribeDocs(d1, d2, - [TreeStyleOpInfoForDebug(from: 0, to: 1, value: ["value": "changed"], fromPath: nil), + [TreeStyleOpInfoForDebug(from: 0, to: 1, value: .attributes(["value": "changed"]), fromPath: nil, toPath: nil), TreeEditOpInfoForDebug(from: 0, to: 2, value: nil, fromPath: nil, toPath: nil)], [TreeEditOpInfoForDebug(from: 0, to: 2, value: nil, fromPath: nil, toPath: nil)]) @@ -3789,9 +3887,9 @@ final class TreeIntegrationTreeChangeGeneration: XCTestCase { await subscribeDocs(d1, d2, - [TreeStyleOpInfoForDebug(from: 0, to: 1, value: ["bold": "true"], fromPath: nil), - TreeStyleOpInfoForDebug(from: 0, to: 1, value: ["bold": "false"], fromPath: nil)], - [TreeStyleOpInfoForDebug(from: 0, to: 1, value: ["bold": "false"], fromPath: nil)]) + [TreeStyleOpInfoForDebug(from: 0, to: 1, value: .attributes(["bold": "true"]), fromPath: nil, toPath: nil), + TreeStyleOpInfoForDebug(from: 0, to: 1, value: .attributes(["bold": "false"]), fromPath: nil, toPath: nil)], + [TreeStyleOpInfoForDebug(from: 0, to: 1, value: .attributes(["bold": "false"]), fromPath: nil, toPath: nil)]) try await d1.update { root, _ in try (root.t as? JSONTree)?.style(0, 1, ["bold": "true"])