diff --git a/Domain/Domain.xcodeproj/project.pbxproj b/Domain/Domain.xcodeproj/project.pbxproj index 92dd8ad..1a2fe07 100644 --- a/Domain/Domain.xcodeproj/project.pbxproj +++ b/Domain/Domain.xcodeproj/project.pbxproj @@ -40,8 +40,13 @@ 6F3BCDD02CF3322C005F6642 /* ChatUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3BCDCF2CF3322C005F6642 /* ChatUseCase.swift */; }; 6F3BCDD22CF45510005F6642 /* ChatUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3BCDD12CF45510005F6642 /* ChatUseCaseTests.swift */; }; 6F47898F2CEB8D8F0016B7AB /* TextObjectUseCaseInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F47898E2CEB8D8F0016B7AB /* TextObjectUseCaseInterface.swift */; }; + 6F5FC1562D2BF5100049A44F /* LWWRegister.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5FC1552D2BF5100049A44F /* LWWRegister.swift */; }; + 6F5FC1582D2BF51E0049A44F /* Timestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5FC1572D2BF51E0049A44F /* Timestamp.swift */; }; + 6F5FC15A2D2C1BFF0049A44F /* WhiteboardObjectRegisters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5FC1592D2C1BFF0049A44F /* WhiteboardObjectRegisters.swift */; }; + 6F5FC15C2D2C1CF80049A44F /* WhiteboardObjectRegistersInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5FC15B2D2C1CF80049A44F /* WhiteboardObjectRegistersInterface.swift */; }; 6F68E7852CEC200000945394 /* TextObjectUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F68E7842CEC200000945394 /* TextObjectUseCase.swift */; }; 6F68E7872CEC593300945394 /* TextObjectUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F68E7862CEC593300945394 /* TextObjectUseCaseTests.swift */; }; + 6F6A89A72D2E9367008CF899 /* LWWRegisterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F6A89A62D2E9367008CF899 /* LWWRegisterTests.swift */; }; A81E7BF32CF70C17007E8414 /* GameObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81E7BF22CF70C17007E8414 /* GameObject.swift */; }; A81E7BF92CF72503007E8414 /* GameObjectUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81E7BF82CF72503007E8414 /* GameObjectUseCase.swift */; }; A81E7BFB2CF725C2007E8414 /* GameObjectUseCaseInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81E7BFA2CF725C2007E8414 /* GameObjectUseCaseInterface.swift */; }; @@ -99,8 +104,13 @@ 6F3BCDCF2CF3322C005F6642 /* ChatUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUseCase.swift; sourceTree = ""; }; 6F3BCDD12CF45510005F6642 /* ChatUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUseCaseTests.swift; sourceTree = ""; }; 6F47898E2CEB8D8F0016B7AB /* TextObjectUseCaseInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextObjectUseCaseInterface.swift; sourceTree = ""; }; + 6F5FC1552D2BF5100049A44F /* LWWRegister.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LWWRegister.swift; sourceTree = ""; }; + 6F5FC1572D2BF51E0049A44F /* Timestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timestamp.swift; sourceTree = ""; }; + 6F5FC1592D2C1BFF0049A44F /* WhiteboardObjectRegisters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhiteboardObjectRegisters.swift; sourceTree = ""; }; + 6F5FC15B2D2C1CF80049A44F /* WhiteboardObjectRegistersInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhiteboardObjectRegistersInterface.swift; sourceTree = ""; }; 6F68E7842CEC200000945394 /* TextObjectUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextObjectUseCase.swift; sourceTree = ""; }; 6F68E7862CEC593300945394 /* TextObjectUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextObjectUseCaseTests.swift; sourceTree = ""; }; + 6F6A89A62D2E9367008CF899 /* LWWRegisterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LWWRegisterTests.swift; sourceTree = ""; }; A81E7BF22CF70C17007E8414 /* GameObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameObject.swift; sourceTree = ""; }; A81E7BF82CF72503007E8414 /* GameObjectUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameObjectUseCase.swift; sourceTree = ""; }; A81E7BFA2CF725C2007E8414 /* GameObjectUseCaseInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameObjectUseCaseInterface.swift; sourceTree = ""; }; @@ -197,6 +207,7 @@ 0080E9192CE4B0880095B958 /* UseCase */, 0080E8CC2CE446270095B958 /* Repository */, 00036B852CF58D7C007D1244 /* WhiteboardObjectSetInterface.swift */, + 6F5FC15B2D2C1CF80049A44F /* WhiteboardObjectRegistersInterface.swift */, ); path = Interface; sourceTree = ""; @@ -239,6 +250,7 @@ 00D2DD942CE88EDA0089F0BA /* ManageWhiteboardObjectsUseCaseTests.swift */, 007BCEDB2CEB852C009E6935 /* AddPhotoUseCaseTests.swift */, 6F3BCDD12CF45510005F6642 /* ChatUseCaseTests.swift */, + 6F6A89A62D2E9367008CF899 /* LWWRegisterTests.swift */, ); path = DomainTests; sourceTree = ""; @@ -246,6 +258,9 @@ 00D2DD962CE8A4DB0089F0BA /* Model */ = { isa = PBXGroup; children = ( + 6F5FC1572D2BF51E0049A44F /* Timestamp.swift */, + 6F5FC1552D2BF5100049A44F /* LWWRegister.swift */, + 6F5FC1592D2C1BFF0049A44F /* WhiteboardObjectRegisters.swift */, 00D2DD8E2CE88C640089F0BA /* WhiteboardTool.swift */, 005BCC502CF01D29001B3623 /* WhiteboardObjectSet.swift */, ); @@ -418,6 +433,7 @@ buildActionMask = 2147483647; files = ( 007BCEDC2CEB852C009E6935 /* AddPhotoUseCaseTests.swift in Sources */, + 6F6A89A72D2E9367008CF899 /* LWWRegisterTests.swift in Sources */, 6F3BCDD22CF45510005F6642 /* ChatUseCaseTests.swift in Sources */, 00D2DD952CE88EDA0089F0BA /* ManageWhiteboardObjectsUseCaseTests.swift in Sources */, 0080E9582CE4D8760095B958 /* DrawObjectUseCaseTests.swift in Sources */, @@ -434,9 +450,11 @@ A8E97C052CE5E3AB00B28063 /* ProfileUseCaseInterface.swift in Sources */, 004B217D2CEB2B2300A5BEB8 /* DomainError.swift in Sources */, 6F3BCDC82CF31CB4005F6642 /* ChatMessage.swift in Sources */, + 6F5FC15C2D2C1CF80049A44F /* WhiteboardObjectRegistersInterface.swift in Sources */, 005BCC512CF01D29001B3623 /* WhiteboardObjectSet.swift in Sources */, A8E97BDB2CE5A6D500B28063 /* ProfileRepositoryInterface.swift in Sources */, A8E97BD92CE5A6B800B28063 /* ProfileIcon.swift in Sources */, + 6F5FC1562D2BF5100049A44F /* LWWRegister.swift in Sources */, 00036B862CF58D7C007D1244 /* WhiteboardObjectSetInterface.swift in Sources */, A81E7BF32CF70C17007E8414 /* GameObject.swift in Sources */, A8E97BD82CE5A6B800B28063 /* Profile.swift in Sources */, @@ -447,6 +465,7 @@ 6F3BCDD02CF3322C005F6642 /* ChatUseCase.swift in Sources */, 5BDFD9342CE1F7DB00DA4F5B /* Whiteboard.swift in Sources */, A81E7BFB2CF725C2007E8414 /* GameObjectUseCaseInterface.swift in Sources */, + 6F5FC1582D2BF51E0049A44F /* Timestamp.swift in Sources */, 5BDFD9372CE2E6BC00DA4F5B /* WhiteboardRepositoryInterface.swift in Sources */, 5B6542482CE44631000168AD /* WhiteboardUseCaseInterface.swift in Sources */, 00D2DD842CE8864B0089F0BA /* ManageWhiteboardObjectUseCaseInterface.swift in Sources */, @@ -454,6 +473,7 @@ A81E7C012CF72A4C007E8414 /* GameRepositoryInterface.swift in Sources */, 6F47898F2CEB8D8F0016B7AB /* TextObjectUseCaseInterface.swift in Sources */, 00D2DD882CE88BD80089F0BA /* ManageWhiteboardToolUseCaseInterface.swift in Sources */, + 6F5FC15A2D2C1BFF0049A44F /* WhiteboardObjectRegisters.swift in Sources */, 003D5A2B2CEB1FF0005F3D09 /* PhotoObject.swift in Sources */, 6F3BCDCA2CF31DBA005F6642 /* ChatRepositoryInterface.swift in Sources */, 0080E8CE2CE4463B0095B958 /* WhiteboardObjectRepositoryInterface.swift in Sources */, diff --git a/Domain/Domain/Sources/Interface/WhiteboardObjectRegistersInterface.swift b/Domain/Domain/Sources/Interface/WhiteboardObjectRegistersInterface.swift new file mode 100644 index 0000000..11983d6 --- /dev/null +++ b/Domain/Domain/Sources/Interface/WhiteboardObjectRegistersInterface.swift @@ -0,0 +1,39 @@ +// +// WhiteboardObjectRegistersInterface.swift +// Domain +// +// Created by 박승찬 on 1/6/25. +// + +import Foundation + +public protocol WhiteboardObjectRegistersInterface { + /// 집합에 레지스터가 있는지 확인합니다. + /// - Parameter register: 확인할 레지스터 + /// - Returns: 오브젝트 존재 여부 + func contains(register: LWWRegister) async -> Bool + + /// 집합에 레지스터를 추가합니다. + /// - Parameter object: 추가할 레지스터 + func insert(register: LWWRegister) async + + /// 집합에서 레지스터를 삭제합니다. + /// - Parameter register: 삭제할 레지스터 + func remove(register: LWWRegister) async + + /// 모든 화이트보드 오브젝트 레지스터들을 삭제합니다. + func removeAll() async + + /// 집합에 있는 레지스터를 업데이트 합니다. + /// - Parameter register: 업데이트할 레지스터 + func update(register: LWWRegister) async + + /// ID로 집합에있는 레지스터를 가져옵니다. + /// - Parameter id: 가져올 레지스터의 오브젝트 ID + /// - Returns: 화이트보드 오브젝트 + func fetchObjectByID(id: UUID) async -> WhiteboardObject? + + /// 모든 화이트보드 오브젝트 레지스터들을 가져옵니다. + /// - Returns: 화이트보드 레지스터 배열 + func fetchAll() async -> [LWWRegister] +} diff --git a/Domain/Domain/Sources/Model/LWWRegister.swift b/Domain/Domain/Sources/Model/LWWRegister.swift new file mode 100644 index 0000000..bc60a0f --- /dev/null +++ b/Domain/Domain/Sources/Model/LWWRegister.swift @@ -0,0 +1,34 @@ +// +// LWWRegister.swift +// Domain +// +// Created by 박승찬 on 1/6/25. +// + +import Foundation + +public struct LWWRegister { + public let whiteboardObject: WhiteboardObject + private let timestamp: Timestamp + + public init(whiteboardObject: WhiteboardObject, timestamp: Timestamp) { + self.whiteboardObject = whiteboardObject + self.timestamp = timestamp + } + + public func merge(register: LWWRegister) -> LWWRegister { + timestamp < register.timestamp ? register: self + } +} + +extension LWWRegister: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(whiteboardObject) + } +} + +extension LWWRegister: Comparable { + public static func < (lhs: LWWRegister, rhs: LWWRegister) -> Bool { + lhs.timestamp < rhs.timestamp + } +} diff --git a/Domain/Domain/Sources/Model/Timestamp.swift b/Domain/Domain/Sources/Model/Timestamp.swift new file mode 100644 index 0000000..186ab17 --- /dev/null +++ b/Domain/Domain/Sources/Model/Timestamp.swift @@ -0,0 +1,23 @@ +// +// Timestamp.swift +// Domain +// +// Created by 박승찬 on 1/6/25. +// + +import Foundation + +public struct Timestamp: Comparable { + let updatedAt: Date + let updatedBy: UUID + + public init(updatedAt: Date, updatedBy: UUID) { + self.updatedAt = updatedAt + self.updatedBy = updatedBy + } + + public static func < (lhs: Timestamp, rhs: Timestamp) -> Bool { + if lhs.updatedAt == rhs.updatedAt { return lhs.updatedBy < rhs.updatedBy } + return lhs.updatedAt < rhs.updatedAt + } +} diff --git a/Domain/Domain/Sources/Model/WhiteboardObjectRegisters.swift b/Domain/Domain/Sources/Model/WhiteboardObjectRegisters.swift new file mode 100644 index 0000000..3e426c6 --- /dev/null +++ b/Domain/Domain/Sources/Model/WhiteboardObjectRegisters.swift @@ -0,0 +1,54 @@ +// +// WhiteboardObjectRegisters.swift +// Domain +// +// Created by 박승찬 on 1/6/25. +// + +import Foundation + +actor WhiteboardObjectRegisters: WhiteboardObjectRegistersInterface { + private var registers: Set + + init() { + registers = [] + } + + func contains(register: LWWRegister) async -> Bool { + registers.contains(register) + } + + func insert(register: LWWRegister) async { + registers.insert(register) + } + + func remove(register: LWWRegister) async { + registers.remove(register) + } + + func removeAll() async { + registers.removeAll() + } + + func update(register: LWWRegister) async { + if registers.contains(register) { + registers.remove(register) + await insert(register: register.merge(register: register)) + } else { + await insert(register: register) + } + } + + func fetchObjectByID(id: UUID) async -> WhiteboardObject? { + return registers + .first(where: { $0.whiteboardObject.id == id })? + .whiteboardObject + .deepCopy() + } + + func fetchAll() async -> [LWWRegister] { + registers + .sorted { $0 < $1 } + .map { $0 } + } +} diff --git a/Domain/DomainTests/LWWRegisterTests.swift b/Domain/DomainTests/LWWRegisterTests.swift new file mode 100644 index 0000000..476e513 --- /dev/null +++ b/Domain/DomainTests/LWWRegisterTests.swift @@ -0,0 +1,134 @@ +// +// LWWRegisterTests.swift +// DomainTests +// +// Created by 박승찬 on 1/8/25. +// + +import Domain +import XCTest + +final class LWWRegisterTests: XCTestCase { + private var register: LWWRegister! + private var defaultTimestamp: Timestamp! + private var defaultDate: Date! + private var defaultObject: WhiteboardObject! + + override func setUp() { + super.setUp() + defaultObject = TextObject( + id: UUID(), + centerPosition: CGPoint(x: 0, y: 0), + size: CGSize(width: 100, height: 100), + text: "default") + defaultDate = Date() + defaultTimestamp = Timestamp(updatedAt: defaultDate, updatedBy: UUID()) + register = LWWRegister(whiteboardObject: defaultObject, timestamp: defaultTimestamp) + } + + override func tearDown() { + register = nil + defaultTimestamp = nil + defaultDate = nil + defaultObject = nil + } + + // Timestamp가 같을 때 + func testMergeWhenEqualTimestmap() { + // 준비 + let textObject = TextObject( + id: UUID(), + centerPosition: CGPoint(x: 50, y: 50), + size: CGSize(width: 200, height: 200), + text: "equal") + let mockRegister = LWWRegister(whiteboardObject: textObject, timestamp: defaultTimestamp) + + // 실행 + let sut = register.merge(register: mockRegister) + + // 검증 + XCTAssertEqual(sut, register) + } + + // 새로 들어온 updatedAt이 더 빠를 때 + func testMergeWhenIncomingTimestampIsEarlier() { + // 준비 + let earlierDate = defaultDate.addingTimeInterval(-10) + let earlierTimestamp = Timestamp(updatedAt: earlierDate, updatedBy: UUID()) + let textObject = TextObject( + id: UUID(), + centerPosition: CGPoint(x: 50, y: 50), + size: CGSize(width: 200, height: 200), + text: "incoming") + let mockRegister = LWWRegister(whiteboardObject: textObject, timestamp: earlierTimestamp) + + // 실행 + let sut = register.merge(register: mockRegister) + + // 검증 + XCTAssertEqual(sut, register) + } + + // updatedAt은 같지만 새로 들어온 UUID가 작을 때 + func testMergeWhenIncomingTimestampHasSmallerUUID() { + // 준비 + guard let smallerUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000000") else { + XCTFail("Test UUID생성 실패") + return + } + let smallerTimestamp = Timestamp(updatedAt: defaultDate, updatedBy: smallerUUID) + let textObject = TextObject( + id: UUID(), + centerPosition: CGPoint(x: 50, y: 50), + size: CGSize(width: 200, height: 200), + text: "incoming") + let mockRegister = LWWRegister(whiteboardObject: textObject, timestamp: smallerTimestamp) + + // 실행 + let sut = register.merge(register: mockRegister) + + // 검증 + XCTAssertEqual(sut, register) + } + + // 새로 들어온 updatedAt이 더 느릴 때 + func testMergeWhenIncomingTimestampIsLater() { + // 준비 + let laterDate = defaultDate.addingTimeInterval(10) + let laterTimestamp = Timestamp(updatedAt: laterDate, updatedBy: UUID()) + let textObject = TextObject( + id: UUID(), + centerPosition: CGPoint(x: 50, y: 50), + size: CGSize(width: 200, height: 200), + text: "incoming") + let mockRegister = LWWRegister(whiteboardObject: textObject, timestamp: laterTimestamp) + + // 실행 + let sut = register.merge(register: mockRegister) + + // 검증 + XCTAssertEqual(sut, mockRegister) + } + + // updatedAt은 같지만 새로 들어온 UUID가 클 때 + func testMergeWhenIncomingTimestampHasLargerUUID() { + // 준비 + guard let largerUUID = UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF") else { + XCTFail("Test UUID생성 실패") + return + } + let largerTimestamp = Timestamp(updatedAt: defaultDate, updatedBy: largerUUID) + let textObject = TextObject( + id: UUID(), + centerPosition: CGPoint(x: 50, y: 50), + size: CGSize(width: 200, height: 200), + text: "incoming") + let mockRegister = LWWRegister(whiteboardObject: textObject, timestamp: largerTimestamp) + + // 실행 + let sut = register.merge(register: mockRegister) + + // 검증 + XCTAssertEqual(sut, mockRegister) + } +}