Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Sources/Cairn/App/CairnApp.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import SwiftData
import SwiftUI

@main
struct CairnApp: App {
private let environment = CairnEnvironment()

var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(environment.modelContainer)
}
}

Expand Down
25 changes: 25 additions & 0 deletions Sources/Cairn/Cache/CachedRelease.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation
import SwiftData

@Model
final class CachedRelease {
var tagName: String
var assetNames: [String]
var assetURLs: [String]

// 元プランのスニペットにはない逆参照。CachedRepository側のdeleteRule: .cascadeを
// 機能させるため双方向関係として明示的に追加している。
var repository: CachedRepository?

init(
tagName: String,
assetNames: [String] = [],
assetURLs: [String] = [],
repository: CachedRepository? = nil
) {
self.tagName = tagName
self.assetNames = assetNames
self.assetURLs = assetURLs
self.repository = repository
}
}
46 changes: 46 additions & 0 deletions Sources/Cairn/Cache/CachedRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation
import SwiftData

@Model
final class CachedRepository {
@Attribute(.unique) var githubId: Int
var fullName: String
var topics: [String]
var starCount: Int
var primaryLanguage: String
var htmlURL: String
// Phase3のCategory enumのrawValue相当。Phase2ではCategory型に依存させず素のStringのまま持つ。
var category: String
var subTags: [String]
var lastFetchedAt: Date
var lastReleaseCheckedAt: Date?

@Relationship(deleteRule: .cascade, inverse: \CachedRelease.repository)
var releases: [CachedRelease]

init(
githubId: Int,
fullName: String,
topics: [String] = [],
starCount: Int,
primaryLanguage: String,
htmlURL: String,
category: String,
subTags: [String] = [],
lastFetchedAt: Date,
lastReleaseCheckedAt: Date? = nil,
releases: [CachedRelease] = []
) {
self.githubId = githubId
self.fullName = fullName
self.topics = topics
self.starCount = starCount
self.primaryLanguage = primaryLanguage
self.htmlURL = htmlURL
self.category = category
self.subTags = subTags
self.lastFetchedAt = lastFetchedAt
self.lastReleaseCheckedAt = lastReleaseCheckedAt
self.releases = releases
}
}
31 changes: 31 additions & 0 deletions Sources/Cairn/Cache/CairnEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation
import SwiftData

/// アプリ全体で共有する永続化コンテナを保持する軽量な環境容器。
///
/// Phase2時点ではModelContainerのみを保持する。GitHubClient/AuthenticationState等の
/// 実配線はUI機能フェーズで本容器を拡張して行う(その際はApp/への移動を検討する)。
@MainActor
final class CairnEnvironment {
let modelContainer: ModelContainer

/// - Parameter inMemory: `true`の場合、ディスクへ永続化しないインメモリコンテナを生成する(テスト用)。
/// 本番の通常起動では`false`(デフォルト)を使う。
init(inMemory: Bool = false) {
let schema = Schema([
CachedRepository.self,
CachedRelease.self,
InstalledApp.self,
])
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: inMemory
)

do {
modelContainer = try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("ModelContainerの初期化に失敗しました: \(error)")
}
}
}
17 changes: 17 additions & 0 deletions Sources/Cairn/Cache/InstalledApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
import SwiftData

@Model
final class InstalledApp {
@Attribute(.unique) var bundleIdentifier: String
var appName: String
var installedVersion: String
var installDate: Date

init(bundleIdentifier: String, appName: String, installedVersion: String, installDate: Date) {
self.bundleIdentifier = bundleIdentifier
self.appName = appName
self.installedVersion = installedVersion
self.installDate = installDate
}
}
55 changes: 55 additions & 0 deletions Tests/CairnTests/Cache/CachedReleaseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation
import SwiftData
import Testing

@testable import Cairn

@MainActor
@Suite(.serialized)
struct CachedReleaseTests {
private func makeEnvironment() -> CairnEnvironment {
CairnEnvironment(inMemory: true)
}

@Test
func standaloneReleaseWithoutRepositoryCanBeSaved() throws {
let environment = makeEnvironment()
let context = environment.modelContainer.mainContext
let release = CachedRelease(
tagName: "v0.1.0",
assetNames: ["App.zip"],
assetURLs: ["https://example.com/App.zip"]
)
context.insert(release)
try context.save()

let fetched = try context.fetch(FetchDescriptor<CachedRelease>())

#expect(fetched.count == 1)
#expect(fetched.first?.repository == nil)
}

@Test
func attachingMultipleReleasesReflectsOnRepositorySide() throws {
let environment = makeEnvironment()
let context = environment.modelContainer.mainContext
let repository = CachedRepository(
githubId: 3,
fullName: "owner/inverse-repo",
starCount: 0,
primaryLanguage: "Swift",
htmlURL: "https://github.com/owner/inverse-repo",
category: "utilities",
lastFetchedAt: Date(timeIntervalSince1970: 3000)
)
context.insert(repository)

let releaseA = CachedRelease(tagName: "v1.0.0", repository: repository)
let releaseB = CachedRelease(tagName: "v2.0.0", repository: repository)
context.insert(releaseA)
context.insert(releaseB)
try context.save()

#expect(repository.releases.count == 2)
}
}
107 changes: 107 additions & 0 deletions Tests/CairnTests/Cache/CachedRepositoryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation
import SwiftData
import Testing

