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
@@ -1,9 +1,8 @@
import Foundation
import SwiftData
import Testing
@testable import Todos
@MainActor
@Suite("ContentView view model tests")
struct ContentViewModelTests {
@@ -1,9 +1,9 @@
import Foundation
import SwiftData
import Testing
@testable import Todos
@MainActor
@Suite("NewTaskView view model tests")
struct NewTaskViewModelTests {
@@ -121,11 +121,9 @@ struct NewTaskViewModelTests {
// MARK: Functions tests
@Test("save creates todo with all properties")
@MainActor
func saveWithAllProperties() throws {
// GIVEN
let container = try modelContainer
let context = container.mainContext
let repository = MockTodoRepository()
let viewModel = NewTaskView.ViewModel()
// WHEN
@@ -133,12 +131,10 @@ struct NewTaskViewModelTests {
viewModel.note = "Finish by end of week"
viewModel.due = Date(timeIntervalSince1970: 1000000000)
viewModel.save(to: context)
viewModel.save(to: repository)
// THEN
let descriptor = FetchDescriptor<Todo>()
let todos = try context.fetch(descriptor)
let todo = try #require(todos.first)
let todo = try #require(repository.todos.first)
#expect(todo.title == viewModel.title)
#expect(todo.note == viewModel.note)
@@ -147,11 +143,9 @@ struct NewTaskViewModelTests {
}
@Test("save creates todo without note when note is empty")
@MainActor
func saveWithEmptyNote() throws {
// GIVEN
let container = try modelContainer
let context = container.mainContext
let repository = MockTodoRepository()
let viewModel = NewTaskView.ViewModel()
// WHEN
@@ -159,12 +153,10 @@ struct NewTaskViewModelTests {
viewModel.note = ""
viewModel.due = Date()
viewModel.save(to: context)
viewModel.save(to: repository)
// THEN
let descriptor = FetchDescriptor<Todo>()
let todos = try context.fetch(descriptor)
let todo = try #require(todos.first)
let todo = try #require(repository.todos.first)
#expect(todo.title == viewModel.title)
#expect(todo.note == nil)
@@ -173,11 +165,9 @@ struct NewTaskViewModelTests {
}
@Test("save creates todo with note containing only whitespace as nil")
@MainActor
func saveWithWhitespaceNote() throws {
// GIVEN
let container = try modelContainer
let context = container.mainContext
let repository = MockTodoRepository()
let viewModel = NewTaskView.ViewModel()
// WHEN
@@ -185,12 +175,10 @@ struct NewTaskViewModelTests {
viewModel.note = " "
viewModel.due = Date()
viewModel.save(to: context)
viewModel.save(to: repository)
// THEN
let descriptor = FetchDescriptor<Todo>()
let todos = try context.fetch(descriptor)
let todo = try #require(todos.first)
let todo = try #require(repository.todos.first)
#expect(todo.title == viewModel.title)
#expect(todo.note == nil)
@@ -199,46 +187,24 @@ struct NewTaskViewModelTests {
}
@Test("save inserts multiple todos independently")
@MainActor
func saveMultipleTodos() throws {
func saveMultipleTodos() {
// GIVEN
let container = try modelContainer
let context = container.mainContext
let repository = MockTodoRepository()
let viewModelFirst = NewTaskView.ViewModel()
let viewModelSecond = NewTaskView.ViewModel()
// WHEN
viewModelFirst.title = "First task"
viewModelFirst.save(to: context)
viewModelFirst.save(to: repository)
viewModelSecond.title = "Second task"
viewModelSecond.note = "With a note"
viewModelSecond.save(to: context)
viewModelSecond.save(to: repository)
// THEN
let descriptor = FetchDescriptor<Todo>()
let todos = try context.fetch(descriptor)
#expect(todos.count == 2)
#expect(todos.contains { $0.title == "First task" })
#expect(todos.contains { $0.title == "Second task" })
#expect(repository.todos.count == 2)
#expect(repository.todos.contains { $0.title == "First task" })
#expect(repository.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 SwiftData
import Testing
@testable import Todos
@MainActor
@Suite("TaskGroupListView view model tests")
struct TaskGroupListViewModelTests {
@@ -33,7 +33,7 @@ struct TaskGroupListViewModelTests {
@Test(
"count returns zero when empty data",
arguments: TodoGroup.allCases
arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
)
func countWhenEmptyData(for group: TodoGroup) throws {
// GIVEN
@@ -48,7 +48,7 @@ struct TaskGroupListViewModelTests {
@Test(
"count returns correct count when filled data",
arguments: TodoGroup.allCases
arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
)
func countWhenFilledData(for group: TodoGroup) throws {
// GIVEN
@@ -1,10 +1,9 @@
import Foundation
import SwiftData
import SwiftUI
import Testing
@testable import Todos
@MainActor
@Suite("TaskList view model tests")
struct TaskListViewModelTests {
@@ -12,7 +11,7 @@ struct TaskListViewModelTests {
@Test(
"properties are set correctly during initialization",
arguments: TodoGroup.allCases
arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
)
func initialization(for group: TodoGroup) {
// GIVEN
@@ -28,7 +27,7 @@ struct TaskListViewModelTests {
@Test(
"buttonCompleted returns correct values when showCompleted is true",
arguments: TodoGroup.allCases, [true, false]
arguments: [TodoGroup.today, .upcoming, .overdue, .completed], [true, false]
)
func buttonCompleted(
for group: TodoGroup,
@@ -56,7 +55,7 @@ struct TaskListViewModelTests {
@Test(
"canShowCompleted returns correct value for each group",
arguments: zip(
TodoGroup.allCases,
[TodoGroup.today, .upcoming, .overdue, .completed],
[true, true, true, false]
)
)
@@ -76,7 +75,7 @@ struct TaskListViewModelTests {
@Test(
"navigationTitle returns correct view title for each group",
arguments: TodoGroup.allCases
arguments: [TodoGroup.today, .upcoming, .overdue, .completed]
)
func navigationTitle(for group: TodoGroup) {
// GIVEN
@@ -91,43 +90,59 @@ struct TaskListViewModelTests {
// MARK: Method tests
@Test("deleteTasks removes a single task from model context")
func deleteTasksSingle() throws {
@Test("delete removes a single todo task from repository")
func deleteSingleTodo() {
// GIVEN
let container = try ModelContainer(
for: Todo.self,
configurations: .init(isStoredInMemoryOnly: true)
)
let context = ModelContext(container)
let viewModel = TaskListView.ViewModel(group: .today)
let todosToInsert: [Todo] = [
let todos: [Todo] = [
.init(title: "Task 1", due: .now, order: 0),
.init(title: "Task 2", due: .now, order: 1),
.init(title: "Task 3", due: .now, order: 2),
]
let countInitial = todosToInsert.count
for todo in todosToInsert {
context.insert(todo)
}
let repository = MockTodoRepository(todos: todos)
let viewModel = TaskListView.ViewModel(group: .today)
// WHEN
viewModel.deleteTasks(
todosToInsert,
viewModel.delete(
todos,
at: IndexSet(integer: 1),
from: context
from: repository
)
// THEN
let descriptor = FetchDescriptor<Todo>()
let todos = try context.fetch(descriptor)
#expect(repository.todos.count == 2)
#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)
#expect(todos.count == 2)
#expect(todos.contains(where: { $0.title == "Task 1" }))
#expect(todos.contains(where: { $0.title == "Task 3" }))
#expect(!todos.contains(where: { $0.title == "Task 2" }))
@Test("delete removes multiple todo tasks from repository")
func deleteMultipleTodos() {
// GIVEN
let todos: [Todo] = [
.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")
@@ -180,7 +195,7 @@ struct TaskListViewModelTests {
#expect(tasks[3].order == 1)
}
@Test("toggleCompletedVisbility toggles the showCompleted property")
@Test("toggleCompletedVisibility toggles the showCompleted property")
func toggleCompletedVisibility() {
// GIVEN
let viewModel = TaskListView.ViewModel(group: .today)