From 547d829512a220fd3806cc008438b9c221cd2934 Mon Sep 17 00:00:00 2001 From: hubab1 Date: Sun, 19 Jul 2026 13:42:30 +0100 Subject: [PATCH] feat(research): add project workspace shell --- OpenASO.xcodeproj/project.pbxproj | 12 + OpenASO/App/RootView.swift | 14 + .../KeywordResearchProjectEditorSheet.swift | 154 +++++++ .../KeywordResearchWorkspaceView.swift | 414 ++++++++++++++++++ ...ywordResearchWorkspaceSelectionTests.swift | 41 ++ 5 files changed, 635 insertions(+) create mode 100644 OpenASO/Features/KeywordResearch/KeywordResearchProjectEditorSheet.swift create mode 100644 OpenASO/Features/KeywordResearch/KeywordResearchWorkspaceView.swift create mode 100644 OpenASOTests/KeywordResearchWorkspaceSelectionTests.swift diff --git a/OpenASO.xcodeproj/project.pbxproj b/OpenASO.xcodeproj/project.pbxproj index e730e34..c32490c 100644 --- a/OpenASO.xcodeproj/project.pbxproj +++ b/OpenASO.xcodeproj/project.pbxproj @@ -154,6 +154,9 @@ D34D00000000000000000002 /* KeywordResearchHistoryModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34D00000000000000000012 /* KeywordResearchHistoryModelTests.swift */; }; D34E00000000000000000001 /* KeywordResearchModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34E00000000000000000011 /* KeywordResearchModelFactory.swift */; }; D34E00000000000000000002 /* KeywordResearchModelFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34E00000000000000000012 /* KeywordResearchModelFactoryTests.swift */; }; + D34E00000000000000000003 /* KeywordResearchWorkspaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34E00000000000000000013 /* KeywordResearchWorkspaceView.swift */; }; + D34E00000000000000000004 /* KeywordResearchProjectEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34E00000000000000000014 /* KeywordResearchProjectEditorSheet.swift */; }; + D34F00000000000000000002 /* KeywordResearchWorkspaceSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D34F00000000000000000012 /* KeywordResearchWorkspaceSelectionTests.swift */; }; D20B00000000000000000007 /* Persistence/EstimatedKeywordDifficultyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20B00000000000000000014 /* Persistence/EstimatedKeywordDifficultyStore.swift */; }; D20B00000000000000000008 /* Persistence/EstimatedKeywordDifficultyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20B00000000000000000014 /* Persistence/EstimatedKeywordDifficultyStore.swift */; }; D20B00000000000000000009 /* EstimatedKeywordDifficultyStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20B00000000000000000015 /* EstimatedKeywordDifficultyStoreTests.swift */; }; @@ -435,6 +438,9 @@ D34D00000000000000000012 /* KeywordResearchHistoryModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordResearchHistoryModelTests.swift; sourceTree = ""; }; D34E00000000000000000011 /* KeywordResearchModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordResearchModelFactory.swift; sourceTree = ""; }; D34E00000000000000000012 /* KeywordResearchModelFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordResearchModelFactoryTests.swift; sourceTree = ""; }; + D34E00000000000000000013 /* KeywordResearchWorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordResearchWorkspaceView.swift; sourceTree = ""; }; + D34E00000000000000000014 /* KeywordResearchProjectEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordResearchProjectEditorSheet.swift; sourceTree = ""; }; + D34F00000000000000000012 /* KeywordResearchWorkspaceSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordResearchWorkspaceSelectionTests.swift; sourceTree = ""; }; D20B00000000000000000014 /* Persistence/EstimatedKeywordDifficultyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence/EstimatedKeywordDifficultyStore.swift; sourceTree = ""; }; D20B00000000000000000015 /* EstimatedKeywordDifficultyStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedKeywordDifficultyStoreTests.swift; sourceTree = ""; }; A20000000000000000000011 /* KeywordTableCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordTableCells.swift; sourceTree = ""; }; @@ -590,6 +596,7 @@ D34C00000000000000000012 /* KeywordResearchProjectDetailModelTests.swift */, D34D00000000000000000012 /* KeywordResearchHistoryModelTests.swift */, D34E00000000000000000012 /* KeywordResearchModelFactoryTests.swift */, + D34F00000000000000000012 /* KeywordResearchWorkspaceSelectionTests.swift */, D09D00000000000000000007 /* TrackedKeywordRefreshStatusTests.swift */, D20B00000000000000000015 /* EstimatedKeywordDifficultyStoreTests.swift */, D02000000000000000000004 /* v0.3.2-v1 */, @@ -663,6 +670,8 @@ D34C00000000000000000011 /* KeywordResearchProjectDetailModel.swift */, D34D00000000000000000011 /* KeywordResearchHistoryModel.swift */, D34E00000000000000000011 /* KeywordResearchModelFactory.swift */, + D34E00000000000000000013 /* KeywordResearchWorkspaceView.swift */, + D34E00000000000000000014 /* KeywordResearchProjectEditorSheet.swift */, ); path = KeywordResearch; sourceTree = ""; @@ -1246,6 +1255,8 @@ D34C00000000000000000001 /* KeywordResearchProjectDetailModel.swift in Sources */, D34D00000000000000000001 /* KeywordResearchHistoryModel.swift in Sources */, D34E00000000000000000001 /* KeywordResearchModelFactory.swift in Sources */, + D34E00000000000000000003 /* KeywordResearchWorkspaceView.swift in Sources */, + D34E00000000000000000004 /* KeywordResearchProjectEditorSheet.swift in Sources */, 9A6937276AF0E3CAEDA90C36 /* RankingSource.swift in Sources */, A40000000000000000000003 /* RatingsDetail.swift in Sources */, A40000000000000000000001 /* RatingsDashboardSupport.swift in Sources */, @@ -1300,6 +1311,7 @@ D34C00000000000000000002 /* KeywordResearchProjectDetailModelTests.swift in Sources */, D34D00000000000000000002 /* KeywordResearchHistoryModelTests.swift in Sources */, D34E00000000000000000002 /* KeywordResearchModelFactoryTests.swift in Sources */, + D34F00000000000000000002 /* KeywordResearchWorkspaceSelectionTests.swift in Sources */, D09D00000000000000000008 /* TrackedKeywordRefreshStatusTests.swift in Sources */, D20B00000000000000000009 /* EstimatedKeywordDifficultyStoreTests.swift in Sources */, D14000000000000000000002 /* AppMetadataRefreshProgressStoreTests.swift in Sources */, diff --git a/OpenASO/App/RootView.swift b/OpenASO/App/RootView.swift index 7a57fa2..cefe91b 100644 --- a/OpenASO/App/RootView.swift +++ b/OpenASO/App/RootView.swift @@ -4,6 +4,7 @@ struct RootView: View { @Environment(AppServices.self) private var services @State private var selectedApp: TrackedApp? + @State private var isPresentingKeywordResearch = false var body: some View { NavigationSplitView { @@ -13,6 +14,19 @@ struct RootView: View { RootDetailView(selectedApp: selectedApp) } .navigationTitle("OpenASO") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + isPresentingKeywordResearch = true + } label: { + Label("Keyword Research", systemImage: "magnifyingglass") + } + .help("Open the pre-live keyword research workspace") + } + } + .sheet(isPresented: $isPresentingKeywordResearch) { + KeywordResearchWorkspaceLauncher() + } .task { guard !Self.isRunningUnderTests else { return diff --git a/OpenASO/Features/KeywordResearch/KeywordResearchProjectEditorSheet.swift b/OpenASO/Features/KeywordResearch/KeywordResearchProjectEditorSheet.swift new file mode 100644 index 0000000..3293812 --- /dev/null +++ b/OpenASO/Features/KeywordResearch/KeywordResearchProjectEditorSheet.swift @@ -0,0 +1,154 @@ +import SwiftUI + +struct ProjectEditorContext: Identifiable { + let id: UUID + let project: KeywordResearchProjectSnapshot? + let initialDraft: KeywordResearchProjectDraft + + static func create() -> Self { + let draft = KeywordResearchProjectDraft() + return Self(id: draft.id, project: nil, initialDraft: draft) + } + + static func edit(_ project: KeywordResearchProjectSnapshot) -> Self { + Self( + id: project.id, + project: project, + initialDraft: KeywordResearchProjectDraft(project: project) + ) + } +} + +struct ProjectEditorOutcome: Sendable { + let project: KeywordResearchProjectSnapshot? + let error: KeywordResearchErrorPresentation? +} + +struct KeywordResearchProjectEditorSheet: View { + @Environment(\.dismiss) private var dismiss + + let context: ProjectEditorContext + let storefronts: [BundledStorefront] + let save: @MainActor ( + _ context: ProjectEditorContext, + _ draft: KeywordResearchProjectDraft + ) async -> ProjectEditorOutcome + + @State private var draft: KeywordResearchProjectDraft + @State private var isSaving = false + @State private var error: KeywordResearchErrorPresentation? + + init( + context: ProjectEditorContext, + storefronts: [BundledStorefront], + save: @escaping @MainActor ( + _ context: ProjectEditorContext, + _ draft: KeywordResearchProjectDraft + ) async -> ProjectEditorOutcome + ) { + self.context = context + self.storefronts = storefronts + self.save = save + _draft = State(initialValue: context.initialDraft) + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text(context.project == nil ? "New Research Project" : "Edit Research Project") + .font(.title2) + .fontWeight(.semibold) + + Form { + TextField("Name", text: $draft.name) + TextField("Bundle identifier (optional)", text: $draft.bundleID) + + if storefronts.isEmpty { + TextField("Default storefront", text: $draft.defaultStorefront) + } else { + Picker("Default storefront", selection: $draft.defaultStorefront) { + ForEach(storefronts, id: \.code) { storefront in + Text("\(storefront.flagEmoji) \(storefront.name)") + .tag(storefront.code.lowercased()) + } + } + } + + Picker("Default platform", selection: $draft.defaultPlatform) { + ForEach(AppPlatform.allCases) { platform in + Text(platform.displayName).tag(platform) + } + } + + TextField("Notes (optional)", text: $draft.notes, axis: .vertical) + .lineLimit(3...8) + } + .formStyle(.grouped) + + if let error { + VStack(alignment: .leading, spacing: 4) { + Label(error.title, systemImage: "exclamationmark.triangle") + .fontWeight(.medium) + Text(error.message) + if let recoverySuggestion = error.recoverySuggestion { + Text(recoverySuggestion) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(error.accessibilityLabel) + } + + HStack { + Spacer() + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + .disabled(isSaving) + + Button { + submit() + } label: { + if isSaving { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + Text("Saving…") + } + } else { + Text(context.project == nil ? "Create" : "Save") + } + } + .accessibilityLabel( + isSaving + ? "Saving Research Project" + : (context.project == nil ? "Create Research Project" : "Save Research Project") + ) + .keyboardShortcut(.defaultAction) + .disabled(isSaving || draft.name.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty) + } + } + .padding(24) + .frame(minWidth: 520, minHeight: 460) + .interactiveDismissDisabled(isSaving) + } + + private func submit() { + guard !isSaving else { return } + isSaving = true + error = nil + + Task { + let outcome = await save(context, draft) + isSaving = false + if outcome.project != nil { + dismiss() + } else { + error = outcome.error ?? .presenting(OpenASOError.unexpectedResponse) + } + } + } +} diff --git a/OpenASO/Features/KeywordResearch/KeywordResearchWorkspaceView.swift b/OpenASO/Features/KeywordResearch/KeywordResearchWorkspaceView.swift new file mode 100644 index 0000000..4255890 --- /dev/null +++ b/OpenASO/Features/KeywordResearch/KeywordResearchWorkspaceView.swift @@ -0,0 +1,414 @@ +import SwiftUI + +struct KeywordResearchWorkspaceLauncher: View { + @Environment(AppServices.self) private var services + + var body: some View { + if let factory = KeywordResearchModelFactory(services: services) { + KeywordResearchWorkspaceView( + factory: factory, + storefronts: (try? services.storefrontCatalog.bundledStorefronts()) ?? [] + ) + } else { + ContentUnavailableView( + "Research Workspace Unavailable", + systemImage: "exclamationmark.triangle", + description: Text( + "OpenASO could not initialize the persistent research services. " + + "Close this sheet and retry after the workspace store is available." + ) + ) + .frame(minWidth: 680, minHeight: 480) + } + } +} + +struct KeywordResearchWorkspaceView: View { + @Environment(\.dismiss) private var dismiss + + private let storefronts: [BundledStorefront] + + @State private var projectsModel: KeywordResearchProjectsModel + @State private var selectedProjectGeneration: KeywordResearchProjectGeneration? + @State private var editorContext: ProjectEditorContext? + @State private var projectPendingDeletion: KeywordResearchProjectSnapshot? + @State private var deletingProjectGeneration: KeywordResearchProjectGeneration? + @State private var deletionError: KeywordResearchErrorPresentation? + + init( + factory: KeywordResearchModelFactory, + storefronts: [BundledStorefront] + ) { + self.storefronts = storefronts + _projectsModel = State(initialValue: factory.makeProjectsModel()) + } + + var body: some View { + NavigationSplitView { + projectSidebar + .navigationSplitViewColumnWidth(min: 260, ideal: 300, max: 380) + } detail: { + projectDetail + } + .navigationTitle("Keyword Research") + .frame(minWidth: 900, minHeight: 620) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + if deletingProjectGeneration != nil { + ProgressView("Deleting Project…") + .controlSize(.small) + } else if let selectedProject { + Button { + editorContext = .edit(selectedProject) + } label: { + Label("Edit Project", systemImage: "pencil") + } + .disabled(projectsModel.mutationState.isRunning) + + Button(role: .destructive) { + projectPendingDeletion = selectedProject + } label: { + Label("Delete Project", systemImage: "trash") + } + .disabled(projectsModel.mutationState.isRunning) + } + + Button("Close") { + dismiss() + } + .keyboardShortcut(.cancelAction) + .disabled(deletingProjectGeneration != nil) + } + } + .interactiveDismissDisabled(deletingProjectGeneration != nil) + .task { + guard projectsModel.loadState == .idle else { return } + await projectsModel.reload() + reconcileSelection() + } + .onChange(of: projectsModel.projects) { + reconcileSelection() + } + .sheet(item: $editorContext) { context in + KeywordResearchProjectEditorSheet( + context: context, + storefronts: storefronts, + save: saveProject + ) + } + .confirmationDialog( + "Delete research project?", + isPresented: Binding( + get: { projectPendingDeletion != nil }, + set: { if !$0 { projectPendingDeletion = nil } } + ), + titleVisibility: .visible + ) { + if let projectPendingDeletion { + Button("Delete \(projectPendingDeletion.name)", role: .destructive) { + deleteProject(projectPendingDeletion) + } + } + } message: { + Text( + "This removes the project and its keyword memberships. " + + "Shared query evidence used elsewhere is retained." + ) + } + .alert(item: $deletionError) { error in + Alert( + title: Text(error.title), + message: Text([ + error.message, + error.recoverySuggestion, + ].compactMap { $0 }.joined(separator: "\n\n")), + dismissButton: .default(Text("OK")) { + projectsModel.clearMutationResult() + } + ) + } + } + + private var selectedProject: KeywordResearchProjectSnapshot? { + guard let selectedProjectGeneration else { return nil } + return projectsModel.projects.first { + $0.generation == selectedProjectGeneration + } + } + + private var projectSidebar: some View { + VStack(spacing: 0) { + HStack { + Text("Research Projects") + .font(.headline) + Spacer() + Button("Reload Projects", systemImage: "arrow.clockwise") { + Task { await reloadProjects() } + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help("Reload Projects") + .disabled(projectsModel.loadState.isLoading) + + Button("New Research Project", systemImage: "plus") { + editorContext = .create() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help("New Research Project") + .disabled(projectsModel.mutationState.isRunning) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + + Divider() + + if projectsModel.projects.isEmpty { + emptyProjectState + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(selection: $selectedProjectGeneration) { + ForEach(projectsModel.projects) { project in + ProjectRow(project: project) + .tag(project.generation) + } + } + + projectListFooter + } + } + .background(.background) + } + + @ViewBuilder + private var emptyProjectState: some View { + switch projectsModel.loadState { + case .loading: + ProgressView("Loading research projects…") + .controlSize(.small) + case .failed(let error): + ContentUnavailableView { + Label(error.title, systemImage: "exclamationmark.triangle") + } description: { + Text(error.message) + } actions: { + Button("Try Again") { + Task { await reloadProjects() } + } + } + .accessibilityLabel(error.accessibilityLabel) + case .idle, .loaded, .loadingNextPage: + ContentUnavailableView { + Label("No Research Projects", systemImage: "lightbulb") + } description: { + Text("Create a project before an app is live, without using a fake App Store ID.") + } actions: { + Button("New Project") { + editorContext = .create() + } + } + } + } + + @ViewBuilder + private var projectListFooter: some View { + if projectsModel.requiresReload { + StatusRow( + title: "Projects changed", + message: "Reload before continuing to the next page.", + systemImage: "arrow.clockwise", + actionTitle: "Reload" + ) { + Task { await reloadProjects() } + } + } else if case .failed(let error) = projectsModel.loadState { + StatusRow( + title: error.title, + message: error.message, + systemImage: "exclamationmark.triangle", + actionTitle: "Try Again" + ) { + Task { await reloadProjects() } + } + .accessibilityLabel(error.accessibilityLabel) + } else if projectsModel.loadState == .loadingNextPage { + ProgressView("Loading more…") + .controlSize(.small) + .frame(maxWidth: .infinity) + .padding(10) + } else if projectsModel.hasMoreProjects { + Button("Load More") { + Task { await projectsModel.loadNextPage() } + } + .frame(maxWidth: .infinity) + .padding(10) + } + } + + @ViewBuilder + private var projectDetail: some View { + if let project = selectedProject { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text(project.name) + .font(.largeTitle) + .fontWeight(.semibold) + Text("Pre-live research project") + .foregroundStyle(.secondary) + } + + LabeledContent("Default storefront", value: project.defaultStorefront.uppercased()) + LabeledContent("Default platform", value: project.defaultPlatform.displayName) + LabeledContent("Bundle identifier", value: project.bundleID ?? "Not set") + + VStack(alignment: .leading, spacing: 6) { + Text("Notes") + .font(.headline) + Text(project.notes.isEmpty ? "No notes" : project.notes) + .foregroundStyle(project.notes.isEmpty ? .secondary : .primary) + .textSelection(.enabled) + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Independent research") + .font(.headline) + Text( + "This workspace does not claim that the project has an App Store rank. " + + "Ranking evidence belongs to shared keyword search results." + ) + .foregroundStyle(.secondary) + } + } + .padding(28) + .frame(maxWidth: 720, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .contain) + .accessibilityLabel(project.keywordResearchAccessibilityLabel) + } else { + ContentUnavailableView( + "Select a Research Project", + systemImage: "sidebar.left", + description: Text("Choose a project from the sidebar or create a new one.") + ) + } + } + + private func saveProject( + context: ProjectEditorContext, + draft: KeywordResearchProjectDraft + ) async -> ProjectEditorOutcome { + let project: KeywordResearchProjectSnapshot? + if let existing = context.project { + project = await projectsModel.update( + revision: existing.revision, + with: draft + ) + } else { + project = await projectsModel.create(draft) + } + + if let project { + selectedProjectGeneration = project.generation + return ProjectEditorOutcome(project: project, error: nil) + } + return ProjectEditorOutcome( + project: nil, + error: projectsModel.mutationState.presentedError + ) + } + + private func deleteProject(_ project: KeywordResearchProjectSnapshot) { + projectPendingDeletion = nil + deletingProjectGeneration = project.generation + deletionError = nil + Task { + guard await projectsModel.delete(project) else { + deletingProjectGeneration = nil + deletionError = projectsModel.mutationState.presentedError + ?? .presenting(OpenASOError.unexpectedResponse) + return + } + deletingProjectGeneration = nil + if selectedProjectGeneration == project.generation { + selectedProjectGeneration = nil + } + reconcileSelection() + } + } + + private func reloadProjects() async { + await projectsModel.reload() + reconcileSelection() + } + + private func reconcileSelection() { + selectedProjectGeneration = KeywordResearchWorkspaceSelection.reconciled( + selectedProjectGeneration, + projects: projectsModel.projects + ) + } +} + +enum KeywordResearchWorkspaceSelection { + static func reconciled( + _ selection: KeywordResearchProjectGeneration?, + projects: [KeywordResearchProjectSnapshot] + ) -> KeywordResearchProjectGeneration? { + guard let selection else { return projects.first?.generation } + return projects.contains { $0.generation == selection } ? selection : nil + } +} + +private struct ProjectRow: View { + let project: KeywordResearchProjectSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(project.name) + .fontWeight(.medium) + .lineLimit(1) + Text("\(project.defaultStorefront.uppercased()) · \(project.defaultPlatform.displayName)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel(project.keywordResearchAccessibilityLabel) + } +} + +private struct StatusRow: View { + let title: String + let message: String + let systemImage: String + let actionTitle: String + let action: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: systemImage) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(title).fontWeight(.medium) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Button(actionTitle, action: action) + .controlSize(.small) + } + .padding(10) + } +} + +private extension KeywordResearchMutationState { + var presentedError: KeywordResearchErrorPresentation? { + guard case .failed(_, let error) = self else { return nil } + return error + } +} diff --git a/OpenASOTests/KeywordResearchWorkspaceSelectionTests.swift b/OpenASOTests/KeywordResearchWorkspaceSelectionTests.swift new file mode 100644 index 0000000..74c228d --- /dev/null +++ b/OpenASOTests/KeywordResearchWorkspaceSelectionTests.swift @@ -0,0 +1,41 @@ +import Testing +@testable import OpenASO + +struct KeywordResearchWorkspaceSelectionTests { + @Test + func selectsFirstProjectOnlyWhenThereIsNoExistingSelection() { + let first = makeProject(name: "First") + let second = makeProject(name: "Second") + + #expect(KeywordResearchWorkspaceSelection.reconciled( + nil, + projects: [first, second] + ) == first.generation) + } + + @Test + func preservesOnlyTheExactSelectedGeneration() { + let selected = makeProject(name: "Selected") + let updated = makeProject( + id: selected.id, + incarnationID: selected.incarnationID, + name: "Updated", + createdAt: selected.createdAt, + updatedAt: selected.updatedAt.addingTimeInterval(1) + ) + let reincarnated = makeProject(id: selected.id, name: "Reincarnated") + + #expect(KeywordResearchWorkspaceSelection.reconciled( + selected.generation, + projects: [updated] + ) == selected.generation) + #expect(KeywordResearchWorkspaceSelection.reconciled( + selected.generation, + projects: [reincarnated] + ) == nil) + #expect(KeywordResearchWorkspaceSelection.reconciled( + selected.generation, + projects: [] + ) == nil) + } +}