@testable import Cairn

@MainActor
@Suite(.serialized)
struct CachedRepositoryTests {
// CairnEnvironmentを戻り値のModelContextだけでなくインスタンスごと保持し続けないと、
// スコープを抜けた時点でModelContainerが解放されクラッシュするため、
// 各テストで環境そのものをローカル変数に束縛する。
private func makeEnvironment() -> CairnEnvironment {
CairnEnvironment(inMemory: true)
}

@Test
func insertedRepositoryCanBeFetchedBack() throws {
let environment = makeEnvironment()
let context = environment.modelContainer.mainContext
let repository = CachedRepository(
githubId: 1,
fullName: "owner/repo",
topics: ["macos-app"],
starCount: 42,
primaryLanguage: "Swift",
htmlURL: "https://github.com/owner/repo",
category: "utilities",
subTags: ["cli"],
lastFetchedAt: Date(timeIntervalSince1970: 1000)
)
context.insert(repository)
try context.save()

let fetched = try context.fetch(FetchDescriptor<CachedRepository>())

#expect(fetched.count == 1)
#expect(fetched.first?.fullName == "owner/repo")
#expect(fetched.first?.starCount == 42)
#expect(fetched.first?.lastReleaseCheckedAt == nil)
}

@Test
func duplicateGithubIdOverwritesExistingRecord() throws {
// SwiftDataの`.unique`制約は例外を投げず、既存レコードを上書き(upsert)する挙動を
// 実機で確認済み。よってエラーではなく1件に集約されることを期待値とする。
let environment = makeEnvironment()
let context = environment.modelContainer.mainContext

context.insert(
CachedRepository(
githubId: 10,
fullName: "owner/first",
starCount: 1,
primaryLanguage: "Swift",
htmlURL: "https://github.com/owner/first",
category: "utilities",
lastFetchedAt: Date(timeIntervalSince1970: 1000)
)
)
try context.save()

context.insert(
CachedRepository(
githubId: 10,
fullName: "owner/second",
starCount: 2,
primaryLanguage: "Swift",
htmlURL: "https://github.com/owner/second",
category: "utilities",
lastFetchedAt: Date(timeIntervalSince1970: 2000)
)
)
try context.save()

let fetched = try context.fetch(FetchDescriptor<CachedRepository>())
#expect(fetched.count == 1)
#expect(fetched.first?.fullName == "owner/second")
}

@Test
func deletingRepositoryCascadesToItsReleases() throws {
let environment = makeEnvironment()
let context = environment.modelContainer.mainContext
let repository = CachedRepository(
githubId: 2,
fullName: "owner/cascade-repo",
starCount: 0,
primaryLanguage: "Swift",
htmlURL: "https://github.com/owner/cascade-repo",
category: "utilities",
lastFetchedAt: Date(timeIntervalSince1970: 2000),
releases: [
CachedRelease(tagName: "v1.0.0"),
CachedRelease(tagName: "v1.1.0"),
]
)
context.insert(repository)
try context.save()

context.delete(repository)
try context.save()

let remainingReleases = try context.fetch(FetchDescriptor<CachedRelease>())
#expect(remainingReleases.isEmpty)
}
}
22 changes: 22 additions & 0 deletions Tests/CairnTests/Cache/CairnEnvironmentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Testing

@testable import Cairn

@MainActor
@Suite
struct CairnEnvironmentTests {
@Test
func inMemoryContainerInitializesWithoutError() {
let environment = CairnEnvironment(inMemory: true)
let entityNames = environment.modelContainer.schema.entities.map(\.name).sorted()

#expect(entityNames == ["CachedRelease", "CachedRepository", "InstalledApp"])
}

@Test
func onDiskContainerInitializesWithoutError() {
// デフォルト引数(inMemory: false)でも生成が失敗しないことのみ確認する。
// Application Support配下に実ファイルが作られるが、後片付けは行わない。
_ = CairnEnvironment()
}
}
Loading
Loading