Skip to content
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
4 changes: 4 additions & 0 deletions UMCApp/Core/Domain/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import ProjectDescriptionHelpers
let project = coreProject(
name: "CoreDomain",
bundleIdSuffix: "domain",
// #1212: watch 출석 도메인이 ActivityDomain → CoreDomain 경로로 canonical 모델을 재사용한다.
// 소스는 Foundation/UMCFoundation 만 쓰므로 watchOS 제약이 없다.
destinations: [.iPhone, .appleWatch],
deploymentTargets: .multiplatform(iOS: "26.4", watchOS: "26.4"),
dependencies: [
.project(target: "UMCFoundation", path: .relativeToRoot("Core/Foundation")),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// AttendanceTimeWindow+Schedule.swift
// ActivityDomain
//
// Created by euijjang97 on 8/30/26.
//

import Foundation
import HomeDomain

// MARK: - 출석 시간대 판정 (순수 함수)

extension AttendanceTimeWindow {

/// 클라이언트 상수(`AttendancePolicy`) 기반 판정.
///
/// 서버 출석 정책이 붙지 않은 일정/세션에 쓰는 폴백 규칙이다.
/// 종일 일정은 지각 구간을 두지 않고 종료 시각까지 전부 정시로 본다.
public init(startsAt: Date, endsAt: Date, isAllDay: Bool, now: Date) {
let onTimeThreshold = TimeInterval(
AttendancePolicy.onTimeThresholdMinutes * 60
)
let lateThreshold = TimeInterval(
AttendancePolicy.lateThresholdMinutes * 60
)

if isAllDay {
if now < startsAt.addingTimeInterval(-onTimeThreshold) {
self = .tooEarly
} else if now <= endsAt {
self = .onTime
} else {
self = .expired
}
return
}

if now < startsAt.addingTimeInterval(-onTimeThreshold) {
self = .tooEarly
} else if now <= startsAt.addingTimeInterval(onTimeThreshold) {
self = .onTime
} else if now <= startsAt.addingTimeInterval(lateThreshold) {
self = .lateWindow
} else {
self = .expired
}
}

/// 서버 출석 정책 우선 판정 — `policy` 가 `nil` 이면 클라이언트 상수로 폴백한다.
public init(
policy: ScheduleAttendancePolicy?,
startsAt: Date,
endsAt: Date,
isAllDay: Bool,
now: Date
) {
guard let policy else {
self.init(startsAt: startsAt, endsAt: endsAt, isAllDay: isAllDay, now: now)
return
}

if now < policy.checkInStartAt {
self = .tooEarly
} else if now <= policy.onTimeEndAt {
self = .onTime
} else if now <= policy.lateEndAt {
self = .lateWindow
} else {
self = .expired
}
}

/// 일정(canonical `HomeDomain` 모델) 기반 편의 진입점.
public init(schedule: ScheduleDetailData, now: Date = Date()) {
self.init(
policy: schedule.attendancePolicy,
startsAt: schedule.startsAt,
endsAt: schedule.endsAt,
isAllDay: schedule.isAllDay,
now: now
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,38 +164,19 @@ public final class ChallengerAttendanceUseCase: ChallengerAttendanceUseCaseProto
// MARK: - 시간 윈도우

/// 기준 시각(`now`)이 어느 출석 시간대에 속하는지 판정
///
/// 판정 규칙 자체는 `AttendanceTimeWindow` 의 순수 이니셜라이저가 소유한다.
/// 전송 계층 없이 시간대만 필요한 호출부(워치 등)가 UseCase 조립 없이 같은 규칙을 쓴다.
public func isWithinAttendanceTime(
info: SessionInfo,
now: Date
) -> AttendanceTimeWindow {
let onTimeThreshold = TimeInterval(
AttendancePolicy.onTimeThresholdMinutes * 60
)
let lateThreshold = TimeInterval(
AttendancePolicy.lateThresholdMinutes * 60
AttendanceTimeWindow(
startsAt: info.startTime,
endsAt: info.endTime,
isAllDay: info.isAllDay,
now: now
)
let startTime = info.startTime

if info.isAllDay {
if now < startTime.addingTimeInterval(-onTimeThreshold) {
return .tooEarly
}
if now <= info.endTime {
return .onTime
}
return .expired
}

if now < startTime.addingTimeInterval(-onTimeThreshold) {
return .tooEarly
}
if now <= startTime.addingTimeInterval(onTimeThreshold) {
return .onTime
}
if now <= startTime.addingTimeInterval(lateThreshold) {
return .lateWindow
}
return .expired
}

// MARK: - 위치/지오펜스
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
//
// AttendanceTimeWindowScheduleTests.swift
// ActivityDomainTests
//
// Created by euijjang97 on 8/30/26.
//

import Foundation
import HomeDomain
import Testing
@testable import ActivityDomain

// MARK: - Helpers

/// 결정론적 기준 시각: epoch 10_000. 모든 케이스가 이 값을 기준으로 상대 정의된다.
private let fixedNow = Date(timeIntervalSince1970: 10_000)

private let onTimeSec = TimeInterval(AttendancePolicy.onTimeThresholdMinutes * 60)
private let lateSec = TimeInterval(AttendancePolicy.lateThresholdMinutes * 60)

private func makeSchedule(
startsAt: Date,
endsAt: Date,
attendancePolicy: ScheduleAttendancePolicy? = nil
) -> ScheduleDetailData {
ScheduleDetailData(
scheduleId: "1",
name: "정기 세션",
description: "",
tags: [],
startsAt: startsAt,
endsAt: endsAt,
isParticipant: true,
attendancePolicy: attendancePolicy
)
}

// MARK: - 상수 기반 판정

@Suite("AttendanceTimeWindow — 클라이언트 상수 기반 판정")
struct AttendanceTimeWindowConstantTests {

@Test("일반 — 시작 onTime 임계 이전 → tooEarly")
func nonAllDayTooEarly() {
let window = AttendanceTimeWindow(
startsAt: fixedNow.addingTimeInterval(onTimeSec + 60),
endsAt: fixedNow.addingTimeInterval(onTimeSec + 3_600),
isAllDay: false,
now: fixedNow
)
#expect(window == .tooEarly)
}

@Test("일반 — onTime 경계값(now == start + onTime) → onTime (boundary 포함)")
func nonAllDayOnTimeUpperBoundary() {
let window = AttendanceTimeWindow(
startsAt: fixedNow.addingTimeInterval(-onTimeSec),
endsAt: fixedNow.addingTimeInterval(3_600),
isAllDay: false,
now: fixedNow
)
#expect(window == .onTime)
}

@Test("일반 — onTime 초과 ~ late 임계 → lateWindow")
func nonAllDayLateWindow() {
let window = AttendanceTimeWindow(
startsAt: fixedNow.addingTimeInterval(-(onTimeSec + 60)),
endsAt: fixedNow.addingTimeInterval(3_600),
isAllDay: false,
now: fixedNow
)
#expect(window == .lateWindow)
}

@Test("일반 — late 임계 초과 → expired")
func nonAllDayExpired() {
let window = AttendanceTimeWindow(
startsAt: fixedNow.addingTimeInterval(-(lateSec + 60)),
endsAt: fixedNow.addingTimeInterval(3_600),
isAllDay: false,
now: fixedNow
)
#expect(window == .expired)
}

@Test("종일 — 시작 onTime 임계 이전 → tooEarly")
func allDayTooEarly() {
let window = AttendanceTimeWindow(
startsAt: fixedNow.addingTimeInterval(onTimeSec + 60),
endsAt: fixedNow.addingTimeInterval(onTimeSec + 86_400),
isAllDay: true,
now: fixedNow
)
#expect(window == .tooEarly)
}

@Test("종일 — late 임계를 지나도 종료 전이면 onTime (lateWindow 미분기)")
func allDayStaysOnTimeUntilEnd() {
let window = AttendanceTimeWindow(
startsAt: fixedNow.addingTimeInterval(-(lateSec + 3_600)),
endsAt: fixedNow.addingTimeInterval(3_600),
isAllDay: true,
now: fixedNow
)
#expect(window == .onTime)
}

@Test("종일 — 종료 시각 지남 → expired")
func allDayExpired() {
let window = AttendanceTimeWindow(
startsAt: fixedNow.addingTimeInterval(-86_400),
endsAt: fixedNow.addingTimeInterval(-60),
isAllDay: true,
now: fixedNow
)
#expect(window == .expired)
}
}

// MARK: - 서버 정책 기반 판정

@Suite("AttendanceTimeWindow — 서버 출석 정책 기반 판정")
struct AttendanceTimeWindowPolicyTests {

private static let policy = ScheduleAttendancePolicy(
checkInStartAt: fixedNow.addingTimeInterval(-600),
onTimeEndAt: fixedNow.addingTimeInterval(600),
lateEndAt: fixedNow.addingTimeInterval(1_800)
)

private func window(now: Date) -> AttendanceTimeWindow {
AttendanceTimeWindow(
policy: Self.policy,
// 정책이 있으면 무시되는 값들 — 폴백 경로와 다른 결과가 나오도록 일부러 어긋나게 둔다.
startsAt: fixedNow.addingTimeInterval(-86_400),
endsAt: fixedNow.addingTimeInterval(-86_000),
isAllDay: false,
now: now
)
}

@Test("체크인 시작 전 → tooEarly")
func beforeCheckInStart() {
#expect(window(now: fixedNow.addingTimeInterval(-601)) == .tooEarly)
}

@Test("체크인 시작 ~ 정시 마감 → onTime (양 경계 포함)")
func withinOnTime() {
#expect(window(now: fixedNow.addingTimeInterval(-600)) == .onTime)
#expect(window(now: fixedNow.addingTimeInterval(600)) == .onTime)
}

@Test("정시 마감 초과 ~ 지각 마감 → lateWindow (경계 포함)")
func withinLateWindow() {
#expect(window(now: fixedNow.addingTimeInterval(601)) == .lateWindow)
#expect(window(now: fixedNow.addingTimeInterval(1_800)) == .lateWindow)
}

@Test("지각 마감 초과 → expired")
func afterLateEnd() {
#expect(window(now: fixedNow.addingTimeInterval(1_801)) == .expired)
}

@Test("정책 nil → 클라이언트 상수 경로와 동일한 결과")
func nilPolicyFallsBackToConstants() {
let startsAt = fixedNow.addingTimeInterval(-(onTimeSec + 60))
let endsAt = fixedNow.addingTimeInterval(3_600)

let fallback = AttendanceTimeWindow(
policy: nil,
startsAt: startsAt,
endsAt: endsAt,
isAllDay: false,
now: fixedNow
)
let constant = AttendanceTimeWindow(
startsAt: startsAt,
endsAt: endsAt,
isAllDay: false,
now: fixedNow
)

#expect(fallback == constant)
#expect(fallback == .lateWindow)
}
}

// MARK: - 일정 기반 편의 진입점

@Suite("AttendanceTimeWindow — ScheduleDetailData 위임")
struct AttendanceTimeWindowScheduleDelegationTests {

@Test("정책이 붙은 일정은 정책 분기를 따른다")
func scheduleUsesAttachedPolicy() {
let schedule = makeSchedule(
// 상수 경로였다면 expired 가 될 과거 일정.
startsAt: fixedNow.addingTimeInterval(-86_400),
endsAt: fixedNow.addingTimeInterval(-86_000),
attendancePolicy: ScheduleAttendancePolicy(
checkInStartAt: fixedNow.addingTimeInterval(-600),
onTimeEndAt: fixedNow.addingTimeInterval(600),
lateEndAt: fixedNow.addingTimeInterval(1_800)
)
)
#expect(AttendanceTimeWindow(schedule: schedule, now: fixedNow) == .onTime)
}

@Test("정책 없는 일정은 상수 분기로 폴백한다")
func scheduleWithoutPolicyFallsBack() {
let schedule = makeSchedule(
startsAt: fixedNow.addingTimeInterval(onTimeSec + 60),
endsAt: fixedNow.addingTimeInterval(onTimeSec + 3_600)
)
#expect(AttendanceTimeWindow(schedule: schedule, now: fixedNow) == .tooEarly)
}

@Test("now 기본값 — 먼 미래 일정은 tooEarly")
func defaultNowUsesCurrentDate() {
let now = Date()
let schedule = makeSchedule(
startsAt: now.addingTimeInterval(3_600),
endsAt: now.addingTimeInterval(7_200)
)
#expect(AttendanceTimeWindow(schedule: schedule) == .tooEarly)
}
}
Loading
Loading