diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 33545269..5b5fc36c 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -11,6 +11,7 @@ extension AppGraph { func preparePresentationDependencies() { prepareDependencies { dependencies in prepareEntryDependencies(&dependencies) + prepareDevelopmentDependencies(&dependencies) prepareTodoDependencies(&dependencies) prepareHomeDependencies(&dependencies) prepareTodayDependencies(&dependencies) @@ -21,6 +22,39 @@ extension AppGraph { } private extension AppGraph { + func prepareDevelopmentDependencies(_ dependencies: inout DependencyValues) { + DevelopmentDependencyPreparation.prepareGoal( + &dependencies, + fetchGoalUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .fetchDevelopmentGoalUseCase + ) + DevelopmentDependencyPreparation.prepareQuery( + &dependencies, + fetchRecordsUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordsUseCase, + fetchRecordHistoryUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordHistoryUseCase, + fetchRecordVersionUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordVersionUseCase + ) + DevelopmentDependencyPreparation.prepareMutation( + &dependencies, + createRecordUseCase: developmentGraphSet + .developmentRecordMutationUseCaseGraph + .createDevelopmentRecordUseCase, + saveRecordDraftUseCase: developmentGraphSet + .developmentRecordMutationUseCaseGraph + .saveDevelopmentRecordDraftUseCase, + confirmRecordUseCase: developmentGraphSet + .developmentRecordMutationUseCaseGraph + .confirmDevelopmentRecordUseCase + ) + } + func prepareEntryDependencies(_ dependencies: inout DependencyValues) { PresentationDependencyPreparation.prepareRoot( &dependencies, diff --git a/Application/Data/Sources/DTO/DevelopmentRecordDTO.swift b/Application/Data/Sources/DTO/DevelopmentRecordDTO.swift index c73ff511..766974f2 100644 --- a/Application/Data/Sources/DTO/DevelopmentRecordDTO.swift +++ b/Application/Data/Sources/DTO/DevelopmentRecordDTO.swift @@ -11,15 +11,21 @@ public struct DevelopmentRecordDraftRequest: Encodable { public let title: String public let markdownContent: String public let baseVersionId: String? + public let revisionId: String? + public let expectedRevisionId: String? public init( title: String, markdownContent: String, - baseVersionId: String? + baseVersionId: String?, + revisionId: String? = nil, + expectedRevisionId: String? = nil ) { self.title = title self.markdownContent = markdownContent self.baseVersionId = baseVersionId + self.revisionId = revisionId + self.expectedRevisionId = expectedRevisionId } } @@ -35,15 +41,18 @@ public struct DevelopmentRecordConfirmationRequest { public let versionId: String public let kind: String public let sourceVersionId: String? + public let draftRevisionId: String? public init( versionId: String, kind: String, - sourceVersionId: String? + sourceVersionId: String?, + draftRevisionId: String? = nil ) { self.versionId = versionId self.kind = kind self.sourceVersionId = sourceVersionId + self.draftRevisionId = draftRevisionId } } @@ -61,17 +70,20 @@ public struct DevelopmentRecordDraftResponse { public let title: String public let markdownContent: String public let baseVersionId: String? + public let revisionId: String? public let updatedAt: Date public init( title: String, markdownContent: String, baseVersionId: String?, + revisionId: String? = nil, updatedAt: Date ) { self.title = title self.markdownContent = markdownContent self.baseVersionId = baseVersionId + self.revisionId = revisionId self.updatedAt = updatedAt } } diff --git a/Application/Data/Sources/Mapper/DevelopmentRecordMapping.swift b/Application/Data/Sources/Mapper/DevelopmentRecordMapping.swift index be89745f..0d627180 100644 --- a/Application/Data/Sources/Mapper/DevelopmentRecordMapping.swift +++ b/Application/Data/Sources/Mapper/DevelopmentRecordMapping.swift @@ -8,11 +8,16 @@ import Domain public extension DevelopmentRecordDraftRequest { - static func fromDomain(_ draft: DevelopmentRecord.Draft) -> Self { + static func fromDomain( + _ draft: DevelopmentRecord.Draft, + expectedRevisionId: String? = nil + ) -> Self { Self( title: draft.title, markdownContent: draft.markdownContent, - baseVersionId: draft.baseVersionId + baseVersionId: draft.baseVersionId, + revisionId: draft.revisionId, + expectedRevisionId: expectedRevisionId ) } } @@ -23,6 +28,7 @@ public extension DevelopmentRecordDraftResponse { title: title, markdownContent: markdownContent, baseVersionId: baseVersionId, + revisionId: revisionId, updatedAt: updatedAt ) } diff --git a/Application/Data/Sources/Protocol/DevelopmentRecordService.swift b/Application/Data/Sources/Protocol/DevelopmentRecordService.swift index 0852fcf4..f63ad5c1 100644 --- a/Application/Data/Sources/Protocol/DevelopmentRecordService.swift +++ b/Application/Data/Sources/Protocol/DevelopmentRecordService.swift @@ -13,6 +13,11 @@ public protocol DevelopmentRecordService { ) async throws -> DevelopmentRecordResponse func fetchRecords(goalId: String) async throws -> [DevelopmentRecordResponse] func fetchRecord(goalId: String, recordId: String) async throws -> DevelopmentRecordResponse + func fetchVersion( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecordVersionResponse func fetchVersions( goalId: String, recordId: String diff --git a/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift b/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift index 60d12822..38dad98e 100644 --- a/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift @@ -47,6 +47,22 @@ final class DevelopmentRecordRepositoryImpl: DevelopmentRecordRepository { } } + func fetchVersion( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version { + do { + return try await service.fetchVersion( + goalId: goalId, + recordId: recordId, + versionId: versionId + ).toDomain() + } catch { + throw error.toDomain() + } + } + func fetchVersions( goalId: String, recordId: String @@ -61,13 +77,14 @@ final class DevelopmentRecordRepositoryImpl: DevelopmentRecordRepository { func saveDraft( goalId: String, recordId: String, + expectedRevisionId: String?, draft: DevelopmentRecord.Draft ) async throws -> DevelopmentRecord { do { let response = try await service.saveDraft( goalId: goalId, recordId: recordId, - request: .fromDomain(draft) + request: .fromDomain(draft, expectedRevisionId: expectedRevisionId) ) return try response.toDomain() } catch { @@ -80,7 +97,8 @@ final class DevelopmentRecordRepositoryImpl: DevelopmentRecordRepository { recordId: String, versionId: String, kind: DevelopmentRecord.Version.Kind, - sourceVersionId: String? + sourceVersionId: String?, + draftRevisionId: String? ) async throws -> DevelopmentRecord.Version { do { let response = try await service.confirmDraft( @@ -89,7 +107,8 @@ final class DevelopmentRecordRepositoryImpl: DevelopmentRecordRepository { request: .init( versionId: versionId, kind: kind.storageValue, - sourceVersionId: sourceVersionId + sourceVersionId: sourceVersionId, + draftRevisionId: draftRevisionId ) ) return try response.toDomain() diff --git a/Application/Data/Tests/Mapper/DevelopmentRecordMappingTests.swift b/Application/Data/Tests/Mapper/DevelopmentRecordMappingTests.swift index 404f5e57..de650890 100644 --- a/Application/Data/Tests/Mapper/DevelopmentRecordMappingTests.swift +++ b/Application/Data/Tests/Mapper/DevelopmentRecordMappingTests.swift @@ -17,6 +17,7 @@ struct DevelopmentRecordMappingTests { title: "기록", markdownContent: "본문", baseVersionId: "version-1", + revisionId: "revision-1", updatedAt: .distantPast ) @@ -25,6 +26,7 @@ struct DevelopmentRecordMappingTests { #expect(request.title == "기록") #expect(request.markdownContent == "본문") #expect(request.baseVersionId == "version-1") + #expect(request.revisionId == "revision-1") } @Test("저장 버전 종류는 Domain 종류로 변환한다") diff --git a/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift b/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift index d3155b3d..9bdfc080 100644 --- a/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift @@ -45,7 +45,8 @@ struct DevelopmentRecordRepositoryImplTests { recordId: "record-1", versionId: "version-2", kind: .correction, - sourceVersionId: "version-1" + sourceVersionId: "version-1", + draftRevisionId: "revision-1" ) let request = try #require(await service.confirmationRequest()) @@ -53,6 +54,7 @@ struct DevelopmentRecordRepositoryImplTests { #expect(request.versionId == "version-2") #expect(request.kind == "correction") #expect(request.sourceVersionId == "version-1") + #expect(request.draftRevisionId == "revision-1") } } @@ -100,6 +102,14 @@ private actor DevelopmentRecordServiceSpy: DevelopmentRecordService { [version] } + func fetchVersion( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecordVersionResponse { + version + } + func saveDraft( goalId: String, recordId: String, diff --git a/Application/Domain/Sources/Entity/DevelopmentRecord.swift b/Application/Domain/Sources/Entity/DevelopmentRecord.swift index 50246cc1..1acdf6dc 100644 --- a/Application/Domain/Sources/Entity/DevelopmentRecord.swift +++ b/Application/Domain/Sources/Entity/DevelopmentRecord.swift @@ -12,12 +12,14 @@ public struct DevelopmentRecord: Hashable { public let title: String public let markdownContent: String public let baseVersionId: String? + public let revisionId: String? public let updatedAt: Date public init( title: String, markdownContent: String, baseVersionId: String?, + revisionId: String? = nil, updatedAt: Date ) throws { guard title.containsMeaningfulCharacter else { @@ -26,10 +28,14 @@ public struct DevelopmentRecord: Hashable { guard baseVersionId?.containsMeaningfulCharacter != false else { throw DomainLayerError.invalidDevelopmentRecordVersion } + guard revisionId?.containsMeaningfulCharacter != false else { + throw DomainLayerError.invalidDevelopmentRecordVersion + } self.title = title self.markdownContent = markdownContent self.baseVersionId = baseVersionId + self.revisionId = revisionId self.updatedAt = updatedAt } } diff --git a/Application/Domain/Sources/Graph/DevelopmentRecordQueryUseCaseGraph.swift b/Application/Domain/Sources/Graph/DevelopmentRecordQueryUseCaseGraph.swift index 4c689c4a..06e4a972 100644 --- a/Application/Domain/Sources/Graph/DevelopmentRecordQueryUseCaseGraph.swift +++ b/Application/Domain/Sources/Graph/DevelopmentRecordQueryUseCaseGraph.swift @@ -26,4 +26,9 @@ public final class DevelopmentRecordQueryUseCaseGraph { private func makeFetchDevelopmentRecordHistoryUseCase() -> FetchDevelopmentRecordHistoryUseCase { FetchDevelopmentRecordHistoryUseCaseImpl(input.repository) } + + @Provide + private func makeFetchDevelopmentRecordVersionUseCase() -> FetchDevelopmentRecordVersionUseCase { + FetchDevelopmentRecordVersionUseCaseImpl(input.repository) + } } diff --git a/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift index 11d23b7a..66188f87 100644 --- a/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift +++ b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift @@ -13,10 +13,16 @@ public protocol DevelopmentRecordRepository { ) async throws -> DevelopmentRecord func fetchRecords(goalId: String) async throws -> [DevelopmentRecord] func fetchRecord(goalId: String, recordId: String) async throws -> DevelopmentRecord + func fetchVersion( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version func fetchVersions(goalId: String, recordId: String) async throws -> [DevelopmentRecord.Version] func saveDraft( goalId: String, recordId: String, + expectedRevisionId: String?, draft: DevelopmentRecord.Draft ) async throws -> DevelopmentRecord func confirmDraft( @@ -24,7 +30,8 @@ public protocol DevelopmentRecordRepository { recordId: String, versionId: String, kind: DevelopmentRecord.Version.Kind, - sourceVersionId: String? + sourceVersionId: String?, + draftRevisionId: String? ) async throws -> DevelopmentRecord.Version func restoreVersion( goalId: String, diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift index 288b789e..bfde3fe0 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift @@ -6,5 +6,11 @@ // public protocol ConfirmDevelopmentRecordUseCase { - func execute(goalId: String, recordId: String) async throws -> DevelopmentRecord.Version + func execute( + goalId: String, + recordId: String, + versionId: String, + baseVersionId: String?, + draftRevisionId: String? + ) async throws -> DevelopmentRecord.Version } diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift index 8b664d31..ac4bfc10 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift @@ -5,53 +5,95 @@ // Created by opfic on 8/27/26. // -import Foundation - public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecordUseCase { private let repository: DevelopmentRecordRepository private let goalRepository: DevelopmentGoalRepository - private let idProvider: () -> String init( _ repository: DevelopmentRecordRepository, - _ goalRepository: DevelopmentGoalRepository, - idProvider: @escaping () -> String = { UUID().uuidString } + _ goalRepository: DevelopmentGoalRepository ) { self.repository = repository self.goalRepository = goalRepository - self.idProvider = idProvider } - public func execute(goalId: String, recordId: String) async throws -> DevelopmentRecord.Version { + public func execute( + goalId: String, + recordId: String, + versionId: String, + baseVersionId: String?, + draftRevisionId: String? + ) async throws -> DevelopmentRecord.Version { + let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) + if record.currentVersion?.id == versionId { + return try await confirmedVersion( + record: record, + goalId: goalId, + recordId: recordId, + versionId: versionId + ) + } + let goal = try await goalRepository.fetchGoal(goalId) guard goal.status == .inProgress else { throw DomainLayerError.developmentGoalIsNotInProgress } - let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) guard record.id == recordId, record.goalId == goalId else { throw DomainLayerError.invalidData(context: "developmentRecord") } - guard record.draft != nil else { + guard let draft = record.draft else { throw DomainLayerError.developmentRecordDraftNotFound } + guard record.currentVersion?.id == baseVersionId, + draft.baseVersionId == baseVersionId, + draft.revisionId == draftRevisionId else { + throw DomainLayerError.developmentRecordDraftConflict + } - if let currentVersion = record.currentVersion { + let kind = baseVersionId == nil + ? DevelopmentRecord.Version.Kind.initial + : .correction + do { return try await repository.confirmDraft( goalId: goalId, recordId: recordId, - versionId: idProvider(), - kind: .correction, - sourceVersionId: currentVersion.id + versionId: versionId, + kind: kind, + sourceVersionId: baseVersionId, + draftRevisionId: draftRevisionId ) + } catch { + guard let record = try? await repository.fetchRecord( + goalId: goalId, + recordId: recordId + ), let version = try? await confirmedVersion( + record: record, + goalId: goalId, + recordId: recordId, + versionId: versionId + ) else { + throw error + } + return version } + } +} - return try await repository.confirmDraft( +private extension ConfirmDevelopmentRecordUseCaseImpl { + func confirmedVersion( + record: DevelopmentRecord, + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version { + guard record.currentVersion?.id == versionId else { + throw DomainLayerError.developmentRecordVersionNotFound + } + return try await repository.fetchVersion( goalId: goalId, recordId: recordId, - versionId: idProvider(), - kind: .initial, - sourceVersionId: nil + versionId: versionId ) } } diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift index c8e4d0e1..b30dc59a 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCase.swift @@ -8,6 +8,7 @@ public protocol CreateDevelopmentRecordUseCase { func execute( goalId: String, + recordId: String, title: String, markdownContent: String ) async throws -> DevelopmentRecord diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift index 08345081..ffa6d6c0 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift @@ -10,23 +10,24 @@ import Foundation public final class CreateDevelopmentRecordUseCaseImpl: CreateDevelopmentRecordUseCase { private let repository: DevelopmentRecordRepository private let goalRepository: DevelopmentGoalRepository - private let idProvider: () -> String + private let revisionIdProvider: () -> String private let now: () -> Date init( _ repository: DevelopmentRecordRepository, _ goalRepository: DevelopmentGoalRepository, - idProvider: @escaping () -> String = { UUID().uuidString }, + revisionIdProvider: @escaping () -> String = { UUID().uuidString }, now: @escaping () -> Date = Date.init ) { self.repository = repository self.goalRepository = goalRepository - self.idProvider = idProvider + self.revisionIdProvider = revisionIdProvider self.now = now } public func execute( goalId: String, + recordId: String, title: String, markdownContent: String ) async throws -> DevelopmentRecord { @@ -39,10 +40,11 @@ public final class CreateDevelopmentRecordUseCaseImpl: CreateDevelopmentRecordUs title: title, markdownContent: markdownContent, baseVersionId: nil, + revisionId: revisionIdProvider(), updatedAt: now() ) return try await repository.createRecord( - id: idProvider(), + id: recordId, goalId: goalId, draft: draft ) diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift index a3f59842..9c88e357 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift @@ -9,6 +9,8 @@ public protocol SaveDevelopmentRecordDraftUseCase { func execute( goalId: String, recordId: String, + baseVersionId: String?, + draftRevisionId: String?, title: String, markdownContent: String ) async throws -> DevelopmentRecord diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift index a77a3c42..a100bc76 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift @@ -10,21 +10,26 @@ import Foundation public final class SaveDevelopmentRecordDraftUseCaseImpl: SaveDevelopmentRecordDraftUseCase { private let repository: DevelopmentRecordRepository private let goalRepository: DevelopmentGoalRepository + private let revisionIdProvider: () -> String private let now: () -> Date init( _ repository: DevelopmentRecordRepository, _ goalRepository: DevelopmentGoalRepository, + revisionIdProvider: @escaping () -> String = { UUID().uuidString }, now: @escaping () -> Date = Date.init ) { self.repository = repository self.goalRepository = goalRepository + self.revisionIdProvider = revisionIdProvider self.now = now } public func execute( goalId: String, recordId: String, + baseVersionId: String?, + draftRevisionId: String?, title: String, markdownContent: String ) async throws -> DevelopmentRecord { @@ -37,15 +42,23 @@ public final class SaveDevelopmentRecordDraftUseCaseImpl: SaveDevelopmentRecordD guard record.id == recordId, record.goalId == goalId else { throw DomainLayerError.invalidData(context: "developmentRecord") } + guard record.currentVersion?.id == baseVersionId else { + throw DomainLayerError.developmentRecordDraftConflict + } + guard record.draft?.revisionId == draftRevisionId else { + throw DomainLayerError.developmentRecordDraftConflict + } let updatedDraft = try DevelopmentRecord.Draft( title: title, markdownContent: markdownContent, - baseVersionId: record.currentVersion?.id, + baseVersionId: baseVersionId, + revisionId: revisionIdProvider(), updatedAt: now() ) return try await repository.saveDraft( goalId: goalId, recordId: recordId, + expectedRevisionId: draftRevisionId, draft: updatedDraft ) } diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift index 44106ad2..66c3af80 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCase.swift @@ -8,3 +8,11 @@ public protocol FetchDevelopmentRecordHistoryUseCase { func execute(goalId: String, recordId: String) async throws -> [DevelopmentRecord.Version] } + +public protocol FetchDevelopmentRecordVersionUseCase { + func execute( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift index 8f1237b4..087a52e2 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchDevelopmentRecordHistoryUseCaseImpl.swift @@ -19,3 +19,23 @@ public final class FetchDevelopmentRecordHistoryUseCaseImpl: FetchDevelopmentRec try await repository.fetchVersions(goalId: goalId, recordId: recordId) } } + +public final class FetchDevelopmentRecordVersionUseCaseImpl: FetchDevelopmentRecordVersionUseCase { + private let repository: DevelopmentRecordRepository + + init(_ repository: DevelopmentRecordRepository) { + self.repository = repository + } + + public func execute( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version { + try await repository.fetchVersion( + goalId: goalId, + recordId: recordId, + versionId: versionId + ) + } +} diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift new file mode 100644 index 00000000..d2927028 --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift @@ -0,0 +1,102 @@ +// +// DevelopmentRecordConcurrencyUseCaseTests.swift +// DomainTests +// +// Created by opfic on 9/13/26. +// + +import Testing +@testable import Domain + +struct DevelopmentRecordConcurrencyUseCaseTests { + @Test("기대한 기준 버전이 바뀐 Draft 저장을 거부한다") + func 기대한_기준_버전이_바뀐_Draft_저장을_거부한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let repository = DevelopmentRecordRepositorySpy(record: try makeDevelopmentRecordConfirmed()) + let useCase = SaveDevelopmentRecordDraftUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordDraftConflict) { + try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + baseVersionId: nil, + draftRevisionId: nil, + title: "오래된 초안", + markdownContent: "본문" + ) + } + #expect(await repository.draftRequests().isEmpty) + } + + @Test("기대한 Draft 개정이 바뀌면 저장과 확정을 거부한다") + func 기대한_Draft_개정이_바뀌면_저장과_확정을_거부한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let record = try makeDevelopmentRecordInitialDraft(revisionId: "revision-2") + let repository = DevelopmentRecordRepositorySpy(record: record) + let saveUseCase = SaveDevelopmentRecordDraftUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + let confirmUseCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordDraftConflict) { + try await saveUseCase.execute( + goalId: "goal-1", + recordId: "record-1", + baseVersionId: nil, + draftRevisionId: "revision-1", + title: "오래된 초안", + markdownContent: "본문" + ) + } + await expectDevelopmentRecordDomainError(.developmentRecordDraftConflict) { + try await confirmUseCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-1", + baseVersionId: nil, + draftRevisionId: "revision-1" + ) + } + #expect(await repository.draftRequests().isEmpty) + #expect(await repository.confirmRequests().isEmpty) + } + + @Test("최초 확정 중 현재 버전이 바뀌면 정정 확정으로 전환하지 않는다") + func 최초_확정_중_현재_버전이_바뀌면_정정_확정으로_전환하지_않는다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeDevelopmentRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + revisionId: "revision-1", + updatedAt: .distantPast + ) + ) + let repository = DevelopmentRecordRepositorySpy(record: record) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordDraftConflict) { + try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-2", + baseVersionId: nil, + draftRevisionId: "revision-1" + ) + } + #expect(await repository.confirmRequests().isEmpty) + } +} diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift new file mode 100644 index 00000000..789e461e --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift @@ -0,0 +1,178 @@ +// +// DevelopmentRecordConfirmationUseCaseTests.swift +// DomainTests +// +// Created by opfic on 9/13/26. +// + +import Testing +@testable import Domain + +struct DevelopmentRecordConfirmUseCaseTests { + @Test("최초 Draft 확정은 initial 버전으로 요청한다") + func 최초_Draft_확정은_initial_버전으로_요청한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let record = try makeDevelopmentRecordInitialDraft() + let version = try makeDevelopmentRecordInitialVersion() + let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-1", + baseVersionId: nil, + draftRevisionId: "revision-1" + ) + + #expect(result == version) + #expect(await repository.confirmRequests() == [ + .init( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-1", + kind: .initial, + sourceVersionId: nil, + draftRevisionId: "revision-1" + ) + ]) + } + + @Test("확정 버전 기반 Draft 확정은 correction 버전으로 요청한다") + func 확정_버전_기반_Draft_확정은_correction_버전으로_요청한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeDevelopmentRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + revisionId: "revision-1", + updatedAt: .distantPast + ) + ) + let version = try makeDevelopmentRecordCorrectionVersion( + id: "version-2", + sourceVersionId: currentVersion.id + ) + let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + _ = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-2", + baseVersionId: currentVersion.id, + draftRevisionId: "revision-1" + ) + + #expect(await repository.confirmRequests() == [ + .init( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-2", + kind: .correction, + sourceVersionId: currentVersion.id, + draftRevisionId: "revision-1" + ) + ]) + } + + @Test("Draft가 없는 기록의 확정을 거부한다") + func Draft가_없는_기록의_확정을_거부한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let repository = DevelopmentRecordRepositorySpy(record: try makeDevelopmentRecordConfirmed()) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordDraftNotFound) { + try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-2", + baseVersionId: nil, + draftRevisionId: nil + ) + } + #expect(await repository.confirmRequests().isEmpty) + } + + @Test("확정 응답 조회가 실패해도 저장된 버전을 복구한다") + func 확정_응답_조회가_실패해도_저장된_버전을_복구한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let draft = try makeDevelopmentRecordInitialDraft() + let confirmedRecord = try makeDevelopmentRecordConfirmed() + let version = try makeDevelopmentRecordInitialVersion() + let repository = DevelopmentRecordRepositorySpy( + record: draft, + subsequentRecords: [confirmedRecord], + versions: [version], + confirmError: DevelopmentRecordRepositorySpyError.unconfigured + ) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: version.id, + baseVersionId: nil, + draftRevisionId: "revision-1" + ) + + #expect(result == version) + #expect(await repository.confirmRequests().count == 1) + #expect(await repository.exactVersionQueries() == [ + .init(goalId: "goal-1", recordId: "record-1", versionId: version.id) + ]) + } + + @Test("확정 결과 조회가 연속 실패해도 같은 versionId로 복구한다") + func 확정_결과_조회가_연속_실패해도_같은_versionId로_복구한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let draft = try makeDevelopmentRecordInitialDraft() + let confirmedRecord = try makeDevelopmentRecordConfirmed() + let version = try makeDevelopmentRecordInitialVersion() + let repository = DevelopmentRecordRepositorySpy( + record: draft, + subsequentRecords: [draft, confirmedRecord], + versions: [version], + confirmError: DevelopmentRecordRepositorySpyError.unconfigured + ) + let useCase = ConfirmDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await #expect(throws: DevelopmentRecordRepositorySpyError.self) { + try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: version.id, + baseVersionId: nil, + draftRevisionId: "revision-1" + ) + } + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: version.id, + baseVersionId: nil, + draftRevisionId: "revision-1" + ) + + #expect(result == version) + #expect(await repository.confirmRequests().count == 1) + } +} diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift index 0ef8827a..67964dcb 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift @@ -55,9 +55,16 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { let recordId: String } + struct VersionQuery: Equatable { + let goalId: String + let recordId: String + let versionId: String + } + struct DraftRequest: Equatable { let goalId: String let recordId: String + let expectedRevisionId: String? let draft: DevelopmentRecord.Draft } @@ -67,6 +74,7 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { let versionId: String let kind: DevelopmentRecord.Version.Kind let sourceVersionId: String? + let draftRevisionId: String? } struct RestoreRequest: Equatable { @@ -78,14 +86,16 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { private let createResult: DevelopmentRecord? private let records: [DevelopmentRecord] - private let record: DevelopmentRecord? + private var recordsToReturn: [DevelopmentRecord] private let versions: [DevelopmentRecord.Version] private let savedRecord: DevelopmentRecord? private let confirmedVersion: DevelopmentRecord.Version? + private let confirmError: Error? private let restoredVersion: DevelopmentRecord.Version? private var recordedCreateRequests = [CreateRequest]() private var recordedRecordQueries = [String]() private var recordedVersionQueries = [RecordQuery]() + private var recordedExactVersionQueries = [VersionQuery]() private var recordedDraftRequests = [DraftRequest]() private var recordedConfirmRequests = [ConfirmRequest]() private var recordedRestoreRequests = [RestoreRequest]() @@ -94,17 +104,20 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { createResult: DevelopmentRecord? = nil, records: [DevelopmentRecord] = [], record: DevelopmentRecord? = nil, + subsequentRecords: [DevelopmentRecord] = [], versions: [DevelopmentRecord.Version] = [], savedRecord: DevelopmentRecord? = nil, confirmedVersion: DevelopmentRecord.Version? = nil, + confirmError: Error? = nil, restoredVersion: DevelopmentRecord.Version? = nil ) { self.createResult = createResult self.records = records - self.record = record + self.recordsToReturn = record.map { [$0] + subsequentRecords } ?? subsequentRecords self.versions = versions self.savedRecord = savedRecord self.confirmedVersion = confirmedVersion + self.confirmError = confirmError self.restoredVersion = restoredVersion } @@ -123,7 +136,13 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { } func fetchRecord(goalId: String, recordId: String) async throws -> DevelopmentRecord { - try requiredDevelopmentRecordRepositoryResult(record) + guard let record = recordsToReturn.first else { + throw DevelopmentRecordRepositorySpyError.unconfigured + } + if 1 < recordsToReturn.count { + recordsToReturn.removeFirst() + } + return record } func fetchVersions(goalId: String, recordId: String) async throws -> [DevelopmentRecord.Version] { @@ -131,12 +150,34 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { return versions } + func fetchVersion( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version { + recordedExactVersionQueries.append(.init( + goalId: goalId, + recordId: recordId, + versionId: versionId + )) + guard let version = versions.first(where: { $0.id == versionId }) else { + throw DevelopmentRecordRepositorySpyError.unconfigured + } + return version + } + func saveDraft( goalId: String, recordId: String, + expectedRevisionId: String?, draft: DevelopmentRecord.Draft ) async throws -> DevelopmentRecord { - recordedDraftRequests.append(.init(goalId: goalId, recordId: recordId, draft: draft)) + recordedDraftRequests.append(.init( + goalId: goalId, + recordId: recordId, + expectedRevisionId: expectedRevisionId, + draft: draft + )) return try requiredDevelopmentRecordRepositoryResult(savedRecord) } @@ -145,7 +186,8 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { recordId: String, versionId: String, kind: DevelopmentRecord.Version.Kind, - sourceVersionId: String? + sourceVersionId: String?, + draftRevisionId: String? ) async throws -> DevelopmentRecord.Version { recordedConfirmRequests.append( .init( @@ -153,9 +195,13 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { recordId: recordId, versionId: versionId, kind: kind, - sourceVersionId: sourceVersionId + sourceVersionId: sourceVersionId, + draftRevisionId: draftRevisionId ) ) + if let confirmError { + throw confirmError + } return try requiredDevelopmentRecordRepositoryResult(confirmedVersion) } @@ -184,6 +230,10 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { recordedVersionQueries } + func exactVersionQueries() -> [VersionQuery] { + recordedExactVersionQueries + } + func draftRequests() -> [DraftRequest] { recordedDraftRequests } @@ -223,6 +273,7 @@ func makeDevelopmentRecordGoal( } func makeDevelopmentRecordInitialDraft( + revisionId: String = "revision-1", updatedAt: Date = .distantPast ) throws -> DevelopmentRecord { try makeDevelopmentRecord( @@ -231,6 +282,7 @@ func makeDevelopmentRecordInitialDraft( title: "기록", markdownContent: "본문", baseVersionId: nil, + revisionId: revisionId, updatedAt: updatedAt ) ) diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift index 56a1d324..e6fd6e93 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift @@ -19,11 +19,12 @@ struct DevelopmentRecordUseCaseTests { let useCase = CreateDevelopmentRecordUseCaseImpl( repository, DevelopmentRecordGoalRepositorySpy(goal: goal), - idProvider: { "record-1" }, + revisionIdProvider: { "revision-1" }, now: { createdAt } ) let result = try await useCase.execute( goalId: "goal-1", + recordId: "record-1", title: "기록", markdownContent: "본문" ) @@ -37,6 +38,7 @@ struct DevelopmentRecordUseCaseTests { title: "기록", markdownContent: "본문", baseVersionId: nil, + revisionId: "revision-1", updatedAt: createdAt ) ) @@ -54,6 +56,7 @@ struct DevelopmentRecordUseCaseTests { await expectDevelopmentRecordDomainError(.developmentGoalIsNotInProgress) { try await useCase.execute( goalId: "goal-1", + recordId: "record-1", title: "기록", markdownContent: "본문" ) @@ -84,6 +87,24 @@ struct DevelopmentRecordUseCaseTests { ]) } + @Test("기록 버전 조회는 지정한 버전만 반환한다") + func 기록_버전_조회는_지정한_버전만_반환한다() async throws { + let version = try makeDevelopmentRecordInitialVersion() + let repository = DevelopmentRecordRepositorySpy(versions: [version]) + let useCase = FetchDevelopmentRecordVersionUseCaseImpl(repository) + + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: version.id + ) + + #expect(result == version) + #expect(await repository.exactVersionQueries() == [ + .init(goalId: "goal-1", recordId: "record-1", versionId: version.id) + ]) + } + @Test("최초 Draft 저장은 기준 버전 없이 갱신한다") func 최초_Draft_저장은_기준_버전_없이_갱신한다() async throws { let goal = try makeDevelopmentRecordGoal() @@ -94,11 +115,14 @@ struct DevelopmentRecordUseCaseTests { let useCase = SaveDevelopmentRecordDraftUseCaseImpl( repository, DevelopmentRecordGoalRepositorySpy(goal: goal), + revisionIdProvider: { "revision-2" }, now: { updatedAt } ) let result = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + baseVersionId: nil, + draftRevisionId: "revision-1", title: "수정 기록", markdownContent: "수정 본문" ) @@ -108,10 +132,12 @@ struct DevelopmentRecordUseCaseTests { .init( goalId: "goal-1", recordId: "record-1", + expectedRevisionId: "revision-1", draft: try DevelopmentRecord.Draft( title: "수정 기록", markdownContent: "수정 본문", baseVersionId: nil, + revisionId: "revision-2", updatedAt: updatedAt ) ) @@ -128,6 +154,7 @@ struct DevelopmentRecordUseCaseTests { title: "정정 초안", markdownContent: "본문", baseVersionId: currentVersion.id, + revisionId: "revision-1", updatedAt: .distantPast ) ) @@ -145,11 +172,14 @@ struct DevelopmentRecordUseCaseTests { let useCase = SaveDevelopmentRecordDraftUseCaseImpl( repository, DevelopmentRecordGoalRepositorySpy(goal: goal), + revisionIdProvider: { "revision-2" }, now: { updatedAt } ) _ = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + baseVersionId: currentVersion.id, + draftRevisionId: "revision-1", title: "수정 정정 초안", markdownContent: "수정 본문" ) @@ -178,11 +208,14 @@ struct DevelopmentRecordUseCaseTests { let useCase = SaveDevelopmentRecordDraftUseCaseImpl( repository, DevelopmentRecordGoalRepositorySpy(goal: goal), + revisionIdProvider: { "revision-2" }, now: { updatedAt } ) let result = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + baseVersionId: currentVersion.id, + draftRevisionId: nil, title: "정정 초안", markdownContent: "본문" ) @@ -192,91 +225,18 @@ struct DevelopmentRecordUseCaseTests { .init( goalId: "goal-1", recordId: "record-1", + expectedRevisionId: nil, draft: try DevelopmentRecord.Draft( title: "정정 초안", markdownContent: "본문", baseVersionId: currentVersion.id, + revisionId: "revision-2", updatedAt: updatedAt ) ) ]) } - @Test("최초 Draft 확정은 initial 버전으로 요청한다") - func 최초_Draft_확정은_initial_버전으로_요청한다() async throws { - let goal = try makeDevelopmentRecordGoal() - let record = try makeDevelopmentRecordInitialDraft() - let version = try makeDevelopmentRecordInitialVersion() - let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) - let useCase = ConfirmDevelopmentRecordUseCaseImpl( - repository, - DevelopmentRecordGoalRepositorySpy(goal: goal), - idProvider: { "version-1" } - ) - - let result = try await useCase.execute(goalId: "goal-1", recordId: "record-1") - - #expect(result == version) - #expect(await repository.confirmRequests() == [ - .init( - goalId: "goal-1", - recordId: "record-1", - versionId: "version-1", - kind: .initial, - sourceVersionId: nil - ) - ]) - } - - @Test("확정 버전 기반 Draft 확정은 correction 버전으로 요청한다") - func 확정_버전_기반_Draft_확정은_correction_버전으로_요청한다() async throws { - let goal = try makeDevelopmentRecordGoal() - let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) - let record = try makeDevelopmentRecord( - currentVersion: currentVersion, - draft: DevelopmentRecord.Draft( - title: "정정 초안", - markdownContent: "본문", - baseVersionId: currentVersion.id, - updatedAt: .distantPast - ) - ) - let version = try makeDevelopmentRecordCorrectionVersion(id: "version-2", sourceVersionId: currentVersion.id) - let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) - let useCase = ConfirmDevelopmentRecordUseCaseImpl( - repository, - DevelopmentRecordGoalRepositorySpy(goal: goal), - idProvider: { "version-2" } - ) - - _ = try await useCase.execute(goalId: "goal-1", recordId: "record-1") - - #expect(await repository.confirmRequests() == [ - .init( - goalId: "goal-1", - recordId: "record-1", - versionId: "version-2", - kind: .correction, - sourceVersionId: currentVersion.id - ) - ]) - } - - @Test("Draft가 없는 기록의 확정을 거부한다") - func Draft가_없는_기록의_확정을_거부한다() async throws { - let goal = try makeDevelopmentRecordGoal() - let repository = DevelopmentRecordRepositorySpy(record: try makeDevelopmentRecordConfirmed()) - let useCase = ConfirmDevelopmentRecordUseCaseImpl( - repository, - DevelopmentRecordGoalRepositorySpy(goal: goal) - ) - - await expectDevelopmentRecordDomainError(.developmentRecordDraftNotFound) { - try await useCase.execute(goalId: "goal-1", recordId: "record-1") - } - #expect(await repository.confirmRequests().isEmpty) - } - @Test("같은 기록의 과거 버전은 rollback 버전으로 되돌린다") func 같은_기록의_과거_버전은_rollback_버전으로_되돌린다() async throws { let goal = try makeDevelopmentRecordGoal() diff --git a/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift b/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift index 322a9a0e..28735877 100644 --- a/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift +++ b/Application/Infra/Sources/Mapper/DevelopmentRecordDocumentMapper.swift @@ -42,6 +42,7 @@ struct DevelopmentRecordDocumentMapper { title: title, markdownContent: markdownContent, baseVersionId: data[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] as? String, + revisionId: data[DevelopmentRecordDraftFieldKey.revisionId.rawValue] as? String, updatedAt: updatedAt.dateValue() ) } else { @@ -94,6 +95,7 @@ enum DevelopmentRecordDraftFieldKey: String { case title case markdownContent case baseVersionId + case revisionId case updatedAt } diff --git a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift index 943b6949..7d9bc4e9 100644 --- a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift @@ -22,6 +22,7 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { case saveDraft case confirmDraft case restoreVersion + case fetchVersion } } @@ -37,13 +38,26 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } do { - try await store.document( + let reference = store.document( FirestorePath.developmentRecord(uid, goalId: goalId, recordId: recordId) ) - .setData([ - DevelopmentRecordFieldKey.draft.rawValue: Self.makeDraftData(request.draft), - DevelopmentRecordFieldKey.createdAt.rawValue: FieldValue.serverTimestamp() - ]) + _ = try await store.runTransaction { transaction, errorPointer in + do { + let snapshot = try transaction.getDocument(reference) + guard !snapshot.exists else { return nil } + transaction.setData( + [ + DevelopmentRecordFieldKey.draft.rawValue: Self.makeDraftData(request.draft), + DevelopmentRecordFieldKey.createdAt.rawValue: FieldValue.serverTimestamp() + ], + forDocument: reference + ) + return nil + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + } return try await fetchRecord(uid: uid, goalId: goalId, recordId: recordId) } catch { logger.error("Failed to create development record", error: error) @@ -127,6 +141,27 @@ final class DevelopmentRecordServiceImpl: DevelopmentRecordService { } } + func fetchVersion( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecordVersionResponse { + guard let uid = Auth.auth().currentUser?.uid else { throw DataLayerError.notAuthenticated } + + do { + return try await fetchVersion( + uid: uid, + goalId: goalId, + recordId: recordId, + versionId: versionId + ) + } catch { + logger.error("Failed to fetch development record version", error: error) + record(error, code: .fetchVersion) + throw error + } + } + func saveDraft( goalId: String, recordId: String, diff --git a/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift b/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift index 80840b8a..54e620bb 100644 --- a/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift +++ b/Application/Infra/Sources/Service/DevelopmentRecordVersionMutation.swift @@ -15,7 +15,8 @@ extension DevelopmentRecordServiceImpl { ) -> DevelopmentRecordVersionMutation? { let mapper = DevelopmentRecordDocumentMapper() guard let record = mapper.map(goalId: "goal", documentId: "record", data: recordData), - let draft = record.draft else { + let draft = record.draft, + draft.revisionId == request.draftRevisionId else { return nil } @@ -82,6 +83,9 @@ extension DevelopmentRecordServiceImpl { if let baseVersionId = request.baseVersionId { data[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] = baseVersionId } + if let revisionId = request.revisionId { + data[DevelopmentRecordDraftFieldKey.revisionId.rawValue] = revisionId + } return data } @@ -91,7 +95,10 @@ extension DevelopmentRecordServiceImpl { ) -> [String: Any]? { guard (recordData[DevelopmentRecordFieldKey.currentVersionId.rawValue] as? String) == - request.baseVersionId else { + request.baseVersionId, + let draftData = recordData[DevelopmentRecordFieldKey.draft.rawValue] as? [String: Any], + (draftData[DevelopmentRecordDraftFieldKey.revisionId.rawValue] as? String) == + request.expectedRevisionId else { return nil } return makeDraftData(request) diff --git a/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift b/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift index 60820dd0..4090f3b4 100644 --- a/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift +++ b/Application/Infra/Tests/Service/DevelopmentRecordServiceImplTests.swift @@ -136,10 +136,43 @@ struct DevelopmentRecordServiceImplTests { #expect(missingCurrentVersion == nil) } + @Test("기대한 Draft 개정이 바뀌면 저장과 확정을 거부한다") + func 기대한_Draft_개정이_바뀌면_저장과_확정을_거부한다() { + let recordData = makeRecordData( + currentVersionId: nil, + currentVersionNumber: nil, + draftBaseVersionId: nil, + draftRevisionId: "revision-2" + ) + let draftData = DevelopmentRecordServiceImpl.makeDraftData( + recordData: recordData, + request: .init( + title: "기록", + markdownContent: "본문", + baseVersionId: nil, + revisionId: "revision-3", + expectedRevisionId: "revision-1" + ) + ) + let confirmation = DevelopmentRecordServiceImpl.makeConfirmationMutation( + recordData: recordData, + request: .init( + versionId: "version-1", + kind: "initial", + sourceVersionId: nil, + draftRevisionId: "revision-1" + ) + ) + + #expect(draftData == nil) + #expect(confirmation == nil) + } + private func makeRecordData( currentVersionId: String? = nil, currentVersionNumber: Int? = nil, draftBaseVersionId: String?, + draftRevisionId: String? = nil, includesDraft: Bool = true ) -> [String: Any] { var data: [String: Any] = [ @@ -158,6 +191,9 @@ struct DevelopmentRecordServiceImplTests { if let draftBaseVersionId { draft[DevelopmentRecordDraftFieldKey.baseVersionId.rawValue] = draftBaseVersionId } + if let draftRevisionId { + draft[DevelopmentRecordDraftFieldKey.revisionId.rawValue] = draftRevisionId + } data[DevelopmentRecordFieldKey.draft.rawValue] = draft } return data diff --git a/Application/Presentation/Development/Sources/.swiftlint.yml b/Application/Presentation/Development/Sources/.swiftlint.yml new file mode 100644 index 00000000..6a9d7380 --- /dev/null +++ b/Application/Presentation/Development/Sources/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../../.swiftlint.yml diff --git a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift new file mode 100644 index 00000000..eb8225c1 --- /dev/null +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -0,0 +1,119 @@ +// +// DevelopmentDependencyPreparation.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Domain +import PresentationShared + +public enum DevelopmentDependencyPreparation { + public static func prepareGoal( + _ dependencies: inout DependencyValues, + fetchGoalUseCase: FetchDevelopmentGoalUseCase + ) { + dependencies.developmentFetchGoalUseCase = fetchGoalUseCase + } + + public static func prepareQuery( + _ dependencies: inout DependencyValues, + fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, + fetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase, + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase + ) { + dependencies.developmentFetchRecordsUseCase = fetchRecordsUseCase + dependencies.developmentFetchRecordHistoryUseCase = fetchRecordHistoryUseCase + dependencies.developmentFetchRecordVersionUseCase = fetchRecordVersionUseCase + } + + public static func prepareMutation( + _ dependencies: inout DependencyValues, + createRecordUseCase: CreateDevelopmentRecordUseCase, + saveRecordDraftUseCase: SaveDevelopmentRecordDraftUseCase, + confirmRecordUseCase: ConfirmDevelopmentRecordUseCase + ) { + dependencies.developmentCreateRecordUseCase = createRecordUseCase + dependencies.developmentSaveRecordDraftUseCase = saveRecordDraftUseCase + dependencies.developmentConfirmRecordUseCase = confirmRecordUseCase + } +} + +extension DependencyValues { + var developmentFetchGoalUseCase: FetchDevelopmentGoalUseCase { + get { self[DevelopmentFetchGoalUseCaseKey.self] } + set { self[DevelopmentFetchGoalUseCaseKey.self] = newValue } + } + + var developmentFetchRecordsUseCase: FetchDevelopmentRecordsUseCase { + get { self[DevelopmentFetchRecordsUseCaseKey.self] } + set { self[DevelopmentFetchRecordsUseCaseKey.self] = newValue } + } + + var developmentFetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase { + get { self[DevelopmentFetchRecordHistoryUseCaseKey.self] } + set { self[DevelopmentFetchRecordHistoryUseCaseKey.self] = newValue } + } + + var developmentFetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase { + get { self[DevelopmentFetchRecordVersionUseCaseKey.self] } + set { self[DevelopmentFetchRecordVersionUseCaseKey.self] = newValue } + } + + var developmentCreateRecordUseCase: CreateDevelopmentRecordUseCase { + get { self[DevelopmentCreateRecordUseCaseKey.self] } + set { self[DevelopmentCreateRecordUseCaseKey.self] = newValue } + } + + var developmentSaveRecordDraftUseCase: SaveDevelopmentRecordDraftUseCase { + get { self[DevelopmentSaveRecordDraftUseCaseKey.self] } + set { self[DevelopmentSaveRecordDraftUseCaseKey.self] = newValue } + } + + var developmentConfirmRecordUseCase: ConfirmDevelopmentRecordUseCase { + get { self[DevelopmentConfirmRecordUseCaseKey.self] } + set { self[DevelopmentConfirmRecordUseCaseKey.self] = newValue } + } +} + +private enum DevelopmentFetchGoalUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentGoalUseCase { + preconditionFailure("FetchDevelopmentGoalUseCase must be provided.") + } +} + +private enum DevelopmentFetchRecordsUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentRecordsUseCase { + preconditionFailure("FetchDevelopmentRecordsUseCase must be provided.") + } +} + +private enum DevelopmentFetchRecordHistoryUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentRecordHistoryUseCase { + preconditionFailure("FetchDevelopmentRecordHistoryUseCase must be provided.") + } +} + +private enum DevelopmentFetchRecordVersionUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentRecordVersionUseCase { + preconditionFailure("FetchDevelopmentRecordVersionUseCase must be provided.") + } +} + +private enum DevelopmentCreateRecordUseCaseKey: DependencyKey { + static var liveValue: CreateDevelopmentRecordUseCase { + preconditionFailure("CreateDevelopmentRecordUseCase must be provided.") + } +} + +private enum DevelopmentSaveRecordDraftUseCaseKey: DependencyKey { + static var liveValue: SaveDevelopmentRecordDraftUseCase { + preconditionFailure("SaveDevelopmentRecordDraftUseCase must be provided.") + } +} + +private enum DevelopmentConfirmRecordUseCaseKey: DependencyKey { + static var liveValue: ConfirmDevelopmentRecordUseCase { + preconditionFailure("ConfirmDevelopmentRecordUseCase must be provided.") + } +} diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift new file mode 100644 index 00000000..21a2d3a9 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift @@ -0,0 +1,116 @@ +// +// RecordDetailFeature.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Domain +import PresentationShared + +@Reducer +struct RecordDetailFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + let goalTitle: String + let record: DevelopmentRecord + var contentState: ContentState + + var isLoading: Bool { + contentState == .idle || contentState == .loading + } + + init(goalTitle: String, record: DevelopmentRecord) { + self.goalTitle = goalTitle + self.record = record + if record.currentVersion == nil, let draft = record.draft { + self.contentState = .draft(draft) + } else { + self.contentState = .idle + } + } + } + + enum ContentState: Equatable { + case idle + case loading + case draft(DevelopmentRecord.Draft) + case confirmed(DevelopmentRecord.Version) + case failed + } + + enum Action: Equatable { + case alert(PresentationAction) + case view(ViewAction) + case store(StoreAction) + + enum ViewAction: Equatable { + case fetch + } + + enum StoreAction: Equatable { + case loaded(DevelopmentRecord.Version) + case failed + } + } + + @Dependency(\.developmentFetchRecordHistoryUseCase) private var fetchRecordHistoryUseCase + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .alert: + break + case .view(.fetch): + guard state.contentState == .idle || state.contentState == .failed else { break } + state.alert = nil + state.contentState = .loading + return fetchEffect(record: state.record) + case .store(.loaded(let version)): + state.contentState = .confirmed(version) + case .store(.failed): + state.contentState = .failed + state.alert = Self.errorAlert + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +private extension RecordDetailFeature { + func fetchEffect(record: DevelopmentRecord) -> Effect { + .run { [fetchRecordHistoryUseCase] send in + do { + let versions = try await fetchRecordHistoryUseCase.execute( + goalId: record.goalId, + recordId: record.id + ) + guard let currentVersion = record.currentVersion, + let version = versions.first(where: { $0.id == currentVersion.id }) else { + throw DomainLayerError.developmentRecordVersionNotFound + } + await send(.store(.loaded(version))) + } catch { + await send(.store(.failed)) + } + } + } + + static var errorAlert: AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String( + localized: "development_record_detail_error_message", + bundle: PresentationResources.bundle + )) + } + } +} diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift new file mode 100644 index 00000000..9a39dec2 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift @@ -0,0 +1,159 @@ +// +// RecordDetailView.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import Domain +import PresentationShared + +public struct RecordDetailView: View { + @State private var store: StoreOf + + public init(goalTitle: String, record: DevelopmentRecord) { + self._store = State(initialValue: Store( + initialState: RecordDetailFeature.State( + goalTitle: goalTitle, + record: record + ) + ) { + RecordDetailFeature() + }) + } + + public var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + switch store.contentState { + case .draft(let draft): + recordContent( + title: draft.title, + markdownContent: draft.markdownContent, + version: nil + ) + case .confirmed(let version): + recordContent( + title: version.title, + markdownContent: version.markdownContent, + version: version + ) + case .failed: + failureContent + case .idle, .loading: + Color.clear.frame(height: 1) + } + } + .padding() + } + .background(Color.appBackground.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .onAppear { store.send(.view(.fetch)) } + .prominentAlert(store, state: \.alert, action: \.alert) + .overlay { + if store.isLoading { + LoadingView() + } + } + } + + @ViewBuilder + private func recordContent( + title: String, + markdownContent: String, + version: DevelopmentRecord.Version? + ) -> some View { + titleSection(title: title, version: version) + contentCard(markdownContent: markdownContent) + } + + private func titleSection( + title: String, + version: DevelopmentRecord.Version? + ) -> some View { + VStack(alignment: .leading, spacing: 14) { + ScrollView(.horizontal) { + Text(title) + .font(.title.bold()) + .lineLimit(1) + } + .scrollIndicators(.hidden) + + HStack(spacing: 10) { + Text(statusText(isDraft: version == nil)) + .font(.callout.weight(.semibold)) + .foregroundStyle(statusColor(isDraft: version == nil)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(statusBackground(isDraft: version == nil), in: .capsule) + + if let version { + Text(RecordPresentation.versionLabel(version.number)) + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.accent) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.primaryContainer, in: .capsule) + + Text(version.confirmedAt, format: .dateTime.month().day().hour().minute()) + .font(.callout) + .foregroundStyle(Color.textSecondary) + } + } + } + } + + private func contentCard(markdownContent: String) -> some View { + VStack(alignment: .leading, spacing: 16) { + if markdownContent.isEmpty { + ContentUnavailableView( + RecordPresentation.text("development_record_content_empty_title"), + systemImage: "doc.text" + ) + } else { + MarkdownContentView(content: markdownContent) + .frame(minHeight: 420) + } + } + .padding(.vertical, 16) + .background { + RoundedRectangle(cornerRadius: 24) + .fill(Color.surface) + } + } + + private var failureContent: some View { + VStack(spacing: 20) { + ContentUnavailableView( + RecordPresentation.text("common_error_title"), + systemImage: "exclamationmark.triangle", + description: Text( + RecordPresentation.text("development_record_detail_error_message") + ) + ) + + Button(RecordPresentation.text("development_record_detail_retry")) { + store.send(.view(.fetch)) + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity) + .padding(24) + .background(Color.surface, in: .rect(cornerRadius: 24)) + } + + private func statusText(isDraft: Bool) -> String { + isDraft + ? RecordPresentation.text("development_record_draft") + : RecordPresentation.text("development_record_confirmed") + } + + private func statusColor(isDraft: Bool) -> Color { + isDraft ? .warning : .white + } + + private func statusBackground(isDraft: Bool) -> Color { + isDraft ? .warning.opacity(0.12) : .accent + } +} diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift new file mode 100644 index 00000000..0d8612b7 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift @@ -0,0 +1,262 @@ +// +// RecordEditorFeature.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Domain +import Foundation +import PresentationShared + +@Reducer +struct RecordEditorFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + let goalId: String + let goalTitle: String + let recordId: String + let versionId: String + var record: DevelopmentRecord? + var title: String + var markdownContent: String + var selectedTab = EditorTab.write + var isLoading = false + var result: Result? + var confirmationPreparation: ConfirmationPreparation? + + var versionNumber: Int { + (record?.currentVersion?.number ?? 0) + 1 + } + + var isReadyToSave: Bool { + !isLoading + && record?.currentVersion == nil + && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var canConfirmInitialVersion: Bool { + isReadyToSave + } + + init( + goalId: String, + goalTitle: String, + record: DevelopmentRecord? = nil, + recordId: String = UUID().uuidString, + versionId: String = UUID().uuidString + ) { + self.goalId = goalId + self.goalTitle = goalTitle + self.recordId = record?.id ?? recordId + self.versionId = versionId + self.record = record + self.title = record?.draft?.title ?? "" + self.markdownContent = record?.draft?.markdownContent ?? "" + } + } + + enum EditorTab: Equatable { + case write + case preview + } + + enum Result: Equatable { + case saved(DevelopmentRecord) + case confirmed(DevelopmentRecord.Version) + } + + struct ConfirmationPreparation: Equatable { + let record: DevelopmentRecord + let title: String + let markdownContent: String + } + + enum Action: BindableAction, Equatable { + case alert(PresentationAction) + case binding(BindingAction) + case view(ViewAction) + case store(StoreAction) + case delegate(Delegate) + + enum ViewAction: Equatable { + case save + case confirm + } + + enum StoreAction: Equatable { + case saved(DevelopmentRecord) + case preparedForConfirmation(DevelopmentRecord) + case confirmed(DevelopmentRecord.Version) + case failed + } + + enum Delegate: Equatable { + case saved(DevelopmentRecord) + case confirmed(DevelopmentRecord.Version) + } + } + + @Dependency(\.developmentCreateRecordUseCase) private var createRecordUseCase + @Dependency(\.developmentSaveRecordDraftUseCase) private var saveRecordDraftUseCase + @Dependency(\.developmentConfirmRecordUseCase) private var confirmRecordUseCase + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .alert, .binding, .delegate: + break + case .view(.save): + guard state.isReadyToSave else { break } + state.isLoading = true + state.result = nil + state.confirmationPreparation = nil + return saveEffect(state: state) + case .view(.confirm): + guard state.canConfirmInitialVersion else { break } + state.isLoading = true + state.result = nil + if let preparation = state.confirmationPreparation, + preparation.title == state.title, + preparation.markdownContent == state.markdownContent { + return confirmPreparedRecordEffect( + goalId: state.goalId, + recordId: preparation.record.id, + versionId: state.versionId, + draftRevisionId: preparation.record.draft?.revisionId + ) + } + state.confirmationPreparation = nil + return prepareConfirmationEffect(state: state) + case .store(.saved(let record)): + state.record = record + state.isLoading = false + state.result = .saved(record) + state.confirmationPreparation = nil + return .send(.delegate(.saved(record))) + case .store(.preparedForConfirmation(let record)): + state.record = record + state.confirmationPreparation = ConfirmationPreparation( + record: record, + title: state.title, + markdownContent: state.markdownContent + ) + return confirmPreparedRecordEffect( + goalId: state.goalId, + recordId: record.id, + versionId: state.versionId, + draftRevisionId: record.draft?.revisionId + ) + case .store(.confirmed(let version)): + state.isLoading = false + state.result = .confirmed(version) + state.confirmationPreparation = nil + return .send(.delegate(.confirmed(version))) + case .store(.failed): + state.isLoading = false + state.result = nil + state.alert = Self.errorAlert + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +private extension RecordEditorFeature { + func saveEffect(state: State) -> Effect { + .run { [createRecordUseCase, saveRecordDraftUseCase] send in + do { + let record: DevelopmentRecord + if let recordId = state.record?.id { + record = try await saveRecordDraftUseCase.execute( + goalId: state.goalId, + recordId: recordId, + baseVersionId: nil, + draftRevisionId: state.record?.draft?.revisionId, + title: state.title, + markdownContent: state.markdownContent + ) + } else { + record = try await createRecordUseCase.execute( + goalId: state.goalId, + recordId: state.recordId, + title: state.title, + markdownContent: state.markdownContent + ) + } + await send(.store(.saved(record))) + } catch { + await send(.store(.failed)) + } + } + } + + func prepareConfirmationEffect(state: State) -> Effect { + .run { [createRecordUseCase, saveRecordDraftUseCase] send in + do { + let record: DevelopmentRecord + if let recordId = state.record?.id { + record = try await saveRecordDraftUseCase.execute( + goalId: state.goalId, + recordId: recordId, + baseVersionId: nil, + draftRevisionId: state.record?.draft?.revisionId, + title: state.title, + markdownContent: state.markdownContent + ) + } else { + record = try await createRecordUseCase.execute( + goalId: state.goalId, + recordId: state.recordId, + title: state.title, + markdownContent: state.markdownContent + ) + } + await send(.store(.preparedForConfirmation(record))) + } catch { + await send(.store(.failed)) + } + } + } + + func confirmPreparedRecordEffect( + goalId: String, + recordId: String, + versionId: String, + draftRevisionId: String? + ) -> Effect { + .run { [confirmRecordUseCase] send in + do { + let version = try await confirmRecordUseCase.execute( + goalId: goalId, + recordId: recordId, + versionId: versionId, + baseVersionId: nil, + draftRevisionId: draftRevisionId + ) + await send(.store(.confirmed(version))) + } catch { + await send(.store(.failed)) + } + } + } + + static var errorAlert: AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String( + localized: "development_record_editor_error_message", + bundle: PresentationResources.bundle + )) + } + } +} diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift new file mode 100644 index 00000000..387a4515 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -0,0 +1,304 @@ +// +// RecordEditorView.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import Domain +import PresentationShared + +public struct RecordEditorView: View { + @Environment(\.dismiss) private var dismiss + @State private var store: StoreOf + @FocusState private var focusedField: Field? + @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( + forTextStyle: .title2, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).lineHeight + private let onCompletion: () -> Void + + public init( + goalId: String, + goalTitle: String, + record: DevelopmentRecord? = nil, + onCompletion: @escaping () -> Void = { } + ) { + self._store = State(initialValue: Store( + initialState: RecordEditorFeature.State( + goalId: goalId, + goalTitle: goalTitle, + record: record + ) + ) { + RecordEditorFeature() + }) + self.onCompletion = onCompletion + } + + public var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + versionCard + titleCard + contentCard + } + .padding(.horizontal) + .padding(.bottom, 20) + } + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .safeAreaInset(edge: .bottom, spacing: 0) { confirmBar } + .background(Color.appBackground.ignoresSafeArea()) + .toolbarVisibility(.hidden, for: .navigationBar) + .prominentAlert(store, state: \.alert, action: \.alert) + .onChange(of: store.result) { _, result in + guard result != nil else { return } + onCompletion() + dismiss() + } + } + .interactiveDismissDisabled(store.isLoading) + } + + private var topBar: some View { + HStack { + Button { + dismiss() + } label: { + if #available(iOS 26.0, *) { + Image(systemName: "xmark") + .frame(width: iconSize, height: iconSize) + } else { + Text(RecordPresentation.text("common_close")) + } + } + .font(.title) + .topBarButtonStyle() + .disabled(store.isLoading) + Spacer() + Text(RecordPresentation.text("development_record_editor_title")) + .font(.headline) + Spacer() + Button { + focusedField = nil + store.send(.view(.save)) + } label: { + if #available(iOS 26.0, *) { + Image(systemName: "checkmark") + .frame(width: iconSize, height: iconSize) + .foregroundStyle(Color.primary) + } else { + Text(RecordPresentation.text("development_record_save")) + .foregroundStyle(Color.accent) + } + } + .font(.title) + .topBarButtonStyle(color: Color.surface) + .disabled(!store.isReadyToSave) + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + + private var versionCard: some View { + FieldCard(title: RecordPresentation.text("development_record_version")) { + Text(RecordPresentation.versionLabel(store.versionNumber)) + .font(.body) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + } + } + + private var titleCard: some View { + FieldCard(title: RecordPresentation.text("development_record_title")) { + TextField( + RecordPresentation.text("development_record_title_placeholder"), + text: $store.title + ) + .focused($focusedField, equals: .title) + .padding() + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + } + .disabled(store.isLoading) + } + + private var contentCard: some View { + VStack(spacing: 16) { + ModePicker(store: store, focusedField: _focusedField) + + editorContent + + Text(RecordPresentation.text("development_record_markdown_hint")) + .font(.caption) + .foregroundStyle(Color.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 24) + .fill(Color.surface) + } + .disabled(store.isLoading) + } + + @ViewBuilder + private var editorContent: some View { + switch store.selectedTab { + case .write: + TextEditor(text: $store.markdownContent) + .focused($focusedField, equals: .content) + .font(.body) + .scrollContentBackground(.hidden) + .padding(12) + .frame(minHeight: 340, alignment: .topLeading) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + case .preview: + Group { + if store.markdownContent.isEmpty { + ContentUnavailableView( + RecordPresentation.text("development_record_preview_empty_title"), + systemImage: "doc.text.magnifyingglass", + description: Text( + RecordPresentation.text("development_record_preview_empty_message") + ) + ) + } else { + MarkdownContentView(content: store.markdownContent) + .padding(.vertical, 16) + } + } + .frame(minHeight: 340) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + } + } + + private var confirmBar: some View { + VStack(spacing: 8) { + Button { + focusedField = nil + store.send(.view(.confirm)) + } label: { + Group { + if store.isLoading { + ProgressView() + .tint(Color.white) + } else { + Text(RecordPresentation.text("development_record_confirm")) + } + } + .font(.headline) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) + .disabled(!store.canConfirmInitialVersion) + + Text(String.localizedStringWithFormat( + RecordPresentation.text("development_record_confirm_hint_format"), + RecordPresentation.versionLabel(store.versionNumber) + )) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.surface, ignoresSafeAreaEdges: .bottom) + } +} + +private extension View { + @ViewBuilder + func topBarButtonStyle(color: Color = .clear) -> some View { + if #available(iOS 26.0, *) { + adaptiveButtonStyle(shape: .circle, color: color, glassEffect: .enabled) + } else { + adaptiveButtonStyle(color: color, glassEffect: .enabled) + } + } +} + +private struct FieldCard: View { + let title: String + @ViewBuilder let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(title) + .font(.headline) + content + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 24) + .fill(Color.surface) + } + } +} + +private struct ModePicker: View { + @Bindable var store: StoreOf + @FocusState var focusedField: Field? + + var body: some View { + HStack(spacing: 0) { + modeButton( + RecordPresentation.text("development_record_write"), + tab: .write + ) + modeButton( + RecordPresentation.text("development_record_preview"), + tab: .preview + ) + } + .padding(2) + .background(Color.border, in: RoundedRectangle(cornerRadius: 16)) + } + + private func modeButton( + _ title: String, + tab: RecordEditorFeature.EditorTab + ) -> some View { + let isSelected = store.selectedTab == tab + + return Button { + if tab == .write { + store.send(.binding(.set(\.selectedTab, .write))) + focusedField = .content + } else { + focusedField = nil + DispatchQueue.main.async { + store.send(.binding(.set(\.selectedTab, .preview))) + } + } + } label: { + Text(title) + .font(.body) + .foregroundStyle(isSelected ? Color.accent : Color.textSecondary) + .frame(maxWidth: .infinity, minHeight: 36) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 14) + .fill(Color.surface) + .shadow(color: Color.textSecondary.opacity(0.08), radius: 2, y: 2) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} + +private enum Field: Hashable { + case title + case content +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift new file mode 100644 index 00000000..2e95f33e --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -0,0 +1,158 @@ +// +// GoalDetailFeature.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Domain +import Foundation +import PresentationShared + +struct RecordTimelineItem: Equatable, Identifiable { + let record: DevelopmentRecord + let currentVersion: DevelopmentRecord.Version? + + var id: String { record.id } + var title: String { currentVersion?.title ?? record.draft?.title ?? "" } + var isDraft: Bool { currentVersion == nil } + var versionNumber: Int? { currentVersion?.number } + var date: Date { currentVersion?.confirmedAt ?? record.draft?.updatedAt ?? record.createdAt } +} + +@Reducer +struct GoalDetailFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + let goalId: String + var goalTitle = "" + var items = [RecordTimelineItem]() + var isLoading = false + var hasLoaded = false + var hasLoadFailure = false + + init(goalId: String) { + self.goalId = goalId + } + } + + enum Action: Equatable { + case alert(PresentationAction) + case view(ViewAction) + case store(StoreAction) + + enum ViewAction: Equatable { + case fetch + case refresh + } + + enum StoreAction: Equatable { + case loaded(goalTitle: String, items: [RecordTimelineItem]) + case failed + } + } + + @Dependency(\.developmentFetchGoalUseCase) private var fetchGoalUseCase + @Dependency(\.developmentFetchRecordsUseCase) private var fetchRecordsUseCase + @Dependency(\.developmentFetchRecordVersionUseCase) private var fetchRecordVersionUseCase + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .alert: + break + case .view(.fetch): + guard !state.hasLoaded, !state.isLoading else { break } + state.isLoading = true + state.hasLoadFailure = false + return fetchEffect(goalId: state.goalId) + case .view(.refresh): + guard !state.isLoading else { break } + state.isLoading = true + state.hasLoadFailure = false + return fetchEffect(goalId: state.goalId) + case .store(.loaded(let goalTitle, let items)): + state.goalTitle = goalTitle + state.items = items + state.isLoading = false + state.hasLoaded = true + state.hasLoadFailure = false + case .store(.failed): + state.isLoading = false + state.hasLoadFailure = true + state.alert = Self.errorAlert + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +extension GoalDetailFeature { + func fetchEffect(goalId: String) -> Effect { + .run { [fetchGoalUseCase, fetchRecordsUseCase, fetchRecordVersionUseCase] send in + do { + let goal = try await fetchGoalUseCase.execute(goalId) + let records = try await fetchRecordsUseCase.execute(goalId: goalId) + let sortedRecords = records.sorted(by: Self.precedes) + var currentVersions = [DevelopmentRecord.Version?]( + repeating: nil, + count: sortedRecords.count + ) + + try await withThrowingTaskGroup( + of: (Int, DevelopmentRecord.Version).self + ) { group in + for (index, record) in sortedRecords.enumerated() { + guard let reference = record.currentVersion else { continue } + group.addTask { + let version = try await fetchRecordVersionUseCase.execute( + goalId: goalId, + recordId: record.id, + versionId: reference.id + ) + return (index, version) + } + } + + for try await (index, version) in group { + currentVersions[index] = version + } + } + + let items = zip(sortedRecords, currentVersions).map { record, version in + RecordTimelineItem( + record: record, + currentVersion: version + ) + } + + await send(.store(.loaded(goalTitle: goal.title, items: items))) + } catch { + await send(.store(.failed)) + } + } + } + + static func precedes(_ lhs: DevelopmentRecord, _ rhs: DevelopmentRecord) -> Bool { + if lhs.createdAt == rhs.createdAt { return lhs.id < rhs.id } + return lhs.createdAt < rhs.createdAt + } + + static var errorAlert: AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String( + localized: "development_record_timeline_error_message", + bundle: PresentationResources.bundle + )) + } + } +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift new file mode 100644 index 00000000..dbfc8664 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -0,0 +1,260 @@ +// +// GoalDetailView.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import Domain +import PresentationShared + +public struct GoalDetailView: View { + @State private var store: StoreOf + @State private var editorDestination: EditorDestination? + @State private var detailDestination: DetailDestination? + + public init(goalId: String) { + self._store = State(initialValue: Store( + initialState: GoalDetailFeature.State(goalId: goalId) + ) { + GoalDetailFeature() + }) + } + + public var body: some View { + ScrollView { + LazyVStack(spacing: 20, pinnedViews: [.sectionHeaders]) { + Section { + timelineCard + if let draft = store.items.first(where: \.isDraft) { + continueButton(draft.record) + } + } header: { + titleBar + } + .padding(.horizontal) + } + } + .background(Color.appBackground) + .onAppear { store.send(.view(.fetch)) } + .prominentAlert(store, state: \.alert, action: \.alert) + .sheet(item: $editorDestination) { destination in + RecordEditorView( + goalId: store.goalId, + goalTitle: store.goalTitle, + record: destination.record, + onCompletion: finishEditing + ) + } + .navigationDestination(item: $detailDestination) { destination in + RecordDetailView( + goalTitle: store.goalTitle, + record: destination.record + ) + } + .toolbarBackground(Color.appBackground) + } + + private var titleBar: some View { + VStack(alignment: .leading, spacing: 8) { + Text(RecordPresentation.text("development_goal_title")) + .font(.largeTitle.bold()) + + if !store.goalTitle.isEmpty { + ScrollView(.horizontal) { + Text(store.goalTitle) + .font(.headline) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + .scrollIndicators(.hidden) + .contentMargins(.horizontal, 16, for: .scrollContent) + .padding(.horizontal, -16) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.appBackground) + } + + @ViewBuilder + private var timelineCard: some View { + VStack(spacing: 18) { + if !store.hasLoaded, store.hasLoadFailure { + ContentUnavailableView { + Label( + RecordPresentation.text("common_error_title"), + systemImage: "exclamationmark.triangle" + ) + } description: { + Text(RecordPresentation.text("development_record_timeline_error_message")) + } actions: { + Button { + store.send(.view(.refresh)) + } label: { + Text(RecordPresentation.text("development_record_timeline_retry")) + } + .buttonStyle(.borderedProminent) + } + } else if !store.hasLoaded || store.isLoading, store.items.isEmpty { + ProgressView() + .tint(Color.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else if store.items.isEmpty { + ContentUnavailableView( + RecordPresentation.text("development_record_empty_title"), + systemImage: "doc.badge.plus", + description: Text(RecordPresentation.text("development_record_empty_message")) + ) + } else { + LazyVStack(spacing: 0) { + ForEach(Array(store.items.enumerated()), id: \.element.id) { index, item in + TimelineRow( + item: item, + isLast: index == store.items.count - 1, + onSelect: { select(item) } + ) + } + } + } + + if store.hasLoaded { + Button { + editorDestination = EditorDestination(record: nil) + } label: { + Label( + RecordPresentation.text("development_record_add"), + systemImage: "plus" + ) + .font(.callout) + .foregroundStyle(Color.accent) + } + .adaptiveButtonStyle(color: .primaryContainer) + .disabled(store.isLoading) + } + } + .padding() + .background { + RoundedRectangle(cornerRadius: 24) + .fill(Color.surface) + } + } + + private func continueButton(_ record: DevelopmentRecord) -> some View { + Button { + editorDestination = EditorDestination(record: record) + } label: { + Text(RecordPresentation.text("development_record_continue")) + .font(.headline) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) + } + + private func select(_ item: RecordTimelineItem) { + if item.isDraft { + editorDestination = EditorDestination(record: item.record) + } else { + detailDestination = DetailDestination(record: item.record) + } + } + + private func finishEditing() { + editorDestination = nil + store.send(.view(.refresh)) + } +} + +private struct TimelineRow: View { + let item: RecordTimelineItem + let isLast: Bool + let onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + HStack(alignment: .top, spacing: 16) { + timelineIndicator + VStack(alignment: .leading, spacing: 5) { + Text(item.title) + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + .lineLimit(2) + status + if item.isDraft { + RelativeTimeText( + date: item.date, + bodyFont: .caption, + bodyColor: .textTertiary + ) + } else { + Text(item.date, format: .dateTime.month().day()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.border) + .padding(.top, 4) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } + + private var timelineIndicator: some View { + VStack(spacing: 0) { + Circle() + .fill(item.isDraft ? Color.surface : .accent) + .frame(width: 13, height: 13) + .overlay { + Circle() + .strokeBorder(item.isDraft ? Color.warning : .accent, lineWidth: 2) + } + if !isLast { + Rectangle() + .fill(Color.accent.opacity(0.45)) + .frame(width: 2, height: 72) + } + } + .padding(.top, 3) + } + + private var status: some View { + Text(statusText) + .font(.caption) + .foregroundStyle(item.isDraft ? Color.warning : .accent) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background( + item.isDraft ? Color.warning.opacity(0.12) : Color.primaryContainer, + in: .rect(cornerRadius: 8) + ) + } + + private var statusText: String { + guard let versionNumber = item.versionNumber else { + return RecordPresentation.text("development_record_draft_status") + } + return String.localizedStringWithFormat( + RecordPresentation.text("development_record_confirmed_version_format"), + RecordPresentation.versionLabel(versionNumber) + ) + } +} + +private struct EditorDestination: Identifiable { + let id = UUID() + let record: DevelopmentRecord? +} + +private struct DetailDestination: Identifiable { + let record: DevelopmentRecord + var id: String { record.id } +} diff --git a/Application/Presentation/Development/Sources/Record/RecordPresentation.swift b/Application/Presentation/Development/Sources/Record/RecordPresentation.swift new file mode 100644 index 00000000..fcdd7bbe --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/RecordPresentation.swift @@ -0,0 +1,19 @@ +// +// RecordPresentation.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Foundation +import PresentationShared + +enum RecordPresentation { + static func text(_ key: String.LocalizationValue) -> String { + String(localized: key, bundle: PresentationResources.bundle) + } + + static func versionLabel(_ number: Int) -> String { + "#\(number)" + } +} diff --git a/Application/Presentation/Development/Tests/.swiftlint.yml b/Application/Presentation/Development/Tests/.swiftlint.yml new file mode 100644 index 00000000..7bb38c07 --- /dev/null +++ b/Application/Presentation/Development/Tests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../../.swiftlint-tests.yml diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift new file mode 100644 index 00000000..fa7d84d0 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -0,0 +1,87 @@ +// +// GoalDetailFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/13/26. +// + +import Testing +import Foundation +import PresentationShared +@testable import Development + +@MainActor +struct GoalDetailFeatureTests { + @Test("타임라인은 생성 순서로 기록을 정렬하고 최신 확정 버전을 구성한다") + func 타임라인은_생성_순서로_기록을_정렬하고_최신_확정_버전을_구성한다() async throws { + let goal = try makeDevelopmentGoal(title: "개발 목표와 기록 이력 구성") + let confirmedRecord = try makeConfirmedDevelopmentRecord(id: "confirmed") + let draftRecord = try makeDevelopmentRecord( + id: "draft", + createdAt: Date(timeIntervalSince1970: 1_700_000_300) + ) + let version = try makeDevelopmentRecordVersion(recordId: confirmedRecord.id) + let items = [ + RecordTimelineItem(record: confirmedRecord, currentVersion: version), + RecordTimelineItem(record: draftRecord, currentVersion: nil) + ] + let store = TestStore( + initialState: GoalDetailFeature.State(goalId: goal.id) + ) { + GoalDetailFeature() + } withDependencies: { + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub(result: .success(goal)) + $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( + result: .success([draftRecord, confirmedRecord]) + ) + $0.developmentFetchRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseStub( + resultByRecordId: [confirmedRecord.id: .success(version)] + ) + } + + await store.send(.view(.fetch)) { + $0.isLoading = true + } + await store.receive(.store(.loaded(goalTitle: goal.title, items: items))) { + $0.goalTitle = goal.title + $0.items = items + $0.isLoading = false + $0.hasLoaded = true + } + } + + @Test("타임라인 조회 실패는 입력 가능한 재조회 상태를 유지한다") + func 타임라인_조회_실패는_입력_가능한_재조회_상태를_유지한다() async throws { + let goal = try makeDevelopmentGoal() + let store = TestStore( + initialState: GoalDetailFeature.State(goalId: goal.id) + ) { + GoalDetailFeature() + } withDependencies: { + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub(result: .success(goal)) + $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( + result: .failure(RecordTestError.failed) + ) + $0.developmentFetchRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseStub( + resultByRecordId: [:] + ) + } + + await store.send(.view(.fetch)) { + $0.isLoading = true + } + await store.receive(.store(.failed)) { + $0.isLoading = false + $0.hasLoadFailure = true + $0.alert = makeRecordErrorAlert("development_record_timeline_error_message") + } + await store.send(.view(.refresh)) { + $0.isLoading = true + $0.hasLoadFailure = false + } + await store.receive(.store(.failed)) { + $0.isLoading = false + $0.hasLoadFailure = true + } + } +} diff --git a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift new file mode 100644 index 00000000..64271a2b --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift @@ -0,0 +1,90 @@ +// +// RecordDetailFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/13/26. +// + +import Testing +import PresentationShared +@testable import Development + +@MainActor +struct RecordDetailFeatureTests { + @Test("상세는 currentVersion과 일치하는 확정 버전을 표시한다") + func 상세는_currentVersion과_일치하는_확정_버전을_표시한다() async throws { + let record = try makeConfirmedDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let store = TestStore( + initialState: RecordDetailFeature.State( + goalTitle: "개발 목표", + record: record + ) + ) { + RecordDetailFeature() + } withDependencies: { + $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( + resultByRecordId: [record.id: .success([version])] + ) + } + + await store.send(.view(.fetch)) { + $0.contentState = .loading + } + await store.receive(.store(.loaded(version))) { + $0.contentState = .confirmed(version) + } + } + + @Test("확정 전 상세는 이력 조회 없이 초안을 유지한다") + func 확정_전_상세는_이력_조회_없이_초안을_유지한다() async throws { + let record = try makeDevelopmentRecord() + let store = TestStore( + initialState: RecordDetailFeature.State( + goalTitle: "개발 목표", + record: record + ) + ) { + RecordDetailFeature() + } + + let draft = try #require(record.draft) + #expect(store.state.contentState == .draft(draft)) + await store.send(.view(.fetch)) + } + + @Test("확정 기록 조회 실패는 초안 대신 재시도 상태를 표시한다") + func 확정_기록_조회_실패는_초안_대신_재시도_상태를_표시한다() async throws { + let record = try makeConfirmedDevelopmentRecord() + let store = TestStore( + initialState: RecordDetailFeature.State( + goalTitle: "개발 목표", + record: record + ) + ) { + RecordDetailFeature() + } withDependencies: { + $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( + resultByRecordId: [record.id: .failure(RecordTestError.failed)] + ) + } + + await store.send(.view(.fetch)) { + $0.contentState = .loading + } + await store.receive(.store(.failed)) { + $0.contentState = .failed + $0.alert = makeRecordErrorAlert("development_record_detail_error_message") + } + await store.send(.alert(.dismiss)) { + $0.alert = nil + } + await store.send(.view(.fetch)) { + $0.contentState = .loading + } + await store.receive(.store(.failed)) { + $0.contentState = .failed + $0.alert = makeRecordErrorAlert("development_record_detail_error_message") + } + } +} diff --git a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift new file mode 100644 index 00000000..7f6d1ce6 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift @@ -0,0 +1,252 @@ +// +// RecordEditorFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/13/26. +// + +import Testing +import Domain +import PresentationShared +@testable import Development + +@MainActor +struct RecordEditorFeatureTests { + @Test("새 기록 저장은 초안을 생성한다") + func 새_기록_저장은_초안을_생성한다() async throws { + let record = try makeDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let store = makeStore(createResult: .success(record), confirmResult: .success(version)) + await store.send(.binding(.set(\.title, "기록 제목"))) { + $0.title = "기록 제목" + } + + await store.send(.view(.save)) { + $0.isLoading = true + } + await store.receive(.store(.saved(record))) { + $0.record = record + $0.isLoading = false + $0.result = .saved(record) + } + await store.receive(.delegate(.saved(record))) + } + + @Test("최초 확정은 새 초안을 만든 뒤 initial 버전을 생성한다") + func 최초_확정은_새_초안을_만든_뒤_initial_버전을_생성한다() async throws { + let record = try makeDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let store = makeStore(createResult: .success(record), confirmResult: .success(version)) + await store.send(.binding(.set(\.title, "기록 제목"))) { + $0.title = "기록 제목" + } + + await store.send(.view(.confirm)) { + $0.isLoading = true + } + await store.receive(.store(.preparedForConfirmation(record))) { + $0.record = record + $0.confirmationPreparation = .init( + record: record, + title: "기록 제목", + markdownContent: "" + ) + } + await store.receive(.store(.confirmed(version))) { + $0.isLoading = false + $0.result = .confirmed(version) + $0.confirmationPreparation = nil + } + await store.receive(.delegate(.confirmed(version))) + } + + @Test("확정 전 기존 초안은 입력한 내용으로 다시 저장한다") + func 확정_전_기존_초안은_입력한_내용으로_다시_저장한다() async throws { + let record = try makeDevelopmentRecord() + let updatedRecord = try makeDevelopmentRecord( + draft: makeDevelopmentRecordDraft(title: "수정한 제목") + ) + let version = try makeDevelopmentRecordVersion() + let store = makeStore( + record: record, + createResult: .failure(RecordTestError.failed), + saveResult: .success(updatedRecord), + confirmResult: .success(version) + ) + await store.send(.binding(.set(\.title, "수정한 제목"))) { + $0.title = "수정한 제목" + } + + await store.send(.view(.save)) { + $0.isLoading = true + } + await store.receive(.store(.saved(updatedRecord))) { + $0.record = updatedRecord + $0.isLoading = false + $0.result = .saved(updatedRecord) + } + await store.receive(.delegate(.saved(updatedRecord))) + } + + @Test("확정된 기록은 #871 전까지 저장과 확정을 허용하지 않는다") + func 확정된_기록은_871_전까지_저장과_확정을_허용하지_않는다() async throws { + let record = try makeConfirmedDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let store = makeStore( + record: record, + createResult: .failure(RecordTestError.failed), + confirmResult: .success(version) + ) + + #expect(!store.state.isReadyToSave) + #expect(!store.state.canConfirmInitialVersion) + await store.send(.view(.save)) + await store.send(.view(.confirm)) + } + + @Test("새 기록 생성 재시도는 같은 recordId를 사용한다") + func 새_기록_생성_재시도는_같은_recordId를_사용한다() async throws { + let record = try makeDevelopmentRecord(id: "record-1") + let createSpy = CreateDevelopmentRecordUseCaseSpy(results: [ + .failure(RecordTestError.failed), + .success(record) + ]) + let store = TestStore( + initialState: RecordEditorFeature.State( + goalId: "goal", + goalTitle: "개발 목표", + recordId: record.id + ) + ) { + RecordEditorFeature() + } withDependencies: { + $0.developmentCreateRecordUseCase = createSpy + } + await store.send(.binding(.set(\.title, "기록 제목"))) { + $0.title = "기록 제목" + } + + await store.send(.view(.save)) { + $0.isLoading = true + } + await store.receive(.store(.failed)) { + $0.isLoading = false + $0.alert = makeRecordErrorAlert("development_record_editor_error_message") + } + await store.send(.alert(.dismiss)) { + $0.alert = nil + } + await store.send(.view(.save)) { + $0.isLoading = true + } + await store.receive(.store(.saved(record))) { + $0.record = record + $0.isLoading = false + $0.result = .saved(record) + } + await store.receive(.delegate(.saved(record))) + + #expect(await createSpy.requests().map(\.recordId) == [record.id, record.id]) + } + + @Test("확정 실패 뒤 재시도는 준비된 기록과 versionId를 다시 사용한다") + func 확정_실패_뒤_재시도는_준비된_기록과_versionId를_다시_사용한다() async throws { + let record = try makeDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let createSpy = CreateDevelopmentRecordUseCaseSpy(result: .success(record)) + let confirmSpy = ConfirmDevelopmentRecordUseCaseSpy(results: [ + .failure(RecordTestError.failed), + .success(version) + ]) + let store = TestStore( + initialState: RecordEditorFeature.State( + goalId: "goal", + goalTitle: "개발 목표", + versionId: version.id + ) + ) { + RecordEditorFeature() + } withDependencies: { + $0.developmentCreateRecordUseCase = createSpy + $0.developmentSaveRecordDraftUseCase = SaveDevelopmentRecordDraftUseCaseStub( + result: .success(record) + ) + $0.developmentConfirmRecordUseCase = confirmSpy + } + await store.send(.binding(.set(\.title, "기록 제목"))) { + $0.title = "기록 제목" + } + + await store.send(.view(.confirm)) { + $0.isLoading = true + } + await store.receive(.store(.preparedForConfirmation(record))) { + $0.record = record + $0.confirmationPreparation = .init( + record: record, + title: "기록 제목", + markdownContent: "" + ) + } + await store.receive(.store(.failed)) { + $0.isLoading = false + $0.alert = makeRecordErrorAlert("development_record_editor_error_message") + } + await store.send(.alert(.dismiss)) { + $0.alert = nil + } + await store.send(.view(.confirm)) { + $0.isLoading = true + $0.result = nil + } + await store.receive(.store(.confirmed(version))) { + $0.isLoading = false + $0.result = .confirmed(version) + $0.confirmationPreparation = nil + } + await store.receive(.delegate(.confirmed(version))) + + #expect(await createSpy.requests().count == 1) + #expect(await confirmSpy.requests() == [ + .init( + goalId: "goal", + recordId: record.id, + versionId: version.id, + baseVersionId: nil, + draftRevisionId: record.draft?.revisionId + ), + .init( + goalId: "goal", + recordId: record.id, + versionId: version.id, + baseVersionId: nil, + draftRevisionId: record.draft?.revisionId + ) + ]) + } + + private func makeStore( + record: DevelopmentRecord? = nil, + createResult: Result, + saveResult: Result? = nil, + confirmResult: Result + ) -> TestStoreOf { + TestStore( + initialState: RecordEditorFeature.State( + goalId: "goal", + goalTitle: "개발 목표", + record: record + ) + ) { + RecordEditorFeature() + } withDependencies: { + $0.developmentCreateRecordUseCase = CreateDevelopmentRecordUseCaseStub(result: createResult) + $0.developmentSaveRecordDraftUseCase = SaveDevelopmentRecordDraftUseCaseStub( + result: saveResult ?? record.map { .success($0) } ?? createResult + ) + $0.developmentConfirmRecordUseCase = ConfirmDevelopmentRecordUseCaseStub( + result: confirmResult + ) + } + } +} diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift new file mode 100644 index 00000000..b6fe4236 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -0,0 +1,258 @@ +// +// RecordFeatureTestSupport.swift +// DevelopmentTests +// +// Created by opfic on 9/13/26. +// + +import Domain +import Foundation +import PresentationShared + +enum RecordTestError: Error { + case failed +} + +func makeRecordErrorAlert(_ messageKey: String.LocalizationValue) -> AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String(localized: messageKey, bundle: PresentationResources.bundle)) + } +} + +struct FetchDevelopmentGoalUseCaseStub: FetchDevelopmentGoalUseCase { + let result: Result + + func execute(_ goalId: String) async throws -> DevelopmentGoal { + try result.get() + } +} + +struct FetchDevelopmentRecordsUseCaseStub: FetchDevelopmentRecordsUseCase { + let result: Result<[DevelopmentRecord], Error> + + func execute(goalId: String) async throws -> [DevelopmentRecord] { + try result.get() + } +} + +struct FetchDevelopmentRecordHistoryUseCaseStub: FetchDevelopmentRecordHistoryUseCase { + let resultByRecordId: [String: Result<[DevelopmentRecord.Version], Error>] + + func execute( + goalId: String, + recordId: String + ) async throws -> [DevelopmentRecord.Version] { + try resultByRecordId[recordId, default: .failure(RecordTestError.failed)].get() + } +} + +struct FetchDevelopmentRecordVersionUseCaseStub: FetchDevelopmentRecordVersionUseCase { + let resultByRecordId: [String: Result] + + func execute( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version { + try resultByRecordId[recordId, default: .failure(RecordTestError.failed)].get() + } +} + +struct CreateDevelopmentRecordUseCaseStub: CreateDevelopmentRecordUseCase { + let result: Result + + func execute( + goalId: String, + recordId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord { + try result.get() + } +} + +struct SaveDevelopmentRecordDraftUseCaseStub: SaveDevelopmentRecordDraftUseCase { + let result: Result + + func execute( + goalId: String, + recordId: String, + versionId: String, + baseVersionId: String?, + draftRevisionId: String?, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord { + try result.get() + } +} + +struct ConfirmDevelopmentRecordUseCaseStub: ConfirmDevelopmentRecordUseCase { + let result: Result + + func execute( + goalId: String, + recordId: String, + baseVersionId: String?, + draftRevisionId: String? + ) async throws -> DevelopmentRecord.Version { + try result.get() + } +} + +actor CreateDevelopmentRecordUseCaseSpy: CreateDevelopmentRecordUseCase { + struct Request: Equatable { + let goalId: String + let recordId: String + let title: String + let markdownContent: String + } + + private var results: [Result] + private var recordedRequests = [Request]() + + init(result: Result) { + self.results = [result] + } + + init(results: [Result]) { + self.results = results + } + + func execute( + goalId: String, + recordId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord { + recordedRequests.append(.init( + goalId: goalId, + recordId: recordId, + title: title, + markdownContent: markdownContent + )) + guard !results.isEmpty else { throw RecordTestError.failed } + return try results.removeFirst().get() + } + + func requests() -> [Request] { + recordedRequests + } +} + +actor ConfirmDevelopmentRecordUseCaseSpy: ConfirmDevelopmentRecordUseCase { + struct Request: Equatable { + let goalId: String + let recordId: String + let versionId: String + let baseVersionId: String? + let draftRevisionId: String? + } + + private var results: [Result] + private var recordedRequests = [Request]() + + init(results: [Result]) { + self.results = results + } + + func execute( + goalId: String, + recordId: String, + versionId: String, + baseVersionId: String?, + draftRevisionId: String? + ) async throws -> DevelopmentRecord.Version { + recordedRequests.append(.init( + goalId: goalId, + recordId: recordId, + versionId: versionId, + baseVersionId: baseVersionId, + draftRevisionId: draftRevisionId + )) + guard !results.isEmpty else { throw RecordTestError.failed } + return try results.removeFirst().get() + } + + func requests() -> [Request] { + recordedRequests + } +} + +func makeDevelopmentGoal(title: String = "개발 목표") throws -> DevelopmentGoal { + let date = Date(timeIntervalSince1970: 1_700_000_000) + return try DevelopmentGoal( + id: "goal", + title: title, + description: "설명", + status: .inProgress, + createdAt: date, + updatedAt: date, + completedAt: nil + ) +} + +func makeDevelopmentRecord( + id: String = "record", + currentVersion: DevelopmentRecord.CurrentVersion? = nil, + draft: DevelopmentRecord.Draft? = nil, + createdAt: Date = Date(timeIntervalSince1970: 1_700_000_000) +) throws -> DevelopmentRecord { + try DevelopmentRecord( + id: id, + goalId: "goal", + currentVersion: currentVersion, + draft: draft ?? makeDevelopmentRecordDraft(), + createdAt: createdAt + ) +} + +func makeConfirmedDevelopmentRecord( + id: String = "record", + versionId: String = "version" +) throws -> DevelopmentRecord { + try DevelopmentRecord( + id: id, + goalId: "goal", + currentVersion: DevelopmentRecord.CurrentVersion(id: versionId, number: 1), + draft: nil, + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ) +} + +func makeDevelopmentRecordDraft( + title: String = "기록 제목", + markdownContent: String = "# 내용", + revisionId: String = "revision" +) throws -> DevelopmentRecord.Draft { + try DevelopmentRecord.Draft( + title: title, + markdownContent: markdownContent, + baseVersionId: nil, + revisionId: revisionId, + updatedAt: Date(timeIntervalSince1970: 1_700_000_100) + ) +} + +func makeDevelopmentRecordVersion( + id: String = "version", + recordId: String = "record", + title: String = "확정 기록" +) throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: id, + recordId: recordId, + number: 1, + title: title, + markdownContent: "# 확정 내용", + kind: .initial, + sourceVersionId: nil, + confirmedAt: Date(timeIntervalSince1970: 1_700_000_200) + ) +} diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index c8536dbf..8730645a 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -417,6 +417,209 @@ } } }, + "development_goal_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Goal" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } + } + }, + "development_record_add" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Add Record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 추가" } } + } + }, + "development_record_confirm" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정하기" } } + } + }, + "development_record_confirm_hint_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "It will be saved as %@ when confirmed." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정하면 %@으로 기록돼요." } } + } + }, + "development_record_confirmed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirmed" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정" } } + } + }, + "development_record_confirmed_version_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirmed · %@" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정 · %@" } } + } + }, + "development_record_content_empty_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No Content" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성한 내용 없음" } } + } + }, + "development_record_continue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Continue Writing" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 이어서 작성" } } + } + }, + "development_record_detail_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The development record could not be loaded. Please try again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록을 불러오지 못했어요. 다시 시도해주세요." } } + } + }, + "development_record_detail_retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "다시 시도" } } + } + }, + "development_record_draft" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Draft" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "초안" } } + } + }, + "development_record_draft_status" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Draft in progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "초안 작성 중" } } + } + }, + "development_record_editor_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The development record could not be saved. Your input is still available." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록을 저장하지 못했어요. 작성한 내용은 그대로 유지돼요." } } + } + }, + "development_record_editor_mode" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Editor Mode" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성 방식" } } + } + }, + "development_record_editor_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록 작성" } } + } + }, + "development_record_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Add the first record for this goal." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 목표의 첫 번째 기록을 추가해보세요." } } + } + }, + "development_record_empty_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No Development Records" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "아직 개발 기록이 없어요" } } + } + }, + "development_record_markdown_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Markdown supported. The date is recorded automatically." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Markdown 문법을 사용할 수 있어요. 날짜는 자동으로 기록돼요." } } + } + }, + "development_record_preview" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Preview" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "미리보기" } } + } + }, + "development_record_preview_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Write Markdown content to preview it." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Markdown 내용을 작성하면 미리 볼 수 있어요." } } + } + }, + "development_record_preview_empty_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing to Preview" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "미리 볼 내용이 없어요" } } + } + }, + "development_record_result_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Result" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "구현 결과" } } + } + }, + "development_record_save" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Save" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장" } } + } + }, + "development_record_timeline_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The development record timeline could not be loaded. Please try again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록 타임라인을 불러오지 못했어요. 다시 시도해주세요." } } + } + }, + "development_record_timeline_retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "다시 시도" } } + } + }, + "development_record_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Title" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "제목" } } + } + }, + "development_record_title_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Enter a record title" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 제목을 입력하세요" } } + } + }, + "development_record_version" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Version" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "버전" } } + } + }, + "development_record_write" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Write" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성" } } + } + }, "home_select_detail" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift b/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift index a14b6822..f180f52d 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/TodoMarkdownContentView.swift @@ -9,6 +9,19 @@ import SwiftUI import Domain import MarkdownRenderer +public struct MarkdownContentView: View { + private let content: String + + public init(content: String) { + self.content = content + } + + public var body: some View { + MarkdownRendererView(markdown: content) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + struct TodoMarkdownContentView: View { let content: String let referenceItems: [Int: TodoReferenceItem] diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+.swift index 469a717f..e024bbc9 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+.swift @@ -13,6 +13,17 @@ public enum AdaptiveButtonGlassEffect { } public extension View { + func toolbarBackground(_ color: Color) -> some View { + overlay(alignment: .top) { + GeometryReader { proxy in + color + .frame(height: proxy.safeAreaInsets.top) + .offset(y: -proxy.safeAreaInsets.top) + } + .allowsHitTesting(false) + } + } + @ViewBuilder func onScrollOffsetChange(action: @escaping (CGFloat) -> Void) -> some View { if #available(iOS 18, *) { diff --git a/Application/Presentation/Project.swift b/Application/Presentation/Project.swift index 6b909831..bc587cce 100644 --- a/Application/Presentation/Project.swift +++ b/Application/Presentation/Project.swift @@ -88,6 +88,53 @@ let project = Project( ] ) ), + .target( + name: "Development", + destinations: .iOS, + product: .staticFramework, + bundleId: "com.opfic.DevLog.Development", + infoPlist: .file(path: frameworkInfoPlistPath), + sources: ["Development/Sources/**/*.swift"], + scripts: [ + DevLogScripts.swiftLint( + sourcePath: "Development/Sources", + configPath: "Development/Sources/.swiftlint.yml" + ) + ], + dependencies: [ + .project(target: "Domain", path: "../Domain"), + .project(target: "Core", path: "../Core"), + .target(name: "PresentationShared") + ], + settings: frameworkBuildSettings + ), + .target( + name: "DevelopmentTests", + destinations: .iOS, + product: .unitTests, + bundleId: "com.opfic.DevLog.DevelopmentTests", + infoPlist: .file(path: testsInfoPlistPath), + sources: ["Development/Tests/**/*.swift"], + scripts: [ + DevLogScripts.swiftLint( + sourcePath: "Development/Tests", + configPath: "Development/Tests/.swiftlint.yml" + ) + ], + dependencies: [ + .project(target: "Domain", path: "../Domain"), + .project(target: "Core", path: "../Core"), + .target(name: "Development"), + .target(name: "PresentationShared"), + thirdPartyDependency, + ], + settings: .devlog( + base: [ + "ENABLE_USER_SCRIPT_SANDBOXING": "NO", + "TEST_TARGET_NAME": "Development" + ] + ) + ), .target( name: "HomeTab", destinations: .iOS, @@ -338,6 +385,7 @@ let project = Project( ], dependencies: [ .target(name: "Entry"), + .target(name: "Development"), .target(name: "PresentationShared") ], settings: frameworkBuildSettings diff --git a/Application/Presentation/Sources/EntryExports.swift b/Application/Presentation/Sources/EntryExports.swift index cef7895c..b4ffa6d5 100644 --- a/Application/Presentation/Sources/EntryExports.swift +++ b/Application/Presentation/Sources/EntryExports.swift @@ -6,4 +6,5 @@ // @_exported import Entry +@_exported import Development @_exported import PresentationShared