Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions TaleWeaver/UI/ChatMessageView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
60 changes: 0 additions & 60 deletions TaleWeaver/UI/StoryDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions TaleWeaverTests/SceneRepositoryTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
63 changes: 63 additions & 0 deletions TaleWeaverTests/SceneViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}