Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Similar Projects] MBL-2164: Add a Use Case object with stubs #2311

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion Kickstarter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 70;
objects = {

/* Begin PBXBuildFile section */
Expand Down Expand Up @@ -3346,6 +3346,27 @@
E1FDB1E72AEAAC6100285F93 /* CombineTestObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTestObserver.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
AA5480D72D76B8B600EC2849 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
SimilarProjects/SimilarProjectsUseCaseTests.swift,
);
target = A755113B1C8642B3005355CF /* Library-iOS */;
};
AA5480D92D76B8B900EC2849 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
SimilarProjects/SimilarProjectsUseCaseTests.swift,
);
target = A75511441C8642B3005355CF /* Library-iOSTests */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
AA5480D22D76B8A400EC2849 /* UseCases */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (AA5480D72D76B8B600EC2849 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, AA5480D92D76B8B900EC2849 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = UseCases; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */

/* Begin PBXFrameworksBuildPhase section */
A75511381C8642B3005355CF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
Expand Down Expand Up @@ -6449,6 +6470,7 @@
D6B6F9BF20F403F400A295F7 /* UserAttribute.swift */,
1611EF6623B2752A0051CDCC /* UUIDType.swift */,
A734A2661D21A1790080BBD5 /* WKNavigationActionData.swift */,
AA5480D22D76B8A400EC2849 /* UseCases */,
A7C725851C85D36D005A016B /* DataSource */,
778F891A22D3E35600D095C5 /* Extensions */,
A73378F91D0AE33B00C91445 /* Styles */,
Expand Down Expand Up @@ -7387,6 +7409,9 @@
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA5480D22D76B8A400EC2849 /* UseCases */,
);
name = "Library-iOS";
packageProductDependencies = (
06634FC62807A4EB00950F60 /* Prelude_UIKit */,
Expand Down
5 changes: 5 additions & 0 deletions Library/UseCases/SimilarProjects/SimilarProject.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// Represents a project that is similar to the currently viewed project.
public protocol SimilarProject {
/// The identifier for the project.
var pid: String { get }
}
14 changes: 14 additions & 0 deletions Library/UseCases/SimilarProjects/SimilarProjectsState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// Represents the various states of the similar projects presentation.
public enum SimilarProjectsState {
/// The similar projects section is hidden from view.
case hidden

/// The similar projects are currently being loaded .
case loading

/// Similar projects have been successfully loaded.
case loaded(projects: [any SimilarProject])

/// An error occurred while loading similar projects.
case error(error: Error)
}
93 changes: 93 additions & 0 deletions Library/UseCases/SimilarProjects/SimilarProjectsUseCase.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation
import ReactiveSwift

public protocol SimilarProjectsUseCaseType {
var inputs: SimilarProjectsUseCaseInputs { get }
var outputs: SimilarProjectsUseCaseOutputs { get }
}

public protocol SimilarProjectsUseCaseInputs {
/// Call when a user taps on a similar project.
/// Triggers navigation to the selected project's details.
///
/// - Parameter project: The project that was tapped.
func projectTapped(project: any SimilarProject)

/// Call when a project ID is loaded or becomes available.
/// Initiates fetching of similar projects for the given project ID.
///
/// - Parameter projectID: The ID of the project to find similar projects for.
func projectIDLoaded(projectID: String)
}

public protocol SimilarProjectsUseCaseOutputs {
/// The current state of similar projects.
var similarProjects: Property<SimilarProjectsState> { get }

/// Signal that emits when a user has tapped on a similar project.
/// Use this to navigate to the selected project's details.
var navigateToProject: Signal<any SimilarProject, Never> { get }
}

/// A Use Case for fetching similar projects and navigating to them when the user taps them.
public final class SimilarProjectsUseCase: SimilarProjectsUseCaseType, SimilarProjectsUseCaseInputs,
SimilarProjectsUseCaseOutputs {
// MARK: - Initialization

init() {
self.navigateToProject = self.projectTappedSignal

self.projectIDLoadedSignal
.flatMap(.latest, self.fetchProjects(projectID:))
.observeForUI()
.observeValues { [weak self] state in
self?.similarProjectsProperty.value = state
}
}

// MARK: - Data loading

private func fetchProjects(projectID: String) -> SignalProducer<SimilarProjectsState, Never> {
// TODO: Implement this stub in MBL-2165
SignalProducer(value: projectID)
.delay(1.0, on: AppEnvironment.current.scheduler)
.map { _ in
.loaded(projects: [FakeProject(), FakeProject(), FakeProject(), FakeProject()])
}
}

// MARK: - Inputs

private let (projectTappedSignal, projectTappedObserver) = Signal<any SimilarProject, Never>.pipe()
public func projectTapped(project: any SimilarProject) {
self.projectTappedObserver.send(value: project)
}

private let (projectIDLoadedSignal, projectIDLoadedObserver) = Signal<String, Never>.pipe()
public func projectIDLoaded(projectID: String) {
self.projectIDLoadedObserver.send(value: projectID)
}

// MARK: - Outputs

public let navigateToProject: Signal<any SimilarProject, Never>

public let similarProjectsProperty = MutableProperty<SimilarProjectsState>(.loading)
public var similarProjects: Property<SimilarProjectsState> {
Property(self.similarProjectsProperty)
}

// MARK: - Type

public var inputs: any SimilarProjectsUseCaseInputs { return self }
public var outputs: any SimilarProjectsUseCaseOutputs { return self }
}

// MARK: - Supporting Types

private struct FakeProject: SimilarProject {
let pid: String
init() {
self.pid = UUID().uuidString
}
}
76 changes: 76 additions & 0 deletions Library/UseCases/SimilarProjects/SimilarProjectsUseCaseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
@testable import KsApi
@testable import Library
import ReactiveExtensions_TestHelpers
import ReactiveSwift
import XCTest

final class SimilarProjectsUseCaseTests: TestCase {
private var useCase: SimilarProjectsUseCase!
private let projectTappedObserver = TestObserver<SimilarProject, Never>()
private let similarProjectsObserver = TestObserver<SimilarProjectsState, Never>()

override func setUp() {
super.setUp()
self.useCase = SimilarProjectsUseCase()
self.useCase.navigateToProject.observe(self.projectTappedObserver.observer)
self.useCase.similarProjects.producer.start(self.similarProjectsObserver.observer)
}

override func tearDown() {
self.useCase = nil
super.tearDown()
}

func testInitialState() {
// The useCase should start with a loading state
XCTAssertEqual(1, self.similarProjectsObserver.values.count)

if case .loading = self.similarProjectsObserver.values[0] {
// Expected loading state
} else {
XCTFail("Expected initial loading state")
}
}

func testProjectIDLoaded_emitsLoadedState() {
// When loading a project ID
self.useCase.projectIDLoaded(projectID: "project-123")

// Verify we're in loading state
XCTAssertEqual(1, self.similarProjectsObserver.values.count)
if case .loading = self.similarProjectsObserver.values[0] {
// Expected loading state
} else {
XCTFail("Expected loading state")
}

// Advance scheduler to simulate network delay
self.scheduler.advance(by: .seconds(2))

// Verify we received loaded state with projects
XCTAssertEqual(2, self.similarProjectsObserver.values.count)

if case let .loaded(projects) = similarProjectsObserver.values[1] {
XCTAssertEqual(4, projects.count, "Expected 4 similar projects")
} else {
XCTFail("Expected loaded state with projects")
}
}

func testProjectTapped_emitsNavigateToProject() {
// Create a fake project
let project = TestSimilarProject(pid: "project-456")

// Send project tapped event
self.useCase.projectTapped(project: project)

// Verify navigate signal fired
XCTAssertEqual(1, self.projectTappedObserver.values.count)
XCTAssertEqual("project-456", self.projectTappedObserver.values[0].pid)
}
}

// Test helper
private struct TestSimilarProject: SimilarProject {
let pid: String
}
25 changes: 25 additions & 0 deletions Library/ViewModels/ProjectPageViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ public protocol ProjectPageViewModelInputs {

/// Call when right before orientation change on view
func viewWillTransition()

/// Call when a similar project is tapped.
func similarProjectTapped(project: any SimilarProject)
}

public protocol ProjectPageViewModelOutputs {
Expand Down Expand Up @@ -161,6 +164,12 @@ public protocol ProjectPageViewModelOutputs {

/// Emits when a block user request fails.
var didBlockUserError: Signal<(), Never> { get }

/// The current state of similar projects.
var similarProjects: Property<SimilarProjectsState> { get }

/// Signal that emits when a user taps on a similar project.
var navigateToSimilarProject: Signal<any SimilarProject, Never> { get }
}

public protocol ProjectPageViewModelType {
Expand Down Expand Up @@ -743,6 +752,22 @@ public final class ProjectPageViewModel: ProjectPageViewModelType, ProjectPageVi

public var inputs: ProjectPageViewModelInputs { return self }
public var outputs: ProjectPageViewModelOutputs { return self }

// MARK: - Similar Projects

private let similarProjectsUseCase = SimilarProjectsUseCase()

public func similarProjectTapped(project: any SimilarProject) {
self.similarProjectsUseCase.projectTapped(project: project)
}

public var similarProjects: Property<SimilarProjectsState> {
self.similarProjectsUseCase.similarProjects
}

public var navigateToSimilarProject: Signal<any SimilarProject, Never> {
self.similarProjectsUseCase.navigateToProject
}
}

private func fetchProjectFriends(projectOrParam: Either<Project, Param>)
Expand Down