diff --git a/App/Previews/Modifiers/EmptyContainerModifier.swift b/App/Previews/Modifiers/EmptyContainerModifier.swift new file mode 100644 index 0000000..9f9cd28 --- /dev/null +++ b/App/Previews/Modifiers/EmptyContainerModifier.swift @@ -0,0 +1,79 @@ +import SwiftData +import SwiftUI + +/// A preview modifier that provides an empty SwiftData model container for SwiftUI previews. +/// +/// This modifier creates an in-memory SwiftData model container with no data, useful for testing and previewing views in their empty or initial state. +/// It's particularly helpful for testing empty state UI, loading states, or views that should gracefully handle no data. +/// +/// ## Usage +/// Use the `.emptyContainer` trait on your preview: +/// +/// ```swift +/// #Preview(traits: .emptyContainer) { +/// TaskGroupListView() +/// } +/// ``` +/// +/// This automatically provides an empty model container to the preview, allowing SwiftData queries to return empty results. +struct EmptyContainerModifier: PreviewModifier { + + // MARK: Functions + + /// Creates and configures a shared in-memory model container with no data. + /// + /// This method is called once when the preview is loaded and creates an in-memory + /// SwiftData container with no persisted entities. The container is configured + /// to store data only in memory, ensuring previews don't affect any persistent storage. + /// + /// - Returns: An empty `ModelContainer` configured for in-memory storage. + /// - Throws: An error if the model container cannot be created. + static func makeSharedContext() async throws -> ModelContainer { + try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + } + + /// Applies the empty model container to the preview content. + /// + /// This method is called by SwiftUI to apply the modifier to the preview. + /// It injects the empty model container into the SwiftUI environment, making it + /// available to any views that use `@Query` or `@Environment(\.modelContext)`. + /// Queries will return empty results, allowing you to test empty state behavior. + /// + /// - Parameters: + /// - content: The preview content to modify. + /// - context: The empty model container. + /// - Returns: The content with the empty model container applied. + func body( + content: Content, + context: ModelContainer + ) -> some View { + content.modelContainer(context) + } + +} + +// MARK: - PreviewTrait + +extension PreviewTrait where T == Preview.ViewTraits { + + /// A preview trait that provides an empty model container. + /// + /// Use this trait to test views in their empty state without any data. + /// This is particularly useful for testing empty state UI, placeholder content, + /// and ensuring views handle the absence of data gracefully. + /// + /// ## Example + /// ```swift + /// #Preview("Empty State", traits: .emptyContainer) { + /// TaskGroupListView() + /// } + /// ``` + /// + /// The view will receive an empty SwiftData container, causing any `@Query` + /// properties to return empty collections. + static var emptyContainer: Self = .modifier(EmptyContainerModifier()) + +} diff --git a/App/Previews/Modifiers/SampleTaskDataModifier.swift b/App/Previews/Modifiers/SampleTaskDataModifier.swift new file mode 100644 index 0000000..5b463ef --- /dev/null +++ b/App/Previews/Modifiers/SampleTaskDataModifier.swift @@ -0,0 +1,131 @@ +import SwiftData +import SwiftUI + +/// A preview modifier that provides sample task data for SwiftUI previews. +/// +/// This modifier creates an in-memory SwiftData model container populated with representative sample todos across all task groups +/// (today, upcoming, overdue, completed). +/// It's designed to be used with SwiftUI previews to display realistic data without +/// requiring a persistent database. +/// +/// ## Usage +/// Use the `.sampleTasks` trait on your preview: +/// +/// ```swift +/// #Preview(traits: .sampleTasks) { +/// TaskGroupListView() +/// } +/// ``` +/// +/// This automatically provides a model container with sample data to the preview. +struct SampleTaskDataModifier: PreviewModifier { + + // MARK: Functions + + /// Creates and configures a shared in-memory model container with sample task data. + /// + /// This method is called once when the preview is loaded and creates an in-memory + /// SwiftData container populated with 8 representative todo tasks spanning all + /// task groups and various configurations. + /// + /// - Returns: A `ModelContainer` configured for in-memory storage with sample data. + /// - Throws: An error if the model container cannot be created. + static func makeSharedContext() async throws -> ModelContainer { + let modelContainer = try ModelContainer( + for: Todo.self, + configurations: .init(isStoredInMemoryOnly: true) + ) + + let calendar: Calendar = .current + let today: Date = .now + let tomorrow: Date = calendar.date(byAdding: .day, value: 1, to: today) ?? today + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today + + let tasks: [Todo] = [ + .init( + title: "Today task 1", + due: today + ), + .init( + title: "Today task 2", + note: "This is some random note attached to the task.", + due: today + ), + .init( + title: "Tomorrow task", + due: tomorrow + ), + .init( + title: "Next week task", + note: "This is some random note attached to the task.", + due: calendar.date(byAdding: .day, value: 7, to: today) ?? today + ), + .init( + title: "Yesterday task", + due: yesterday + ), + .init( + title: "Last week task", + note: "This is some random note attached to the task.", + due: calendar.date(byAdding: .day, value: -7, to: today) ?? today + ), + .init( + title: "Completed task 1", + due: today, + completed: today + ), + .init( + title: "Completed task 2", + note: "This is some random note attached to the task.", + due: yesterday, + completed: today + ), + ] + + for task in tasks { + modelContainer.mainContext.insert(task) + } + + return modelContainer + } + + /// Applies the model container to the preview content. + /// + /// This method is called by SwiftUI to apply the modifier to the preview. + /// It injects the model container into the SwiftUI environment, making it + /// available to any views that use `@Query` or `@Environment(\.modelContext)`. + /// + /// - Parameters: + /// - content: The preview content to modify. + /// - context: The model container with sample data. + /// - Returns: The content with the model container applied. + func body( + content: Content, + context: ModelContainer + ) -> some View { + content.modelContainer(context) + } + +} + +// MARK: - PreviewTrait + +extension PreviewTrait where T == Preview.ViewTraits { + + /// A preview trait that provides sample task data. + /// + /// Use this trait to automatically populate previews with realistic sample todos + /// without manually creating a model container and inserting data. + /// + /// ## Example + /// ```swift + /// #Preview(traits: .sampleTasks) { + /// TaskGroupListView() + /// } + /// ``` + /// + /// This is equivalent to manually creating a container with sample data but + /// much more concise and reusable across multiple previews. + static var sampleTasks: Self = .modifier(SampleTaskDataModifier()) + +} diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index c025f50..46bd4cd 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -1,6 +1,50 @@ { "sourceLanguage" : "en", "strings" : { + "enumeration.todo-group.title.completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + } + } + }, + "enumeration.todo-group.title.overdue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overdue" + } + } + } + }, + "enumeration.todo-group.title.today" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + } + } + }, + "enumeration.todo-group.title.upcoming" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming" + } + } + } + }, "view.new-task.navigation.title" : { "extractionState" : "manual", "localizations" : { @@ -66,6 +110,17 @@ } } } + }, + "view.task-group-list.navigation.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todos" + } + } + } } }, "version" : "1.1" diff --git a/App/Sources/Enumerations/TodoGroup.swift b/App/Sources/Enumerations/TodoGroup.swift new file mode 100644 index 0000000..ccf3ac2 --- /dev/null +++ b/App/Sources/Enumerations/TodoGroup.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftUI + +/// Categorizes todo tasks into groups based on their due dates and completion status. +/// +/// This enumeration provides a semantic grouping mechanism for organizing and filtering todo tasks throughout the application. +/// Each case represents a distinct category that helps users manage their tasks based on temporal relevance and completion state. +enum TodoGroup { + /// Tasks that have been marked as done, regardless of due date. + case completed + /// Tasks with due dates in the past that remain incomplete. + case overdue + /// Tasks due on the current day that are not yet completed. + case today + /// Tasks due in the future that are not yet completed. + case upcoming +} + +// MARK: - Computed + +extension TodoGroup { + + /// The color associated with the task group. + var color: Color { + switch self { + case .completed: .gray + case .overdue: .red + case .today: .blue + case .upcoming: .green + } + } + + /// The system icon name for the task group. + var iconName: String { + switch self { + case .completed: "checkmark.circle.fill" + case .overdue: "calendar.badge.exclamationmark" + case .today: "calendar.badge.clock" + case .upcoming: "calendar" + } + } + + /// The display title for the task group. + var title: LocalizedStringKey { + switch self { + case .completed: "enumeration.todo-group.title.completed" + case .overdue: "enumeration.todo-group.title.overdue" + case .today: "enumeration.todo-group.title.today" + case .upcoming: "enumeration.todo-group.title.upcoming" + + } + } + +} + +// MARK: - CaseIterable + +extension TodoGroup: CaseIterable { + + static var allCases: [TodoGroup] { [.today, .upcoming, .overdue, .completed] } + +} + +// MARK: - Identifiable + +extension TodoGroup: Identifiable { + + var id: Self { self } + +} diff --git a/App/Sources/View Models/ContentViewModel.swift b/App/Sources/View Models/ContentViewModel.swift index ecddcec..d565b92 100644 --- a/App/Sources/View Models/ContentViewModel.swift +++ b/App/Sources/View Models/ContentViewModel.swift @@ -11,7 +11,7 @@ extension ContentView { /// The view model manages: /// - Presentation state for the new task view within a sheet. @Observable - class ViewModel { + final class ViewModel { // MARK: Properties diff --git a/App/Sources/View Models/NewTaskViewModel.swift b/App/Sources/View Models/NewTaskViewModel.swift index cc300f6..732c753 100644 --- a/App/Sources/View Models/NewTaskViewModel.swift +++ b/App/Sources/View Models/NewTaskViewModel.swift @@ -13,7 +13,7 @@ extension NewTaskView { /// - Form validation (ensuring title is not empty) /// - Saving the todo to SwiftData model context @Observable - class ViewModel { + final class ViewModel { // MARK: Properties diff --git a/App/Sources/View Models/TaskGroupListViewModel.swift b/App/Sources/View Models/TaskGroupListViewModel.swift new file mode 100644 index 0000000..e420f98 --- /dev/null +++ b/App/Sources/View Models/TaskGroupListViewModel.swift @@ -0,0 +1,87 @@ +import SwiftData +import SwiftUI + +extension TaskGroupListView { + + /// View model for managing the logic for the task group list view. + /// + /// This observable class encapsulates the state and business logic for the task group list view. + /// It follows the MVVM pattern to separate presentation logic from the view layer. + /// + /// ## Responsibilities + /// - Providing access to all task groups for iteration + /// - Efficiently counting tasks per group using SwiftData fetch descriptors + /// - Managing date calculations for group categorization + @Observable + final class ViewModel { + + // MARK: Properties + + /// The calendar used for date calculations. + /// + /// This calendar is used to determine day boundaries for categorizing tasks into date groups: today, upcoming, overdue. + /// Defaults to the current calendar. + private let calendar: Calendar + + // MARK: Initialization + + /// Initializes a new view model instance. + /// + /// - Parameter calendar: The calendar to use for date calculations. Defaults to `.current`. + init(calendar: Calendar = .current) { + self.calendar = calendar + } + + // MARK: Computed + + /// Returns all available task groups in an predetermined order. + /// + /// This computed property provides access to all task group cases for iteration in the view. + /// + /// - Returns: An array of all `TodoGroup` cases in a predetermined order. + var groups: [TodoGroup] { + TodoGroup.allCases + } + + // MARK: Functions + + /// Calculates the number of tasks for a given task group from an in-memory collection. + /// + /// This method filters the provided tasks array to count only those tasks that belong + /// to the specified group based on their due date and completion status. Tasks are + /// categorized as follows: + /// + /// - **Completed**: Tasks with a non-nil `completed` date + /// - **Overdue**: Incomplete tasks due before today + /// - **Today**: Incomplete tasks due today (from start of day to end of day) + /// - **Upcoming**: Incomplete tasks due tomorrow or later + /// + /// - Parameters: + /// - tasks: The array of tasks to filter and count. + /// - group: The task group to count tasks for. + /// - Returns: The number of tasks in the specified group. + func count( + _ tasks: [Todo], + for group: TodoGroup + ) -> Int { + let today = calendar.startOfDay(for: .now) + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? .now + + return tasks + .filter { task in + switch group { + case .completed: + task.completed != nil + case .overdue: + task.completed == nil && task.due < today + case .today: + task.completed == nil && task.due >= today && task.due < tomorrow + case .upcoming: + task.completed == nil && task.due >= tomorrow + } + } + .count + } + + } +} diff --git a/App/Sources/Views/ContentView.swift b/App/Sources/Views/ContentView.swift index 34bb847..0e861e8 100644 --- a/App/Sources/Views/ContentView.swift +++ b/App/Sources/Views/ContentView.swift @@ -27,7 +27,7 @@ struct ContentView: View { var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - List {} + TaskGroupListView() Button(action: viewModel.showNewTask) { Image(systemName: "plus") diff --git a/App/Sources/Views/NewTaskView.swift b/App/Sources/Views/NewTaskView.swift index 9a7ec71..7b95d0b 100644 --- a/App/Sources/Views/NewTaskView.swift +++ b/App/Sources/Views/NewTaskView.swift @@ -52,7 +52,7 @@ struct NewTaskView: View { "view.new-task.placeholder.title", text: $viewModel.title ) - .autocapitalization(.none) + .autocapitalization(.sentences) .autocorrectionDisabled(false) .keyboardType(.asciiCapable) @@ -61,7 +61,7 @@ struct NewTaskView: View { text: $viewModel.note, axis: .vertical ) - .autocapitalization(.none) + .autocapitalization(.sentences) .autocorrectionDisabled(false) .keyboardType(.asciiCapable) .lineLimit(3 ... 6) @@ -99,7 +99,9 @@ struct NewTaskView: View { // MARK: - Previews -#Preview("New todo task") { +#Preview( + "New todo task", + traits: .emptyContainer +) { NewTaskView() - .modelContainer(for: Todo.self, inMemory: true) } diff --git a/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift b/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift new file mode 100644 index 0000000..47e06dc --- /dev/null +++ b/App/Sources/Views/TaskGroupList/TaskGroupListCard.swift @@ -0,0 +1,113 @@ +import SwiftUI + +extension TaskGroupListView { + + /// A card-style view representing a single todo task group with its count. + /// + /// This view displays a task group as a compact card showing: + /// - The group's icon and title on the left + /// - The task count on the right + /// - A tinted background using the group's color + /// + /// The view is designed to be used within the `TaskGroupListView` and is typically + /// wrapped in a `NavigationLink` to allow navigation to detailed task lists. + struct Card: View { + + // MARK: Properties + + /// The number of tasks. + private let count: Int + + /// The task group. + private let group: TodoGroup + + // MARK: Initializers + + /// Creates a task group list card. + /// - Parameters: + /// - group: The task group to display. + /// - count: The number of tasks in the group. + init( + group: TodoGroup, + count: Int + ) { + self.count = count + self.group = group + } + + // MARK: Body + + /// The content and behavior of the view. + /// + /// Displays a horizontal card layout with the group icon and title on the left, + /// and the task count on the right, all within a colored background. + var body: some View { + HStack { + VStack( + alignment: .leading, + spacing: 8 + ) { + Image(systemName: group.iconName) + .font(.system(size: 40)) + .foregroundStyle(group.color) + + Text(group.title) + .font(.headline) + .foregroundStyle(.primary) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + } + .padding(.vertical, 24) + + Spacer() + + Text("\(count)") + .font(.title) + .fontWeight(.bold) + .foregroundStyle(group.color) + } + .padding(.horizontal, 24) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(group.color.opacity(0.1)) + ) + } + } + +} + +// MARK: - Previews + +#Preview("Today item") { + TaskGroupListView.Card( + group: .today, + count: .random(in: 0...20) + ) + .padding(.horizontal, 16) +} + +#Preview("Upcoming item") { + TaskGroupListView.Card( + group: .upcoming, + count: .random(in: 0...20) + ) + .padding(.horizontal, 16) +} + +#Preview("Overdue item") { + TaskGroupListView.Card( + group: .overdue, + count: .random(in: 0...20) + ) + .padding(.horizontal, 16) +} + +#Preview("Completed item") { + TaskGroupListView.Card( + group: .completed, + count: .random(in: 0...20) + ) + .padding(.horizontal, 16) +} diff --git a/App/Sources/Views/TaskGroupListView.swift b/App/Sources/Views/TaskGroupListView.swift new file mode 100644 index 0000000..da3a86d --- /dev/null +++ b/App/Sources/Views/TaskGroupListView.swift @@ -0,0 +1,77 @@ +import SwiftData +import SwiftUI + +/// A view that displays todo tasks grouped by date and completion categories. +/// +/// This view serves as the main interface for browsing tasks organized into four groups: +/// - **Today**: Tasks due today that are not yet completed +/// - **Upcoming**: Tasks due in the future that are not yet completed +/// - **Overdue**: Tasks due in the past that are not yet completed +/// - **Completed**: Tasks that have been marked as done +struct TaskGroupListView: View { + + // MARK: Properties + + /// Query to monitor all todo tasks for changes. + /// + /// This query triggers view updates whenever todo tasks are inserted, updated, or deleted, + /// ensuring the task counts displayed in the view remain accurate. + @Query + private var tasks: [Todo] + + /// The view model managing the state and logic for this view. + /// + /// Handles task counting and provides access to all task groups. + /// Initialized with the default calendar for date calculations. + @State + private var viewModel = ViewModel() + + // MARK: Body + + /// The content and behavior of the view. + /// + /// Displays a scrollable vertical list of task group cards. Each card shows: + /// - The group's icon in its associated color + /// - The group's title + /// - The current count of tasks in that group + /// + /// Cards are tappable and navigate to a detailed task list for the selected group. + /// Task counts are fetched on-demand from the model context for each render. + var body: some View { + ScrollView { + VStack(spacing: 16) { + ForEach(viewModel.groups) { group in + NavigationLink(destination: TaskListView(group: group)) { + Card( + group: group, + count: viewModel.count(tasks, for: group) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + .navigationTitle("view.task-group-list.navigation.title") + } +} + +// MARK: - Previews + +#Preview( + "Task group list with empty tasks", + traits: .emptyContainer +) { + NavigationStack { + TaskGroupListView() + } +} + +#Preview( + "Task group list with sample tasks", + traits: .sampleTasks +) { + NavigationStack { + TaskGroupListView() + } +} diff --git a/Todos.xcodeproj/project.pbxproj b/Todos.xcodeproj/project.pbxproj index da9cfef..7747608 100644 --- a/Todos.xcodeproj/project.pbxproj +++ b/Todos.xcodeproj/project.pbxproj @@ -378,6 +378,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "App/Previews/Modifiers/SampleTaskDataModifier.swift App/Previews/Modifiers/EmptyContainerModifier.swift"; DEVELOPMENT_TEAM = 7FMNM89WKG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -423,6 +424,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "App/Previews/Modifiers/SampleTaskDataModifier.swift App/Previews/Modifiers/EmptyContainerModifier.swift"; DEVELOPMENT_TEAM = 7FMNM89WKG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; diff --git a/UnitTests/Tests/Enumerations/TodoGroupTests.swift b/UnitTests/Tests/Enumerations/TodoGroupTests.swift new file mode 100644 index 0000000..e58058e --- /dev/null +++ b/UnitTests/Tests/Enumerations/TodoGroupTests.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftUI +import Testing + +@testable import Todos + +@Suite("TodoGroup enumeration tests") +struct TodoGroupTests { + + // MARK: Computed tests + + @Test( + "group color match expected values", + arguments: zip( + TodoGroup.allCases, + [Color.blue, .green, .red, .gray] + ) + ) + func color( + for group: TodoGroup, + expects expectation: Color + ) { + // GIVEN + // WHEN + let color = group.color + + // THEN + #expect(color == expectation) + } + + @Test( + "group icon names match expected values", + arguments: zip( + TodoGroup.allCases, + ["calendar.badge.clock", "calendar", "calendar.badge.exclamationmark", "checkmark.circle.fill"] + ) + ) + func iconName( + for group: TodoGroup, + expects expectation: String + ) { + // GIVEN + // WHEN + let iconName = group.iconName + + // THEN + #expect(iconName == expectation) + } + + @Test( + "group titles match expected values", + arguments: zip( + TodoGroup.allCases, + ["today", "upcoming", "overdue", "completed"] + ) + ) + func title( + for group: TodoGroup, + expects expectation: String + ) { + // GIVEN + let key = "enumeration.todo-group.title." + expectation + + // WHEN + let title = group.title + + // THEN + #expect(title == .init(key)) + } + + // MARK: Case Iteration Tests + + @Test("all cases contains all cases, in correct order") + func allCases() { + // GIVEN + // WHEN + let allCases = TodoGroup.allCases + + // THEN + #expect(allCases.count == 4) + #expect(allCases[0] == .today) + #expect(allCases[1] == .upcoming) + #expect(allCases[2] == .overdue) + #expect(allCases[3] == .completed) + } + + // MARK: Identifiable Tests + + @Test("id returns an identity of a case", arguments: TodoGroup.allCases) + func id(for group: TodoGroup) { + // GIVEN + // WHEN + let id = group.id + + // THEN + #expect(id == group) + } + +} diff --git a/UnitTests/Tests/View Models/ContentViewModelTests.swift b/UnitTests/Tests/View Models/ContentViewModelTests.swift index 668bd6e..adb8e25 100644 --- a/UnitTests/Tests/View Models/ContentViewModelTests.swift +++ b/UnitTests/Tests/View Models/ContentViewModelTests.swift @@ -4,7 +4,7 @@ import Testing @testable import Todos -@Suite("ContentView.ViewModel Tests") +@Suite("ContentView view model tests") struct ContentViewModelTests { // MARK: Initialization Tests diff --git a/UnitTests/Tests/View Models/NewTaskViewModelTests.swift b/UnitTests/Tests/View Models/NewTaskViewModelTests.swift index e33bc0c..12961b1 100644 --- a/UnitTests/Tests/View Models/NewTaskViewModelTests.swift +++ b/UnitTests/Tests/View Models/NewTaskViewModelTests.swift @@ -4,7 +4,7 @@ import Testing @testable import Todos -@Suite("NewTaskView ViewModel Tests") +@Suite("NewTaskView view model tests") struct NewTaskViewModelTests { // MARK: Initialization tests diff --git a/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift b/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift new file mode 100644 index 0000000..7ee98fd --- /dev/null +++ b/UnitTests/Tests/View Models/TaskGroupListViewModelTests.swift @@ -0,0 +1,82 @@ +import Foundation +import SwiftData +import Testing + +@testable import Todos + +@Suite("TaskGroupListView view model tests") +struct TaskGroupListViewModelTests { + + // MARK: Properties + + private let calendar: Calendar = .current + + // MARK: Computed tests + + @Test("groups returns cases in predetermined order") + func groups() { + // GIVEN + let viewModel = TaskGroupListView.ViewModel() + + // WHEN + let groups = viewModel.groups + + // THEN + #expect(groups.count == 4) + #expect(groups[0] == .today) + #expect(groups[1] == .upcoming) + #expect(groups[2] == .overdue) + #expect(groups[3] == .completed) + } + + // MARK: Functions tests + + @Test( + "count returns zero when empty data", + arguments: TodoGroup.allCases + ) + func countWhenEmptyData(for group: TodoGroup) throws { + // GIVEN + let tasks: [Todo] = [] + let viewModel = TaskGroupListView.ViewModel() + + // WHEN + let count = viewModel.count(tasks, for: group) + + // THEN + #expect(count == 0) + } + + @Test( + "count returns correct count when filled data", + arguments: TodoGroup.allCases + ) + func countWhenFilledData(for group: TodoGroup) throws { + // GIVEN + let today: Date = .now + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today + let nextWeek = calendar.date(byAdding: .day, value: 7, to: today) ?? today + let lastWeek = calendar.date(byAdding: .day, value: -7, to: today) ?? today + + let tasks = [ + Todo(title: "Today task #1", due: today), + Todo(title: "Today task #2", due: today), + Todo(title: "Tomorrow task #1", due: tomorrow), + Todo(title: "Next week task #1", due: nextWeek), + Todo(title: "Yesterday task #1", due: yesterday), + Todo(title: "Last week task #1", due: lastWeek), + Todo(title: "Completed task #1", due: tomorrow, completed: today), + Todo(title: "Completed task #2", due: yesterday, completed: today) + ] + + let viewModel = TaskGroupListView.ViewModel() + + // WHEN + let count = viewModel.count(tasks, for: group) + + // THEN + #expect(count == 2) + } + +}