Todo repository protocol (#8)

This PR contains the work done to decouple the direct use of the `ModelContext` from `SwiftData` from the view model that uses it. In addition, the project transitioned to use Swift 6 and strict concurrency which brought some issues with the unit tests, so they were amended as well.

Reviewed-on: rock-n-code/todos-app#8
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #8.
This commit is contained in:
2026-02-11 18:22:26 +00:00
committed by javier
parent b8e6e515dc
commit bbd7ec264b
13 changed files with 187 additions and 115 deletions
@@ -0,0 +1,12 @@
import SwiftData
/// Extends ModelContext to conform to the TodoRepository protocol.
///
/// This extension allows ModelContext from SwiftData to be used as a todo repository,
/// enabling dependency injection and facilitating unit testing by allowing mock
/// repositories to be substituted during tests.
///
/// The ModelContext methods `delete(_:)` and `insert(_:)` already match the
/// TodoRepository protocol requirements, so this extension simply declares conformance
/// without requiring additional implementation.
extension ModelContext: TodoRepository {}
@@ -0,0 +1,28 @@
import SwiftData
/// A protocol defining the interface for a todo repository.
///
/// This protocol abstracts the underlying data persistence mechanism for todo tasks,
/// allowing for dependency injection and easier testing. Implementations handle the
/// storage and retrieval of Todo objects.
protocol TodoRepository {
/// Deletes a todo task from the repository.
///
/// This method removes the specified todo from persistent storage. The deletion
/// is typically performed immediately, though the actual persistence may be deferred
/// until a save operation occurs depending on the implementation.
///
/// - Parameter todo: The todo task to delete from the repository.
func delete(_ todo: Todo)
/// Inserts a new todo task into the repository.
///
/// This method adds a new todo to persistent storage. The todo is typically
/// tracked immediately, though the actual persistence may be deferred until a
/// save operation occurs depending on the implementation.
///
/// - Parameter todo: The todo task to insert into the repository.
func insert(_ todo: Todo)
}
@@ -57,14 +57,14 @@ extension NewTaskView {
// MARK: Functions // MARK: Functions
/// Saves a new todo task to the SwiftData model context. /// Saves a new todo task to the repository.
/// ///
/// This function creates a new `Todo` instance with the current property values and inserts it into the provided model context. /// This function creates a new `Todo` instance with the current property values and inserts it into the provided repository.
/// The title and note are trimmed of leading and trailing whitespace before saving. /// The title and note are trimmed of leading and trailing whitespace before saving.
/// The `note` property value is only included if it's not empty after trimming. /// The `note` property value is only included if it's not empty after trimming.
/// ///
/// - Parameter modelContext: The SwiftData model context to insert a new todo task. /// - Parameter repository: The repository to insert a new todo task.
func save(to modelContext: ModelContext) { func save(to repository: TodoRepository) {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -74,7 +74,7 @@ extension NewTaskView {
due: due due: due
) )
modelContext.insert(todo) repository.insert(todo)
} }
} }
@@ -87,21 +87,21 @@ extension TaskListView {
/// Deletes tasks at the specified indices. /// Deletes tasks at the specified indices.
/// ///
/// This method removes tasks from the model context after a swipe-to-delete action. /// This method removes tasks from the repository after a swipe-to-delete action.
/// The deletion is performed through SwiftData's model context, which handles /// The deletion is performed through the repository, which handles
/// persistence and triggers automatic UI updates. /// persistence and triggers automatic UI updates.
/// ///
/// - Parameters: /// - Parameters:
/// - tasks: The current array of tasks. /// - tasks: The current array of tasks.
/// - offsets: The index set of tasks to delete. /// - offsets: The index set of tasks to delete.
/// - modelContext: The SwiftData model context to perform the deletion. /// - repository: The repository to perform the deletion.
func deleteTasks( func delete(
_ tasks: [Todo], _ tasks: [Todo],
at offsets: IndexSet, at offsets: IndexSet,
from modelContext: ModelContext from repository: TodoRepository
) { ) {
for index in offsets { for index in offsets {
modelContext.delete(tasks[index]) repository.delete(tasks[index])
} }
} }
+1 -1
View File
@@ -45,7 +45,7 @@ struct TaskListView: View {
group: viewModel.group, group: viewModel.group,
showCompleted: viewModel.showCompleted showCompleted: viewModel.showCompleted
) { tasks, offsets in ) { tasks, offsets in
viewModel.deleteTasks( viewModel.delete(
tasks, tasks,
at: offsets, at: offsets,
from: modelContext from: modelContext
+4 -2
View File
@@ -410,8 +410,9 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26.0; XROS_DEPLOYMENT_TARGET = 26.0;
}; };
@@ -456,8 +457,9 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26.0; XROS_DEPLOYMENT_TARGET = 26.0;
}; };
+49
View File
@@ -0,0 +1,49 @@
@testable import class Todos.Todo
@testable import protocol Todos.TodoRepository
/// A mock implementation of the TodoRepository protocol for unit testing.
///
/// This mock repository provides a simple in-memory storage mechanism for testing
/// without requiring a full SwiftData ModelContext setup. It tracks inserted and
/// deleted todos, allowing tests to verify repository operations.
final class MockTodoRepository {
// MARK: Properties
/// Array of todo tasks managed by this mock repository.
///
/// This property tracks all todos in the repository, maintaining state across
/// insert and delete operations. It serves as the in-memory storage for the mock,
/// allowing tests to verify repository behavior without requiring SwiftData.
private(set) var todos: [Todo]
// MARK: Initializers
/// Creates a new mock repository with an optional initial set of todo tasks.
///
/// This initializer allows tests to set up a repository with pre-existing todos
/// or start with an empty repository. The default parameter value provides
/// convenient initialization for most test cases.
///
/// - Parameter todos: An array of todo tasks to initialize the repository with. Defaults to an empty array.
init(todos: [Todo] = []) {
self.todos = todos
}
}
// MARK: - TodoRepository
extension MockTodoRepository: TodoRepository {
// MARK: Functions
func delete(_ todo: Todo) {
todos.removeAll { $0.id == todo.id }
}
func insert(_ todo: Todo) {
todos.append(todo)
}
}
@@ -1,9 +1,9 @@
import Foundation
import SwiftUI import SwiftUI
import Testing import Testing
@testable import Todos @testable import Todos
@MainActor
@Suite("TodoGroup enumeration tests") @Suite("TodoGroup enumeration tests")
struct TodoGroupTests { struct TodoGroupTests {
@@ -12,7 +12,7 @@ struct TodoGroupTests {
@Test( @Test(
"canShowCompleted returns correct values", "canShowCompleted returns correct values",
arguments: zip( arguments: zip(
TodoGroup.allCases, [TodoGroup.today, .upcoming, .overdue, .completed],
[true, true, true, false] [true, true, true, false]
) )
) )
@@ -31,7 +31,7 @@ struct TodoGroupTests {
@Test( @Test(
"canReorder returns correct values", "canReorder returns correct values",
arguments: zip( arguments: zip(
TodoGroup.allCases, [TodoGroup.today, .upcoming, .overdue, .completed],
[true, true, true, false] [true, true, true, false]
) )
) )
@@ -50,7 +50,7 @@ struct TodoGroupTests {
@Test( @Test(
"group color match expected values", "group color match expected values",
arguments: zip( arguments: zip(
TodoGroup.allCases, [TodoGroup.today, .upcoming, .overdue, .completed],
[Color.blue, .green, .red, .gray] [Color.blue, .green, .red, .gray]
) )
) )
@@ -69,7 +69,7 @@ struct TodoGroupTests {
@Test( @Test(
"group icon names match expected values", "group icon names match expected values",
arguments: zip( arguments: zip(
TodoGroup.allCases, [TodoGroup.today, .upcoming, .overdue, .completed],
["calendar.badge.clock", "calendar", "calendar.badge.exclamationmark", "checkmark.circle.fill"] ["calendar.badge.clock", "calendar", "calendar.badge.exclamationmark", "checkmark.circle.fill"]
) )
) )
@@ -88,7 +88,7 @@ struct TodoGroupTests {
@Test( @Test(
"group titles match expected values", "group titles match expected values",
arguments: zip( arguments: zip(
TodoGroup.allCases, [TodoGroup.today, .upcoming, .overdue, .completed],
["today", "upcoming", "overdue", "completed"] ["today", "upcoming", "overdue", "completed"]
) )
) )
@@ -124,7 +124,10 @@ struct TodoGroupTests {
// MARK: Identifiable Tests // MARK: Identifiable Tests
@Test("id returns an identity of a case", arguments: TodoGroup.allCases) @Test(
"id returns an identity of a case",
arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
)
func id(for group: TodoGroup) { func id(for group: TodoGroup) {
// GIVEN // GIVEN
// WHEN // WHEN
@@ -4,6 +4,7 @@ import Testing
@testable import Todos @testable import Todos
@MainActor
@Suite("TodoFetchDescriptor+Inits extension tests") @Suite("TodoFetchDescriptor+Inits extension tests")
struct TodoFetchDescriptor_InitsTests { struct TodoFetchDescriptor_InitsTests {
@@ -11,9 +12,8 @@ struct TodoFetchDescriptor_InitsTests {
@Test( @Test(
"initialization when empty tasks", "initialization when empty tasks",
arguments: TodoGroup.allCases arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
) )
@MainActor
func initializationWhenEmptyTasks(for group: TodoGroup) throws { func initializationWhenEmptyTasks(for group: TodoGroup) throws {
// GIVEN // GIVEN
let container = try modelContainer let container = try modelContainer
@@ -32,9 +32,8 @@ struct TodoFetchDescriptor_InitsTests {
@Test( @Test(
"initialization with show completed disabled", "initialization with show completed disabled",
arguments: TodoGroup.allCases arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
) )
@MainActor
func initializationWithNoShowCompleted(for group: TodoGroup) throws { func initializationWithNoShowCompleted(for group: TodoGroup) throws {
// GIVEN // GIVEN
let container = try modelContainer let container = try modelContainer
@@ -70,9 +69,8 @@ struct TodoFetchDescriptor_InitsTests {
@Test( @Test(
"initialization with show completed enabled", "initialization with show completed enabled",
arguments: TodoGroup.allCases arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
) )
@MainActor
func initializationWithShowCompleted(for group: TodoGroup) throws { func initializationWithShowCompleted(for group: TodoGroup) throws {
// GIVEN // GIVEN
let container = try modelContainer let container = try modelContainer
@@ -1,9 +1,8 @@
import Foundation
import SwiftData
import Testing import Testing
@testable import Todos @testable import Todos
@MainActor
@Suite("ContentView view model tests") @Suite("ContentView view model tests")
struct ContentViewModelTests { struct ContentViewModelTests {
@@ -1,9 +1,9 @@
import Foundation import Foundation
import SwiftData
import Testing import Testing
@testable import Todos @testable import Todos
@MainActor
@Suite("NewTaskView view model tests") @Suite("NewTaskView view model tests")
struct NewTaskViewModelTests { struct NewTaskViewModelTests {
@@ -121,11 +121,9 @@ struct NewTaskViewModelTests {
// MARK: Functions tests // MARK: Functions tests
@Test("save creates todo with all properties") @Test("save creates todo with all properties")
@MainActor
func saveWithAllProperties() throws { func saveWithAllProperties() throws {
// GIVEN // GIVEN
let container = try modelContainer let repository = MockTodoRepository()
let context = container.mainContext
let viewModel = NewTaskView.ViewModel() let viewModel = NewTaskView.ViewModel()
// WHEN // WHEN
@@ -133,12 +131,10 @@ struct NewTaskViewModelTests {
viewModel.note = "Finish by end of week" viewModel.note = "Finish by end of week"
viewModel.due = Date(timeIntervalSince1970: 1000000000) viewModel.due = Date(timeIntervalSince1970: 1000000000)
viewModel.save(to: context) viewModel.save(to: repository)
// THEN // THEN
let descriptor = FetchDescriptor<Todo>() let todo = try #require(repository.todos.first)
let todos = try context.fetch(descriptor)
let todo = try #require(todos.first)
#expect(todo.title == viewModel.title) #expect(todo.title == viewModel.title)
#expect(todo.note == viewModel.note) #expect(todo.note == viewModel.note)
@@ -147,11 +143,9 @@ struct NewTaskViewModelTests {
} }
@Test("save creates todo without note when note is empty") @Test("save creates todo without note when note is empty")
@MainActor
func saveWithEmptyNote() throws { func saveWithEmptyNote() throws {
// GIVEN // GIVEN
let container = try modelContainer let repository = MockTodoRepository()
let context = container.mainContext
let viewModel = NewTaskView.ViewModel() let viewModel = NewTaskView.ViewModel()
// WHEN // WHEN
@@ -159,12 +153,10 @@ struct NewTaskViewModelTests {
viewModel.note = "" viewModel.note = ""
viewModel.due = Date() viewModel.due = Date()
viewModel.save(to: context) viewModel.save(to: repository)
// THEN // THEN
let descriptor = FetchDescriptor<Todo>() let todo = try #require(repository.todos.first)
let todos = try context.fetch(descriptor)
let todo = try #require(todos.first)
#expect(todo.title == viewModel.title) #expect(todo.title == viewModel.title)
#expect(todo.note == nil) #expect(todo.note == nil)
@@ -173,11 +165,9 @@ struct NewTaskViewModelTests {
} }
@Test("save creates todo with note containing only whitespace as nil") @Test("save creates todo with note containing only whitespace as nil")
@MainActor
func saveWithWhitespaceNote() throws { func saveWithWhitespaceNote() throws {
// GIVEN // GIVEN
let container = try modelContainer let repository = MockTodoRepository()
let context = container.mainContext
let viewModel = NewTaskView.ViewModel() let viewModel = NewTaskView.ViewModel()
// WHEN // WHEN
@@ -185,12 +175,10 @@ struct NewTaskViewModelTests {
viewModel.note = " " viewModel.note = " "
viewModel.due = Date() viewModel.due = Date()
viewModel.save(to: context) viewModel.save(to: repository)
// THEN // THEN
let descriptor = FetchDescriptor<Todo>() let todo = try #require(repository.todos.first)
let todos = try context.fetch(descriptor)
let todo = try #require(todos.first)
#expect(todo.title == viewModel.title) #expect(todo.title == viewModel.title)
#expect(todo.note == nil) #expect(todo.note == nil)
@@ -199,46 +187,24 @@ struct NewTaskViewModelTests {
} }
@Test("save inserts multiple todos independently") @Test("save inserts multiple todos independently")
@MainActor func saveMultipleTodos() {
func saveMultipleTodos() throws {
// GIVEN // GIVEN
let container = try modelContainer let repository = MockTodoRepository()
let context = container.mainContext
let viewModelFirst = NewTaskView.ViewModel() let viewModelFirst = NewTaskView.ViewModel()
let viewModelSecond = NewTaskView.ViewModel() let viewModelSecond = NewTaskView.ViewModel()
// WHEN // WHEN
viewModelFirst.title = "First task" viewModelFirst.title = "First task"
viewModelFirst.save(to: context) viewModelFirst.save(to: repository)
viewModelSecond.title = "Second task" viewModelSecond.title = "Second task"
viewModelSecond.note = "With a note" viewModelSecond.note = "With a note"
viewModelSecond.save(to: context) viewModelSecond.save(to: repository)
// THEN // THEN
let descriptor = FetchDescriptor<Todo>() #expect(repository.todos.count == 2)
let todos = try context.fetch(descriptor) #expect(repository.todos.contains { $0.title == "First task" })
#expect(repository.todos.contains { $0.title == "Second task" })
#expect(todos.count == 2)
#expect(todos.contains { $0.title == "First task" })
#expect(todos.contains { $0.title == "Second task" })
}
}
// MARK: - Helpers
private extension NewTaskViewModelTests {
// MARK: Computed
var modelContainer: ModelContainer {
get throws {
try ModelContainer(
for: Todo.self,
configurations: .init(isStoredInMemoryOnly: true)
)
}
} }
} }
@@ -1,9 +1,9 @@
import Foundation import Foundation
import SwiftData
import Testing import Testing
@testable import Todos @testable import Todos
@MainActor
@Suite("TaskGroupListView view model tests") @Suite("TaskGroupListView view model tests")
struct TaskGroupListViewModelTests { struct TaskGroupListViewModelTests {
@@ -33,7 +33,7 @@ struct TaskGroupListViewModelTests {
@Test( @Test(
"count returns zero when empty data", "count returns zero when empty data",
arguments: TodoGroup.allCases arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
) )
func countWhenEmptyData(for group: TodoGroup) throws { func countWhenEmptyData(for group: TodoGroup) throws {
// GIVEN // GIVEN
@@ -48,7 +48,7 @@ struct TaskGroupListViewModelTests {
@Test( @Test(
"count returns correct count when filled data", "count returns correct count when filled data",
arguments: TodoGroup.allCases arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
) )
func countWhenFilledData(for group: TodoGroup) throws { func countWhenFilledData(for group: TodoGroup) throws {
// GIVEN // GIVEN
@@ -1,10 +1,9 @@
import Foundation
import SwiftData
import SwiftUI import SwiftUI
import Testing import Testing
@testable import Todos @testable import Todos
@MainActor
@Suite("TaskList view model tests") @Suite("TaskList view model tests")
struct TaskListViewModelTests { struct TaskListViewModelTests {
@@ -12,7 +11,7 @@ struct TaskListViewModelTests {
@Test( @Test(
"properties are set correctly during initialization", "properties are set correctly during initialization",
arguments: TodoGroup.allCases arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
) )
func initialization(for group: TodoGroup) { func initialization(for group: TodoGroup) {
// GIVEN // GIVEN
@@ -28,7 +27,7 @@ struct TaskListViewModelTests {
@Test( @Test(
"buttonCompleted returns correct values when showCompleted is true", "buttonCompleted returns correct values when showCompleted is true",
arguments: TodoGroup.allCases, [true, false] arguments: [TodoGroup.today, .upcoming, .overdue, .completed], [true, false]
) )
func buttonCompleted( func buttonCompleted(
for group: TodoGroup, for group: TodoGroup,
@@ -56,7 +55,7 @@ struct TaskListViewModelTests {
@Test( @Test(
"canShowCompleted returns correct value for each group", "canShowCompleted returns correct value for each group",
arguments: zip( arguments: zip(
TodoGroup.allCases, [TodoGroup.today, .upcoming, .overdue, .completed],
[true, true, true, false] [true, true, true, false]
) )
) )
@@ -76,7 +75,7 @@ struct TaskListViewModelTests {
@Test( @Test(
"navigationTitle returns correct view title for each group", "navigationTitle returns correct view title for each group",
arguments: TodoGroup.allCases arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
) )
func navigationTitle(for group: TodoGroup) { func navigationTitle(for group: TodoGroup) {
// GIVEN // GIVEN
@@ -91,43 +90,59 @@ struct TaskListViewModelTests {
// MARK: Method tests // MARK: Method tests
@Test("deleteTasks removes a single task from model context") @Test("delete removes a single todo task from repository")
func deleteTasksSingle() throws { func deleteSingleTodo() {
// GIVEN // GIVEN
let container = try ModelContainer( let todos: [Todo] = [
for: Todo.self,
configurations: .init(isStoredInMemoryOnly: true)
)
let context = ModelContext(container)
let viewModel = TaskListView.ViewModel(group: .today)
let todosToInsert: [Todo] = [
.init(title: "Task 1", due: .now, order: 0), .init(title: "Task 1", due: .now, order: 0),
.init(title: "Task 2", due: .now, order: 1), .init(title: "Task 2", due: .now, order: 1),
.init(title: "Task 3", due: .now, order: 2), .init(title: "Task 3", due: .now, order: 2),
] ]
let countInitial = todosToInsert.count let repository = MockTodoRepository(todos: todos)
let viewModel = TaskListView.ViewModel(group: .today)
for todo in todosToInsert {
context.insert(todo)
}
// WHEN // WHEN
viewModel.deleteTasks( viewModel.delete(
todosToInsert, todos,
at: IndexSet(integer: 1), at: IndexSet(integer: 1),
from: context from: repository
) )
// THEN // THEN
let descriptor = FetchDescriptor<Todo>() #expect(repository.todos.count == 2)
let todos = try context.fetch(descriptor) #expect(repository.todos.contains { $0.title == "Task 1" })
#expect(repository.todos.contains { $0.title == "Task 3" })
#expect(!repository.todos.contains { $0.title == "Task 2" })
}
#expect(countInitial == 3) @Test("delete removes multiple todo tasks from repository")
#expect(todos.count == 2) func deleteMultipleTodos() {
#expect(todos.contains(where: { $0.title == "Task 1" })) // GIVEN
#expect(todos.contains(where: { $0.title == "Task 3" })) let todos: [Todo] = [
#expect(!todos.contains(where: { $0.title == "Task 2" })) .init(title: "Task 1", due: .now, order: 0),
.init(title: "Task 2", due: .now, order: 1),
.init(title: "Task 3", due: .now, order: 2),
.init(title: "Task 4", due: .now, order: 3),
.init(title: "Task 5", due: .now, order: 4),
]
let repository = MockTodoRepository(todos: todos)
let viewModel = TaskListView.ViewModel(group: .today)
// WHEN
viewModel.delete(
todos,
at: IndexSet([1, 3]),
from: repository
)
// THEN
#expect(repository.todos.count == 3)
#expect(repository.todos.contains { $0.title == "Task 1" })
#expect(repository.todos.contains { $0.title == "Task 3" })
#expect(repository.todos.contains { $0.title == "Task 5" })
#expect(!repository.todos.contains { $0.title == "Task 2" })
#expect(!repository.todos.contains { $0.title == "Task 4" })
} }
@Test("reorderTasks updates order property when moving task forward") @Test("reorderTasks updates order property when moving task forward")
@@ -180,7 +195,7 @@ struct TaskListViewModelTests {
#expect(tasks[3].order == 1) #expect(tasks[3].order == 1)
} }
@Test("toggleCompletedVisbility toggles the showCompleted property") @Test("toggleCompletedVisibility toggles the showCompleted property")
func toggleCompletedVisibility() { func toggleCompletedVisibility() {
// GIVEN // GIVEN
let viewModel = TaskListView.ViewModel(group: .today) let viewModel = TaskListView.ViewModel(group: .today)