Skip to content
Merged
86 changes: 67 additions & 19 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,89 @@ name: Continuous Integration
on:
pull_request:
branches:
- qa
- develop

jobs:
Continuous_Integration:
unit-tests:
name: Unit Tests
runs-on: macos-latest
environment: Configuration Files
steps:
- uses: actions/checkout@v4

# .mise.toml에 고정된 tuist 설치
- uses: jdx/mise-action@v2

- name: Install Tuist
# 러너 기본 Xcode(최신)에서는 고정된 realm-core가 컴파일되지 않아 로컬과 같은 26.1로 고정
- name: Select Xcode
run: |
bash <(curl -Ls https://install.tuist.io)
ls -d /Applications/Xcode*.app
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version

# 러너 이미지에는 기본 Xcode용 런타임만 있어, 고정한 Xcode에 맞는 iOS 시뮬레이터 런타임을 내려받는다
- name: Download iOS simulator runtime
run: sudo xcodebuild -downloadPlatform iOS

- name: Create Release & Debug Configuration Files
- name: Create secret xcconfig files
env:
BASE_URL: ${{ secrets.BASE_URL }}
KAKAO_API_KEY: ${{ secrets.KAKAO_API_KEY }}
KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }}
NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }}
HOLIDAY_API_KEY: ${{ secrets.HOLIDAY_API_KEY }}
APP_GROUP_ID: ${{ secrets.APP_GROUP_ID }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
cd EATSSU_MVC/EATSSU_MVC/Resources
mkdir Secrets
cd Secrets
mkdir -p EATSSU/App/Resources/Secrets
for config in Release Debug; do
{
echo "BASE_URL = $BASE_URL"
echo "KAKAO_API_KEY = $KAKAO_API_KEY"
echo "KAKAO_REST_API_KEY = $KAKAO_REST_API_KEY"
echo "NAVER_CLIENT_ID = $NAVER_CLIENT_ID"
echo "HOLIDAY_API_KEY = $HOLIDAY_API_KEY"
echo "APP_GROUP_ID = $APP_GROUP_ID"
echo "POSTHOG_API_KEY = $POSTHOG_API_KEY"
} > "EATSSU/App/Resources/Secrets/$config.xcconfig"
done

echo "BASE_URL = $BASE_URL" >> Release.xcconfig
echo "KAKAO_API_KEY = $KAKAO_API_KEY" >> Release.xcconfig
cat Release.xcconfig
- name: Create GoogleService-Info.plist
env:
GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST }}
run: echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > EATSSU/App/Resources/GoogleService-Info.plist

echo "BASE_URL = $BASE_URL" >> Debug.xcconfig
echo "KAKAO_API_KEY = $KAKAO_API_KEY" >> Debug.xcconfig
cat Debug.xcconfig
- name: Install dependencies
run: tuist install

- name: Install Dependencies
- name: Generate project
run: tuist generate --no-open

- name: Select or create simulator
id: simulator
run: |
tuist install

