diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d26fd..162d902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,9 +34,9 @@ All notable changes to TaleWeaver will be documented in this file. - Dark-mode review of new views. ### 6 Cleanup / Tests -- Remove unused chat code from StoryDetailView. -- Extract `ChatMessageView` into its own file. -- Unit tests for `SceneRepository` and `SceneViewModel`. +- [x] Remove unused chat code from StoryDetailView. +- [x] Extract `ChatMessageView` into its own file. +- [ ] Unit tests for `SceneRepository` and `SceneViewModel`. ## [1.0.0] - 2025-05-15 ### Added diff --git a/TaleWeaver/UI/ChatMessageView.swift b/TaleWeaver/UI/ChatMessageView.swift new file mode 100644 index 0000000..fb79b98 --- /dev/null +++ b/TaleWeaver/UI/ChatMessageView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// Displays a single chat message with optional character avatar. +struct ChatMessageView: View { + let prompt: StoryPrompt + let userCharacter: Character? + + var body: some View { + HStack(alignment: .top) { + if let userCharacter = userCharacter, + let avatarURL = userCharacter.avatarURL, + !avatarURL.isEmpty, + let url = URL(string: avatarURL) { + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFill() + .frame(width: 40, height: 40) + .clipShape(Circle()) + } placeholder: { + Circle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 40, height: 40) + .overlay( + Text(String(userCharacter.name?.prefix(1) ?? "U")) + .font(.headline) + .foregroundColor(.gray) + ) + } + } else { + Circle() + .fill(Color.blue.opacity(0.2)) + .frame(width: 40, height: 40) + .overlay( + Text("U") + .font(.headline) + .foregroundColor(.blue) + ) + } + + VStack(alignment: .leading, spacing: 4) { + Text(userCharacter?.name ?? "User") + .font(.subheadline) + .fontWeight(.semibold) + + Text(prompt.promptText ?? "") + .font(.body) + .padding(12) + .background(Color.blue.opacity(0.1)) + .cornerRadius(12) + + Text(prompt.createdAt ?? Date(), style: .time) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel("Message from \(userCharacter?.name ?? "User"): \(prompt.promptText ?? "")") + } +} + +#Preview { + let ctx = PersistenceController.preview.container.viewContext + let story = Story(context: ctx) + let prompt = StoryPrompt(context: ctx) + prompt.promptText = "Hello world" + prompt.createdAt = Date() + return ChatMessageView(prompt: prompt, userCharacter: story.userCharacter) + .environment(\.managedObjectContext, ctx) +} diff --git a/TaleWeaver/UI/StoryDetailView.swift b/TaleWeaver/UI/StoryDetailView.swift index 6ca28c2..4db0173 100644 --- a/TaleWeaver/UI/StoryDetailView.swift +++ b/TaleWeaver/UI/StoryDetailView.swift @@ -99,66 +99,6 @@ struct StoryDetailView: View { } } -// MARK: - ChatMessageView used by SceneDetailView -struct ChatMessageView: View { - let prompt: StoryPrompt - let userCharacter: Character? - - var body: some View { - HStack(alignment: .top) { - // Avatar or placeholder - if let userCharacter = userCharacter, - let avatarURL = userCharacter.avatarURL, - !avatarURL.isEmpty { - AsyncImage(url: URL(string: avatarURL)) { image in - image - .resizable() - .scaledToFill() - .frame(width: 40, height: 40) - .clipShape(Circle()) - } placeholder: { - Circle() - .fill(Color.gray.opacity(0.3)) - .frame(width: 40, height: 40) - .overlay( - Text(String((userCharacter.name?.prefix(1) ?? "U"))) - .font(.headline) - .foregroundColor(.gray) - ) - } - } else { - Circle() - .fill(Color.blue.opacity(0.2)) - .frame(width: 40, height: 40) - .overlay( - Text("U") - .font(.headline) - .foregroundColor(.blue) - ) - } - - VStack(alignment: .leading, spacing: 4) { - Text(userCharacter?.name ?? "User") - .font(.subheadline) - .fontWeight(.semibold) - - Text(prompt.promptText ?? "") - .font(.body) - .padding(12) - .background(Color.blue.opacity(0.1)) - .cornerRadius(12) - - Text(prompt.createdAt ?? Date(), style: .time) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel("Message from \(userCharacter?.name ?? "User"): \(prompt.promptText ?? "")") - } -} // MARK: - Preview #Preview { diff --git a/TaleWeaverTests/SceneRepositoryTests.swift b/TaleWeaverTests/SceneRepositoryTests.swift new file mode 100644 index 0000000..4e4b4dd --- /dev/null +++ b/TaleWeaverTests/SceneRepositoryTests.swift @@ -0,0 +1,65 @@ +import XCTest +import CoreData +@testable import TaleWeaver + +final class SceneRepositoryTests: XCTestCase { + var container: NSPersistentContainer! + var repository: SceneRepository! + var story: Story! + + override func setUp() { + super.setUp() + container = NSPersistentContainer(name: "TaleWeaver") + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + container.loadPersistentStores { _, error in + XCTAssertNil(error) + } + + story = Story(context: container.viewContext) + story.id = UUID() + story.title = "Test Story" + + repository = SceneRepository(context: container.viewContext) + } + + override func tearDown() { + repository = nil + story = nil + container = nil + super.tearDown() + } + + func testCreateAndFetchScene() throws { + _ = repository.createScene(for: story, title: "Intro", summary: "start") + try repository.save() + + let scenes = try repository.fetchScenes(for: story) + XCTAssertEqual(scenes.count, 1) + XCTAssertEqual(scenes.first?.title, "Intro") + } + + func testUpdateScene() throws { + let scene = repository.createScene(for: story, title: "Intro", summary: nil) + try repository.save() + + repository.updateScene(scene, title: "Updated", summary: "new") + try repository.save() + + let scenes = try repository.fetchScenes(for: story) + XCTAssertEqual(scenes.first?.title, "Updated") + XCTAssertEqual(scenes.first?.summary, "new") + } + + func testDeleteScene() throws { + let scene = repository.createScene(for: story, title: "Intro", summary: nil) + try repository.save() + + repository.deleteScene(scene) + try repository.save() + + let scenes = try repository.fetchScenes(for: story) + XCTAssertTrue(scenes.isEmpty) + } +} diff --git a/TaleWeaverTests/SceneViewModelTests.swift b/TaleWeaverTests/SceneViewModelTests.swift new file mode 100644 index 0000000..1b9ed11 --- /dev/null +++ b/TaleWeaverTests/SceneViewModelTests.swift @@ -0,0 +1,63 @@ +import XCTest +import CoreData +@testable import TaleWeaver + +@MainActor +final class SceneViewModelTests: XCTestCase { + var container: NSPersistentContainer! + var repository: SceneRepository! + var viewModel: SceneViewModel! + var story: Story! + + override func setUp() { + super.setUp() + container = NSPersistentContainer(name: "TaleWeaver") + let description = NSPersistentStoreDescription() + description.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [description] + container.loadPersistentStores { _, error in + XCTAssertNil(error) + } + + story = Story(context: container.viewContext) + story.id = UUID() + story.title = "Story" + repository = SceneRepository(context: container.viewContext) + viewModel = SceneViewModel(story: story, repository: repository) + } + + override func tearDown() { + viewModel = nil + repository = nil + story = nil + container = nil + super.tearDown() + } + + func testAddScene() { + viewModel.addScene(title: "One", summary: nil) + XCTAssertEqual(viewModel.scenes.count, 1) + XCTAssertEqual(viewModel.scenes.first?.title, "One") + } + + func testUpdateScene() { + let scene = viewModel.addScene(title: "Old", summary: nil) + viewModel.updateScene(scene, title: "New", summary: "Sum") + XCTAssertEqual(viewModel.scenes.first?.title, "New") + XCTAssertEqual(viewModel.scenes.first?.summary, "Sum") + } + + func testDeleteScene() { + let scene = viewModel.addScene(title: "Temp", summary: nil) + viewModel.deleteScene(scene) + XCTAssertTrue(viewModel.scenes.isEmpty) + } + + func testMoveScenes() { + let first = viewModel.addScene(title: "First", summary: nil) + let second = viewModel.addScene(title: "Second", summary: nil) + _ = first; _ = second + viewModel.moveScenes(from: IndexSet(integer: 0), to: 2) + XCTAssertEqual(viewModel.scenes.last?.title, "First") + } +}