diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d26fd..b2464d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,38 +5,38 @@ All notable changes to TaleWeaver will be documented in this file. ## TODO – Next Steps (Scenes & AI integration) – 2025-05-18 ### 0 Delete Capabilty -- Add ability in UI )to delete scenes, stories, and user characters. +- [x] Add ability in UI to delete scenes, stories, and user characters. ### 1 Scene Workflow -- **SceneListView** refreshes after save; remaining +- [x] **SceneListView** refreshes after save; remaining - Optional drag-to-reorder scenes -- **SceneDetailView** +- [x] **SceneDetailView** 1. Toolbar “Add Character” opens picker and inserts system prompt “ enters the scene>”. 2. Show active-character chips / avatars at top. 3. Ability to remove a character (optional). -- **SceneEditorView** +- [x] **SceneEditorView** - Multi-select character assignment when creating / editing a scene. ### 2 Character Workflow -- **StoryCharacterListView** +- [x] **StoryCharacterListView** - When both user & story sections are empty show placeholder with “+ Add Character” button. ### 3 AI / OpenAI -- Surface `OpenAIError.apiError(message)` to user. -- Add dedicated `generateSceneDescription(theme:)` wrapper (currently re-uses `generateStory`). +- [x] Surface `OpenAIError.apiError(message)` to user. +- [x] Add dedicated `generateSceneDescription(theme:)` wrapper (currently re-uses `generateStory`). ### 4 Core Data / Migration -- Optional debug toggle: wipe store automatically when migration fails (dev builds only). -- Consider background context for chat message inserts to avoid UI hitch. +- [x] Optional debug toggle: wipe store automatically when migration fails (dev builds only). +- [x] Consider background context for chat message inserts to avoid UI hitch. ### 5 UI & Accessibility -- Resolve system keyboard accessory AutoLayout warnings. -- Dark-mode review of new views. +- [x] Resolve system keyboard accessory AutoLayout warnings. +- [x] 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. +- [x] 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..31a4be5 --- /dev/null +++ b/TaleWeaverTests/SceneRepositoryTests.swift @@ -0,0 +1,60 @@ +import XCTest +import CoreData +@testable import TaleWeaver + +final class SceneRepositoryTests: XCTestCase { + var persistence: PersistenceController! + var context: NSManagedObjectContext! + var repository: SceneRepository! + var story: Story! + + override func setUp() { + super.setUp() + persistence = PersistenceController(inMemory: true) + context = persistence.container.viewContext + repository = SceneRepository(context: context) + story = Story(context: context) + story.id = UUID() + story.title = "Story" + } + + override func tearDown() { + persistence = nil + context = nil + repository = nil + story = nil + super.tearDown() + } + + func testCreateAndFetchScene() throws { + _ = repository.createScene(for: story, title: "Scene", summary: nil) + try repository.save() + + let scenes = try repository.fetchScenes(for: story) + XCTAssertEqual(scenes.count, 1) + XCTAssertEqual(scenes.first?.title, "Scene") + } + + func testUpdateScene() throws { + let scene = repository.createScene(for: story, title: "A", summary: "B") + try repository.save() + + repository.updateScene(scene, title: "New", summary: "C") + try repository.save() + + let fetched = try repository.fetchScenes(for: story) + XCTAssertEqual(fetched.first?.title, "New") + XCTAssertEqual(fetched.first?.summary, "C") + } + + func testDeleteScene() throws { + let scene = repository.createScene(for: story, title: "Temp", 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..5fef2a7 --- /dev/null +++ b/TaleWeaverTests/SceneViewModelTests.swift @@ -0,0 +1,57 @@ +import XCTest +import CoreData +@testable import TaleWeaver + +final class SceneViewModelTests: XCTestCase { + var persistence: PersistenceController! + var context: NSManagedObjectContext! + var repository: SceneRepository! + var story: Story! + var viewModel: SceneViewModel! + + override func setUp() { + super.setUp() + persistence = PersistenceController(inMemory: true) + context = persistence.container.viewContext + repository = SceneRepository(context: context) + story = Story(context: context) + story.id = UUID() + story.title = "Story" + viewModel = SceneViewModel(story: story, repository: repository) + } + + override func tearDown() { + persistence = nil + context = nil + repository = nil + story = nil + viewModel = nil + super.tearDown() + } + + func testAddScene() { + let scene = viewModel.addScene(title: "One", summary: nil) + XCTAssertEqual(viewModel.scenes.count, 1) + XCTAssertEqual(scene.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() { + _ = viewModel.addScene(title: "A", summary: nil) + _ = viewModel.addScene(title: "B", summary: nil) + viewModel.moveScenes(from: IndexSet(integer: 0), to: 1) + XCTAssertEqual(viewModel.scenes.first?.title, "B") + } +}