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:
@@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user