From 7baa5bf0795f6ab184cc744b52590470e4fd6dee Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:15:37 +0900 Subject: [PATCH 01/36] =?UTF-8?q?chore:=20Development=20Presentation=20tar?= =?UTF-8?q?get=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Development/Sources/.swiftlint.yml | 1 + .../DevelopmentDependencyPreparation.swift | 96 +++++++++++++++++++ .../Development/Tests/.swiftlint.yml | 1 + Application/Presentation/Project.swift | 47 +++++++++ 4 files changed, 145 insertions(+) create mode 100644 Application/Presentation/Development/Sources/.swiftlint.yml create mode 100644 Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift create mode 100644 Application/Presentation/Development/Tests/.swiftlint.yml 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..b995e061 --- /dev/null +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -0,0 +1,96 @@ +// +// DevelopmentDependencyPreparation.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Domain +import PresentationShared + +public enum DevelopmentDependencyPreparation { + public static func prepare( + _ dependencies: inout DependencyValues, + fetchGoalUseCase: FetchDevelopmentGoalUseCase, + fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, + fetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase, + createRecordUseCase: CreateDevelopmentRecordUseCase, + saveRecordDraftUseCase: SaveDevelopmentRecordDraftUseCase, + confirmRecordUseCase: ConfirmDevelopmentRecordUseCase + ) { + dependencies.developmentFetchGoalUseCase = fetchGoalUseCase + dependencies.developmentFetchRecordsUseCase = fetchRecordsUseCase + dependencies.developmentFetchRecordHistoryUseCase = fetchRecordHistoryUseCase + 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 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 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/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/Project.swift b/Application/Presentation/Project.swift index 6b909831..a08a97d2 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, From 6c9c6a2f77d834b88732f3afa26c71b211d8b6dd Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:16:20 +0900 Subject: [PATCH 02/36] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=83=81=ED=83=9C=EC=99=80=20?= =?UTF-8?q?DI=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 25 +++ .../DevelopmentRecordDetailFeature.swift | 106 +++++++++++++ .../DevelopmentRecordTimelineFeature.swift | 142 ++++++++++++++++++ Application/Presentation/Project.swift | 1 + .../Presentation/Sources/EntryExports.swift | 1 + 5 files changed, 275 insertions(+) create mode 100644 Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift create mode 100644 Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 33545269..e80f826b 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,30 @@ extension AppGraph { } private extension AppGraph { + func prepareDevelopmentDependencies(_ dependencies: inout DependencyValues) { + DevelopmentDependencyPreparation.prepare( + &dependencies, + fetchGoalUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .fetchDevelopmentGoalUseCase, + fetchRecordsUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordsUseCase, + fetchRecordHistoryUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordHistoryUseCase, + createRecordUseCase: developmentGraphSet + .developmentRecordMutationUseCaseGraph + .createDevelopmentRecordUseCase, + saveRecordDraftUseCase: developmentGraphSet + .developmentRecordMutationUseCaseGraph + .saveDevelopmentRecordDraftUseCase, + confirmRecordUseCase: developmentGraphSet + .developmentRecordMutationUseCaseGraph + .confirmDevelopmentRecordUseCase + ) + } + func prepareEntryDependencies(_ dependencies: inout DependencyValues) { PresentationDependencyPreparation.prepareRoot( &dependencies, diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift new file mode 100644 index 00000000..ad030458 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift @@ -0,0 +1,106 @@ +// +// DevelopmentRecordDetailFeature.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Domain +import PresentationShared + +@Reducer +struct DevelopmentRecordDetailFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + let goalTitle: String + let record: DevelopmentRecord + var currentVersion: DevelopmentRecord.Version? + var isLoading = false + var hasLoaded = false + + init(goalTitle: String, record: DevelopmentRecord) { + self.goalTitle = goalTitle + self.record = record + } + } + + 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.hasLoaded, !state.isLoading else { break } + guard state.record.currentVersion != nil else { + state.hasLoaded = true + break + } + state.isLoading = true + return fetchEffect(record: state.record) + case .store(.loaded(let version)): + state.currentVersion = version + state.isLoading = false + state.hasLoaded = true + case .store(.failed): + state.isLoading = false + state.alert = Self.errorAlert + } + + return .none + } + .ifLet(\.\$alert, action: \.alert) + } +} + +private extension DevelopmentRecordDetailFeature { + 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/Timeline/DevelopmentRecordTimelineFeature.swift b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift new file mode 100644 index 00000000..9598a630 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift @@ -0,0 +1,142 @@ +// +// DevelopmentRecordTimelineFeature.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Domain +import Foundation +import PresentationShared + +struct DevelopmentRecordTimelineItem: 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 DevelopmentRecordTimelineFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + let goalId: String + var goalTitle = "" + var items = [DevelopmentRecordTimelineItem]() + var isLoading = false + var hasLoaded = 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: [DevelopmentRecordTimelineItem]) + case failed + } + } + + @Dependency(\.developmentFetchGoalUseCase) private var fetchGoalUseCase + @Dependency(\.developmentFetchRecordsUseCase) private var fetchRecordsUseCase + @Dependency(\.developmentFetchRecordHistoryUseCase) private var fetchRecordHistoryUseCase + + 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 + return fetchEffect(goalId: state.goalId) + case .view(.refresh): + guard !state.isLoading else { break } + state.isLoading = true + return fetchEffect(goalId: state.goalId) + case .store(.loaded(let goalTitle, let items)): + state.goalTitle = goalTitle + state.items = items + state.isLoading = false + state.hasLoaded = true + case .store(.failed): + state.isLoading = false + state.alert = Self.errorAlert + } + + return .none + } + .ifLet(\.\$alert, action: \.alert) + } +} + +private extension DevelopmentRecordTimelineFeature { + func fetchEffect(goalId: String) -> Effect { + .run { [fetchGoalUseCase, fetchRecordsUseCase, fetchRecordHistoryUseCase] send in + do { + let goal = try await fetchGoalUseCase.execute(goalId) + let records = try await fetchRecordsUseCase.execute(goalId: goalId) + var items = [DevelopmentRecordTimelineItem]() + + for record in records.sorted(by: Self.precedes) { + let currentVersion: DevelopmentRecord.Version? + if let reference = record.currentVersion { + let versions = try await fetchRecordHistoryUseCase.execute( + goalId: goalId, + recordId: record.id + ) + guard let version = versions.first(where: { $0.id == reference.id }) else { + throw DomainLayerError.developmentRecordVersionNotFound + } + currentVersion = version + } else { + currentVersion = nil + } + items.append(DevelopmentRecordTimelineItem( + record: record, + currentVersion: currentVersion + )) + } + + 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/Project.swift b/Application/Presentation/Project.swift index a08a97d2..bc587cce 100644 --- a/Application/Presentation/Project.swift +++ b/Application/Presentation/Project.swift @@ -385,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 From b5846acbb1fa85236a2d4d4d18f20a3037df7ecb Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:16:58 +0900 Subject: [PATCH 03/36] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=B4=88=EC=95=88=EA=B3=BC=20=EC=B5=9C=EC=B4=88=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=EC=83=81=ED=83=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevelopmentRecordEditorFeature.swift | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift diff --git a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift new file mode 100644 index 00000000..f0900bad --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift @@ -0,0 +1,193 @@ +// +// DevelopmentRecordEditorFeature.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Domain +import Foundation +import PresentationShared + +@Reducer +struct DevelopmentRecordEditorFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + let goalId: String + let goalTitle: String + var record: DevelopmentRecord? + var title: String + var markdownContent: String + var selectedTab = EditorTab.write + var isLoading = false + var result: Result? + + 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) { + self.goalId = goalId + self.goalTitle = goalTitle + 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) + } + + 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 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 + return saveEffect(state: state) + case .view(.confirm): + guard state.canConfirmInitialVersion else { break } + state.isLoading = true + state.result = nil + return confirmEffect(state: state) + case .store(.saved(let record)): + state.record = record + state.isLoading = false + state.result = .saved(record) + return .send(.delegate(.saved(record))) + case .store(.confirmed(let version)): + state.isLoading = false + state.result = .confirmed(version) + 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 DevelopmentRecordEditorFeature { + 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, + title: state.title, + markdownContent: state.markdownContent + ) + } else { + record = try await createRecordUseCase.execute( + goalId: state.goalId, + title: state.title, + markdownContent: state.markdownContent + ) + } + await send(.store(.saved(record))) + } catch { + await send(.store(.failed)) + } + } + } + + func confirmEffect(state: State) -> Effect { + .run { [createRecordUseCase, saveRecordDraftUseCase, confirmRecordUseCase] send in + do { + let record: DevelopmentRecord + if let recordId = state.record?.id { + record = try await saveRecordDraftUseCase.execute( + goalId: state.goalId, + recordId: recordId, + title: state.title, + markdownContent: state.markdownContent + ) + } else { + record = try await createRecordUseCase.execute( + goalId: state.goalId, + title: state.title, + markdownContent: state.markdownContent + ) + } + let version = try await confirmRecordUseCase.execute( + goalId: state.goalId, + recordId: record.id + ) + 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 + )) + } + } +} From c99a7531022845432bf296039080aa3496faad8f Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:30:20 +0900 Subject: [PATCH 04/36] =?UTF-8?q?refactor:=20Markdown=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EA=B2=BD=EA=B3=84=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/TodoMarkdownContentView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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] From 0fa86415db5e414054218381d517ee32d935e6a4 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:31:08 +0900 Subject: [PATCH 05/36] =?UTF-8?q?ui:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevelopmentRecordPresentation.swift | 19 ++ .../DevelopmentRecordTimelineView.swift | 220 ++++++++++++++++++ .../Resources/Localizable.xcstrings | 189 +++++++++++++++ 3 files changed, 428 insertions(+) create mode 100644 Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift create mode 100644 Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift diff --git a/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift b/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift new file mode 100644 index 00000000..f5073fd7 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift @@ -0,0 +1,19 @@ +// +// DevelopmentRecordPresentation.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import Foundation +import PresentationShared + +enum DevelopmentRecordPresentation { + static func text(_ key: String.LocalizationValue) -> String { + String(localized: key, bundle: PresentationResources.bundle) + } + + static func versionLabel(_ number: Int) -> String { + "v1.\(max(0, number - 1))" + } +} diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift new file mode 100644 index 00000000..2ae26e3a --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift @@ -0,0 +1,220 @@ +// +// DevelopmentRecordTimelineView.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import Domain +import PresentationShared + +public struct DevelopmentRecordTimelineView: 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: DevelopmentRecordTimelineFeature.State(goalId: goalId) + ) { + DevelopmentRecordTimelineFeature() + }) + } + + public var body: some View { + VStack(spacing: 20) { + timelineCard + if let draft = store.items.first(where: \.isDraft) { + continueButton(draft.record) + } + } + .onAppear { store.send(.view(.fetch)) } + .prominentAlert(store, state: \.alert, action: \.alert) + .sheet(item: $editorDestination) { destination in + DevelopmentRecordEditorView( + goalId: store.goalId, + goalTitle: store.goalTitle, + record: destination.record, + onCompletion: finishEditing + ) + } + .navigationDestination(item: $detailDestination) { destination in + DevelopmentRecordDetailView( + goalTitle: store.goalTitle, + record: destination.record + ) + } + } + + private var timelineCard: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text(DevelopmentRecordPresentation.text("development_record_section_title")) + .font(.title3) + Spacer() + Button { + editorDestination = EditorDestination(record: nil) + } label: { + Label( + DevelopmentRecordPresentation.text("development_record_add"), + systemImage: "plus" + ) + .font(.callout) + .foregroundStyle(Color.accent) + } + .adaptiveButtonStyle(color: .primaryContainer) + .disabled(store.isLoading) + } + + timelineContent + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 24) + .fill(Color.surface) + } + } + + @ViewBuilder + private var timelineContent: some View { + if store.isLoading, store.items.isEmpty { + ProgressView() + .tint(Color.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else if store.items.isEmpty { + ContentUnavailableView( + DevelopmentRecordPresentation.text("development_record_empty_title"), + systemImage: "doc.badge.plus", + description: Text(DevelopmentRecordPresentation.text("development_record_empty_message")) + ) + } else { + LazyVStack(spacing: 0) { + ForEach(store.items.enumerated(), id: \.element.id) { index, item in + TimelineRow( + item: item, + isLast: index == store.items.count - 1, + onSelect: { select(item) } + ) + } + } + } + } + + private func continueButton(_ record: DevelopmentRecord) -> some View { + Button { + editorDestination = EditorDestination(record: record) + } label: { + Text(DevelopmentRecordPresentation.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: DevelopmentRecordTimelineItem) { + 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: DevelopmentRecordTimelineItem + let isLast: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(alignment: .top, spacing: 14) { + 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) + .accessibilityElement(children: .combine) + } + + private var timelineIndicator: some View { + VStack(spacing: 0) { + Circle() + .fill(item.isDraft ? Color.surface : Color.accent) + .frame(width: 13, height: 13) + .overlay { + Circle() + .strokeBorder(item.isDraft ? Color.warning : Color.accent, lineWidth: 2) + } + if !isLast { + Rectangle() + .fill(Color.accent.opacity(0.45)) + .frame(width: 2, minHeight: 72) + } + } + .padding(.top, 5) + } + + 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 DevelopmentRecordPresentation.text("development_record_draft_status") + } + return String.localizedStringWithFormat( + DevelopmentRecordPresentation.text("development_record_confirmed_version_format"), + DevelopmentRecordPresentation.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/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index c8536dbf..3baca63f 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -417,6 +417,195 @@ } } }, + "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_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" : "Write 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_section_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Records" } }, + "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_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Record 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" : { From 540ef2f6172ece0f76da7f41eb7dd0b605cf5651 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:31:28 +0900 Subject: [PATCH 06/36] =?UTF-8?q?ui:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=9E=91=EC=84=B1=EA=B3=BC=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Detail/DevelopmentRecordDetailView.swift | 129 ++++++++++ .../Editor/DevelopmentRecordEditorView.swift | 232 ++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift create mode 100644 Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift new file mode 100644 index 00000000..d3b65043 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift @@ -0,0 +1,129 @@ +// +// DevelopmentRecordDetailView.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import Domain +import PresentationShared + +public struct DevelopmentRecordDetailView: View { + @State private var store: StoreOf + + public init(goalTitle: String, record: DevelopmentRecord) { + self._store = State(initialValue: Store( + initialState: DevelopmentRecordDetailFeature.State( + goalTitle: goalTitle, + record: record + ) + ) { + DevelopmentRecordDetailFeature() + }) + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + goalBadge + titleSection + contentCard + } + .padding() + } + .background(Color.appBackground.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .onAppear { store.send(.view(.fetch)) } + .prominentAlert(store, state: \.alert, action: \.alert) + .overlay { + if store.isLoading { + LoadingView() + } + } + } + + private var goalBadge: some View { + Text(store.goalTitle) + .font(.callout) + .foregroundStyle(Color.accent) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.primaryContainer, in: .capsule) + .lineLimit(1) + } + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text(title) + .font(.largeTitle.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 10) { + Text(statusText) + .font(.callout.weight(.semibold)) + .foregroundStyle(statusColor) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(statusBackground, in: .capsule) + + if let version = store.currentVersion { + Text(DevelopmentRecordPresentation.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 var contentCard: some View { + VStack(alignment: .leading, spacing: 16) { + Text(DevelopmentRecordPresentation.text("development_record_result_title")) + .font(.title2) + + if content.isEmpty { + ContentUnavailableView( + DevelopmentRecordPresentation.text("development_record_content_empty_title"), + systemImage: "doc.text" + ) + } else { + MarkdownContentView(content: content) + .frame(minHeight: 420) + } + } + .padding(24) + .background { + RoundedRectangle(cornerRadius: 24) + .fill(Color.surface) + } + } + + private var title: String { + store.currentVersion?.title ?? store.record.draft?.title ?? "" + } + + private var content: String { + store.currentVersion?.markdownContent ?? store.record.draft?.markdownContent ?? "" + } + + private var statusText: String { + store.currentVersion == nil + ? DevelopmentRecordPresentation.text("development_record_draft") + : DevelopmentRecordPresentation.text("development_record_confirmed") + } + + private var statusColor: Color { + store.currentVersion == nil ? .warning : .white + } + + private var statusBackground: Color { + store.currentVersion == nil ? .warning.opacity(0.12) : .accent + } +} diff --git a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift new file mode 100644 index 00000000..70eb66db --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift @@ -0,0 +1,232 @@ +// +// DevelopmentRecordEditorView.swift +// Development +// +// Created by opfic on 9/13/26. +// + +import SwiftUI +import Domain +import PresentationShared + +public struct DevelopmentRecordEditorView: View { + @Environment(\.dismiss) private var dismiss + @State private var store: StoreOf + @FocusState private var focusedField: Field? + private let onCompletion: () -> Void + + public init( + goalId: String, + goalTitle: String, + record: DevelopmentRecord? = nil, + onCompletion: @escaping () -> Void = { } + ) { + self._store = State(initialValue: Store( + initialState: DevelopmentRecordEditorFeature.State( + goalId: goalId, + goalTitle: goalTitle, + record: record + ) + ) { + DevelopmentRecordEditorFeature() + }) + self.onCompletion = onCompletion + } + + public var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + goalBadge + 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() + } + } + } + + private var topBar: some View { + HStack { + Button(DevelopmentRecordPresentation.text("common_close")) { + dismiss() + } + .foregroundStyle(Color.textSecondary) + Spacer() + Text(DevelopmentRecordPresentation.text("development_record_editor_title")) + .font(.headline) + Spacer() + Button(DevelopmentRecordPresentation.text("development_record_save")) { + store.send(.view(.save)) + } + .fontWeight(.semibold) + .foregroundStyle(Color.accent) + .disabled(!store.isReadyToSave) + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + + private var goalBadge: some View { + Text(store.goalTitle) + .font(.callout) + .foregroundStyle(Color.accent) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.primaryContainer, in: .capsule) + .lineLimit(1) + } + + private var versionCard: some View { + FieldCard(title: DevelopmentRecordPresentation.text("development_record_version")) { + Text(DevelopmentRecordPresentation.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: DevelopmentRecordPresentation.text("development_record_title")) { + TextField( + DevelopmentRecordPresentation.text("development_record_title_placeholder"), + text: $store.title + ) + .focused($focusedField, equals: .title) + .padding() + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + } + } + + private var contentCard: some View { + VStack(spacing: 16) { + Picker( + DevelopmentRecordPresentation.text("development_record_editor_mode"), + selection: $store.selectedTab + ) { + Text(DevelopmentRecordPresentation.text("development_record_write")) + .tag(DevelopmentRecordEditorFeature.EditorTab.write) + Text(DevelopmentRecordPresentation.text("development_record_preview")) + .tag(DevelopmentRecordEditorFeature.EditorTab.preview) + } + .pickerStyle(.segmented) + + editorContent + + Text(DevelopmentRecordPresentation.text("development_record_markdown_hint")) + .font(.caption) + .foregroundStyle(Color.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(20) + .background { + RoundedRectangle(cornerRadius: 24) + .fill(Color.surface) + } + } + + @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( + DevelopmentRecordPresentation.text("development_record_preview_empty_title"), + systemImage: "doc.text.magnifyingglass", + description: Text( + DevelopmentRecordPresentation.text("development_record_preview_empty_message") + ) + ) + } else { + MarkdownContentView(content: store.markdownContent) + } + } + .frame(minHeight: 340) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + } + } + + private var confirmBar: some View { + VStack(spacing: 8) { + Button { + store.send(.view(.confirm)) + } label: { + Group { + if store.isLoading { + ProgressView() + .tint(Color.white) + } else { + Text(DevelopmentRecordPresentation.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( + DevelopmentRecordPresentation.text("development_record_confirm_hint_format"), + DevelopmentRecordPresentation.versionLabel(store.versionNumber) + )) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.surface, ignoresSafeAreaEdges: .bottom) + } +} + +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 enum Field: Hashable { + case title + case content +} From 4e86674ac39bc0585d4e6adf1dbabf3dd2529c0b Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:34:10 +0900 Subject: [PATCH 07/36] =?UTF-8?q?test:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=83=81=ED=83=9C=20=ED=9D=90=EB=A6=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevelopmentRecordTimelineFeature.swift | 2 +- .../DevelopmentRecordDetailFeatureTests.swift | 56 +++++++ .../DevelopmentRecordEditorFeatureTests.swift | 120 +++++++++++++++ .../DevelopmentRecordFeatureTestSupport.swift | 143 ++++++++++++++++++ ...evelopmentRecordTimelineFeatureTests.swift | 83 ++++++++++ 5 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift create mode 100644 Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift create mode 100644 Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift create mode 100644 Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift index 9598a630..abc845b6 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift @@ -85,7 +85,7 @@ struct DevelopmentRecordTimelineFeature { } } -private extension DevelopmentRecordTimelineFeature { +extension DevelopmentRecordTimelineFeature { func fetchEffect(goalId: String) -> Effect { .run { [fetchGoalUseCase, fetchRecordsUseCase, fetchRecordHistoryUseCase] send in do { diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift new file mode 100644 index 00000000..deaa36a4 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift @@ -0,0 +1,56 @@ +// +// DevelopmentRecordDetailFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/13/26. +// + +import Testing +@testable import Development + +@MainActor +struct DevelopmentRecordDetailFeatureTests { + @Test("상세는 currentVersion과 일치하는 확정 버전을 표시한다") + func 상세는_currentVersion과_일치하는_확정_버전을_표시한다() async throws { + let record = try makeConfirmedDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let store = TestStore( + initialState: DevelopmentRecordDetailFeature.State( + goalTitle: "개발 목표", + record: record + ) + ) { + DevelopmentRecordDetailFeature() + } withDependencies: { + $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( + resultByRecordId: [record.id: .success([version])] + ) + } + + await store.send(.view(.fetch)) { + $0.isLoading = true + } + await store.receive(.store(.loaded(version))) { + $0.currentVersion = version + $0.isLoading = false + $0.hasLoaded = true + } + } + + @Test("확정 전 상세는 이력 조회 없이 초안을 유지한다") + func 확정_전_상세는_이력_조회_없이_초안을_유지한다() async throws { + let record = try makeDevelopmentRecord() + let store = TestStore( + initialState: DevelopmentRecordDetailFeature.State( + goalTitle: "개발 목표", + record: record + ) + ) { + DevelopmentRecordDetailFeature() + } + + await store.send(.view(.fetch)) { + $0.hasLoaded = true + } + } +} diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift new file mode 100644 index 00000000..720a0f8b --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift @@ -0,0 +1,120 @@ +// +// DevelopmentRecordEditorFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/13/26. +// + +import Testing +@testable import Development + +@MainActor +struct DevelopmentRecordEditorFeatureTests { + @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(.confirmed(version))) { + $0.isLoading = false + $0.result = .confirmed(version) + } + 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(DevelopmentRecordTestError.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(DevelopmentRecordTestError.failed), + confirmResult: .success(version) + ) + + #expect(!store.state.isReadyToSave) + #expect(!store.state.canConfirmInitialVersion) + await store.send(.view(.save)) + await store.send(.view(.confirm)) + } + + private func makeStore( + record: DevelopmentRecord? = nil, + createResult: Result, + saveResult: Result? = nil, + confirmResult: Result + ) -> TestStoreOf { + TestStore( + initialState: DevelopmentRecordEditorFeature.State( + goalId: "goal", + goalTitle: "개발 목표", + record: record + ) + ) { + DevelopmentRecordEditorFeature() + } 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/DevelopmentRecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift new file mode 100644 index 00000000..ad786706 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift @@ -0,0 +1,143 @@ +// +// DevelopmentRecordFeatureTestSupport.swift +// DevelopmentTests +// +// Created by opfic on 9/13/26. +// + +import Domain +import Foundation + +enum DevelopmentRecordTestError: Error { + case failed +} + +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(DevelopmentRecordTestError.failed)].get() + } +} + +struct CreateDevelopmentRecordUseCaseStub: CreateDevelopmentRecordUseCase { + let result: Result + + func execute( + goalId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord { + try result.get() + } +} + +struct SaveDevelopmentRecordDraftUseCaseStub: SaveDevelopmentRecordDraftUseCase { + let result: Result + + func execute( + goalId: String, + recordId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord { + try result.get() + } +} + +struct ConfirmDevelopmentRecordUseCaseStub: ConfirmDevelopmentRecordUseCase { + let result: Result + + func execute(goalId: String, recordId: String) async throws -> DevelopmentRecord.Version { + try result.get() + } +} + +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 = "# 내용" +) throws -> DevelopmentRecord.Draft { + try DevelopmentRecord.Draft( + title: title, + markdownContent: markdownContent, + baseVersionId: nil, + 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/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift new file mode 100644 index 00000000..c0ce1b24 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift @@ -0,0 +1,83 @@ +// +// DevelopmentRecordTimelineFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/13/26. +// + +import Testing +import Foundation +@testable import Development + +@MainActor +struct DevelopmentRecordTimelineFeatureTests { + @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 = [ + DevelopmentRecordTimelineItem(record: confirmedRecord, currentVersion: version), + DevelopmentRecordTimelineItem(record: draftRecord, currentVersion: nil) + ] + let store = TestStore( + initialState: DevelopmentRecordTimelineFeature.State(goalId: goal.id) + ) { + DevelopmentRecordTimelineFeature() + } withDependencies: { + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub(result: .success(goal)) + $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( + result: .success([draftRecord, confirmedRecord]) + ) + $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( + 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: DevelopmentRecordTimelineFeature.State(goalId: goal.id) + ) { + DevelopmentRecordTimelineFeature() + } withDependencies: { + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub(result: .success(goal)) + $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( + result: .failure(DevelopmentRecordTestError.failed) + ) + $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( + resultByRecordId: [:] + ) + } + + await store.send(.view(.fetch)) { + $0.isLoading = true + } + await store.receive(.store(.failed)) { + $0.isLoading = false + $0.alert = DevelopmentRecordTimelineFeature.errorAlert + } + await store.send(.view(.refresh)) { + $0.isLoading = true + } + await store.receive(.store(.failed)) { + $0.isLoading = false + } + } +} From b5bf0c2e153fa9c893414a2070581169b437e5ed Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:54:16 +0900 Subject: [PATCH 08/36] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20target=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/Detail/DevelopmentRecordDetailFeature.swift | 2 +- .../Record/Editor/DevelopmentRecordEditorFeature.swift | 2 +- .../Record/Timeline/DevelopmentRecordTimelineFeature.swift | 2 +- .../Record/Timeline/DevelopmentRecordTimelineView.swift | 4 ++-- .../Tests/Record/DevelopmentRecordDetailFeatureTests.swift | 1 + .../Tests/Record/DevelopmentRecordEditorFeatureTests.swift | 2 ++ .../Tests/Record/DevelopmentRecordTimelineFeatureTests.swift | 1 + 7 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift index ad030458..9fd4ce0f 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift @@ -66,7 +66,7 @@ struct DevelopmentRecordDetailFeature { return .none } - .ifLet(\.\$alert, action: \.alert) + .ifLet(\.$alert, action: \.alert) } } diff --git a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift index f0900bad..7cdc5122 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift @@ -117,7 +117,7 @@ struct DevelopmentRecordEditorFeature { return .none } - .ifLet(\.\$alert, action: \.alert) + .ifLet(\.$alert, action: \.alert) } } diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift index abc845b6..9229ee1d 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift @@ -81,7 +81,7 @@ struct DevelopmentRecordTimelineFeature { return .none } - .ifLet(\.\$alert, action: \.alert) + .ifLet(\.$alert, action: \.alert) } } diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift index 2ae26e3a..706ca567 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift @@ -91,7 +91,7 @@ public struct DevelopmentRecordTimelineView: View { ) } else { LazyVStack(spacing: 0) { - ForEach(store.items.enumerated(), id: \.element.id) { index, item in + ForEach(Array(store.items.enumerated()), id: \.element.id) { index, item in TimelineRow( item: item, isLast: index == store.items.count - 1, @@ -180,7 +180,7 @@ private struct TimelineRow: View { if !isLast { Rectangle() .fill(Color.accent.opacity(0.45)) - .frame(width: 2, minHeight: 72) + .frame(width: 2, height: 72) } } .padding(.top, 5) diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift index deaa36a4..b9259d76 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift @@ -6,6 +6,7 @@ // import Testing +import PresentationShared @testable import Development @MainActor diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift index 720a0f8b..5f773c7a 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift @@ -6,6 +6,8 @@ // import Testing +import Domain +import PresentationShared @testable import Development @MainActor diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift index c0ce1b24..0ba6a3fa 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import PresentationShared @testable import Development @MainActor From 2df25afcf46fa6967f6ec94042908400b911f3e1 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 20:55:32 +0900 Subject: [PATCH 09/36] =?UTF-8?q?refactor:=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20DI=20=EC=A4=80=EB=B9=84=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 12 +++++++--- .../DevelopmentDependencyPreparation.swift | 22 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index e80f826b..70219f19 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -23,17 +23,23 @@ extension AppGraph { private extension AppGraph { func prepareDevelopmentDependencies(_ dependencies: inout DependencyValues) { - DevelopmentDependencyPreparation.prepare( + DevelopmentDependencyPreparation.prepareGoal( &dependencies, fetchGoalUseCase: developmentGraphSet .developmentGoalUseCaseGraph - .fetchDevelopmentGoalUseCase, + .fetchDevelopmentGoalUseCase + ) + DevelopmentDependencyPreparation.prepareQuery( + &dependencies, fetchRecordsUseCase: developmentGraphSet .developmentRecordQueryUseCaseGraph .fetchDevelopmentRecordsUseCase, fetchRecordHistoryUseCase: developmentGraphSet .developmentRecordQueryUseCaseGraph - .fetchDevelopmentRecordHistoryUseCase, + .fetchDevelopmentRecordHistoryUseCase + ) + DevelopmentDependencyPreparation.prepareMutation( + &dependencies, createRecordUseCase: developmentGraphSet .developmentRecordMutationUseCaseGraph .createDevelopmentRecordUseCase, diff --git a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift index b995e061..90f78646 100644 --- a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -9,18 +9,28 @@ import Domain import PresentationShared public enum DevelopmentDependencyPreparation { - public static func prepare( + public static func prepareGoal( + _ dependencies: inout DependencyValues, + fetchGoalUseCase: FetchDevelopmentGoalUseCase + ) { + dependencies.developmentFetchGoalUseCase = fetchGoalUseCase + } + + public static func prepareQuery( _ dependencies: inout DependencyValues, - fetchGoalUseCase: FetchDevelopmentGoalUseCase, fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, - fetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase, + fetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase + ) { + dependencies.developmentFetchRecordsUseCase = fetchRecordsUseCase + dependencies.developmentFetchRecordHistoryUseCase = fetchRecordHistoryUseCase + } + + public static func prepareMutation( + _ dependencies: inout DependencyValues, createRecordUseCase: CreateDevelopmentRecordUseCase, saveRecordDraftUseCase: SaveDevelopmentRecordDraftUseCase, confirmRecordUseCase: ConfirmDevelopmentRecordUseCase ) { - dependencies.developmentFetchGoalUseCase = fetchGoalUseCase - dependencies.developmentFetchRecordsUseCase = fetchRecordsUseCase - dependencies.developmentFetchRecordHistoryUseCase = fetchRecordHistoryUseCase dependencies.developmentCreateRecordUseCase = createRecordUseCase dependencies.developmentSaveRecordDraftUseCase = saveRecordDraftUseCase dependencies.developmentConfirmRecordUseCase = confirmRecordUseCase From d7eb7896d8a5dd9ccba679a9b81c97a5e076094f Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 21:50:14 +0900 Subject: [PATCH 10/36] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EB=B2=84=EC=A0=84=20=EA=B3=84=EC=95=BD=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfirmDevelopmentRecordUseCase.swift | 6 +- .../ConfirmDevelopmentRecordUseCaseImpl.swift | 16 ++- .../SaveDevelopmentRecordDraftUseCase.swift | 1 + ...aveDevelopmentRecordDraftUseCaseImpl.swift | 6 +- ...lopmentRecordConcurrencyUseCaseTests.swift | 61 +++++++++++ ...opmentRecordConfirmationUseCaseTests.swift | 101 ++++++++++++++++++ .../DevelopmentRecordUseCaseTests.swift | 78 +------------- .../DevelopmentRecordEditorFeature.swift | 5 +- .../DevelopmentRecordFeatureTestSupport.swift | 7 +- 9 files changed, 198 insertions(+), 83 deletions(-) create mode 100644 Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift create mode 100644 Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift index 288b789e..e78b3373 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift @@ -6,5 +6,9 @@ // public protocol ConfirmDevelopmentRecordUseCase { - func execute(goalId: String, recordId: String) async throws -> DevelopmentRecord.Version + func execute( + goalId: String, + recordId: String, + baseVersionId: 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..3440c0d1 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift @@ -22,7 +22,11 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord self.idProvider = idProvider } - public func execute(goalId: String, recordId: String) async throws -> DevelopmentRecord.Version { + public func execute( + goalId: String, + recordId: String, + baseVersionId: String? + ) async throws -> DevelopmentRecord.Version { let goal = try await goalRepository.fetchGoal(goalId) guard goal.status == .inProgress else { throw DomainLayerError.developmentGoalIsNotInProgress @@ -32,17 +36,21 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord 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 else { + throw DomainLayerError.developmentRecordDraftConflict + } - if let currentVersion = record.currentVersion { + if let baseVersionId { return try await repository.confirmDraft( goalId: goalId, recordId: recordId, versionId: idProvider(), kind: .correction, - sourceVersionId: currentVersion.id + sourceVersionId: baseVersionId ) } diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift index a3f59842..b495a1d1 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift @@ -9,6 +9,7 @@ public protocol SaveDevelopmentRecordDraftUseCase { func execute( goalId: String, recordId: String, + baseVersionId: 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..17a65e61 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift @@ -25,6 +25,7 @@ public final class SaveDevelopmentRecordDraftUseCaseImpl: SaveDevelopmentRecordD public func execute( goalId: String, recordId: String, + baseVersionId: String?, title: String, markdownContent: String ) async throws -> DevelopmentRecord { @@ -37,10 +38,13 @@ 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 + } let updatedDraft = try DevelopmentRecord.Draft( title: title, markdownContent: markdownContent, - baseVersionId: record.currentVersion?.id, + baseVersionId: baseVersionId, updatedAt: now() ) return try await repository.saveDraft( diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift new file mode 100644 index 00000000..d4cb5758 --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift @@ -0,0 +1,61 @@ +// +// 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, + title: "오래된 초안", + markdownContent: "본문" + ) + } + #expect(await repository.draftRequests().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, + 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", + baseVersionId: nil + ) + } + #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..cd60d2d5 --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift @@ -0,0 +1,101 @@ +// +// 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), + idProvider: { "version-1" } + ) + + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + baseVersionId: nil + ) + + #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", + baseVersionId: currentVersion.id + ) + + #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", + baseVersionId: nil + ) + } + #expect(await repository.confirmRequests().isEmpty) + } +} diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift index 56a1d324..0e61f3b9 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift @@ -99,6 +99,7 @@ struct DevelopmentRecordUseCaseTests { let result = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + baseVersionId: nil, title: "수정 기록", markdownContent: "수정 본문" ) @@ -150,6 +151,7 @@ struct DevelopmentRecordUseCaseTests { _ = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + baseVersionId: currentVersion.id, title: "수정 정정 초안", markdownContent: "수정 본문" ) @@ -183,6 +185,7 @@ struct DevelopmentRecordUseCaseTests { let result = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + baseVersionId: currentVersion.id, title: "정정 초안", markdownContent: "본문" ) @@ -202,81 +205,6 @@ struct DevelopmentRecordUseCaseTests { ]) } - @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/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift index 7cdc5122..a6638ed7 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift @@ -130,6 +130,7 @@ private extension DevelopmentRecordEditorFeature { record = try await saveRecordDraftUseCase.execute( goalId: state.goalId, recordId: recordId, + baseVersionId: nil, title: state.title, markdownContent: state.markdownContent ) @@ -155,6 +156,7 @@ private extension DevelopmentRecordEditorFeature { record = try await saveRecordDraftUseCase.execute( goalId: state.goalId, recordId: recordId, + baseVersionId: nil, title: state.title, markdownContent: state.markdownContent ) @@ -167,7 +169,8 @@ private extension DevelopmentRecordEditorFeature { } let version = try await confirmRecordUseCase.execute( goalId: state.goalId, - recordId: record.id + recordId: record.id, + baseVersionId: nil ) await send(.store(.confirmed(version))) } catch { diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift index ad786706..87bde0eb 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift @@ -57,6 +57,7 @@ struct SaveDevelopmentRecordDraftUseCaseStub: SaveDevelopmentRecordDraftUseCase func execute( goalId: String, recordId: String, + baseVersionId: String?, title: String, markdownContent: String ) async throws -> DevelopmentRecord { @@ -67,7 +68,11 @@ struct SaveDevelopmentRecordDraftUseCaseStub: SaveDevelopmentRecordDraftUseCase struct ConfirmDevelopmentRecordUseCaseStub: ConfirmDevelopmentRecordUseCase { let result: Result - func execute(goalId: String, recordId: String) async throws -> DevelopmentRecord.Version { + func execute( + goalId: String, + recordId: String, + baseVersionId: String? + ) async throws -> DevelopmentRecord.Version { try result.get() } } From 2346115f862d1e7744809367a59960911987a058 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 21:55:14 +0900 Subject: [PATCH 11/36] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=ED=99=95=EC=A0=95=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfirmDevelopmentRecordUseCaseImpl.swift | 43 +++++++++--- ...opmentRecordConfirmationUseCaseTests.swift | 31 +++++++++ .../DevelopmentRecordUseCaseTestSupport.swift | 19 +++++- .../DevelopmentRecordEditorFeature.swift | 30 +++++++-- .../DevelopmentRecordEditorFeatureTests.swift | 61 +++++++++++++++++ .../DevelopmentRecordFeatureTestSupport.swift | 65 +++++++++++++++++++ 6 files changed, 231 insertions(+), 18 deletions(-) diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift index 3440c0d1..1a25b608 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift @@ -44,22 +44,45 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord throw DomainLayerError.developmentRecordDraftConflict } - if let baseVersionId { + let versionId = idProvider() + let kind = baseVersionId == nil + ? DevelopmentRecord.Version.Kind.initial + : .correction + do { return try await repository.confirmDraft( goalId: goalId, recordId: recordId, - versionId: idProvider(), - kind: .correction, + versionId: versionId, + kind: kind, sourceVersionId: baseVersionId ) + } catch { + guard let version = try? await confirmedVersion( + goalId: goalId, + recordId: recordId, + versionId: versionId + ) else { + throw error + } + return version } + } +} - return try await repository.confirmDraft( - goalId: goalId, - recordId: recordId, - versionId: idProvider(), - kind: .initial, - sourceVersionId: nil - ) +private extension ConfirmDevelopmentRecordUseCaseImpl { + func confirmedVersion( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version { + let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) + guard record.currentVersion?.id == versionId else { + throw DomainLayerError.developmentRecordVersionNotFound + } + let versions = try await repository.fetchVersions(goalId: goalId, recordId: recordId) + guard let version = versions.first(where: { $0.id == versionId }) else { + throw DomainLayerError.developmentRecordVersionNotFound + } + return version } } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift index cd60d2d5..23a3fee9 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift @@ -98,4 +98,35 @@ struct DevelopmentRecordConfirmUseCaseTests { } #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), + idProvider: { version.id } + ) + + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + baseVersionId: nil + ) + + #expect(result == version) + #expect(await repository.confirmRequests().count == 1) + #expect(await repository.versionQueries() == [ + .init(goalId: "goal-1", recordId: "record-1") + ]) + } } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift index 0ef8827a..75bfef3f 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift @@ -78,10 +78,11 @@ 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]() @@ -94,17 +95,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 +127,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] { @@ -156,6 +166,9 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { sourceVersionId: sourceVersionId ) ) + if let confirmError { + throw confirmError + } return try requiredDevelopmentRecordRepositoryResult(confirmedVersion) } diff --git a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift index a6638ed7..a648c2f8 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift @@ -70,6 +70,7 @@ struct DevelopmentRecordEditorFeature { enum StoreAction: Equatable { case saved(DevelopmentRecord) + case preparedForConfirmation(DevelopmentRecord) case confirmed(DevelopmentRecord.Version) case failed } @@ -99,12 +100,18 @@ struct DevelopmentRecordEditorFeature { guard state.canConfirmInitialVersion else { break } state.isLoading = true state.result = nil - return confirmEffect(state: state) + return prepareConfirmationEffect(state: state) case .store(.saved(let record)): state.record = record state.isLoading = false state.result = .saved(record) return .send(.delegate(.saved(record))) + case .store(.preparedForConfirmation(let record)): + state.record = record + return confirmPreparedRecordEffect( + goalId: state.goalId, + recordId: record.id + ) case .store(.confirmed(let version)): state.isLoading = false state.result = .confirmed(version) @@ -148,8 +155,8 @@ private extension DevelopmentRecordEditorFeature { } } - func confirmEffect(state: State) -> Effect { - .run { [createRecordUseCase, saveRecordDraftUseCase, confirmRecordUseCase] send in + func prepareConfirmationEffect(state: State) -> Effect { + .run { [createRecordUseCase, saveRecordDraftUseCase] send in do { let record: DevelopmentRecord if let recordId = state.record?.id { @@ -167,9 +174,22 @@ private extension DevelopmentRecordEditorFeature { markdownContent: state.markdownContent ) } + await send(.store(.preparedForConfirmation(record))) + } catch { + await send(.store(.failed)) + } + } + } + + func confirmPreparedRecordEffect( + goalId: String, + recordId: String + ) -> Effect { + .run { [confirmRecordUseCase] send in + do { let version = try await confirmRecordUseCase.execute( - goalId: state.goalId, - recordId: record.id, + goalId: goalId, + recordId: recordId, baseVersionId: nil ) await send(.store(.confirmed(version))) diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift index 5f773c7a..3bac9f1a 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift @@ -44,6 +44,9 @@ struct DevelopmentRecordEditorFeatureTests { await store.send(.view(.confirm)) { $0.isLoading = true } + await store.receive(.store(.preparedForConfirmation(record))) { + $0.record = record + } await store.receive(.store(.confirmed(version))) { $0.isLoading = false $0.result = .confirmed(version) @@ -95,6 +98,64 @@ struct DevelopmentRecordEditorFeatureTests { await store.send(.view(.confirm)) } + @Test("확정 실패 뒤 재시도는 생성된 기록을 다시 사용한다") + func 확정_실패_뒤_재시도는_생성된_기록을_다시_사용한다() async throws { + let record = try makeDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let createSpy = CreateDevelopmentRecordUseCaseSpy(result: .success(record)) + let confirmSpy = ConfirmDevelopmentRecordUseCaseSpy(results: [ + .failure(DevelopmentRecordTestError.failed), + .success(version) + ]) + let store = TestStore( + initialState: DevelopmentRecordEditorFeature.State( + goalId: "goal", + goalTitle: "개발 목표" + ) + ) { + DevelopmentRecordEditorFeature() + } 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 + } + await store.receive(.store(.failed)) { + $0.isLoading = false + $0.alert = DevelopmentRecordEditorFeature.errorAlert + } + await store.send(.alert(.dismiss)) { + $0.alert = nil + } + await store.send(.view(.confirm)) { + $0.isLoading = true + $0.result = nil + } + await store.receive(.store(.preparedForConfirmation(record))) + await store.receive(.store(.confirmed(version))) { + $0.isLoading = false + $0.result = .confirmed(version) + } + await store.receive(.delegate(.confirmed(version))) + + #expect(await createSpy.requests().count == 1) + #expect(await confirmSpy.requests() == [ + .init(goalId: "goal", recordId: record.id, baseVersionId: nil), + .init(goalId: "goal", recordId: record.id, baseVersionId: nil) + ]) + } + private func makeStore( record: DevelopmentRecord? = nil, createResult: Result, diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift index 87bde0eb..22af7f1e 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift @@ -77,6 +77,71 @@ struct ConfirmDevelopmentRecordUseCaseStub: ConfirmDevelopmentRecordUseCase { } } +actor CreateDevelopmentRecordUseCaseSpy: CreateDevelopmentRecordUseCase { + struct Request: Equatable { + let goalId: String + let title: String + let markdownContent: String + } + + private let result: Result + private var recordedRequests = [Request]() + + init(result: Result) { + self.result = result + } + + func execute( + goalId: String, + title: String, + markdownContent: String + ) async throws -> DevelopmentRecord { + recordedRequests.append(.init( + goalId: goalId, + title: title, + markdownContent: markdownContent + )) + return try result.get() + } + + func requests() -> [Request] { + recordedRequests + } +} + +actor ConfirmDevelopmentRecordUseCaseSpy: ConfirmDevelopmentRecordUseCase { + struct Request: Equatable { + let goalId: String + let recordId: String + let baseVersionId: String? + } + + private var results: [Result] + private var recordedRequests = [Request]() + + init(results: [Result]) { + self.results = results + } + + func execute( + goalId: String, + recordId: String, + baseVersionId: String? + ) async throws -> DevelopmentRecord.Version { + recordedRequests.append(.init( + goalId: goalId, + recordId: recordId, + baseVersionId: baseVersionId + )) + guard !results.isEmpty else { throw DevelopmentRecordTestError.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( From db34014eee198e07d04590a7972fa4f1b7bd0462 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 21:57:48 +0900 Subject: [PATCH 12/36] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=83=81=EC=84=B8=20=EC=8B=A4=ED=8C=A8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevelopmentRecordDetailFeature.swift | 38 +++++--- .../Detail/DevelopmentRecordDetailView.swift | 88 ++++++++++++++----- .../DevelopmentRecordDetailFeatureTests.swift | 43 +++++++-- .../Resources/Localizable.xcstrings | 7 ++ 4 files changed, 136 insertions(+), 40 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift index 9fd4ce0f..eaf8461e 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift @@ -15,16 +15,31 @@ struct DevelopmentRecordDetailFeature { @Presents var alert: AlertState? let goalTitle: String let record: DevelopmentRecord - var currentVersion: DevelopmentRecord.Version? - var isLoading = false - var hasLoaded = false + 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) @@ -35,7 +50,7 @@ struct DevelopmentRecordDetailFeature { } enum StoreAction: Equatable { - case loaded(DevelopmentRecord.Version?) + case loaded(DevelopmentRecord.Version) case failed } } @@ -48,19 +63,14 @@ struct DevelopmentRecordDetailFeature { case .alert: break case .view(.fetch): - guard !state.hasLoaded, !state.isLoading else { break } - guard state.record.currentVersion != nil else { - state.hasLoaded = true - break - } - state.isLoading = true + 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.currentVersion = version - state.isLoading = false - state.hasLoaded = true + state.contentState = .confirmed(version) case .store(.failed): - state.isLoading = false + state.contentState = .failed state.alert = Self.errorAlert } diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift index d3b65043..16d14456 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift @@ -27,8 +27,7 @@ public struct DevelopmentRecordDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 20) { goalBadge - titleSection - contentCard + detailContent } .padding() } @@ -53,21 +52,56 @@ public struct DevelopmentRecordDetailView: View { .lineLimit(1) } - private var titleSection: some View { + @ViewBuilder + private var detailContent: some View { + 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) + } + } + + @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) { Text(title) .font(.largeTitle.bold()) .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 10) { - Text(statusText) + Text(statusText(isDraft: version == nil)) .font(.callout.weight(.semibold)) - .foregroundStyle(statusColor) + .foregroundStyle(statusColor(isDraft: version == nil)) .padding(.horizontal, 12) .padding(.vertical, 7) - .background(statusBackground, in: .capsule) + .background(statusBackground(isDraft: version == nil), in: .capsule) - if let version = store.currentVersion { + if let version { Text(DevelopmentRecordPresentation.versionLabel(version.number)) .font(.callout.weight(.semibold)) .foregroundStyle(Color.accent) @@ -83,18 +117,18 @@ public struct DevelopmentRecordDetailView: View { } } - private var contentCard: some View { + private func contentCard(markdownContent: String) -> some View { VStack(alignment: .leading, spacing: 16) { Text(DevelopmentRecordPresentation.text("development_record_result_title")) .font(.title2) - if content.isEmpty { + if markdownContent.isEmpty { ContentUnavailableView( DevelopmentRecordPresentation.text("development_record_content_empty_title"), systemImage: "doc.text" ) } else { - MarkdownContentView(content: content) + MarkdownContentView(content: markdownContent) .frame(minHeight: 420) } } @@ -105,25 +139,37 @@ public struct DevelopmentRecordDetailView: View { } } - private var title: String { - store.currentVersion?.title ?? store.record.draft?.title ?? "" - } + private var failureContent: some View { + VStack(spacing: 20) { + ContentUnavailableView( + DevelopmentRecordPresentation.text("common_error_title"), + systemImage: "exclamationmark.triangle", + description: Text( + DevelopmentRecordPresentation.text("development_record_detail_error_message") + ) + ) - private var content: String { - store.currentVersion?.markdownContent ?? store.record.draft?.markdownContent ?? "" + Button(DevelopmentRecordPresentation.text("development_record_detail_retry")) { + store.send(.view(.fetch)) + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity) + .padding(24) + .background(Color.surface, in: .rect(cornerRadius: 24)) } - private var statusText: String { - store.currentVersion == nil + private func statusText(isDraft: Bool) -> String { + isDraft ? DevelopmentRecordPresentation.text("development_record_draft") : DevelopmentRecordPresentation.text("development_record_confirmed") } - private var statusColor: Color { - store.currentVersion == nil ? .warning : .white + private func statusColor(isDraft: Bool) -> Color { + isDraft ? .warning : .white } - private var statusBackground: Color { - store.currentVersion == nil ? .warning.opacity(0.12) : .accent + private func statusBackground(isDraft: Bool) -> Color { + isDraft ? .warning.opacity(0.12) : .accent } } diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift index b9259d76..61deba3a 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift @@ -29,12 +29,10 @@ struct DevelopmentRecordDetailFeatureTests { } await store.send(.view(.fetch)) { - $0.isLoading = true + $0.contentState = .loading } await store.receive(.store(.loaded(version))) { - $0.currentVersion = version - $0.isLoading = false - $0.hasLoaded = true + $0.contentState = .confirmed(version) } } @@ -50,8 +48,43 @@ struct DevelopmentRecordDetailFeatureTests { DevelopmentRecordDetailFeature() } + 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: DevelopmentRecordDetailFeature.State( + goalTitle: "개발 목표", + record: record + ) + ) { + DevelopmentRecordDetailFeature() + } withDependencies: { + $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( + resultByRecordId: [record.id: .failure(DevelopmentRecordTestError.failed)] + ) + } + await store.send(.view(.fetch)) { - $0.hasLoaded = true + $0.contentState = .loading + } + await store.receive(.store(.failed)) { + $0.contentState = .failed + $0.alert = DevelopmentRecordDetailFeature.errorAlert + } + 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 = DevelopmentRecordDetailFeature.errorAlert } } } diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 3baca63f..7330d75b 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -473,6 +473,13 @@ "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" : { From 34c930ae354c2e6ca60db5f2192b27a1c5a48f67 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 21:59:13 +0900 Subject: [PATCH 13/36] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=9A=94=EC=B2=AD=20=EC=A4=91=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=EC=9E=A0=EA=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/Editor/DevelopmentRecordEditorView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift index 70eb66db..2f683938 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift @@ -69,6 +69,7 @@ public struct DevelopmentRecordEditorView: View { .font(.headline) Spacer() Button(DevelopmentRecordPresentation.text("development_record_save")) { + focusedField = nil store.send(.view(.save)) } .fontWeight(.semibold) @@ -110,6 +111,7 @@ public struct DevelopmentRecordEditorView: View { .padding() .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) } + .disabled(store.isLoading) } private var contentCard: some View { @@ -137,6 +139,7 @@ public struct DevelopmentRecordEditorView: View { RoundedRectangle(cornerRadius: 24) .fill(Color.surface) } + .disabled(store.isLoading) } @ViewBuilder @@ -172,6 +175,7 @@ public struct DevelopmentRecordEditorView: View { private var confirmBar: some View { VStack(spacing: 8) { Button { + focusedField = nil store.send(.view(.confirm)) } label: { Group { From 0c954baf9ed9b34b7a29ae5097fbe81fa94ee77f Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 22:06:42 +0900 Subject: [PATCH 14/36] =?UTF-8?q?test:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=8B=A4=ED=8C=A8=20=EC=83=81=ED=83=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevelopmentRecordDetailFeatureTests.swift | 4 ++-- .../DevelopmentRecordEditorFeatureTests.swift | 2 +- .../DevelopmentRecordFeatureTestSupport.swift | 13 +++++++++++++ .../DevelopmentRecordTimelineFeatureTests.swift | 2 +- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift index 61deba3a..f60a3291 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift @@ -74,7 +74,7 @@ struct DevelopmentRecordDetailFeatureTests { } await store.receive(.store(.failed)) { $0.contentState = .failed - $0.alert = DevelopmentRecordDetailFeature.errorAlert + $0.alert = makeDevelopmentRecordErrorAlert("development_record_detail_error_message") } await store.send(.alert(.dismiss)) { $0.alert = nil @@ -84,7 +84,7 @@ struct DevelopmentRecordDetailFeatureTests { } await store.receive(.store(.failed)) { $0.contentState = .failed - $0.alert = DevelopmentRecordDetailFeature.errorAlert + $0.alert = makeDevelopmentRecordErrorAlert("development_record_detail_error_message") } } } diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift index 3bac9f1a..3c3afdb1 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift @@ -133,7 +133,7 @@ struct DevelopmentRecordEditorFeatureTests { } await store.receive(.store(.failed)) { $0.isLoading = false - $0.alert = DevelopmentRecordEditorFeature.errorAlert + $0.alert = makeDevelopmentRecordErrorAlert("development_record_editor_error_message") } await store.send(.alert(.dismiss)) { $0.alert = nil diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift index 22af7f1e..2ab42ae8 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift @@ -7,11 +7,24 @@ import Domain import Foundation +import PresentationShared enum DevelopmentRecordTestError: Error { case failed } +func makeDevelopmentRecordErrorAlert(_ 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 diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift b/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift index 0ba6a3fa..def29d04 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift @@ -72,7 +72,7 @@ struct DevelopmentRecordTimelineFeatureTests { } await store.receive(.store(.failed)) { $0.isLoading = false - $0.alert = DevelopmentRecordTimelineFeature.errorAlert + $0.alert = makeDevelopmentRecordErrorAlert("development_record_timeline_error_message") } await store.send(.view(.refresh)) { $0.isLoading = true From b8a5ac4af7959dca6fc4619e63d8aa7c2b2ca77c Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 13 Sep 2026 23:50:05 +0900 Subject: [PATCH 15/36] =?UTF-8?q?ui:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EC=A0=9C=EB=AA=A9=20=EC=B6=95?= =?UTF-8?q?=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PresentationShared/Resources/Localizable.xcstrings | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 7330d75b..178f42bf 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -511,7 +511,7 @@ "development_record_editor_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Write Development Record" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Record" } }, "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록 작성" } } } }, @@ -588,8 +588,8 @@ "development_record_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Record Title" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 제목" } } + "en" : { "stringUnit" : { "state" : "translated", "value" : "Title" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "제목" } } } }, "development_record_title_placeholder" : { From ffb9f98a9b3e23790116d6f86984b6d12836e8f8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 00:35:23 +0900 Subject: [PATCH 16/36] =?UTF-8?q?ui:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=B2=84=EC=A0=84=20=EC=88=9C=EB=B2=88=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/DevelopmentRecordPresentation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift b/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift index f5073fd7..c492d5e5 100644 --- a/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift +++ b/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift @@ -14,6 +14,6 @@ enum DevelopmentRecordPresentation { } static func versionLabel(_ number: Int) -> String { - "v1.\(max(0, number - 1))" + "#\(number)" } } From c226c45240dfb325e69c80275222f8293c5a283f Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 01:01:22 +0900 Subject: [PATCH 17/36] =?UTF-8?q?ui:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EC=9C=84=EA=B3=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevelopmentRecordTimelineView.swift | 21 +++++++++++++++++-- .../Resources/Localizable.xcstrings | 14 ++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift index 706ca567..d4de3577 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift @@ -24,6 +24,7 @@ public struct DevelopmentRecordTimelineView: View { public var body: some View { VStack(spacing: 20) { + titleBar timelineCard if let draft = store.items.first(where: \.isDraft) { continueButton(draft.record) @@ -47,11 +48,27 @@ public struct DevelopmentRecordTimelineView: View { } } + private var titleBar: some View { + VStack(alignment: .leading, spacing: 8) { + Text(DevelopmentRecordPresentation.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) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + private var timelineCard: some View { VStack(alignment: .leading, spacing: 18) { HStack { - Text(DevelopmentRecordPresentation.text("development_record_section_title")) - .font(.title3) Spacer() Button { editorDestination = EditorDestination(record: nil) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 178f42bf..cdbfa414 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -417,6 +417,13 @@ } } }, + "development_goal_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Goal" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } + } + }, "development_record_add" : { "extractionState" : "manual", "localizations" : { @@ -571,13 +578,6 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장" } } } }, - "development_record_section_title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Records" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록" } } - } - }, "development_record_timeline_error_message" : { "extractionState" : "manual", "localizations" : { From 7513d4ad2fca891612962cd654e81e02deb54e6c Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 01:03:31 +0900 Subject: [PATCH 18/36] =?UTF-8?q?ui:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/Detail/DevelopmentRecordDetailView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift index 16d14456..9d0f76eb 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift @@ -119,9 +119,6 @@ public struct DevelopmentRecordDetailView: View { private func contentCard(markdownContent: String) -> some View { VStack(alignment: .leading, spacing: 16) { - Text(DevelopmentRecordPresentation.text("development_record_result_title")) - .font(.title2) - if markdownContent.isEmpty { ContentUnavailableView( DevelopmentRecordPresentation.text("development_record_content_empty_title"), From 7889ba6342cbbf13fb9ab402aa56d05f7bb95670 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 01:03:46 +0900 Subject: [PATCH 19/36] =?UTF-8?q?ui:=20=EB=A7=88=ED=81=AC=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EC=83=81=ED=95=98=20?= =?UTF-8?q?=ED=8C=A8=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/Detail/DevelopmentRecordDetailView.swift | 2 +- .../Sources/Record/Editor/DevelopmentRecordEditorView.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift index 9d0f76eb..479181cf 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift @@ -129,7 +129,7 @@ public struct DevelopmentRecordDetailView: View { .frame(minHeight: 420) } } - .padding(24) + .padding(.vertical, 16) .background { RoundedRectangle(cornerRadius: 24) .fill(Color.surface) diff --git a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift index 2f683938..1da392c8 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift @@ -165,6 +165,7 @@ public struct DevelopmentRecordEditorView: View { ) } else { MarkdownContentView(content: store.markdownContent) + .padding(.vertical, 16) } } .frame(minHeight: 340) From a8a13447a9294ecdd7cf8c166535e9e33ece484e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 01:08:26 +0900 Subject: [PATCH 20/36] =?UTF-8?q?ui:=20Button=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/Timeline/DevelopmentRecordTimelineView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift index d4de3577..fd82f69f 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift @@ -152,8 +152,9 @@ private struct TimelineRow: View { let onSelect: () -> Void var body: some View { - Button(action: onSelect) { - HStack(alignment: .top, spacing: 14) { + Button { + onSelect() + } label: { timelineIndicator VStack(alignment: .leading, spacing: 5) { Text(item.title) From 61a3414fb58b86b5c2397199d1bfba4f7b714b9f Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 01:08:39 +0900 Subject: [PATCH 21/36] =?UTF-8?q?ui:=20=EC=84=A4=EA=B3=84=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/Timeline/DevelopmentRecordTimelineView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift index fd82f69f..b143ca6a 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift @@ -183,7 +183,6 @@ private struct TimelineRow: View { .contentShape(.rect) } .buttonStyle(.plain) - .accessibilityElement(children: .combine) } private var timelineIndicator: some View { From 1ba773a41bb756bdc92cfd366a2d3b02044c64ab Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 08:49:11 +0900 Subject: [PATCH 22/36] =?UTF-8?q?ui:=20=EC=83=81=EB=8B=A8=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=20=EC=98=81=EC=97=AD=20=EB=B0=B0=EA=B2=BD=20=EB=AA=A8?= =?UTF-8?q?=EB=94=94=ED=8C=8C=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PresentationShared/Sources/Extension/View+.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) 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, *) { From 8b786d29f88b4881d8ce49109762e0031b1a9dee Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 08:55:10 +0900 Subject: [PATCH 23/36] =?UTF-8?q?ui:=20=ED=8C=8C=EC=9D=BC=20=EB=A6=AC?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20+=20=EC=9D=BC=EB=B6=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...imelineView.swift => GoalDetailView.swift} | 106 +++++++++--------- 1 file changed, 55 insertions(+), 51 deletions(-) rename Application/Presentation/Development/Sources/Record/Timeline/{DevelopmentRecordTimelineView.swift => GoalDetailView.swift} (72%) diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift b/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift similarity index 72% rename from Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift rename to Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift index b143ca6a..f70acfbd 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineView.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordTimelineView.swift +// GoalDetailView.swift // Development // // Created by opfic on 9/13/26. @@ -9,7 +9,7 @@ import SwiftUI import Domain import PresentationShared -public struct DevelopmentRecordTimelineView: View { +public struct GoalDetailView: View { @State private var store: StoreOf @State private var editorDestination: EditorDestination? @State private var detailDestination: DetailDestination? @@ -23,13 +23,20 @@ public struct DevelopmentRecordTimelineView: View { } public var body: some View { - VStack(spacing: 20) { - titleBar - timelineCard - if let draft = store.items.first(where: \.isDraft) { - continueButton(draft.record) + 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 @@ -46,6 +53,7 @@ public struct DevelopmentRecordTimelineView: View { record: destination.record ) } + .toolbarBackground(Color.appBackground) } private var titleBar: some View { @@ -61,62 +69,57 @@ public struct DevelopmentRecordTimelineView: View { .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(alignment: .leading, spacing: 18) { - HStack { - Spacer() - Button { - editorDestination = EditorDestination(record: nil) - } label: { - Label( - DevelopmentRecordPresentation.text("development_record_add"), - systemImage: "plus" - ) - .font(.callout) - .foregroundStyle(Color.accent) + VStack(spacing: 18) { + if store.isLoading, store.items.isEmpty { + ProgressView() + .tint(Color.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else if store.items.isEmpty { + ContentUnavailableView( + DevelopmentRecordPresentation.text("development_record_empty_title"), + systemImage: "doc.badge.plus", + description: Text(DevelopmentRecordPresentation.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) } + ) + } } - .adaptiveButtonStyle(color: .primaryContainer) - .disabled(store.isLoading) } - - timelineContent } - .padding(20) + .padding() .background { RoundedRectangle(cornerRadius: 24) .fill(Color.surface) } - } - - @ViewBuilder - private var timelineContent: some View { - if store.isLoading, store.items.isEmpty { - ProgressView() - .tint(Color.accent) - .frame(maxWidth: .infinity) - .padding(.vertical, 32) - } else if store.items.isEmpty { - ContentUnavailableView( - DevelopmentRecordPresentation.text("development_record_empty_title"), - systemImage: "doc.badge.plus", - description: Text(DevelopmentRecordPresentation.text("development_record_empty_message")) + Button { + editorDestination = EditorDestination(record: nil) + } label: { + Label( + DevelopmentRecordPresentation.text("development_record_add"), + systemImage: "plus" ) - } 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) } - ) - } - } + .font(.callout) + .foregroundStyle(Color.accent) } + .adaptiveButtonStyle(color: .primaryContainer) + .disabled(store.isLoading) } private func continueButton(_ record: DevelopmentRecord) -> some View { @@ -155,6 +158,7 @@ private struct TimelineRow: View { Button { onSelect() } label: { + HStack(alignment: .top, spacing: 16) { timelineIndicator VStack(alignment: .leading, spacing: 5) { Text(item.title) @@ -188,11 +192,11 @@ private struct TimelineRow: View { private var timelineIndicator: some View { VStack(spacing: 0) { Circle() - .fill(item.isDraft ? Color.surface : Color.accent) + .fill(item.isDraft ? Color.surface : .accent) .frame(width: 13, height: 13) .overlay { Circle() - .strokeBorder(item.isDraft ? Color.warning : Color.accent, lineWidth: 2) + .strokeBorder(item.isDraft ? Color.warning : .accent, lineWidth: 2) } if !isLast { Rectangle() @@ -200,7 +204,7 @@ private struct TimelineRow: View { .frame(width: 2, height: 72) } } - .padding(.top, 5) + .padding(.top, 3) } private var status: some View { From 916f768c01cf7aa2eff2b8ee52f01c769ac2c1e2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 09:12:48 +0900 Subject: [PATCH 24/36] =?UTF-8?q?ui:=20=EA=B8=B0=EB=A1=9D=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20View=20=EC=9D=B4=EB=A6=84=EA=B3=BC=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EC=9C=84=EA=B3=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...etailView.swift => RecordDetailView.swift} | 67 ++++++++----------- .../Record/Timeline/GoalDetailView.swift | 2 +- 2 files changed, 28 insertions(+), 41 deletions(-) rename Application/Presentation/Development/Sources/Record/Detail/{DevelopmentRecordDetailView.swift => RecordDetailView.swift} (77%) diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift similarity index 77% rename from Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift rename to Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift index 479181cf..e40f0ba4 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordDetailView.swift +// RecordDetailView.swift // Development // // Created by opfic on 9/13/26. @@ -9,7 +9,7 @@ import SwiftUI import Domain import PresentationShared -public struct DevelopmentRecordDetailView: View { +public struct RecordDetailView: View { @State private var store: StoreOf public init(goalTitle: String, record: DevelopmentRecord) { @@ -25,9 +25,25 @@ public struct DevelopmentRecordDetailView: View { public var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 20) { - goalBadge - detailContent + 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() } @@ -42,38 +58,6 @@ public struct DevelopmentRecordDetailView: View { } } - private var goalBadge: some View { - Text(store.goalTitle) - .font(.callout) - .foregroundStyle(Color.accent) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Color.primaryContainer, in: .capsule) - .lineLimit(1) - } - - @ViewBuilder - private var detailContent: some View { - 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) - } - } - @ViewBuilder private func recordContent( title: String, @@ -89,9 +73,12 @@ public struct DevelopmentRecordDetailView: View { version: DevelopmentRecord.Version? ) -> some View { VStack(alignment: .leading, spacing: 14) { - Text(title) - .font(.largeTitle.bold()) - .frame(maxWidth: .infinity, alignment: .leading) + ScrollView(.horizontal) { + Text(title) + .font(.title.bold()) + .lineLimit(1) + } + .scrollIndicators(.hidden) HStack(spacing: 10) { Text(statusText(isDraft: version == nil)) diff --git a/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift index f70acfbd..3a34b8df 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift @@ -48,7 +48,7 @@ public struct GoalDetailView: View { ) } .navigationDestination(item: $detailDestination) { destination in - DevelopmentRecordDetailView( + RecordDetailView( goalTitle: store.goalTitle, record: destination.record ) From f86e74832003ce95ce8d695ca757fe94fdae9cd3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 09:20:07 +0900 Subject: [PATCH 25/36] =?UTF-8?q?ui:=20=EB=B2=84=ED=8A=BC=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/Timeline/GoalDetailView.swift | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift index 3a34b8df..70d58271 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift @@ -40,7 +40,7 @@ public struct GoalDetailView: View { .onAppear { store.send(.view(.fetch)) } .prominentAlert(store, state: \.alert, action: \.alert) .sheet(item: $editorDestination) { destination in - DevelopmentRecordEditorView( + RecordEditorView( goalId: store.goalId, goalTitle: store.goalTitle, record: destination.record, @@ -102,24 +102,25 @@ public struct GoalDetailView: View { } } } + + Button { + editorDestination = EditorDestination(record: nil) + } label: { + Label( + DevelopmentRecordPresentation.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) } - Button { - editorDestination = EditorDestination(record: nil) - } label: { - Label( - DevelopmentRecordPresentation.text("development_record_add"), - systemImage: "plus" - ) - .font(.callout) - .foregroundStyle(Color.accent) - } - .adaptiveButtonStyle(color: .primaryContainer) - .disabled(store.isLoading) } private func continueButton(_ record: DevelopmentRecord) -> some View { From 0d43dad49951ce8f83091401cdec63c5a91746dc Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 09:49:09 +0900 Subject: [PATCH 26/36] =?UTF-8?q?ui:=20=EA=B8=B0=EB=A1=9D=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20View=20=EB=AA=85=EC=B9=AD=EA=B3=BC=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ditorView.swift => RecordEditorView.swift} | 123 +++++++++++++----- 1 file changed, 94 insertions(+), 29 deletions(-) rename Application/Presentation/Development/Sources/Record/Editor/{DevelopmentRecordEditorView.swift => RecordEditorView.swift} (67%) diff --git a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift similarity index 67% rename from Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift rename to Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index 1da392c8..bb097add 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordEditorView.swift +// RecordEditorView.swift // Development // // Created by opfic on 9/13/26. @@ -9,10 +9,14 @@ import SwiftUI import Domain import PresentationShared -public struct DevelopmentRecordEditorView: View { +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( @@ -36,8 +40,7 @@ public struct DevelopmentRecordEditorView: View { public var body: some View { NavigationStack { ScrollView { - VStack(alignment: .leading, spacing: 20) { - goalBadge + LazyVStack(alignment: .leading, spacing: 20) { versionCard titleCard contentCard @@ -60,20 +63,37 @@ public struct DevelopmentRecordEditorView: View { private var topBar: some View { HStack { - Button(DevelopmentRecordPresentation.text("common_close")) { + Button { dismiss() + } label: { + if #available(iOS 26.0, *) { + Image(systemName: "xmark") + .frame(width: iconSize, height: iconSize) + } else { + Text(DevelopmentRecordPresentation.text("common_close")) + } } - .foregroundStyle(Color.textSecondary) + .font(.title) + .topBarButtonStyle() Spacer() Text(DevelopmentRecordPresentation.text("development_record_editor_title")) .font(.headline) Spacer() - Button(DevelopmentRecordPresentation.text("development_record_save")) { + 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(DevelopmentRecordPresentation.text("development_record_save")) + .foregroundStyle(Color.accent) + } } - .fontWeight(.semibold) - .foregroundStyle(Color.accent) + .font(.title) + .topBarButtonStyle(color: Color.surface) .disabled(!store.isReadyToSave) } .padding(.horizontal) @@ -81,16 +101,6 @@ public struct DevelopmentRecordEditorView: View { .background(Color.appBackground, ignoresSafeAreaEdges: .top) } - private var goalBadge: some View { - Text(store.goalTitle) - .font(.callout) - .foregroundStyle(Color.accent) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Color.primaryContainer, in: .capsule) - .lineLimit(1) - } - private var versionCard: some View { FieldCard(title: DevelopmentRecordPresentation.text("development_record_version")) { Text(DevelopmentRecordPresentation.versionLabel(store.versionNumber)) @@ -116,16 +126,7 @@ public struct DevelopmentRecordEditorView: View { private var contentCard: some View { VStack(spacing: 16) { - Picker( - DevelopmentRecordPresentation.text("development_record_editor_mode"), - selection: $store.selectedTab - ) { - Text(DevelopmentRecordPresentation.text("development_record_write")) - .tag(DevelopmentRecordEditorFeature.EditorTab.write) - Text(DevelopmentRecordPresentation.text("development_record_preview")) - .tag(DevelopmentRecordEditorFeature.EditorTab.preview) - } - .pickerStyle(.segmented) + ModePicker(store: store, focusedField: _focusedField) editorContent @@ -208,6 +209,17 @@ public struct DevelopmentRecordEditorView: View { } } +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 @@ -231,6 +243,59 @@ private struct FieldCard: View { } } +private struct ModePicker: View { + @Bindable var store: StoreOf + @FocusState var focusedField: Field? + + var body: some View { + HStack(spacing: 0) { + modeButton( + DevelopmentRecordPresentation.text("development_record_write"), + tab: .write + ) + modeButton( + DevelopmentRecordPresentation.text("development_record_preview"), + tab: .preview + ) + } + .padding(2) + .background(Color.border, in: RoundedRectangle(cornerRadius: 16)) + } + + private func modeButton( + _ title: String, + tab: DevelopmentRecordEditorFeature.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 From 9313954bc31eb4216a07e17e8a528aba35d985ec Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 09:49:23 +0900 Subject: [PATCH 27/36] =?UTF-8?q?ui:=20=EA=B8=B0=EB=A1=9D=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PresentationShared/Resources/Localizable.xcstrings | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index cdbfa414..d42e1780 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -539,8 +539,8 @@ "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 지원 · 날짜는 자동으로 기록돼요." } } + "en" : { "stringUnit" : { "state" : "translated", "value" : "Markdown supported. The date is recorded automatically." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Markdown 문법을 사용할 수 있어요. 날짜는 자동으로 기록돼요." } } } }, "development_record_preview" : { From e309e78a83f3b9298d8302f2aa6edb4fd50d6ad6 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 10:06:39 +0900 Subject: [PATCH 28/36] =?UTF-8?q?refactor:=20RecordPresentation=20?= =?UTF-8?q?=EB=AA=85=EC=B9=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/Detail/RecordDetailView.swift | 14 ++++----- .../Record/Editor/RecordEditorView.swift | 30 +++++++++---------- ...ntation.swift => RecordPresentation.swift} | 4 +-- .../Record/Timeline/GoalDetailView.swift | 16 +++++----- 4 files changed, 32 insertions(+), 32 deletions(-) rename Application/Presentation/Development/Sources/Record/{DevelopmentRecordPresentation.swift => RecordPresentation.swift} (81%) diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift index e40f0ba4..ae8fb308 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift @@ -89,7 +89,7 @@ public struct RecordDetailView: View { .background(statusBackground(isDraft: version == nil), in: .capsule) if let version { - Text(DevelopmentRecordPresentation.versionLabel(version.number)) + Text(RecordPresentation.versionLabel(version.number)) .font(.callout.weight(.semibold)) .foregroundStyle(Color.accent) .padding(.horizontal, 12) @@ -108,7 +108,7 @@ public struct RecordDetailView: View { VStack(alignment: .leading, spacing: 16) { if markdownContent.isEmpty { ContentUnavailableView( - DevelopmentRecordPresentation.text("development_record_content_empty_title"), + RecordPresentation.text("development_record_content_empty_title"), systemImage: "doc.text" ) } else { @@ -126,14 +126,14 @@ public struct RecordDetailView: View { private var failureContent: some View { VStack(spacing: 20) { ContentUnavailableView( - DevelopmentRecordPresentation.text("common_error_title"), + RecordPresentation.text("common_error_title"), systemImage: "exclamationmark.triangle", description: Text( - DevelopmentRecordPresentation.text("development_record_detail_error_message") + RecordPresentation.text("development_record_detail_error_message") ) ) - Button(DevelopmentRecordPresentation.text("development_record_detail_retry")) { + Button(RecordPresentation.text("development_record_detail_retry")) { store.send(.view(.fetch)) } .buttonStyle(.borderedProminent) @@ -145,8 +145,8 @@ public struct RecordDetailView: View { private func statusText(isDraft: Bool) -> String { isDraft - ? DevelopmentRecordPresentation.text("development_record_draft") - : DevelopmentRecordPresentation.text("development_record_confirmed") + ? RecordPresentation.text("development_record_draft") + : RecordPresentation.text("development_record_confirmed") } private func statusColor(isDraft: Bool) -> Color { diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index bb097add..8c7ca09c 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -70,13 +70,13 @@ public struct RecordEditorView: View { Image(systemName: "xmark") .frame(width: iconSize, height: iconSize) } else { - Text(DevelopmentRecordPresentation.text("common_close")) + Text(RecordPresentation.text("common_close")) } } .font(.title) .topBarButtonStyle() Spacer() - Text(DevelopmentRecordPresentation.text("development_record_editor_title")) + Text(RecordPresentation.text("development_record_editor_title")) .font(.headline) Spacer() Button { @@ -88,7 +88,7 @@ public struct RecordEditorView: View { .frame(width: iconSize, height: iconSize) .foregroundStyle(Color.primary) } else { - Text(DevelopmentRecordPresentation.text("development_record_save")) + Text(RecordPresentation.text("development_record_save")) .foregroundStyle(Color.accent) } } @@ -102,8 +102,8 @@ public struct RecordEditorView: View { } private var versionCard: some View { - FieldCard(title: DevelopmentRecordPresentation.text("development_record_version")) { - Text(DevelopmentRecordPresentation.versionLabel(store.versionNumber)) + FieldCard(title: RecordPresentation.text("development_record_version")) { + Text(RecordPresentation.versionLabel(store.versionNumber)) .font(.body) .frame(maxWidth: .infinity, alignment: .leading) .padding() @@ -112,9 +112,9 @@ public struct RecordEditorView: View { } private var titleCard: some View { - FieldCard(title: DevelopmentRecordPresentation.text("development_record_title")) { + FieldCard(title: RecordPresentation.text("development_record_title")) { TextField( - DevelopmentRecordPresentation.text("development_record_title_placeholder"), + RecordPresentation.text("development_record_title_placeholder"), text: $store.title ) .focused($focusedField, equals: .title) @@ -130,7 +130,7 @@ public struct RecordEditorView: View { editorContent - Text(DevelopmentRecordPresentation.text("development_record_markdown_hint")) + Text(RecordPresentation.text("development_record_markdown_hint")) .font(.caption) .foregroundStyle(Color.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) @@ -158,10 +158,10 @@ public struct RecordEditorView: View { Group { if store.markdownContent.isEmpty { ContentUnavailableView( - DevelopmentRecordPresentation.text("development_record_preview_empty_title"), + RecordPresentation.text("development_record_preview_empty_title"), systemImage: "doc.text.magnifyingglass", description: Text( - DevelopmentRecordPresentation.text("development_record_preview_empty_message") + RecordPresentation.text("development_record_preview_empty_message") ) ) } else { @@ -185,7 +185,7 @@ public struct RecordEditorView: View { ProgressView() .tint(Color.white) } else { - Text(DevelopmentRecordPresentation.text("development_record_confirm")) + Text(RecordPresentation.text("development_record_confirm")) } } .font(.headline) @@ -197,8 +197,8 @@ public struct RecordEditorView: View { .disabled(!store.canConfirmInitialVersion) Text(String.localizedStringWithFormat( - DevelopmentRecordPresentation.text("development_record_confirm_hint_format"), - DevelopmentRecordPresentation.versionLabel(store.versionNumber) + RecordPresentation.text("development_record_confirm_hint_format"), + RecordPresentation.versionLabel(store.versionNumber) )) .font(.caption) .foregroundStyle(Color.textTertiary) @@ -250,11 +250,11 @@ private struct ModePicker: View { var body: some View { HStack(spacing: 0) { modeButton( - DevelopmentRecordPresentation.text("development_record_write"), + RecordPresentation.text("development_record_write"), tab: .write ) modeButton( - DevelopmentRecordPresentation.text("development_record_preview"), + RecordPresentation.text("development_record_preview"), tab: .preview ) } diff --git a/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift b/Application/Presentation/Development/Sources/Record/RecordPresentation.swift similarity index 81% rename from Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift rename to Application/Presentation/Development/Sources/Record/RecordPresentation.swift index c492d5e5..fcdd7bbe 100644 --- a/Application/Presentation/Development/Sources/Record/DevelopmentRecordPresentation.swift +++ b/Application/Presentation/Development/Sources/Record/RecordPresentation.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordPresentation.swift +// RecordPresentation.swift // Development // // Created by opfic on 9/13/26. @@ -8,7 +8,7 @@ import Foundation import PresentationShared -enum DevelopmentRecordPresentation { +enum RecordPresentation { static func text(_ key: String.LocalizationValue) -> String { String(localized: key, bundle: PresentationResources.bundle) } diff --git a/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift index 70d58271..ba197782 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift @@ -58,7 +58,7 @@ public struct GoalDetailView: View { private var titleBar: some View { VStack(alignment: .leading, spacing: 8) { - Text(DevelopmentRecordPresentation.text("development_goal_title")) + Text(RecordPresentation.text("development_goal_title")) .font(.largeTitle.bold()) if !store.goalTitle.isEmpty { @@ -87,9 +87,9 @@ public struct GoalDetailView: View { .padding(.vertical, 32) } else if store.items.isEmpty { ContentUnavailableView( - DevelopmentRecordPresentation.text("development_record_empty_title"), + RecordPresentation.text("development_record_empty_title"), systemImage: "doc.badge.plus", - description: Text(DevelopmentRecordPresentation.text("development_record_empty_message")) + description: Text(RecordPresentation.text("development_record_empty_message")) ) } else { LazyVStack(spacing: 0) { @@ -107,7 +107,7 @@ public struct GoalDetailView: View { editorDestination = EditorDestination(record: nil) } label: { Label( - DevelopmentRecordPresentation.text("development_record_add"), + RecordPresentation.text("development_record_add"), systemImage: "plus" ) .font(.callout) @@ -127,7 +127,7 @@ public struct GoalDetailView: View { Button { editorDestination = EditorDestination(record: record) } label: { - Text(DevelopmentRecordPresentation.text("development_record_continue")) + Text(RecordPresentation.text("development_record_continue")) .font(.headline) .foregroundStyle(Color.white) .frame(maxWidth: .infinity) @@ -222,11 +222,11 @@ private struct TimelineRow: View { private var statusText: String { guard let versionNumber = item.versionNumber else { - return DevelopmentRecordPresentation.text("development_record_draft_status") + return RecordPresentation.text("development_record_draft_status") } return String.localizedStringWithFormat( - DevelopmentRecordPresentation.text("development_record_confirmed_version_format"), - DevelopmentRecordPresentation.versionLabel(versionNumber) + RecordPresentation.text("development_record_confirmed_version_format"), + RecordPresentation.versionLabel(versionNumber) ) } } From cd072733c12cdda77b6d32272a7b87712e7379d7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 11:37:10 +0900 Subject: [PATCH 29/36] =?UTF-8?q?refactor:=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20Feature=20=EB=AA=85=EC=B9=AD=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...eature.swift => RecordDetailFeature.swift} | 6 ++--- .../Record/Detail/RecordDetailView.swift | 6 ++--- ...eature.swift => RecordEditorFeature.swift} | 6 ++--- .../Record/Editor/RecordEditorView.swift | 10 ++++----- .../GoalDetailFeature.swift} | 16 +++++++------- .../GoalDetailView.swift | 10 ++++----- ...sts.swift => GoalDetailFeatureTests.swift} | 20 ++++++++--------- ...s.swift => RecordDetailFeatureTests.swift} | 22 +++++++++---------- ...s.swift => RecordEditorFeatureTests.swift} | 22 +++++++++---------- ...t.swift => RecordFeatureTestSupport.swift} | 10 ++++----- 10 files changed, 64 insertions(+), 64 deletions(-) rename Application/Presentation/Development/Sources/Record/Detail/{DevelopmentRecordDetailFeature.swift => RecordDetailFeature.swift} (96%) rename Application/Presentation/Development/Sources/Record/Editor/{DevelopmentRecordEditorFeature.swift => RecordEditorFeature.swift} (98%) rename Application/Presentation/Development/Sources/Record/{Timeline/DevelopmentRecordTimelineFeature.swift => GoalDetail/GoalDetailFeature.swift} (90%) rename Application/Presentation/Development/Sources/Record/{Timeline => GoalDetail}/GoalDetailView.swift (96%) rename Application/Presentation/Development/Tests/Record/{DevelopmentRecordTimelineFeatureTests.swift => GoalDetailFeatureTests.swift} (79%) rename Application/Presentation/Development/Tests/Record/{DevelopmentRecordDetailFeatureTests.swift => RecordDetailFeatureTests.swift} (78%) rename Application/Presentation/Development/Tests/Record/{DevelopmentRecordEditorFeatureTests.swift => RecordEditorFeatureTests.swift} (90%) rename Application/Presentation/Development/Tests/Record/{DevelopmentRecordFeatureTestSupport.swift => RecordFeatureTestSupport.swift} (94%) diff --git a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift similarity index 96% rename from Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift rename to Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift index eaf8461e..21a2d3a9 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/DevelopmentRecordDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordDetailFeature.swift +// RecordDetailFeature.swift // Development // // Created by opfic on 9/13/26. @@ -9,7 +9,7 @@ import Domain import PresentationShared @Reducer -struct DevelopmentRecordDetailFeature { +struct RecordDetailFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? @@ -80,7 +80,7 @@ struct DevelopmentRecordDetailFeature { } } -private extension DevelopmentRecordDetailFeature { +private extension RecordDetailFeature { func fetchEffect(record: DevelopmentRecord) -> Effect { .run { [fetchRecordHistoryUseCase] send in do { diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift index ae8fb308..9a39dec2 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift @@ -10,16 +10,16 @@ import Domain import PresentationShared public struct RecordDetailView: View { - @State private var store: StoreOf + @State private var store: StoreOf public init(goalTitle: String, record: DevelopmentRecord) { self._store = State(initialValue: Store( - initialState: DevelopmentRecordDetailFeature.State( + initialState: RecordDetailFeature.State( goalTitle: goalTitle, record: record ) ) { - DevelopmentRecordDetailFeature() + RecordDetailFeature() }) } diff --git a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift similarity index 98% rename from Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift rename to Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift index a648c2f8..359168e0 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/DevelopmentRecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordEditorFeature.swift +// RecordEditorFeature.swift // Development // // Created by opfic on 9/13/26. @@ -10,7 +10,7 @@ import Foundation import PresentationShared @Reducer -struct DevelopmentRecordEditorFeature { +struct RecordEditorFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? @@ -128,7 +128,7 @@ struct DevelopmentRecordEditorFeature { } } -private extension DevelopmentRecordEditorFeature { +private extension RecordEditorFeature { func saveEffect(state: State) -> Effect { .run { [createRecordUseCase, saveRecordDraftUseCase] send in do { diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index 8c7ca09c..c485aae8 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -11,7 +11,7 @@ import PresentationShared public struct RecordEditorView: View { @Environment(\.dismiss) private var dismiss - @State private var store: StoreOf + @State private var store: StoreOf @FocusState private var focusedField: Field? @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( forTextStyle: .title2, @@ -26,13 +26,13 @@ public struct RecordEditorView: View { onCompletion: @escaping () -> Void = { } ) { self._store = State(initialValue: Store( - initialState: DevelopmentRecordEditorFeature.State( + initialState: RecordEditorFeature.State( goalId: goalId, goalTitle: goalTitle, record: record ) ) { - DevelopmentRecordEditorFeature() + RecordEditorFeature() }) self.onCompletion = onCompletion } @@ -244,7 +244,7 @@ private struct FieldCard: View { } private struct ModePicker: View { - @Bindable var store: StoreOf + @Bindable var store: StoreOf @FocusState var focusedField: Field? var body: some View { @@ -264,7 +264,7 @@ private struct ModePicker: View { private func modeButton( _ title: String, - tab: DevelopmentRecordEditorFeature.EditorTab + tab: RecordEditorFeature.EditorTab ) -> some View { let isSelected = store.selectedTab == tab diff --git a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift similarity index 90% rename from Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift rename to Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index 9229ee1d..5e4ff542 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/DevelopmentRecordTimelineFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordTimelineFeature.swift +// GoalDetailFeature.swift // Development // // Created by opfic on 9/13/26. @@ -9,7 +9,7 @@ import Domain import Foundation import PresentationShared -struct DevelopmentRecordTimelineItem: Equatable, Identifiable { +struct RecordTimelineItem: Equatable, Identifiable { let record: DevelopmentRecord let currentVersion: DevelopmentRecord.Version? @@ -21,13 +21,13 @@ struct DevelopmentRecordTimelineItem: Equatable, Identifiable { } @Reducer -struct DevelopmentRecordTimelineFeature { +struct GoalDetailFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? let goalId: String var goalTitle = "" - var items = [DevelopmentRecordTimelineItem]() + var items = [RecordTimelineItem]() var isLoading = false var hasLoaded = false @@ -47,7 +47,7 @@ struct DevelopmentRecordTimelineFeature { } enum StoreAction: Equatable { - case loaded(goalTitle: String, items: [DevelopmentRecordTimelineItem]) + case loaded(goalTitle: String, items: [RecordTimelineItem]) case failed } } @@ -85,13 +85,13 @@ struct DevelopmentRecordTimelineFeature { } } -extension DevelopmentRecordTimelineFeature { +extension GoalDetailFeature { func fetchEffect(goalId: String) -> Effect { .run { [fetchGoalUseCase, fetchRecordsUseCase, fetchRecordHistoryUseCase] send in do { let goal = try await fetchGoalUseCase.execute(goalId) let records = try await fetchRecordsUseCase.execute(goalId: goalId) - var items = [DevelopmentRecordTimelineItem]() + var items = [RecordTimelineItem]() for record in records.sorted(by: Self.precedes) { let currentVersion: DevelopmentRecord.Version? @@ -107,7 +107,7 @@ extension DevelopmentRecordTimelineFeature { } else { currentVersion = nil } - items.append(DevelopmentRecordTimelineItem( + items.append(RecordTimelineItem( record: record, currentVersion: currentVersion )) diff --git a/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift similarity index 96% rename from Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift rename to Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index ba197782..8f3165e0 100644 --- a/Application/Presentation/Development/Sources/Record/Timeline/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -10,15 +10,15 @@ import Domain import PresentationShared public struct GoalDetailView: View { - @State private var store: StoreOf + @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: DevelopmentRecordTimelineFeature.State(goalId: goalId) + initialState: GoalDetailFeature.State(goalId: goalId) ) { - DevelopmentRecordTimelineFeature() + GoalDetailFeature() }) } @@ -136,7 +136,7 @@ public struct GoalDetailView: View { .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) } - private func select(_ item: DevelopmentRecordTimelineItem) { + private func select(_ item: RecordTimelineItem) { if item.isDraft { editorDestination = EditorDestination(record: item.record) } else { @@ -151,7 +151,7 @@ public struct GoalDetailView: View { } private struct TimelineRow: View { - let item: DevelopmentRecordTimelineItem + let item: RecordTimelineItem let isLast: Bool let onSelect: () -> Void diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift similarity index 79% rename from Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift rename to Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index def29d04..070aa042 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordTimelineFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordTimelineFeatureTests.swift +// GoalDetailFeatureTests.swift // DevelopmentTests // // Created by opfic on 9/13/26. @@ -11,7 +11,7 @@ import PresentationShared @testable import Development @MainActor -struct DevelopmentRecordTimelineFeatureTests { +struct GoalDetailFeatureTests { @Test("타임라인은 생성 순서로 기록을 정렬하고 최신 확정 버전을 구성한다") func 타임라인은_생성_순서로_기록을_정렬하고_최신_확정_버전을_구성한다() async throws { let goal = try makeDevelopmentGoal(title: "개발 목표와 기록 이력 구성") @@ -22,13 +22,13 @@ struct DevelopmentRecordTimelineFeatureTests { ) let version = try makeDevelopmentRecordVersion(recordId: confirmedRecord.id) let items = [ - DevelopmentRecordTimelineItem(record: confirmedRecord, currentVersion: version), - DevelopmentRecordTimelineItem(record: draftRecord, currentVersion: nil) + RecordTimelineItem(record: confirmedRecord, currentVersion: version), + RecordTimelineItem(record: draftRecord, currentVersion: nil) ] let store = TestStore( - initialState: DevelopmentRecordTimelineFeature.State(goalId: goal.id) + initialState: GoalDetailFeature.State(goalId: goal.id) ) { - DevelopmentRecordTimelineFeature() + GoalDetailFeature() } withDependencies: { $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub(result: .success(goal)) $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( @@ -54,13 +54,13 @@ struct DevelopmentRecordTimelineFeatureTests { func 타임라인_조회_실패는_입력_가능한_재조회_상태를_유지한다() async throws { let goal = try makeDevelopmentGoal() let store = TestStore( - initialState: DevelopmentRecordTimelineFeature.State(goalId: goal.id) + initialState: GoalDetailFeature.State(goalId: goal.id) ) { - DevelopmentRecordTimelineFeature() + GoalDetailFeature() } withDependencies: { $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub(result: .success(goal)) $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( - result: .failure(DevelopmentRecordTestError.failed) + result: .failure(RecordTestError.failed) ) $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( resultByRecordId: [:] @@ -72,7 +72,7 @@ struct DevelopmentRecordTimelineFeatureTests { } await store.receive(.store(.failed)) { $0.isLoading = false - $0.alert = makeDevelopmentRecordErrorAlert("development_record_timeline_error_message") + $0.alert = makeRecordErrorAlert("development_record_timeline_error_message") } await store.send(.view(.refresh)) { $0.isLoading = true diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift similarity index 78% rename from Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift rename to Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift index f60a3291..64271a2b 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordDetailFeatureTests.swift +// RecordDetailFeatureTests.swift // DevelopmentTests // // Created by opfic on 9/13/26. @@ -10,18 +10,18 @@ import PresentationShared @testable import Development @MainActor -struct DevelopmentRecordDetailFeatureTests { +struct RecordDetailFeatureTests { @Test("상세는 currentVersion과 일치하는 확정 버전을 표시한다") func 상세는_currentVersion과_일치하는_확정_버전을_표시한다() async throws { let record = try makeConfirmedDevelopmentRecord() let version = try makeDevelopmentRecordVersion() let store = TestStore( - initialState: DevelopmentRecordDetailFeature.State( + initialState: RecordDetailFeature.State( goalTitle: "개발 목표", record: record ) ) { - DevelopmentRecordDetailFeature() + RecordDetailFeature() } withDependencies: { $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( resultByRecordId: [record.id: .success([version])] @@ -40,12 +40,12 @@ struct DevelopmentRecordDetailFeatureTests { func 확정_전_상세는_이력_조회_없이_초안을_유지한다() async throws { let record = try makeDevelopmentRecord() let store = TestStore( - initialState: DevelopmentRecordDetailFeature.State( + initialState: RecordDetailFeature.State( goalTitle: "개발 목표", record: record ) ) { - DevelopmentRecordDetailFeature() + RecordDetailFeature() } let draft = try #require(record.draft) @@ -57,15 +57,15 @@ struct DevelopmentRecordDetailFeatureTests { func 확정_기록_조회_실패는_초안_대신_재시도_상태를_표시한다() async throws { let record = try makeConfirmedDevelopmentRecord() let store = TestStore( - initialState: DevelopmentRecordDetailFeature.State( + initialState: RecordDetailFeature.State( goalTitle: "개발 목표", record: record ) ) { - DevelopmentRecordDetailFeature() + RecordDetailFeature() } withDependencies: { $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( - resultByRecordId: [record.id: .failure(DevelopmentRecordTestError.failed)] + resultByRecordId: [record.id: .failure(RecordTestError.failed)] ) } @@ -74,7 +74,7 @@ struct DevelopmentRecordDetailFeatureTests { } await store.receive(.store(.failed)) { $0.contentState = .failed - $0.alert = makeDevelopmentRecordErrorAlert("development_record_detail_error_message") + $0.alert = makeRecordErrorAlert("development_record_detail_error_message") } await store.send(.alert(.dismiss)) { $0.alert = nil @@ -84,7 +84,7 @@ struct DevelopmentRecordDetailFeatureTests { } await store.receive(.store(.failed)) { $0.contentState = .failed - $0.alert = makeDevelopmentRecordErrorAlert("development_record_detail_error_message") + $0.alert = makeRecordErrorAlert("development_record_detail_error_message") } } } diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift similarity index 90% rename from Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift rename to Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift index 3c3afdb1..008fddbd 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordEditorFeatureTests.swift +// RecordEditorFeatureTests.swift // DevelopmentTests // // Created by opfic on 9/13/26. @@ -11,7 +11,7 @@ import PresentationShared @testable import Development @MainActor -struct DevelopmentRecordEditorFeatureTests { +struct RecordEditorFeatureTests { @Test("새 기록 저장은 초안을 생성한다") func 새_기록_저장은_초안을_생성한다() async throws { let record = try makeDevelopmentRecord() @@ -63,7 +63,7 @@ struct DevelopmentRecordEditorFeatureTests { let version = try makeDevelopmentRecordVersion() let store = makeStore( record: record, - createResult: .failure(DevelopmentRecordTestError.failed), + createResult: .failure(RecordTestError.failed), saveResult: .success(updatedRecord), confirmResult: .success(version) ) @@ -88,7 +88,7 @@ struct DevelopmentRecordEditorFeatureTests { let version = try makeDevelopmentRecordVersion() let store = makeStore( record: record, - createResult: .failure(DevelopmentRecordTestError.failed), + createResult: .failure(RecordTestError.failed), confirmResult: .success(version) ) @@ -104,16 +104,16 @@ struct DevelopmentRecordEditorFeatureTests { let version = try makeDevelopmentRecordVersion() let createSpy = CreateDevelopmentRecordUseCaseSpy(result: .success(record)) let confirmSpy = ConfirmDevelopmentRecordUseCaseSpy(results: [ - .failure(DevelopmentRecordTestError.failed), + .failure(RecordTestError.failed), .success(version) ]) let store = TestStore( - initialState: DevelopmentRecordEditorFeature.State( + initialState: RecordEditorFeature.State( goalId: "goal", goalTitle: "개발 목표" ) ) { - DevelopmentRecordEditorFeature() + RecordEditorFeature() } withDependencies: { $0.developmentCreateRecordUseCase = createSpy $0.developmentSaveRecordDraftUseCase = SaveDevelopmentRecordDraftUseCaseStub( @@ -133,7 +133,7 @@ struct DevelopmentRecordEditorFeatureTests { } await store.receive(.store(.failed)) { $0.isLoading = false - $0.alert = makeDevelopmentRecordErrorAlert("development_record_editor_error_message") + $0.alert = makeRecordErrorAlert("development_record_editor_error_message") } await store.send(.alert(.dismiss)) { $0.alert = nil @@ -161,15 +161,15 @@ struct DevelopmentRecordEditorFeatureTests { createResult: Result, saveResult: Result? = nil, confirmResult: Result - ) -> TestStoreOf { + ) -> TestStoreOf { TestStore( - initialState: DevelopmentRecordEditorFeature.State( + initialState: RecordEditorFeature.State( goalId: "goal", goalTitle: "개발 목표", record: record ) ) { - DevelopmentRecordEditorFeature() + RecordEditorFeature() } withDependencies: { $0.developmentCreateRecordUseCase = CreateDevelopmentRecordUseCaseStub(result: createResult) $0.developmentSaveRecordDraftUseCase = SaveDevelopmentRecordDraftUseCaseStub( diff --git a/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift similarity index 94% rename from Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift rename to Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index 2ab42ae8..a2524a6b 100644 --- a/Application/Presentation/Development/Tests/Record/DevelopmentRecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -1,5 +1,5 @@ // -// DevelopmentRecordFeatureTestSupport.swift +// RecordFeatureTestSupport.swift // DevelopmentTests // // Created by opfic on 9/13/26. @@ -9,11 +9,11 @@ import Domain import Foundation import PresentationShared -enum DevelopmentRecordTestError: Error { +enum RecordTestError: Error { case failed } -func makeDevelopmentRecordErrorAlert(_ messageKey: String.LocalizationValue) -> AlertState { +func makeRecordErrorAlert(_ messageKey: String.LocalizationValue) -> AlertState { AlertState { TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) } actions: { @@ -48,7 +48,7 @@ struct FetchDevelopmentRecordHistoryUseCaseStub: FetchDevelopmentRecordHistoryUs goalId: String, recordId: String ) async throws -> [DevelopmentRecord.Version] { - try resultByRecordId[recordId, default: .failure(DevelopmentRecordTestError.failed)].get() + try resultByRecordId[recordId, default: .failure(RecordTestError.failed)].get() } } @@ -146,7 +146,7 @@ actor ConfirmDevelopmentRecordUseCaseSpy: ConfirmDevelopmentRecordUseCase { recordId: recordId, baseVersionId: baseVersionId )) - guard !results.isEmpty else { throw DevelopmentRecordTestError.failed } + guard !results.isEmpty else { throw RecordTestError.failed } return try results.removeFirst().get() } From 7f42dea0bb6d42e08f721375eec23c90db25054a Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 11:49:35 +0900 Subject: [PATCH 30/36] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=EA=B0=9C=EC=A0=95=20=EC=B6=A9=EB=8F=8C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/DTO/DevelopmentRecordDTO.swift | 16 +++++++- .../Mapper/DevelopmentRecordMapping.swift | 10 ++++- .../DevelopmentRecordRepositoryImpl.swift | 9 ++-- .../DevelopmentRecordMappingTests.swift | 2 + ...DevelopmentRecordRepositoryImplTests.swift | 4 +- .../Sources/Entity/DevelopmentRecord.swift | 6 +++ .../DevelopmentRecordRepository.swift | 4 +- .../ConfirmDevelopmentRecordUseCase.swift | 3 +- .../ConfirmDevelopmentRecordUseCaseImpl.swift | 9 ++-- .../CreateDevelopmentRecordUseCaseImpl.swift | 4 ++ .../SaveDevelopmentRecordDraftUseCase.swift | 1 + ...aveDevelopmentRecordDraftUseCaseImpl.swift | 9 ++++ ...lopmentRecordConcurrencyUseCaseTests.swift | 41 ++++++++++++++++++- ...opmentRecordConfirmationUseCaseTests.swift | 19 ++++++--- .../DevelopmentRecordUseCaseTestSupport.swift | 18 ++++++-- .../DevelopmentRecordUseCaseTests.swift | 13 ++++++ .../DevelopmentRecordDocumentMapper.swift | 2 + .../DevelopmentRecordVersionMutation.swift | 11 ++++- .../DevelopmentRecordServiceImplTests.swift | 36 ++++++++++++++++ .../Record/Editor/RecordEditorFeature.swift | 11 +++-- .../Record/RecordEditorFeatureTests.swift | 14 ++++++- .../Record/RecordFeatureTestSupport.swift | 15 +++++-- 22 files changed, 223 insertions(+), 34 deletions(-) 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/Repository/DevelopmentRecordRepositoryImpl.swift b/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift index 60d12822..edf57ad2 100644 --- a/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/DevelopmentRecordRepositoryImpl.swift @@ -61,13 +61,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 +81,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 +91,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..1621f07b 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") } } 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/Protocol/DevelopmentRecordRepository.swift b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift index 11d23b7a..712b8e03 100644 --- a/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift +++ b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift @@ -17,6 +17,7 @@ public protocol DevelopmentRecordRepository { func saveDraft( goalId: String, recordId: String, + expectedRevisionId: String?, draft: DevelopmentRecord.Draft ) async throws -> DevelopmentRecord func confirmDraft( @@ -24,7 +25,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 e78b3373..3e717920 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift @@ -9,6 +9,7 @@ public protocol ConfirmDevelopmentRecordUseCase { func execute( goalId: String, recordId: String, - baseVersionId: 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 1a25b608..f619646e 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift @@ -25,7 +25,8 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord public func execute( goalId: String, recordId: String, - baseVersionId: String? + baseVersionId: String?, + draftRevisionId: String? ) async throws -> DevelopmentRecord.Version { let goal = try await goalRepository.fetchGoal(goalId) guard goal.status == .inProgress else { @@ -40,7 +41,8 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord throw DomainLayerError.developmentRecordDraftNotFound } guard record.currentVersion?.id == baseVersionId, - draft.baseVersionId == baseVersionId else { + draft.baseVersionId == baseVersionId, + draft.revisionId == draftRevisionId else { throw DomainLayerError.developmentRecordDraftConflict } @@ -54,7 +56,8 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord recordId: recordId, versionId: versionId, kind: kind, - sourceVersionId: baseVersionId + sourceVersionId: baseVersionId, + draftRevisionId: draftRevisionId ) } catch { guard let version = try? await confirmedVersion( diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift index 08345081..e4364655 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift @@ -11,17 +11,20 @@ public final class CreateDevelopmentRecordUseCaseImpl: CreateDevelopmentRecordUs 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 } @@ -39,6 +42,7 @@ public final class CreateDevelopmentRecordUseCaseImpl: CreateDevelopmentRecordUs title: title, markdownContent: markdownContent, baseVersionId: nil, + revisionId: revisionIdProvider(), updatedAt: now() ) return try await repository.createRecord( diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift index b495a1d1..9c88e357 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCase.swift @@ -10,6 +10,7 @@ public protocol SaveDevelopmentRecordDraftUseCase { 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 17a65e61..a100bc76 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Draft/SaveDevelopmentRecordDraftUseCaseImpl.swift @@ -10,15 +10,18 @@ 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 } @@ -26,6 +29,7 @@ public final class SaveDevelopmentRecordDraftUseCaseImpl: SaveDevelopmentRecordD goalId: String, recordId: String, baseVersionId: String?, + draftRevisionId: String?, title: String, markdownContent: String ) async throws -> DevelopmentRecord { @@ -41,15 +45,20 @@ public final class SaveDevelopmentRecordDraftUseCaseImpl: SaveDevelopmentRecordD 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: baseVersionId, + revisionId: revisionIdProvider(), updatedAt: now() ) return try await repository.saveDraft( goalId: goalId, recordId: recordId, + expectedRevisionId: draftRevisionId, draft: updatedDraft ) } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift index d4cb5758..597d2052 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift @@ -23,6 +23,7 @@ struct DevelopmentRecordConcurrencyUseCaseTests { goalId: "goal-1", recordId: "record-1", baseVersionId: nil, + draftRevisionId: nil, title: "오래된 초안", markdownContent: "본문" ) @@ -30,6 +31,42 @@ struct DevelopmentRecordConcurrencyUseCaseTests { #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", + baseVersionId: nil, + draftRevisionId: "revision-1" + ) + } + #expect(await repository.draftRequests().isEmpty) + #expect(await repository.confirmRequests().isEmpty) + } + @Test("최초 확정 중 현재 버전이 바뀌면 정정 확정으로 전환하지 않는다") func 최초_확정_중_현재_버전이_바뀌면_정정_확정으로_전환하지_않는다() async throws { let goal = try makeDevelopmentRecordGoal() @@ -40,6 +77,7 @@ struct DevelopmentRecordConcurrencyUseCaseTests { title: "정정 초안", markdownContent: "본문", baseVersionId: currentVersion.id, + revisionId: "revision-1", updatedAt: .distantPast ) ) @@ -53,7 +91,8 @@ struct DevelopmentRecordConcurrencyUseCaseTests { try await useCase.execute( goalId: "goal-1", recordId: "record-1", - baseVersionId: nil + 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 index 23a3fee9..1d2e91e4 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift @@ -24,7 +24,8 @@ struct DevelopmentRecordConfirmUseCaseTests { let result = try await useCase.execute( goalId: "goal-1", recordId: "record-1", - baseVersionId: nil + baseVersionId: nil, + draftRevisionId: "revision-1" ) #expect(result == version) @@ -34,7 +35,8 @@ struct DevelopmentRecordConfirmUseCaseTests { recordId: "record-1", versionId: "version-1", kind: .initial, - sourceVersionId: nil + sourceVersionId: nil, + draftRevisionId: "revision-1" ) ]) } @@ -49,6 +51,7 @@ struct DevelopmentRecordConfirmUseCaseTests { title: "정정 초안", markdownContent: "본문", baseVersionId: currentVersion.id, + revisionId: "revision-1", updatedAt: .distantPast ) ) @@ -66,7 +69,8 @@ struct DevelopmentRecordConfirmUseCaseTests { _ = try await useCase.execute( goalId: "goal-1", recordId: "record-1", - baseVersionId: currentVersion.id + baseVersionId: currentVersion.id, + draftRevisionId: "revision-1" ) #expect(await repository.confirmRequests() == [ @@ -75,7 +79,8 @@ struct DevelopmentRecordConfirmUseCaseTests { recordId: "record-1", versionId: "version-2", kind: .correction, - sourceVersionId: currentVersion.id + sourceVersionId: currentVersion.id, + draftRevisionId: "revision-1" ) ]) } @@ -93,7 +98,8 @@ struct DevelopmentRecordConfirmUseCaseTests { try await useCase.execute( goalId: "goal-1", recordId: "record-1", - baseVersionId: nil + baseVersionId: nil, + draftRevisionId: nil ) } #expect(await repository.confirmRequests().isEmpty) @@ -120,7 +126,8 @@ struct DevelopmentRecordConfirmUseCaseTests { let result = try await useCase.execute( goalId: "goal-1", recordId: "record-1", - baseVersionId: nil + baseVersionId: nil, + draftRevisionId: "revision-1" ) #expect(result == version) diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift index 75bfef3f..4e5f6663 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift @@ -58,6 +58,7 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { struct DraftRequest: Equatable { let goalId: String let recordId: String + let expectedRevisionId: String? let draft: DevelopmentRecord.Draft } @@ -67,6 +68,7 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { let versionId: String let kind: DevelopmentRecord.Version.Kind let sourceVersionId: String? + let draftRevisionId: String? } struct RestoreRequest: Equatable { @@ -144,9 +146,15 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { 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) } @@ -155,7 +163,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( @@ -163,7 +172,8 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { recordId: recordId, versionId: versionId, kind: kind, - sourceVersionId: sourceVersionId + sourceVersionId: sourceVersionId, + draftRevisionId: draftRevisionId ) ) if let confirmError { @@ -236,6 +246,7 @@ func makeDevelopmentRecordGoal( } func makeDevelopmentRecordInitialDraft( + revisionId: String = "revision-1", updatedAt: Date = .distantPast ) throws -> DevelopmentRecord { try makeDevelopmentRecord( @@ -244,6 +255,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 0e61f3b9..bfe07882 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift @@ -20,6 +20,7 @@ struct DevelopmentRecordUseCaseTests { repository, DevelopmentRecordGoalRepositorySpy(goal: goal), idProvider: { "record-1" }, + revisionIdProvider: { "revision-1" }, now: { createdAt } ) let result = try await useCase.execute( @@ -37,6 +38,7 @@ struct DevelopmentRecordUseCaseTests { title: "기록", markdownContent: "본문", baseVersionId: nil, + revisionId: "revision-1", updatedAt: createdAt ) ) @@ -94,12 +96,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: "수정 본문" ) @@ -109,10 +113,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 ) ) @@ -129,6 +135,7 @@ struct DevelopmentRecordUseCaseTests { title: "정정 초안", markdownContent: "본문", baseVersionId: currentVersion.id, + revisionId: "revision-1", updatedAt: .distantPast ) ) @@ -146,12 +153,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: "수정 본문" ) @@ -180,12 +189,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: "본문" ) @@ -195,10 +206,12 @@ 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 ) ) 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/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/Record/Editor/RecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift index 359168e0..f8e680c9 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift @@ -110,7 +110,8 @@ struct RecordEditorFeature { state.record = record return confirmPreparedRecordEffect( goalId: state.goalId, - recordId: record.id + recordId: record.id, + draftRevisionId: record.draft?.revisionId ) case .store(.confirmed(let version)): state.isLoading = false @@ -138,6 +139,7 @@ private extension RecordEditorFeature { goalId: state.goalId, recordId: recordId, baseVersionId: nil, + draftRevisionId: state.record?.draft?.revisionId, title: state.title, markdownContent: state.markdownContent ) @@ -164,6 +166,7 @@ private extension RecordEditorFeature { goalId: state.goalId, recordId: recordId, baseVersionId: nil, + draftRevisionId: state.record?.draft?.revisionId, title: state.title, markdownContent: state.markdownContent ) @@ -183,14 +186,16 @@ private extension RecordEditorFeature { func confirmPreparedRecordEffect( goalId: String, - recordId: String + recordId: String, + draftRevisionId: String? ) -> Effect { .run { [confirmRecordUseCase] send in do { let version = try await confirmRecordUseCase.execute( goalId: goalId, recordId: recordId, - baseVersionId: nil + baseVersionId: nil, + draftRevisionId: draftRevisionId ) await send(.store(.confirmed(version))) } catch { diff --git a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift index 008fddbd..cea9d8bb 100644 --- a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift @@ -151,8 +151,18 @@ struct RecordEditorFeatureTests { #expect(await createSpy.requests().count == 1) #expect(await confirmSpy.requests() == [ - .init(goalId: "goal", recordId: record.id, baseVersionId: nil), - .init(goalId: "goal", recordId: record.id, baseVersionId: nil) + .init( + goalId: "goal", + recordId: record.id, + baseVersionId: nil, + draftRevisionId: record.draft?.revisionId + ), + .init( + goalId: "goal", + recordId: record.id, + baseVersionId: nil, + draftRevisionId: record.draft?.revisionId + ) ]) } diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index a2524a6b..950d925b 100644 --- a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -71,6 +71,7 @@ struct SaveDevelopmentRecordDraftUseCaseStub: SaveDevelopmentRecordDraftUseCase goalId: String, recordId: String, baseVersionId: String?, + draftRevisionId: String?, title: String, markdownContent: String ) async throws -> DevelopmentRecord { @@ -84,7 +85,8 @@ struct ConfirmDevelopmentRecordUseCaseStub: ConfirmDevelopmentRecordUseCase { func execute( goalId: String, recordId: String, - baseVersionId: String? + baseVersionId: String?, + draftRevisionId: String? ) async throws -> DevelopmentRecord.Version { try result.get() } @@ -127,6 +129,7 @@ actor ConfirmDevelopmentRecordUseCaseSpy: ConfirmDevelopmentRecordUseCase { let goalId: String let recordId: String let baseVersionId: String? + let draftRevisionId: String? } private var results: [Result] @@ -139,12 +142,14 @@ actor ConfirmDevelopmentRecordUseCaseSpy: ConfirmDevelopmentRecordUseCase { func execute( goalId: String, recordId: String, - baseVersionId: String? + baseVersionId: String?, + draftRevisionId: String? ) async throws -> DevelopmentRecord.Version { recordedRequests.append(.init( goalId: goalId, recordId: recordId, - baseVersionId: baseVersionId + baseVersionId: baseVersionId, + draftRevisionId: draftRevisionId )) guard !results.isEmpty else { throw RecordTestError.failed } return try results.removeFirst().get() @@ -198,12 +203,14 @@ func makeConfirmedDevelopmentRecord( func makeDevelopmentRecordDraft( title: String = "기록 제목", - markdownContent: 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) ) } From 8aef5d6b5f7adc9512a3428c4178311836d392c4 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 11:51:03 +0900 Subject: [PATCH 31/36] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9E=90=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateDevelopmentRecordUseCase.swift | 1 + .../CreateDevelopmentRecordUseCaseImpl.swift | 6 +-- .../DevelopmentRecordUseCaseTests.swift | 3 +- .../DevelopmentRecordServiceImpl.swift | 23 +++++++--- .../Record/Editor/RecordEditorFeature.swift | 11 ++++- .../Record/RecordEditorFeatureTests.swift | 45 +++++++++++++++++++ .../Record/RecordFeatureTestSupport.swift | 15 +++++-- 7 files changed, 90 insertions(+), 14 deletions(-) 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 e4364655..ffa6d6c0 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Create/CreateDevelopmentRecordUseCaseImpl.swift @@ -10,26 +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 { @@ -46,7 +44,7 @@ public final class CreateDevelopmentRecordUseCaseImpl: CreateDevelopmentRecordUs updatedAt: now() ) return try await repository.createRecord( - id: idProvider(), + id: recordId, goalId: goalId, draft: draft ) diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift index bfe07882..ca848362 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift @@ -19,12 +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: "본문" ) @@ -56,6 +56,7 @@ struct DevelopmentRecordUseCaseTests { await expectDevelopmentRecordDomainError(.developmentGoalIsNotInProgress) { try await useCase.execute( goalId: "goal-1", + recordId: "record-1", title: "기록", markdownContent: "본문" ) diff --git a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift index 943b6949..cd0f013e 100644 --- a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift +++ b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift @@ -37,13 +37,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) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift index f8e680c9..f68911cb 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift @@ -16,6 +16,7 @@ struct RecordEditorFeature { @Presents var alert: AlertState? let goalId: String let goalTitle: String + let recordId: String var record: DevelopmentRecord? var title: String var markdownContent: String @@ -37,9 +38,15 @@ struct RecordEditorFeature { isReadyToSave } - init(goalId: String, goalTitle: String, record: DevelopmentRecord? = nil) { + init( + goalId: String, + goalTitle: String, + record: DevelopmentRecord? = nil, + recordId: String = UUID().uuidString + ) { self.goalId = goalId self.goalTitle = goalTitle + self.recordId = record?.id ?? recordId self.record = record self.title = record?.draft?.title ?? "" self.markdownContent = record?.draft?.markdownContent ?? "" @@ -146,6 +153,7 @@ private extension RecordEditorFeature { } else { record = try await createRecordUseCase.execute( goalId: state.goalId, + recordId: state.recordId, title: state.title, markdownContent: state.markdownContent ) @@ -173,6 +181,7 @@ private extension RecordEditorFeature { } else { record = try await createRecordUseCase.execute( goalId: state.goalId, + recordId: state.recordId, title: state.title, markdownContent: state.markdownContent ) diff --git a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift index cea9d8bb..8e9f72ba 100644 --- a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift @@ -98,6 +98,51 @@ struct RecordEditorFeatureTests { 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("확정 실패 뒤 재시도는 생성된 기록을 다시 사용한다") func 확정_실패_뒤_재시도는_생성된_기록을_다시_사용한다() async throws { let record = try makeDevelopmentRecord() diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index 950d925b..59305f3b 100644 --- a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -57,6 +57,7 @@ struct CreateDevelopmentRecordUseCaseStub: CreateDevelopmentRecordUseCase { func execute( goalId: String, + recordId: String, title: String, markdownContent: String ) async throws -> DevelopmentRecord { @@ -95,28 +96,36 @@ struct ConfirmDevelopmentRecordUseCaseStub: ConfirmDevelopmentRecordUseCase { actor CreateDevelopmentRecordUseCaseSpy: CreateDevelopmentRecordUseCase { struct Request: Equatable { let goalId: String + let recordId: String let title: String let markdownContent: String } - private let result: Result + private var results: [Result] private var recordedRequests = [Request]() init(result: Result) { - self.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 )) - return try result.get() + guard !results.isEmpty else { throw RecordTestError.failed } + return try results.removeFirst().get() } func requests() -> [Request] { From b0c6d58f0cb2d0166adfb0f3e184c4aa09838477 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 11:52:55 +0900 Subject: [PATCH 32/36] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9E=90=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfirmDevelopmentRecordUseCase.swift | 1 + .../ConfirmDevelopmentRecordUseCaseImpl.swift | 28 ++++++---- ...lopmentRecordConcurrencyUseCaseTests.swift | 2 + ...opmentRecordConfirmationUseCaseTests.swift | 51 ++++++++++++++++--- .../Record/Editor/RecordEditorFeature.swift | 8 ++- .../Record/RecordEditorFeatureTests.swift | 5 +- .../Record/RecordFeatureTestSupport.swift | 4 ++ 7 files changed, 81 insertions(+), 18 deletions(-) diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift index 3e717920..bfde3fe0 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCase.swift @@ -9,6 +9,7 @@ public protocol ConfirmDevelopmentRecordUseCase { 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 f619646e..aea2cee4 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift @@ -5,35 +5,40 @@ // 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, + 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") } @@ -46,7 +51,6 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord throw DomainLayerError.developmentRecordDraftConflict } - let versionId = idProvider() let kind = baseVersionId == nil ? DevelopmentRecord.Version.Kind.initial : .correction @@ -60,7 +64,11 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord draftRevisionId: draftRevisionId ) } catch { - guard let version = try? await confirmedVersion( + guard let record = try? await repository.fetchRecord( + goalId: goalId, + recordId: recordId + ), let version = try? await confirmedVersion( + record: record, goalId: goalId, recordId: recordId, versionId: versionId @@ -74,11 +82,11 @@ public final class ConfirmDevelopmentRecordUseCaseImpl: ConfirmDevelopmentRecord private extension ConfirmDevelopmentRecordUseCaseImpl { func confirmedVersion( + record: DevelopmentRecord, goalId: String, recordId: String, versionId: String ) async throws -> DevelopmentRecord.Version { - let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) guard record.currentVersion?.id == versionId else { throw DomainLayerError.developmentRecordVersionNotFound } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift index 597d2052..d2927028 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConcurrencyUseCaseTests.swift @@ -59,6 +59,7 @@ struct DevelopmentRecordConcurrencyUseCaseTests { try await confirmUseCase.execute( goalId: "goal-1", recordId: "record-1", + versionId: "version-1", baseVersionId: nil, draftRevisionId: "revision-1" ) @@ -91,6 +92,7 @@ struct DevelopmentRecordConcurrencyUseCaseTests { try await useCase.execute( goalId: "goal-1", recordId: "record-1", + versionId: "version-2", baseVersionId: nil, draftRevisionId: "revision-1" ) diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift index 1d2e91e4..e6e2d72b 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift @@ -17,13 +17,13 @@ struct DevelopmentRecordConfirmUseCaseTests { let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) let useCase = ConfirmDevelopmentRecordUseCaseImpl( repository, - DevelopmentRecordGoalRepositorySpy(goal: goal), - idProvider: { "version-1" } + DevelopmentRecordGoalRepositorySpy(goal: goal) ) let result = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + versionId: "version-1", baseVersionId: nil, draftRevisionId: "revision-1" ) @@ -62,13 +62,13 @@ struct DevelopmentRecordConfirmUseCaseTests { let repository = DevelopmentRecordRepositorySpy(record: record, confirmedVersion: version) let useCase = ConfirmDevelopmentRecordUseCaseImpl( repository, - DevelopmentRecordGoalRepositorySpy(goal: goal), - idProvider: { "version-2" } + DevelopmentRecordGoalRepositorySpy(goal: goal) ) _ = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + versionId: "version-2", baseVersionId: currentVersion.id, draftRevisionId: "revision-1" ) @@ -98,6 +98,7 @@ struct DevelopmentRecordConfirmUseCaseTests { try await useCase.execute( goalId: "goal-1", recordId: "record-1", + versionId: "version-2", baseVersionId: nil, draftRevisionId: nil ) @@ -119,13 +120,13 @@ struct DevelopmentRecordConfirmUseCaseTests { ) let useCase = ConfirmDevelopmentRecordUseCaseImpl( repository, - DevelopmentRecordGoalRepositorySpy(goal: goal), - idProvider: { version.id } + DevelopmentRecordGoalRepositorySpy(goal: goal) ) let result = try await useCase.execute( goalId: "goal-1", recordId: "record-1", + versionId: version.id, baseVersionId: nil, draftRevisionId: "revision-1" ) @@ -136,4 +137,42 @@ struct DevelopmentRecordConfirmUseCaseTests { .init(goalId: "goal-1", recordId: "record-1") ]) } + + @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/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift index f68911cb..f91f7cda 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift @@ -17,6 +17,7 @@ struct RecordEditorFeature { let goalId: String let goalTitle: String let recordId: String + let versionId: String var record: DevelopmentRecord? var title: String var markdownContent: String @@ -42,11 +43,13 @@ struct RecordEditorFeature { goalId: String, goalTitle: String, record: DevelopmentRecord? = nil, - recordId: String = UUID().uuidString + 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 ?? "" @@ -118,6 +121,7 @@ struct RecordEditorFeature { return confirmPreparedRecordEffect( goalId: state.goalId, recordId: record.id, + versionId: state.versionId, draftRevisionId: record.draft?.revisionId ) case .store(.confirmed(let version)): @@ -196,6 +200,7 @@ private extension RecordEditorFeature { func confirmPreparedRecordEffect( goalId: String, recordId: String, + versionId: String, draftRevisionId: String? ) -> Effect { .run { [confirmRecordUseCase] send in @@ -203,6 +208,7 @@ private extension RecordEditorFeature { let version = try await confirmRecordUseCase.execute( goalId: goalId, recordId: recordId, + versionId: versionId, baseVersionId: nil, draftRevisionId: draftRevisionId ) diff --git a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift index 8e9f72ba..0cff505f 100644 --- a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift @@ -155,7 +155,8 @@ struct RecordEditorFeatureTests { let store = TestStore( initialState: RecordEditorFeature.State( goalId: "goal", - goalTitle: "개발 목표" + goalTitle: "개발 목표", + versionId: version.id ) ) { RecordEditorFeature() @@ -199,12 +200,14 @@ struct RecordEditorFeatureTests { .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 ) diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index 59305f3b..ec5a47f5 100644 --- a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -71,6 +71,7 @@ struct SaveDevelopmentRecordDraftUseCaseStub: SaveDevelopmentRecordDraftUseCase func execute( goalId: String, recordId: String, + versionId: String, baseVersionId: String?, draftRevisionId: String?, title: String, @@ -137,6 +138,7 @@ actor ConfirmDevelopmentRecordUseCaseSpy: ConfirmDevelopmentRecordUseCase { struct Request: Equatable { let goalId: String let recordId: String + let versionId: String let baseVersionId: String? let draftRevisionId: String? } @@ -151,12 +153,14 @@ actor ConfirmDevelopmentRecordUseCaseSpy: ConfirmDevelopmentRecordUseCase { 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 )) From 33d06da0b7226f3a59ab9392148fab64f8e3ce46 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 11:55:22 +0900 Subject: [PATCH 33/36] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EB=B2=84=EC=A0=84=20=EC=A1=B0=ED=9A=8C=20=EB=B3=91?= =?UTF-8?q?=EB=A0=AC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 5 +- .../Protocol/DevelopmentRecordService.swift | 5 ++ .../DevelopmentRecordRepositoryImpl.swift | 16 ++++++ ...DevelopmentRecordRepositoryImplTests.swift | 8 +++ .../DevelopmentRecordQueryUseCaseGraph.swift | 5 ++ .../DevelopmentRecordRepository.swift | 5 ++ .../ConfirmDevelopmentRecordUseCaseImpl.swift | 10 ++-- ...FetchDevelopmentRecordHistoryUseCase.swift | 8 +++ ...hDevelopmentRecordHistoryUseCaseImpl.swift | 20 ++++++++ ...opmentRecordConfirmationUseCaseTests.swift | 4 +- .../DevelopmentRecordUseCaseTestSupport.swift | 27 ++++++++++ .../DevelopmentRecordUseCaseTests.swift | 18 +++++++ .../DevelopmentRecordServiceImpl.swift | 22 +++++++++ .../DevelopmentDependencyPreparation.swift | 15 +++++- .../Record/GoalDetail/GoalDetailFeature.swift | 49 ++++++++++++------- .../Tests/Record/GoalDetailFeatureTests.swift | 6 +-- .../Record/RecordFeatureTestSupport.swift | 12 +++++ 17 files changed, 204 insertions(+), 31 deletions(-) diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 70219f19..5b5fc36c 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -36,7 +36,10 @@ private extension AppGraph { .fetchDevelopmentRecordsUseCase, fetchRecordHistoryUseCase: developmentGraphSet .developmentRecordQueryUseCaseGraph - .fetchDevelopmentRecordHistoryUseCase + .fetchDevelopmentRecordHistoryUseCase, + fetchRecordVersionUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordVersionUseCase ) DevelopmentDependencyPreparation.prepareMutation( &dependencies, 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 edf57ad2..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 diff --git a/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift b/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift index 1621f07b..9bdfc080 100644 --- a/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/DevelopmentRecordRepositoryImplTests.swift @@ -102,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/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 712b8e03..66188f87 100644 --- a/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift +++ b/Application/Domain/Sources/Protocol/DevelopmentRecordRepository.swift @@ -13,6 +13,11 @@ 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, diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift index aea2cee4..ac4bfc10 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Confirm/ConfirmDevelopmentRecordUseCaseImpl.swift @@ -90,10 +90,10 @@ private extension ConfirmDevelopmentRecordUseCaseImpl { guard record.currentVersion?.id == versionId else { throw DomainLayerError.developmentRecordVersionNotFound } - let versions = try await repository.fetchVersions(goalId: goalId, recordId: recordId) - guard let version = versions.first(where: { $0.id == versionId }) else { - throw DomainLayerError.developmentRecordVersionNotFound - } - return version + return try await repository.fetchVersion( + goalId: goalId, + recordId: recordId, + versionId: versionId + ) } } 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/DevelopmentRecordConfirmationUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift index e6e2d72b..789e461e 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordConfirmationUseCaseTests.swift @@ -133,8 +133,8 @@ struct DevelopmentRecordConfirmUseCaseTests { #expect(result == version) #expect(await repository.confirmRequests().count == 1) - #expect(await repository.versionQueries() == [ - .init(goalId: "goal-1", recordId: "record-1") + #expect(await repository.exactVersionQueries() == [ + .init(goalId: "goal-1", recordId: "record-1", versionId: version.id) ]) } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift index 4e5f6663..67964dcb 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift @@ -55,6 +55,12 @@ 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 @@ -89,6 +95,7 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { 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]() @@ -143,6 +150,22 @@ 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, @@ -207,6 +230,10 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { recordedVersionQueries } + func exactVersionQueries() -> [VersionQuery] { + recordedExactVersionQueries + } + func draftRequests() -> [DraftRequest] { recordedDraftRequests } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift index ca848362..e6fd6e93 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift @@ -87,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() diff --git a/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift b/Application/Infra/Sources/Service/DevelopmentRecordServiceImpl.swift index cd0f013e..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 } } @@ -140,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/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift index 90f78646..eb8225c1 100644 --- a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -19,10 +19,12 @@ public enum DevelopmentDependencyPreparation { public static func prepareQuery( _ dependencies: inout DependencyValues, fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, - fetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase + fetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase, + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase ) { dependencies.developmentFetchRecordsUseCase = fetchRecordsUseCase dependencies.developmentFetchRecordHistoryUseCase = fetchRecordHistoryUseCase + dependencies.developmentFetchRecordVersionUseCase = fetchRecordVersionUseCase } public static func prepareMutation( @@ -53,6 +55,11 @@ extension DependencyValues { 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 } @@ -87,6 +94,12 @@ private enum DevelopmentFetchRecordHistoryUseCaseKey: DependencyKey { } } +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.") diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index 5e4ff542..969bb64f 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -54,7 +54,7 @@ struct GoalDetailFeature { @Dependency(\.developmentFetchGoalUseCase) private var fetchGoalUseCase @Dependency(\.developmentFetchRecordsUseCase) private var fetchRecordsUseCase - @Dependency(\.developmentFetchRecordHistoryUseCase) private var fetchRecordHistoryUseCase + @Dependency(\.developmentFetchRecordVersionUseCase) private var fetchRecordVersionUseCase var body: some ReducerOf { Reduce { state, action in @@ -87,30 +87,41 @@ struct GoalDetailFeature { extension GoalDetailFeature { func fetchEffect(goalId: String) -> Effect { - .run { [fetchGoalUseCase, fetchRecordsUseCase, fetchRecordHistoryUseCase] send in + .run { [fetchGoalUseCase, fetchRecordsUseCase, fetchRecordVersionUseCase] send in do { let goal = try await fetchGoalUseCase.execute(goalId) let records = try await fetchRecordsUseCase.execute(goalId: goalId) - var items = [RecordTimelineItem]() - - for record in records.sorted(by: Self.precedes) { - let currentVersion: DevelopmentRecord.Version? - if let reference = record.currentVersion { - let versions = try await fetchRecordHistoryUseCase.execute( - goalId: goalId, - recordId: record.id - ) - guard let version = versions.first(where: { $0.id == reference.id }) else { - throw DomainLayerError.developmentRecordVersionNotFound + 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) } - currentVersion = version - } else { - currentVersion = nil } - items.append(RecordTimelineItem( + + for try await (index, version) in group { + currentVersions[index] = version + } + } + + let items = zip(sortedRecords, currentVersions).map { record, version in + RecordTimelineItem( record: record, - currentVersion: currentVersion - )) + currentVersion: version + ) } await send(.store(.loaded(goalTitle: goal.title, items: items))) diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index 070aa042..7cbd25bf 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -34,8 +34,8 @@ struct GoalDetailFeatureTests { $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( result: .success([draftRecord, confirmedRecord]) ) - $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( - resultByRecordId: [confirmedRecord.id: .success([version])] + $0.developmentFetchRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseStub( + resultByRecordId: [confirmedRecord.id: .success(version)] ) } @@ -62,7 +62,7 @@ struct GoalDetailFeatureTests { $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( result: .failure(RecordTestError.failed) ) - $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( + $0.developmentFetchRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseStub( resultByRecordId: [:] ) } diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index ec5a47f5..b6fe4236 100644 --- a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -52,6 +52,18 @@ struct FetchDevelopmentRecordHistoryUseCaseStub: FetchDevelopmentRecordHistoryUs } } +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 From b345aec974ad8273aaa3a17d8300e312db0de8c0 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 11:57:17 +0900 Subject: [PATCH 34/36] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/GoalDetail/GoalDetailFeature.swift | 5 +++ .../Record/GoalDetail/GoalDetailView.swift | 42 +++++++++++++------ .../Tests/Record/GoalDetailFeatureTests.swift | 3 ++ .../Resources/Localizable.xcstrings | 7 ++++ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index 969bb64f..2e95f33e 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -30,6 +30,7 @@ struct GoalDetailFeature { var items = [RecordTimelineItem]() var isLoading = false var hasLoaded = false + var hasLoadFailure = false init(goalId: String) { self.goalId = goalId @@ -64,18 +65,22 @@ struct GoalDetailFeature { 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 } diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index 8f3165e0..dbfc8664 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -80,7 +80,23 @@ public struct GoalDetailView: View { @ViewBuilder private var timelineCard: some View { VStack(spacing: 18) { - if store.isLoading, store.items.isEmpty { + 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) @@ -103,18 +119,20 @@ public struct GoalDetailView: View { } } - Button { - editorDestination = EditorDestination(record: nil) - } label: { - Label( - RecordPresentation.text("development_record_add"), - systemImage: "plus" - ) - .font(.callout) - .foregroundStyle(Color.accent) + 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) } - .adaptiveButtonStyle(color: .primaryContainer) - .disabled(store.isLoading) } .padding() .background { diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index 7cbd25bf..fa7d84d0 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -72,13 +72,16 @@ struct GoalDetailFeatureTests { } 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/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index d42e1780..8730645a 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -585,6 +585,13 @@ "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" : { From 32ec12c2b032c69bcd813d5cb60bc6c24e010f67 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 11:57:44 +0900 Subject: [PATCH 35/36] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=A4=91=20=ED=8E=B8=EC=A7=91=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EB=8B=AB=EA=B8=B0=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Development/Sources/Record/Editor/RecordEditorView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index c485aae8..387a4515 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -59,6 +59,7 @@ public struct RecordEditorView: View { dismiss() } } + .interactiveDismissDisabled(store.isLoading) } private var topBar: some View { @@ -75,6 +76,7 @@ public struct RecordEditorView: View { } .font(.title) .topBarButtonStyle() + .disabled(store.isLoading) Spacer() Text(RecordPresentation.text("development_record_editor_title")) .font(.headline) From 94c716fb0ddecae2065431259406da9fc591646d Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 12:05:30 +0900 Subject: [PATCH 36/36] =?UTF-8?q?fix:=20=EA=B8=B0=EB=A1=9D=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EB=8B=A8=EA=B3=84=EB=B6=80=ED=84=B0=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/Editor/RecordEditorFeature.swift | 26 +++++++++++++++++++ .../Record/RecordEditorFeatureTests.swift | 17 +++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift index f91f7cda..0d8612b7 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift @@ -24,6 +24,7 @@ struct RecordEditorFeature { var selectedTab = EditorTab.write var isLoading = false var result: Result? + var confirmationPreparation: ConfirmationPreparation? var versionNumber: Int { (record?.currentVersion?.number ?? 0) + 1 @@ -66,6 +67,12 @@ struct RecordEditorFeature { 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) @@ -105,19 +112,37 @@ struct RecordEditorFeature { 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, @@ -127,6 +152,7 @@ struct RecordEditorFeature { 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 diff --git a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift index 0cff505f..7f6d1ce6 100644 --- a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift @@ -46,10 +46,16 @@ struct RecordEditorFeatureTests { } 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))) } @@ -143,8 +149,8 @@ struct RecordEditorFeatureTests { #expect(await createSpy.requests().map(\.recordId) == [record.id, record.id]) } - @Test("확정 실패 뒤 재시도는 생성된 기록을 다시 사용한다") - func 확정_실패_뒤_재시도는_생성된_기록을_다시_사용한다() async throws { + @Test("확정 실패 뒤 재시도는 준비된 기록과 versionId를 다시 사용한다") + func 확정_실패_뒤_재시도는_준비된_기록과_versionId를_다시_사용한다() async throws { let record = try makeDevelopmentRecord() let version = try makeDevelopmentRecordVersion() let createSpy = CreateDevelopmentRecordUseCaseSpy(result: .success(record)) @@ -176,6 +182,11 @@ struct RecordEditorFeatureTests { } await store.receive(.store(.preparedForConfirmation(record))) { $0.record = record + $0.confirmationPreparation = .init( + record: record, + title: "기록 제목", + markdownContent: "" + ) } await store.receive(.store(.failed)) { $0.isLoading = false @@ -188,10 +199,10 @@ struct RecordEditorFeatureTests { $0.isLoading = true $0.result = nil } - await store.receive(.store(.preparedForConfirmation(record))) await store.receive(.store(.confirmed(version))) { $0.isLoading = false $0.result = .confirmed(version) + $0.confirmationPreparation = nil } await store.receive(.delegate(.confirmed(version)))