- name: Build Project
xcrun simctl list runtimes
DEVICE_ID=$(xcrun simctl list devices available -j | python3 -c '
import json, sys
data = json.load(sys.stdin)["devices"]
for runtime in sorted(data, reverse=True):
for device in data[runtime]:
if device.get("isAvailable") and "iPhone" in device.get("name", ""):
print(device["udid"]); sys.exit(0)
')
if [ -z "$DEVICE_ID" ]; then
echo "No available simulator, creating one"
RUNTIME=$(xcrun simctl list runtimes -j | python3 -c 'import json,sys; rts=[r for r in json.load(sys.stdin)["runtimes"] if r["platform"]=="iOS" and r["isAvailable"]]; print(rts[-1]["identifier"])')
DEVICE_ID=$(xcrun simctl create "ci-iphone" "iPhone 16" "$RUNTIME")
fi
echo "Using simulator $DEVICE_ID"
echo "device_id=$DEVICE_ID" >> "$GITHUB_OUTPUT"

- name: Run unit tests
run: |
tuist build
set -o pipefail
xcodebuild test \
-workspace EATSSU_WORKSPACE.xcworkspace \
-scheme EATSSUUnitTests \
-destination "platform=iOS Simulator,id=${{ steps.simulator.outputs.device_id }}" \
CODE_SIGNING_ALLOWED=NO 2>&1 | tail -100
27 changes: 20 additions & 7 deletions EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,7 @@ final class PartnershipLikeManager {

/// 항목별 현재 찜 상태와 다른 것만 서버 토글. 목록 확보에 실패한 경우엔 지도 응답의 isLiked를 기준으로 삼는다
private func toggle(liked: Bool, store: PartnershipDTO, completion: @escaping (Result<Void, Error>) -> Void) {
let targets = store.partnershipInfos
.filter { currentlyLiked($0) != liked }
.map(\.id)
let targets = Self.toggleTargets(in: store, toBe: liked, isCurrentlyLiked: currentlyLiked)
guard !targets.isEmpty else {
applyLocalState(liked: liked, store: store)
completion(.success(()))
Expand Down Expand Up @@ -190,8 +188,19 @@ final class PartnershipLikeManager {
return hasLoaded ? likedPartnershipIds.contains(info.id) : info.isLiked
}

/// 현재 상태와 목표 상태가 다른 항목만 토글 대상 (서버 API가 토글 방식이라 같은 상태를 다시 호출하면 뒤집힌다)
static func toggleTargets(
in store: PartnershipDTO,
toBe liked: Bool,
isCurrentlyLiked: (PartnershipInfoDTO) -> Bool
) -> [Int] {
store.partnershipInfos
.filter { isCurrentlyLiked($0) != liked }
.map(\.id)
}

/// 찜 목록 응답에서 실제 찜된 항목 id. 플래그가 하나도 없으면(구버전 응답) 소속 항목 전부로 간주
private static func likedIds(in store: PartnershipDTO) -> [Int] {
static func likedIds(in store: PartnershipDTO) -> [Int] {
let flagged = store.partnershipInfos.filter(\.isLiked).map(\.id)
return flagged.isEmpty ? store.partnershipIds : flagged
}
Expand All @@ -209,12 +218,16 @@ final class PartnershipLikeManager {
persistOrder()
}

/// 로컬에 기록된 찜 시각 기준 최근순. 기록이 없는 항목(다른 기기에서 찜 등)은 서버 순서대로 뒤에 둔다
private func sortedByRecent(_ stores: [PartnershipDTO]) -> [PartnershipDTO] {
Self.sortedByRecent(stores, likedAt: likedAtByStoreKey)
}

/// 로컬에 기록된 찜 시각 기준 최근순. 기록이 없는 항목(다른 기기에서 찜 등)은 서버 순서대로 뒤에 둔다
static func sortedByRecent(_ stores: [PartnershipDTO], likedAt: [String: Double]) -> [PartnershipDTO] {
let indexed = stores.enumerated().map { ($0.offset, $0.element) }
return indexed.sorted { lhs, rhs in
let lhsTime = likedAtByStoreKey[lhs.1.storeKey]
let rhsTime = likedAtByStoreKey[rhs.1.storeKey]
let lhsTime = likedAt[lhs.1.storeKey]
let rhsTime = likedAt[rhs.1.storeKey]
switch (lhsTime, rhsTime) {
case let (l?, r?): return l > r
case (_?, nil): return true
Expand Down
9 changes: 7 additions & 2 deletions EATSSU/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,15 +320,20 @@ let project = Project(
sources: ["Tests/UnitTests/**"],
dependencies: [
.target(name: "EATSSU-DEV"),
]
],
settings: .settings(base: [
// 프로젝트 공통 -all_load/-ObjC가 테스트 번들에 상속되면 호스트 앱의 정적 라이브러리를
// 강제 로드하다 링크가 깨진다. 테스트 번들은 호스트 앱 심볼을 쓰므로 비워둔다
"OTHER_LDFLAGS": ""
])
),
],
schemes: [
.scheme(
name: "EATSSU-DEV",
shared: true,
buildAction: .buildAction(targets: [.target("EATSSU-DEV")]),
testAction: .targets(["EATSSU-DEV"]),
testAction: .targets(["EATSSUUnitTests"]),
runAction: .runAction(configuration: "Debug"),
archiveAction: .archiveAction(configuration: "Release"),
profileAction: .profileAction(configuration: "Release"),
Expand Down
71 changes: 71 additions & 0 deletions EATSSU/Tests/UnitTests/AcademicNameLocalizationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// AcademicNameLocalizationTests.swift
// EATSSUUnitTests
//
// Created by 황상환 on 8/31/26.
//

import XCTest

@testable import EATSSU_DEV

/// 단과대/학과명 번역 키 정규화와 언어별 표기 규칙 검증
final class AcademicNameLocalizationTests: XCTestCase {

private var languageSandbox: AppLanguageSandbox!

override func setUp() {
super.setUp()
languageSandbox = AppLanguageSandbox()
}

override func tearDown() {
languageSandbox.restore()
languageSandbox = nil
super.tearDown()
}

// MARK: - normalizedKey (서버 표기 차이 흡수)

func test_아래아_구분자는_키에서_제거된다() {
XCTAssertEqual(TextLiteral.Academic.normalizedKey("산업 ㆍ 정보시스템공학과"), "산업정보시스템공학과")
}

func test_가운뎃점과_공백은_키에서_제거된다() {
XCTAssertEqual(TextLiteral.Academic.normalizedKey("정보통계·보험수리학과"), "정보통계보험수리학과")
}

func test_괄호는_키에서_제거된다() {
XCTAssertEqual(TextLiteral.Academic.normalizedKey("전자정보공학부(전자공학전공)"), "전자정보공학부전자공학전공")
}

// MARK: - 언어별 표기

func test_영어에서_등록된_학과는_영문으로_표기된다() {
languageSandbox.set(.english)
XCTAssertEqual(TextLiteral.Academic.college("인문대학"), "College of Humanities")
XCTAssertEqual(TextLiteral.Academic.department("컴퓨터학부"), "Computer Science & Engineering")
}

func test_영어에서_서버_표기가_달라도_정규화로_매칭된다() {
languageSandbox.set(.english)
XCTAssertEqual(TextLiteral.Academic.department("정보통계·보험수리학과"), "Actuarial Science")
}

func test_등록되지_않은_이름은_원본을_그대로_반환한다() {
languageSandbox.set(.english)
XCTAssertEqual(TextLiteral.Academic.department("없는학과"), "없는학과")
}

func test_빈_문자열은_키를_노출하지_않고_빈_문자열을_반환한다() {
languageSandbox.set(.english)
XCTAssertEqual(TextLiteral.Academic.college(""), "")
}

func test_한국어는_ko_스트링스_표기를_사용한다() {
languageSandbox.set(.korean)
XCTAssertEqual(TextLiteral.Academic.college("인문대학"), "인문대학")
// 서버가 아래아 표기를 줘도 ko 값(스프레드시트 표기)으로 통일된다
XCTAssertEqual(TextLiteral.Academic.department("산업 ㆍ 정보시스템공학과"), "산업정보시스템공학과")
}
}
41 changes: 41 additions & 0 deletions EATSSU/Tests/UnitTests/AppLanguageSandbox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// AppLanguageSandbox.swift
// EATSSUUnitTests
//
// Created by 황상환 on 8/31/26.
//

import Foundation

@testable import EATSSU_DEV

/// 테스트에서 앱 언어를 바꾸고, 종료 시 원래 UserDefaults 상태로 되돌리는 헬퍼
/// (AppLanguageManager가 UserDefaults를 직접 읽으므로 테스트 간 오염을 막는다)
final class AppLanguageSandbox {

private enum Key {
static let selected = "selectedAppLanguage"
static let manual = "didSelectLanguageManually"
}

private let savedLanguage: String?
private let savedManualFlag: Bool

init() {
savedLanguage = UserDefaults.standard.string(forKey: Key.selected)
savedManualFlag = UserDefaults.standard.bool(forKey: Key.manual)
}

func set(_ language: AppLanguage) {
AppLanguageManager.shared.changeLanguage(to: language)
}

func restore() {
if let savedLanguage {
UserDefaults.standard.set(savedLanguage, forKey: Key.selected)
} else {
UserDefaults.standard.removeObject(forKey: Key.selected)
}
UserDefaults.standard.set(savedManualFlag, forKey: Key.manual)
}
}
74 changes: 74 additions & 0 deletions EATSSU/Tests/UnitTests/MealMenuDisplayTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// MealMenuDisplayTests.swift
// EATSSUUnitTests
//
// Created by 황상환 on 8/31/26.
//

import XCTest

@testable import EATSSU_DEV

/// 변동식단 대표메뉴 표시 규칙과 언어 파라미터 검증
final class MealMenuDisplayTests: XCTestCase {

private var languageSandbox: AppLanguageSandbox!

override func setUp() {
super.setUp()
languageSandbox = AppLanguageSandbox()
}

override func tearDown() {
languageSandbox.restore()
languageSandbox = nil
super.tearDown()
}

private func makeMeal(_ menus: [(name: String, isMain: Bool?)]) -> ChangeMenuTableResponse {
ChangeMenuTableResponse(
mealId: 1,
price: 5000,
rating: nil,
briefMenus: menus.enumerated().map { BriefMenus(menuId: $0.offset, name: $0.element.name, isMain: $0.element.isMain) }
)
}

func test_영어에서는_대표메뉴만_표시한다() {
languageSandbox.set(.english)
let meal = makeMeal([("Pork Cutlet", true), ("김치", false), ("밥", false)])
XCTAssertEqual(meal.displayMenus.map(\.name), ["Pork Cutlet"])
}

func test_영어라도_대표메뉴가_없으면_전체를_표시한다() {
// 대표메뉴 데이터가 아직 없는 식단은 전부 isMain=false/nil로 내려온다 (서버 안내)
languageSandbox.set(.english)
let meal = makeMeal([("김치", false), ("밥", nil)])
XCTAssertEqual(meal.displayMenus.count, 2)
}

func test_한국어에서는_대표메뉴_여부와_무관하게_전체를_표시한다() {
languageSandbox.set(.korean)
let meal = makeMeal([("돈까스", true), ("김치", false)])
XCTAssertEqual(meal.displayMenus.count, 2)
}

func test_변동식단_언어_파라미터는_영어만_전달한다() {
languageSandbox.set(.english)
XCTAssertEqual(ChangeMenuTableResponse.mealLanguageParameter, "EN")

languageSandbox.set(.japanese)
XCTAssertNil(ChangeMenuTableResponse.mealLanguageParameter)

languageSandbox.set(.korean)
XCTAssertNil(ChangeMenuTableResponse.mealLanguageParameter)
}

func test_고정메뉴_언어_파라미터는_한국어_외_전부_전달한다() {
languageSandbox.set(.japanese)
XCTAssertEqual(ChangeMenuTableResponse.fixedMenuLanguageParameter, "JA")

languageSandbox.set(.korean)
XCTAssertNil(ChangeMenuTableResponse.fixedMenuLanguageParameter)
}
}
Loading
Loading