Skip to content
Merged
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
10 changes: 0 additions & 10 deletions Makefile

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public enum DomainModule: String, CaseIterable {
case auth = "AuthDomain"
case battle = "BattleDomain"
case comment = "CommentDomain"
case ad = "AdDomain"
case home = "HomeDomain"
case notification = "NotificationDomain"
case perspective = "PerspectiveDomain"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// ChatCoordinator.swift
// Chat
// App
//

import Foundation
Expand All @@ -9,6 +9,7 @@ import PickeCoreLogger
import ChatInterface
import CommentDomainInterface
import ComposableArchitecture
import FeatureAssembly
import PickeDesignKit
import PickeCoreUtility
import TCAFlow
Expand Down Expand Up @@ -99,7 +100,13 @@ extension ChatCoordinator {
action: IndexedRouterActionOf<ChatScreen>
) -> Effect<Action> {
switch action {
case .routeAction(_, action: .preVote(.delegate(.dismiss))):
case let .routeAction(id, action: .preVote(.delegate(.dismiss))):
// 루트(사전투표)의 뒤로가기는 Chat 플로우 전체를 닫고,
// 스택 위에 올라온 최종투표 화면이면 한 단계만 닫아 대화방으로 돌아간다.
guard id == 0 else {
state.routes.goBack()
return .none
}
return .send(.delegate(.dismiss))

case let .routeAction(_, action: .preVote(.delegate(.voteSubmitted(battleId, voteMode, _, isMindChanged)))):
Expand All @@ -115,7 +122,9 @@ extension ChatCoordinator {
// 사전투표 루트에서 감지된 재진입이면 사전투표 화면을 남기지 않고 관점 화면으로 교체.
// (스택 중간 — 최종투표 중복 500 — 이면 기존처럼 push)
if state.routes.count <= 1 {
state.routes = [.root(.comment(.init(battleId: battleId)), embedInNavigationView: true)]
// 배열을 통째로 갈아끼우면 TCAFlow 의 인덱스 기반 스크린 상태 캐시가 이전 화면 상태를
// 그대로 붙들어 화면이 바뀌지 않는다. 같은 인덱스의 screen 만 교체한다.
state.routes[screenAt: 0] = .comment(.init(battleId: battleId))
} else {
state.routes.push(.comment(.init(battleId: battleId)))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
//
// ChatCoordinatorView.swift
// Chat
// App
//

import Foundation

import SwiftUI

import ComposableArchitecture
import FeatureAssembly
import TCAFlow

public struct ChatCoordinatorView: View {
Expand Down
32 changes: 32 additions & 0 deletions Projects/Domain/AdDomain/Interface/Sources/Model/FeedAd.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
public struct FeedAd: Equatable, Sendable, Identifiable {
public let code: String
public let network: String
public let title: String
public let subtitle: String
public let imageURL: String

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

imageURLclickURL 속성은 String 대신 URL 타입으로 선언하는 것이 더 안전하고, 타입 안정성을 높이며, URL 객체에서 제공하는 유용한 기능을 활용할 수 있습니다.

public let ctaText: String
public let clickURL: String

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P3] Minor

imageURLclickURL 속성은 String 대신 URL 타입으로 선언하는 것이 더 안전하고, 타입 안정성을 높이며, URL 객체에서 제공하는 유용한 기능을 활용할 수 있습니다.

public let label: String

public var id: String { code }

public init(
code: String,
network: String,
title: String,
subtitle: String,
imageURL: String,
ctaText: String,
clickURL: String,
label: String
) {
self.code = code
self.network = network
self.title = title
self.subtitle = subtitle
self.imageURL = imageURL
self.ctaText = ctaText
self.clickURL = clickURL
self.label = label
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ComposableArchitecture

public protocol FeedAdInterface: Sendable {
func fetchAds() async throws -> [FeedAd]
func recordImpressions(codes: [String]) async throws
}

public enum FeedAdUseCaseDependency: TestDependencyKey {
public static var testValue: FeedAdInterface { MockFeedAdClient() }
}
Comment on lines +8 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the use-case implementation the dependency key

Replace this standalone FeedAdUseCaseDependency key with FeedAdUseCaseImpl conforming directly to DependencyKey, including explicit live, test, and preview values. The current parallel interface-level key preserves a duplicate repository/use-case registration topology rather than the repository-hidden, implementation-backed use-case dependency required for new domain IO.

AGENTS.md reference: AGENTS.md:L641-L643

Useful? React with 👍 / 👎.


public enum FeedAdRepositoryDependency: TestDependencyKey {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

FeedAdRepositoryDependencyAdDomainInterface 모듈이 아닌, Data 계층(AdData 또는 AdDataInterface)에 정의되어야 합니다. Domain 계층은 Repository의 구체적인 존재나 인터페이스에 직접 의존해서는 안 되며, 오직 UseCase 인터페이스에만 의존해야 합니다. 현재 FeedAdInterface가 UseCase와 Repository 인터페이스 역할을 동시에 수행하고 있어 모듈 아키텍처의 책임이 불분명합니다.

public static var testValue: FeedAdInterface { MockFeedAdClient() }
}

public extension DependencyValues {
var feedAdRepository: FeedAdInterface {
get { self[FeedAdRepositoryDependency.self] }
set { self[FeedAdRepositoryDependency.self] = newValue }
}

var feedAdUseCase: FeedAdInterface {
get { self[FeedAdUseCaseDependency.self] }
set { self[FeedAdUseCaseDependency.self] = newValue }
}
}

private struct MockFeedAdClient: FeedAdInterface {
func fetchAds() async throws -> [FeedAd] { [] }
func recordImpressions(codes: [String]) async throws {}
}
23 changes: 23 additions & 0 deletions Projects/Domain/AdDomain/Project.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

import DependencyPackagePlugin
import DependencyPlugin
import ProjectTemplatePlugin

import ProjectDescription

let project = Project.makeModule(
name: "AdDomain",
bundleId: .appBundleID(name: ".AdDomain"),
product: .framework,
settings: .settings(),
dependencies: [
.serviceAssembly,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

AdDomain.serviceAssembly에 의존하는 것은 모듈 계층 구조 위반입니다. Domain 계층은 Data 계층의 추상화된 인터페이스(DataInterface)에만 의존해야 하며, ServiceAssembly와 같이 Network 또는 APIEndpoint에 가까운 하위 계층에 직접 의존해서는 안 됩니다. 이는 DomainData 간의 단방향 의존성 규칙을 위반합니다.

],
hasTests: true,
hasInterface: true,
interfaceDependencies: [
.SPM.composableArchitecture,
],
hasTesting: false
)
10 changes: 10 additions & 0 deletions Projects/Domain/AdDomain/Sources/AdLiveDependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import AdDomainInterface
import ComposableArchitecture

extension FeedAdUseCaseDependency: DependencyKey {
public static var liveValue: FeedAdInterface { FeedAdUseCaseImpl() }
}

extension FeedAdRepositoryDependency: DependencyKey {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

FeedAdRepositoryDependencyliveValue 구현은 AdDomain 모듈이 아닌 Data 계층(AdData 모듈)에 있어야 합니다. AdDomain은 UseCase 구현을 담당하며, Repository 구현에 대한 지식을 가져서는 안 됩니다. 이는 모듈 아키텍처의 책임 분리 원칙을 위반합니다.

public static var liveValue: FeedAdInterface { FeedAdRepositoryImpl() }
}
25 changes: 25 additions & 0 deletions Projects/Domain/AdDomain/Sources/Model/FeedAdDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import AdDomainInterface

struct FeedAdDTO: Decodable, Sendable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

FeedAdDTOtoDomain() 매핑 로직은 Data 계층(예: Projects/Data/AdData/Model)에 위치해야 합니다. Domain 계층이 DTO 모델을 직접 포함하는 것은 DTO-Entity 매핑이 Data 계층에 머물러야 한다는 모듈 아키텍처 원칙을 위반합니다.

let code: String
let network: String
let title: String
let subtitle: String
let imageUrl: String

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [P4] Readability

imageUrlclickUrl 속성은 imageURL, clickURLFeedAd 모델 및 Swift API 디자인 가이드라인에 맞춰 일관성 있게 명명하는 것이 좋습니다.

Suggested change
let imageUrl: String
let imageURL: String

let ctaText: String
let clickUrl: String

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [P4] Readability

imageUrlclickUrl 속성은 imageURL, clickURLFeedAd 모델 및 Swift API 디자인 가이드라인에 맞춰 일관성 있게 명명하는 것이 좋습니다.

Suggested change
let clickUrl: String
let clickURL: String

let label: String

func toDomain() -> FeedAd {
FeedAd(
code: code,
network: network,
title: title,
subtitle: subtitle,
imageURL: imageUrl,
ctaText: ctaText,
clickURL: clickUrl,
label: label
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import APIEndpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

FeedAdRepositoryImplAPIEndpointPickeNetwork와 같은 하위 Network 계층 모듈을 직접 import하는 것은 모듈 의존성 방향 위반입니다. Data 계층의 Repository는 Network 계층에 직접 의존할 수 있지만, Domain 계층의 모듈은 Data 계층의 추상화된 인터페이스에만 의존해야 합니다. 현재 Repository 구현 자체가 Domain에 있어 이 문제가 발생합니다.

import AdDomainInterface
import ComposableArchitecture
import PickeNetwork

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

FeedAdRepositoryImplAPIEndpointPickeNetwork와 같은 하위 Network 계층 모듈을 직접 import하는 것은 모듈 의존성 방향 위반입니다. Data 계층의 Repository는 Network 계층에 직접 의존할 수 있지만, Domain 계층의 모듈은 Data 계층의 추상화된 인터페이스에만 의존해야 합니다. 현재 Repository 구현 자체가 Domain에 있어 이 문제가 발생합니다.


public struct FeedAdRepositoryImpl: FeedAdInterface {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

FeedAdRepositoryImplData 계층(예: Projects/Data/AdData/Sources)에 위치해야 합니다. 현재 Domain 계층에 Repository 구현이 포함되어 있어 모듈 아키텍처의 책임 분리 원칙을 위반합니다.

@Dependency(\.networkClient) private var client

public init() {}

public func fetchAds() async throws -> [FeedAd] {
let data = try await client.send(
AdsService.list(query: AdsQueryRequest()),
as: [FeedAdDTO].self
)
return data.map { $0.toDomain() }
}

public func recordImpressions(codes: [String]) async throws {
_ = try await client.send(
AdsService.impressions(body: AdsImpressionsRequest(codes: codes)),
as: PickeEmptyResponse.self
)
Comment on lines +20 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Decode impression responses as empty payloads

When the impressions endpoint returns a normal no-content success or an envelope whose data is null, requesting String.self makes NetworkClient.unwrap throw dataMissing, because missing payloads are accepted only for PickeEmptyResponse. In Hifi this removes the deduplication marker and can resend an already accepted impression whenever visibility changes, inflating ad metrics; use the request's default PickeEmptyResponse response instead.

Useful? React with 👍 / 👎.

}
}
16 changes: 16 additions & 0 deletions Projects/Domain/AdDomain/Sources/UseCase/FeedAdUseCaseImpl.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import AdDomainInterface
import ComposableArchitecture

public struct FeedAdUseCaseImpl: FeedAdInterface {
@Dependency(\.feedAdRepository) private var feedAdRepository

public init() {}

public func fetchAds() async throws -> [FeedAd] {
return try await feedAdRepository.fetchAds()
}

public func recordImpressions(codes: [String]) async throws {
try await feedAdRepository.recordImpressions(codes: codes)
}
}
57 changes: 57 additions & 0 deletions Projects/Domain/AdDomain/Tests/Sources/AdDomainTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// AdTests.swift
// AdTests
//

import Foundation
@testable import PickeNetwork

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

AdDomainTests에서 @testable import PickeNetworkimport APIEndpoint를 사용하는 것은 모듈 아키텍처 위반입니다. Domain 계층의 테스트는 Domain 로직만 테스트해야 하며, Data 계층의 의존성은 모의 객체(mock)를 사용하여 격리해야 합니다. Network 또는 Service 계층의 구현 세부 사항에 직접 접근해서는 안 됩니다.

@testable import AdDomain
import AdDomainInterface
import APIEndpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

AdDomainTests에서 @testable import PickeNetworkimport APIEndpoint를 사용하는 것은 모듈 아키텍처 위반입니다. Domain 계층의 테스트는 Domain 로직만 테스트해야 하며, Data 계층의 의존성은 모의 객체(mock)를 사용하여 격리해야 합니다. Network 또는 Service 계층의 구현 세부 사항에 직접 접근해서는 안 됩니다.

import Testing

struct AdDomainTests {
@Test func 광고_DTO를_도메인으로_매핑한다() {
let dto = FeedAdDTO(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

FeedAdDTO에 대한 테스트는 Data 계층에 DTO가 위치해야 한다는 원칙에 따라 AdDataTests와 같은 Data 계층 테스트 모듈에서 수행되어야 합니다. 현재 AdDomainTests에서 FeedAdDTO를 직접 테스트하는 것은 DTO-to-Entity 매핑이 Data 계층에 머물러야 한다는 모듈 아키텍처 원칙을 위반합니다.

code: "a1",
network: "ADPICK",
title: "상품",
subtitle: "상점 · 2.8%",
imageUrl: "https://example.com/image.jpg",
ctaText: "구매하러 가기",
clickUrl: "https://ad.picke.store/c/a1",
label: "광고"
)

#expect(dto.toDomain() == FeedAd(
code: "a1",
network: "ADPICK",
title: "상품",
subtitle: "상점 · 2.8%",
imageURL: "https://example.com/image.jpg",
ctaText: "구매하러 가기",
clickURL: "https://ad.picke.store/c/a1",
label: "광고"
))
}

@Test func 광고_조회_요청을_매핑한다() throws {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

API 요청 매핑 테스트는 APIEndpoint 모듈의 테스트에서 수행되어야 합니다. Domain 계층 테스트에서 API 요청 세부 사항을 직접 확인하는 것은 DomainNetwork 계층의 구현 세부 사항에 의존하게 만드는 모듈 아키텍처 위반입니다.

let request = try AdsService.list(query: AdsQueryRequest()).asURLRequest()
#expect(request.url?.path == "/api/v1/ads")
#expect(request.httpMethod == "GET")
#expect(request.url?.query?.contains("slot=HOME_FEED") == true)
#expect(request.url?.query?.contains("os=IOS") == true)
#expect(request.url?.query?.contains("size=20") == true)
}

@Test func 광고_노출은_codes_JSON을_POST한다() throws {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

API 요청 매핑 테스트는 APIEndpoint 모듈의 테스트에서 수행되어야 합니다. Domain 계층 테스트에서 API 요청 세부 사항을 직접 확인하는 것은 DomainNetwork 계층의 구현 세부 사항에 의존하게 만드는 모듈 아키텍처 위반입니다.

let request = try AdsService.impressions(
body: AdsImpressionsRequest(codes: ["first", "second"])
).asURLRequest()
#expect(request.url?.path == "/api/v1/ads/impressions")
#expect(request.httpMethod == "POST")
let data = try #require(request.httpBody)
let body = try JSONDecoder().decode([String: [String]].self, from: data)
#expect(body == ["codes": ["first", "second"]])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public struct BattleScenarioDataDTO: Decodable {
public let title: String
public let philosophers: [ScenarioPhilosopherDTO]
public let isInteractive: Bool
public let startNodeId: Int
/// 서버가 비대화형 시나리오에서 null 을 내려주므로 옵셔널. 매핑 시 첫 노드로 폴백한다.
public let startNodeId: Int?
public let recommendedPathKey: String
public let audios: [String: String]
public let nodes: [ScenarioNodeDTO]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public extension BattleScenarioDataDTO {
dto.toDomain(fallbackLabel: Self.fallbackLabel(for: idx))
},
isInteractive: isInteractive,
startNodeId: startNodeId,
startNodeId: startNodeId ?? nodes.first?.nodeId ?? 0,
recommendedPathKey: RecommendedPathKey(rawValue: recommendedPathKey),
audios: audios,
nodes: nodes.map { $0.toDomain() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,21 @@ struct BattleRepositoryTests {
#expect(perspective == nil)
}

@Test func fetchMyPerspective_는_data_가_빈_배열이면_nil_을_반환한다() async throws {
let json = """
{ "statusCode": 200, "data": [] }
"""
let repo = withDependencies {
$0.networkClient = StubNetworkClient(stubData: Data(json.utf8))
} operation: {
BattleRepositoryImpl()
}

let perspective = try await repo.fetchMyPerspective(battleId: 1)

#expect(perspective == nil)
}

@Test func fetchMyPerspective_는_provider_가_에러를_던지면_nil_을_반환한다() async throws {
let repo = withDependencies {
$0.networkClient = ThrowingStubNetworkClient()
Expand Down Expand Up @@ -474,6 +489,51 @@ struct BattleRepositoryTests {
#expect(scenario.philosophers[1].label == "B")
}

@Test func fetchScenario_는_startNodeId_가_null_이면_첫_노드를_시작_노드로_쓴다() async throws {
let json = """
{
"statusCode": 200,
"data": {
"battleId": 5,
"title": "시나리오 제목",
"philosophers": [{"label": "A", "name": "소크라테스", "stance": "PRO", "imageUrl": "https://img.picke.app/s.png"}],
"isInteractive": false,
"startNodeId": null,
"recommendedPathKey": "COMMON",
"audios": {},
"nodes": [
{
"nodeId": 11,
"nodeName": "START",
"audioDuration": 85,
"autoNextNodeId": null,
"scripts": [],
"interactiveOptions": []
},
{
"nodeId": 12,
"nodeName": "CLOSING",
"audioDuration": 11,
"autoNextNodeId": null,
"scripts": [],
"interactiveOptions": []
}
]
},
"error": null
}
"""
let repo = withDependencies {
$0.networkClient = StubNetworkClient(stubData: Data(json.utf8))
} operation: {
BattleRepositoryImpl()
}

let scenario = try await repo.fetchScenario(battleId: 5)

#expect(scenario.startNodeId == 11)
}

// MARK: - fetchRecommendedBattles

@Test func fetchRecommendedBattles_는_봉투_data_를_도메인으로_매핑한다() async throws {
Expand Down
Loading
Loading