Nudge-Screen

This commit is contained in:
2026-04-20 09:41:18 +02:00
parent a48e857ada
commit 187c3e4fc6
26 changed files with 2542 additions and 229 deletions
+129
View File
@@ -0,0 +1,129 @@
import Testing
@testable import bookstax
@Suite("BookStackError errorDescription")
struct BookStackErrorTests {
@Test("invalidURL has non-nil description mentioning https")
func invalidURL() {
let desc = BookStackError.invalidURL.errorDescription
#expect(desc != nil)
#expect(desc!.contains("https"))
}
@Test("notAuthenticated has non-nil description")
func notAuthenticated() {
let desc = BookStackError.notAuthenticated.errorDescription
#expect(desc != nil)
#expect(!desc!.isEmpty)
}
@Test("unauthorized mentions token")
func unauthorized() {
let desc = BookStackError.unauthorized.errorDescription
#expect(desc != nil)
#expect(desc!.lowercased().contains("token"))
}
@Test("forbidden mentions 403 or permission")
func forbidden() {
let desc = BookStackError.forbidden.errorDescription
#expect(desc != nil)
let lower = desc!.lowercased()
#expect(lower.contains("403") || lower.contains("permission") || lower.contains("access"))
}
@Test("notFound includes resource name in description")
func notFound() {
let desc = BookStackError.notFound(resource: "MyPage").errorDescription
#expect(desc != nil)
#expect(desc!.contains("MyPage"))
}
@Test("httpError with message returns that message")
func httpErrorWithMessage() {
let desc = BookStackError.httpError(statusCode: 500, message: "Internal Server Error").errorDescription
#expect(desc == "Internal Server Error")
}
@Test("httpError without message includes status code")
func httpErrorWithoutMessage() {
let desc = BookStackError.httpError(statusCode: 503, message: nil).errorDescription
#expect(desc != nil)
#expect(desc!.contains("503"))
}
@Test("decodingError includes detail string")
func decodingError() {
let desc = BookStackError.decodingError("keyNotFound").errorDescription
#expect(desc != nil)
#expect(desc!.contains("keyNotFound"))
}
@Test("networkUnavailable mentions cache or offline")
func networkUnavailable() {
let desc = BookStackError.networkUnavailable.errorDescription
#expect(desc != nil)
let lower = desc!.lowercased()
#expect(lower.contains("cache") || lower.contains("offline") || lower.contains("internet"))
}
@Test("keychainError includes numeric status code")
func keychainError() {
let desc = BookStackError.keychainError(-25300).errorDescription
#expect(desc != nil)
#expect(desc!.contains("-25300"))
}
@Test("sslError mentions SSL or TLS")
func sslError() {
let desc = BookStackError.sslError.errorDescription
#expect(desc != nil)
let upper = desc!.uppercased()
#expect(upper.contains("SSL") || upper.contains("TLS"))
}
@Test("timeout mentions timed out or server")
func timeout() {
let desc = BookStackError.timeout.errorDescription
#expect(desc != nil)
#expect(!desc!.isEmpty)
}
@Test("notReachable includes the host name")
func notReachable() {
let desc = BookStackError.notReachable(host: "wiki.company.com").errorDescription
#expect(desc != nil)
#expect(desc!.contains("wiki.company.com"))
}
@Test("notBookStack includes the host name")
func notBookStack() {
let desc = BookStackError.notBookStack(host: "example.com").errorDescription
#expect(desc != nil)
#expect(desc!.contains("example.com"))
}
@Test("unknown returns the provided message verbatim")
func unknown() {
let msg = "Something went very wrong"
let desc = BookStackError.unknown(msg).errorDescription
#expect(desc == msg)
}
@Test("All cases produce non-nil, non-empty descriptions")
func allCasesHaveDescriptions() {
let errors: [BookStackError] = [
.invalidURL, .notAuthenticated, .unauthorized, .forbidden,
.notFound(resource: "X"), .httpError(statusCode: 400, message: nil),
.decodingError("err"), .networkUnavailable, .keychainError(0),
.sslError, .timeout, .notReachable(host: "host"),
.notBookStack(host: "host"), .unknown("oops")
]
for error in errors {
let desc = error.errorDescription
#expect(desc != nil, "nil description for \(error)")
#expect(!desc!.isEmpty, "empty description for \(error)")
}
}
}
+168
View File
@@ -0,0 +1,168 @@
import Testing
import SwiftUI
@testable import bookstax
// MARK: - Color Hex Parsing
@Suite("Color Hex Parsing")
struct ColorHexParsingTests {
// MARK: Valid 6-digit hex
@Test("6-digit hex without # prefix parses successfully")
func sixDigitNoPound() {
#expect(Color(hex: "FF0000") != nil)
}
@Test("6-digit hex with # prefix parses successfully")
func sixDigitWithPound() {
#expect(Color(hex: "#FF0000") != nil)
}
@Test("Black hex #000000 parses successfully")
func blackHex() {
#expect(Color(hex: "#000000") != nil)
}
@Test("White hex #FFFFFF parses successfully")
func whiteHex() {
#expect(Color(hex: "#FFFFFF") != nil)
}
@Test("Lowercase hex parses successfully")
func lowercaseHex() {
#expect(Color(hex: "#ff6600") != nil)
}
// MARK: Valid 3-digit hex
@Test("3-digit hex #FFF is valid shorthand")
func threeDigitFFF() {
#expect(Color(hex: "#FFF") != nil)
}
@Test("3-digit hex #000 is valid shorthand")
func threeDigitZero() {
#expect(Color(hex: "#000") != nil)
}
@Test("3-digit hex #F60 is expanded to #FF6600")
func threeDigitExpansion() {
let threeDigit = Color(hex: "#F60")
let sixDigit = Color(hex: "#FF6600")
// Both should parse successfully
#expect(threeDigit != nil)
#expect(sixDigit != nil)
}
// MARK: Invalid inputs
@Test("5-digit hex returns nil")
func fiveDigitInvalid() {
#expect(Color(hex: "#FFFFF") == nil)
}
@Test("7-digit hex returns nil")
func sevenDigitInvalid() {
#expect(Color(hex: "#FFFFFFF") == nil)
}
@Test("Empty string returns nil")
func emptyStringInvalid() {
#expect(Color(hex: "") == nil)
}
@Test("Non-hex characters return nil")
func nonHexCharacters() {
#expect(Color(hex: "#GGGGGG") == nil)
}
@Test("Just # returns nil")
func onlyHash() {
#expect(Color(hex: "#") == nil)
}
}
// MARK: - Color Hex Round-trip
@Suite("Color Hex Round-trip")
struct ColorHexRoundTripTests {
@Test("Red #FF0000 round-trips correctly")
func redRoundTrip() {
let color = Color(hex: "#FF0000")!
let hex = color.toHexString()
#expect(hex == "#FF0000")
}
@Test("Green #00FF00 round-trips correctly")
func greenRoundTrip() {
let color = Color(hex: "#00FF00")!
let hex = color.toHexString()
#expect(hex == "#00FF00")
}
@Test("Blue #0000FF round-trips correctly")
func blueRoundTrip() {
let color = Color(hex: "#0000FF")!
let hex = color.toHexString()
#expect(hex == "#0000FF")
}
@Test("Custom color #3A7B55 round-trips correctly")
func customColorRoundTrip() {
let color = Color(hex: "#3A7B55")!
let hex = color.toHexString()
#expect(hex == "#3A7B55")
}
}
// MARK: - AccentTheme
@Suite("AccentTheme Properties")
struct AccentThemeTests {
@Test("All cases are represented in CaseIterable")
func allCasesPresent() {
#expect(AccentTheme.allCases.count == 8)
}
@Test("id equals rawValue")
func idEqualsRawValue() {
for theme in AccentTheme.allCases {
#expect(theme.id == theme.rawValue)
}
}
@Test("Every theme has a non-empty displayName")
func displayNamesNonEmpty() {
for theme in AccentTheme.allCases {
#expect(!theme.displayName.isEmpty)
}
}
@Test("accentColor equals shelfColor")
func accentColorEqualsShelfColor() {
for theme in AccentTheme.allCases {
#expect(theme.accentColor == theme.shelfColor)
}
}
@Test("Ocean theme has expected displayName")
func oceanDisplayName() {
#expect(AccentTheme.ocean.displayName == "Ocean")
}
@Test("Graphite theme has expected displayName")
func graphiteDisplayName() {
#expect(AccentTheme.graphite.displayName == "Graphite")
}
@Test("All themes can be init'd from rawValue")
func initFromRawValue() {
for theme in AccentTheme.allCases {
let reinit = AccentTheme(rawValue: theme.rawValue)
#expect(reinit == theme)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
import Testing
@testable import bookstax
import Foundation
// MARK: - TagDTO
@Suite("TagDTO")
struct TagDTOTests {
@Test("id is composed of name and value")
func idFormat() {
let tag = TagDTO(name: "status", value: "published", order: 0)
#expect(tag.id == "status:published")
}
@Test("id with empty value still includes colon separator")
func idEmptyValue() {
let tag = TagDTO(name: "featured", value: "", order: 0)
#expect(tag.id == "featured:")
}
@Test("Two tags with same name/value have equal ids")
func duplicateIds() {
let t1 = TagDTO(name: "env", value: "prod", order: 0)
let t2 = TagDTO(name: "env", value: "prod", order: 1)
#expect(t1.id == t2.id)
}
@Test("Tags decode from JSON correctly")
func decodeFromJSON() throws {
let json = """
{"name":"topic","value":"swift","order":3}
""".data(using: .utf8)!
let tag = try JSONDecoder().decode(TagDTO.self, from: json)
#expect(tag.name == "topic")
#expect(tag.value == "swift")
#expect(tag.order == 3)
}
}
// MARK: - PageDTO
private let iso8601Formatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private func pageJSON(
slug: String? = nil,
priority: Int? = nil,
draft: Bool? = nil,
tags: String? = nil,
markdown: String? = nil
) -> Data {
var fields: [String] = [
#""id":1,"book_id":10,"name":"Test Page""#,
#""created_at":"2024-01-01T00:00:00.000Z","updated_at":"2024-01-01T00:00:00.000Z""#
]
if let s = slug { fields.append("\"slug\":\"\(s)\"") }
if let p = priority { fields.append("\"priority\":\(p)") }
if let d = draft { fields.append("\"draft\":\(d)") }
if let t = tags { fields.append("\"tags\":[\(t)]") }
if let m = markdown { fields.append("\"markdown\":\"\(m)\"") }
return ("{\(fields.joined(separator: ","))}").data(using: .utf8)!
}
private func makeDecoder() -> JSONDecoder {
// Mirrors the decoder used in BookStackAPI
let decoder = JSONDecoder()
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
if let date = formatter.date(from: str) { return date }
// Fallback without fractional seconds
let fallback = ISO8601DateFormatter()
if let date = fallback.date(from: str) { return date }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date: \(str)")
}
return decoder
}
@Suite("PageDTO JSON Decoding")
struct PageDTOTests {
@Test("Minimal JSON (no optional fields) decodes with defaults")
func minimalDecoding() throws {
let data = pageJSON()
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.id == 1)
#expect(page.bookId == 10)
#expect(page.name == "Test Page")
#expect(page.slug == "") // defaults to ""
#expect(page.priority == 0) // defaults to 0
#expect(page.draftStatus == false) // defaults to false
#expect(page.tags.isEmpty) // defaults to []
#expect(page.markdown == nil)
#expect(page.html == nil)
#expect(page.chapterId == nil)
}
@Test("slug is decoded when present")
func slugDecoded() throws {
let data = pageJSON(slug: "my-page")
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.slug == "my-page")
}
@Test("priority is decoded when present")
func priorityDecoded() throws {
let data = pageJSON(priority: 5)
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.priority == 5)
}
@Test("draft true decodes correctly")
func draftDecoded() throws {
let data = pageJSON(draft: true)
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.draftStatus == true)
}
@Test("tags array is decoded when present")
func tagsDecoded() throws {
let tagJSON = #"{"name":"lang","value":"swift","order":0}"#
let data = pageJSON(tags: tagJSON)
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.tags.count == 1)
#expect(page.tags.first?.name == "lang")
}
@Test("markdown is decoded when present")
func markdownDecoded() throws {
let data = pageJSON(markdown: "# Hello")
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.markdown == "# Hello")
}
}
// MARK: - SearchResultDTO
@Suite("SearchResultDTO JSON Decoding")
struct SearchResultDTOTests {
private func resultJSON(withTags: Bool = false) -> Data {
let tags = withTags ? #","tags":[{"name":"topic","value":"swift","order":0}]"# : ""
return """
{"id":7,"name":"Swift Basics","slug":"swift-basics","type":"page",
"url":"https://example.com/books/1/page/7","preview":"An intro to Swift"\(tags)}
""".data(using: .utf8)!
}
@Test("Result with no tags field defaults to empty array")
func defaultsEmptyTags() throws {
let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON())
#expect(result.tags.isEmpty)
}
@Test("Result with tags decodes them correctly")
func decodesTagsWhenPresent() throws {
let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON(withTags: true))
#expect(result.tags.count == 1)
}
@Test("All fields decode correctly")
func allFieldsDecode() throws {
let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON())
#expect(result.id == 7)
#expect(result.name == "Swift Basics")
#expect(result.slug == "swift-basics")
#expect(result.type == .page)
#expect(result.preview == "An intro to Swift")
}
}
// MARK: - SearchResultDTO.ContentType
@Suite("SearchResultDTO.ContentType")
struct ContentTypeTests {
@Test("All content types have non-empty systemImage")
func systemImagesNonEmpty() {
for type_ in SearchResultDTO.ContentType.allCases {
#expect(!type_.systemImage.isEmpty)
}
}
@Test("Page system image is doc.text")
func pageSystemImage() {
#expect(SearchResultDTO.ContentType.page.systemImage == "doc.text")
}
@Test("Book system image is book.closed")
func bookSystemImage() {
#expect(SearchResultDTO.ContentType.book.systemImage == "book.closed")
}
@Test("Chapter system image is list.bullet.rectangle")
func chapterSystemImage() {
#expect(SearchResultDTO.ContentType.chapter.systemImage == "list.bullet.rectangle")
}
@Test("Shelf system image is books.vertical")
func shelfSystemImage() {
#expect(SearchResultDTO.ContentType.shelf.systemImage == "books.vertical")
}
@Test("ContentType decodes from raw string value")
func rawValueDecoding() throws {
let data = #""page""#.data(using: .utf8)!
let type_ = try JSONDecoder().decode(SearchResultDTO.ContentType.self, from: data)
#expect(type_ == .page)
}
}
// MARK: - PaginatedResponse
@Suite("PaginatedResponse Decoding")
struct PaginatedResponseTests {
@Test("Paginated shelf response decodes total and data")
func paginatedDecoding() throws {
let json = """
{"data":[{"id":1,"name":"My Shelf","slug":"my-shelf","description":"",
"created_at":"2024-01-01T00:00:00.000Z","updated_at":"2024-01-01T00:00:00.000Z"}],
"total":1}
""".data(using: .utf8)!
let decoded = try makeDecoder().decode(PaginatedResponse<ShelfDTO>.self, from: json)
#expect(decoded.total == 1)
#expect(decoded.data.count == 1)
#expect(decoded.data.first?.name == "My Shelf")
}
}
+151
View File
@@ -0,0 +1,151 @@
import Testing
@testable import bookstax
import Foundation
// MARK: - DonationPurchaseState Enum
@Suite("DonationPurchaseState Computed Properties")
struct DonationPurchaseStateTests {
@Test("activePurchasingID returns id when purchasing")
func activePurchasingID() {
let state = DonationPurchaseState.purchasing(productID: "donatebook")
#expect(state.activePurchasingID == "donatebook")
}
@Test("activePurchasingID returns nil for non-purchasing states")
@available(iOS 18.0, *)
func activePurchasingIDNil() {
#expect(DonationPurchaseState.idle.activePurchasingID == nil)
#expect(DonationPurchaseState.thankYou(productID: "x").activePurchasingID == nil)
#expect(DonationPurchaseState.pending(productID: "x").activePurchasingID == nil)
#expect(DonationPurchaseState.failed(productID: "x", message: "err").activePurchasingID == nil)
}
@Test("thankYouID returns id when in thankYou state")
func thankYouID() {
let state = DonationPurchaseState.thankYou(productID: "doneatepage")
#expect(state.thankYouID == "doneatepage")
}
@Test("thankYouID returns nil for other states")
func thankYouIDNil() {
#expect(DonationPurchaseState.idle.thankYouID == nil)
#expect(DonationPurchaseState.purchasing(productID: "x").thankYouID == nil)
}
@Test("pendingID returns id when in pending state")
func pendingID() {
let state = DonationPurchaseState.pending(productID: "donateencyclopaedia")
#expect(state.pendingID == "donateencyclopaedia")
}
@Test("pendingID returns nil for other states")
func pendingIDNil() {
#expect(DonationPurchaseState.idle.pendingID == nil)
#expect(DonationPurchaseState.thankYou(productID: "x").pendingID == nil)
}
@Test("errorMessage returns message when failed for matching id")
func errorMessageMatch() {
let state = DonationPurchaseState.failed(productID: "donatebook", message: "Payment declined")
#expect(state.errorMessage(for: "donatebook") == "Payment declined")
}
@Test("errorMessage returns nil for wrong product id")
func errorMessageNoMatch() {
let state = DonationPurchaseState.failed(productID: "donatebook", message: "error")
#expect(state.errorMessage(for: "doneatepage") == nil)
}
@Test("errorMessage returns nil for non-failed states")
func errorMessageNotFailed() {
#expect(DonationPurchaseState.idle.errorMessage(for: "x") == nil)
#expect(DonationPurchaseState.purchasing(productID: "x").errorMessage(for: "x") == nil)
}
@Test("isIdle returns true only for .idle")
func isIdleCheck() {
#expect(DonationPurchaseState.idle.isIdle == true)
#expect(DonationPurchaseState.purchasing(productID: "x").isIdle == false)
#expect(DonationPurchaseState.thankYou(productID: "x").isIdle == false)
#expect(DonationPurchaseState.failed(productID: "x", message: "e").isIdle == false)
}
@Test("Equatable: same purchasing states are equal")
func equatablePurchasing() {
#expect(DonationPurchaseState.purchasing(productID: "a") == DonationPurchaseState.purchasing(productID: "a"))
#expect(DonationPurchaseState.purchasing(productID: "a") != DonationPurchaseState.purchasing(productID: "b"))
}
@Test("Equatable: idle equals idle")
func equatableIdle() {
#expect(DonationPurchaseState.idle == DonationPurchaseState.idle)
#expect(DonationPurchaseState.idle != DonationPurchaseState.purchasing(productID: "x"))
}
}
// MARK: - shouldShowNudge Timing
@Suite("DonationService shouldShowNudge", .serialized)
@MainActor
struct DonationServiceNudgeTests {
private let installKey = "bookstax.installDate"
private let nudgeKey = "bookstax.lastNudgeDate"
private let historyKey = "bookstax.donationHistory"
init() {
// Clean UserDefaults before each test so DonationService reads fresh state
UserDefaults.standard.removeObject(forKey: installKey)
UserDefaults.standard.removeObject(forKey: nudgeKey)
UserDefaults.standard.removeObject(forKey: historyKey)
}
@Test("Within 3-day grace period: nudge should not show")
func gracePeriodPreventsNudge() {
let recentInstall = Date().addingTimeInterval(-(2 * 24 * 3600)) // 2 days ago
UserDefaults.standard.set(recentInstall, forKey: installKey)
#expect(DonationService.shared.shouldShowNudge == false)
}
@Test("After 3-day grace period: nudge should show")
func afterGracePeriodNudgeShows() {
let oldInstall = Date().addingTimeInterval(-(4 * 24 * 3600)) // 4 days ago
UserDefaults.standard.set(oldInstall, forKey: installKey)
#expect(DonationService.shared.shouldShowNudge == true)
}
@Test("No install date recorded: nudge should not show (safe fallback)")
func noInstallDateFallback() {
// No install date stored graceful fallback
#expect(DonationService.shared.shouldShowNudge == false)
}
@Test("Nudge recently dismissed: should not show again")
func recentNudgeHidden() {
let oldInstall = Date().addingTimeInterval(-(90 * 24 * 3600))
let recentNudge = Date().addingTimeInterval(-(10 * 24 * 3600)) // dismissed 10 days ago
UserDefaults.standard.set(oldInstall, forKey: installKey)
UserDefaults.standard.set(recentNudge, forKey: nudgeKey)
#expect(DonationService.shared.shouldShowNudge == false)
}
@Test("Nudge dismissed ~6 months ago: should show again")
func sixMonthsLaterShowAgain() {
let oldInstall = Date().addingTimeInterval(-(200 * 24 * 3600))
let oldNudge = Date().addingTimeInterval(-(183 * 24 * 3600)) // ~6 months ago
UserDefaults.standard.set(oldInstall, forKey: installKey)
UserDefaults.standard.set(oldNudge, forKey: nudgeKey)
#expect(DonationService.shared.shouldShowNudge == true)
}
@Test("Nudge not yet 6 months: should not show")
func beforeSixMonthsHidden() {
let oldInstall = Date().addingTimeInterval(-(200 * 24 * 3600))
let recentNudge = Date().addingTimeInterval(-(100 * 24 * 3600)) // only 100 days
UserDefaults.standard.set(oldInstall, forKey: installKey)
UserDefaults.standard.set(recentNudge, forKey: nudgeKey)
#expect(DonationService.shared.shouldShowNudge == false)
}
}
@@ -0,0 +1,226 @@
import Testing
@testable import bookstax
// MARK: - URL Validation
@Suite("OnboardingViewModel URL Validation")
struct OnboardingViewModelURLTests {
// MARK: Empty input
@Test("Empty URL sets error and returns false")
func emptyURL() {
let vm = OnboardingViewModel()
vm.serverURLInput = ""
#expect(vm.validateServerURL() == false)
#expect(vm.serverURLError != nil)
}
@Test("Whitespace-only URL sets error and returns false")
func whitespaceURL() {
let vm = OnboardingViewModel()
vm.serverURLInput = " "
#expect(vm.validateServerURL() == false)
#expect(vm.serverURLError != nil)
}
// MARK: Auto-prefix https://
@Test("URL without scheme gets https:// prepended")
func autoprefixHTTPS() {
let vm = OnboardingViewModel()
vm.serverURLInput = "wiki.example.com"
let result = vm.validateServerURL()
#expect(result == true)
#expect(vm.serverURLInput.hasPrefix("https://"))
}
@Test("URL already starting with https:// is left unchanged")
func httpsAlreadyPresent() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.example.com"
let result = vm.validateServerURL()
#expect(result == true)
#expect(vm.serverURLInput == "https://wiki.example.com")
}
@Test("URL starting with http:// is left as-is (not double-prefixed)")
func httpNotDoublePrefixed() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://wiki.example.com"
let result = vm.validateServerURL()
#expect(result == true)
#expect(vm.serverURLInput == "http://wiki.example.com")
}
// MARK: Trailing slash removal
@Test("Trailing slash is stripped from URL")
func trailingSlash() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.example.com/"
_ = vm.validateServerURL()
#expect(vm.serverURLInput == "https://wiki.example.com")
}
@Test("Multiple trailing slashes only last slash stripped then accepted")
func multipleSlashesInPath() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.example.com/path/"
_ = vm.validateServerURL()
#expect(vm.serverURLInput == "https://wiki.example.com/path")
}
// MARK: Successful validation
@Test("Valid URL clears any previous error")
func validURLClearsError() {
let vm = OnboardingViewModel()
vm.serverURLError = "Previous error"
vm.serverURLInput = "https://books.mycompany.com"
#expect(vm.validateServerURL() == true)
#expect(vm.serverURLError == nil)
}
}
// MARK: - isHTTP Flag
@Suite("OnboardingViewModel isHTTP")
struct OnboardingViewModelHTTPTests {
@Test("http:// URL sets isHTTP = true")
func httpURL() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://wiki.local"
#expect(vm.isHTTP == true)
}
@Test("https:// URL sets isHTTP = false")
func httpsURL() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.local"
#expect(vm.isHTTP == false)
}
@Test("Empty URL is not HTTP")
func emptyNotHTTP() {
let vm = OnboardingViewModel()
vm.serverURLInput = ""
#expect(vm.isHTTP == false)
}
}
// MARK: - isRemoteServer Detection
@Suite("OnboardingViewModel isRemoteServer")
struct OnboardingViewModelRemoteTests {
// MARK: Local / private addresses
@Test("localhost is not remote")
func localhost() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://localhost"
#expect(vm.isRemoteServer == false)
}
@Test("127.0.0.1 is not remote")
func loopback() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://127.0.0.1"
#expect(vm.isRemoteServer == false)
}
@Test("IPv6 loopback ::1 is not remote")
func ipv6Loopback() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://[::1]"
#expect(vm.isRemoteServer == false)
}
@Test(".local mDNS host is not remote")
func mdnsLocal() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://bookstack.local"
#expect(vm.isRemoteServer == false)
}
@Test("Plain hostname without dots is not remote")
func plainHostname() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://mywiki"
#expect(vm.isRemoteServer == false)
}
@Test("10.x.x.x private range is not remote")
func privateClass_A() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://10.0.1.50"
#expect(vm.isRemoteServer == false)
}
@Test("192.168.x.x private range is not remote")
func privateClass_C() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://192.168.1.100"
#expect(vm.isRemoteServer == false)
}
@Test("172.16.x.x private range is not remote")
func privateClass_B_low() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://172.16.0.1"
#expect(vm.isRemoteServer == false)
}
@Test("172.31.x.x private range is not remote")
func privateClass_B_high() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://172.31.255.255"
#expect(vm.isRemoteServer == false)
}
@Test("172.15.x.x is outside private range and is remote")
func justBelowPrivateClass_B() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://172.15.0.1"
#expect(vm.isRemoteServer == true)
}
@Test("172.32.x.x is outside private range and is remote")
func justAbovePrivateClass_B() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://172.32.0.1"
#expect(vm.isRemoteServer == true)
}
// MARK: Remote addresses
@Test("Public IP 8.8.8.8 is remote")
func publicIP() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://8.8.8.8"
#expect(vm.isRemoteServer == true)
}
@Test("Public domain with subdomain is remote")
func publicDomain() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.mycompany.com"
#expect(vm.isRemoteServer == true)
}
@Test("Top-level domain name is remote")
func topLevelDomain() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://bookstack.io"
#expect(vm.isRemoteServer == true)
}
@Test("Empty URL is not remote")
func emptyIsNotRemote() {
let vm = OnboardingViewModel()
vm.serverURLInput = ""
#expect(vm.isRemoteServer == false)
}
}
@@ -0,0 +1,244 @@
import Testing
@testable import bookstax
import Foundation
// MARK: - Helpers
private func makePageDTO(markdown: String? = "# Hello", tags: [TagDTO] = []) -> PageDTO {
PageDTO(
id: 42,
bookId: 1,
chapterId: nil,
name: "Test Page",
slug: "test-page",
html: nil,
markdown: markdown,
priority: 0,
draftStatus: false,
tags: tags,
createdAt: Date(),
updatedAt: Date()
)
}
// MARK: - Initialisation
@Suite("PageEditorViewModel Initialisation")
struct PageEditorViewModelInitTests {
@Test("Create mode starts with empty title and content")
func createModeDefaults() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
#expect(vm.title.isEmpty)
#expect(vm.markdownContent.isEmpty)
#expect(vm.tags.isEmpty)
#expect(vm.isHtmlOnlyPage == false)
}
@Test("Edit mode populates title and markdown from page")
func editModePopulates() {
let page = makePageDTO(markdown: "## Content")
let vm = PageEditorViewModel(mode: .edit(page: page))
#expect(vm.title == "Test Page")
#expect(vm.markdownContent == "## Content")
#expect(vm.isHtmlOnlyPage == false)
}
@Test("Edit mode with nil markdown sets isHtmlOnlyPage")
func htmlOnlyPage() {
let page = makePageDTO(markdown: nil)
let vm = PageEditorViewModel(mode: .edit(page: page))
#expect(vm.isHtmlOnlyPage == true)
#expect(vm.markdownContent.isEmpty)
}
@Test("Edit mode with existing tags loads them")
func editModeLoadsTags() {
let tags = [TagDTO(name: "topic", value: "swift", order: 0)]
let page = makePageDTO(tags: tags)
let vm = PageEditorViewModel(mode: .edit(page: page))
#expect(vm.tags.count == 1)
#expect(vm.tags.first?.name == "topic")
}
}
// MARK: - hasUnsavedChanges
@Suite("PageEditorViewModel hasUnsavedChanges")
struct PageEditorViewModelUnsavedTests {
@Test("No changes after init → hasUnsavedChanges is false")
func noChangesAfterInit() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
#expect(vm.hasUnsavedChanges == false)
}
@Test("Changing title → hasUnsavedChanges is true")
func titleChange() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "New Title"
#expect(vm.hasUnsavedChanges == true)
}
@Test("Changing markdownContent → hasUnsavedChanges is true")
func contentChange() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.markdownContent = "Some text"
#expect(vm.hasUnsavedChanges == true)
}
@Test("Adding a tag → hasUnsavedChanges is true")
func tagAddition() {
let page = makePageDTO()
let vm = PageEditorViewModel(mode: .edit(page: page))
vm.addTag(name: "new-tag")
#expect(vm.hasUnsavedChanges == true)
}
@Test("Restoring original values → hasUnsavedChanges is false again")
func revertChanges() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "Changed"
vm.title = ""
#expect(vm.hasUnsavedChanges == false)
}
}
// MARK: - isSaveDisabled
@Suite("PageEditorViewModel isSaveDisabled")
struct PageEditorViewModelSaveDisabledTests {
@Test("Empty title disables save in create mode")
func emptyTitleCreate() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.markdownContent = "Some content"
#expect(vm.isSaveDisabled == true)
}
@Test("Empty content disables save in create mode even if title is set")
func emptyContentCreate() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "My Page"
vm.markdownContent = ""
#expect(vm.isSaveDisabled == true)
}
@Test("Whitespace-only content disables save in create mode")
func whitespaceContentCreate() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "My Page"
vm.markdownContent = " \n "
#expect(vm.isSaveDisabled == true)
}
@Test("Title and content both set enables save in create mode")
func validCreate() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "My Page"
vm.markdownContent = "Hello world"
#expect(vm.isSaveDisabled == false)
}
@Test("Edit mode only requires title empty content is allowed")
func editOnlyNeedsTitle() {
let page = makePageDTO(markdown: nil)
let vm = PageEditorViewModel(mode: .edit(page: page))
vm.title = "Existing Page"
vm.markdownContent = ""
#expect(vm.isSaveDisabled == false)
}
@Test("Empty title disables save in edit mode")
func emptyTitleEdit() {
let page = makePageDTO()
let vm = PageEditorViewModel(mode: .edit(page: page))
vm.title = ""
#expect(vm.isSaveDisabled == true)
}
}
// MARK: - Tag Management
@Suite("PageEditorViewModel Tags")
struct PageEditorViewModelTagTests {
@Test("addTag appends a new tag")
func addTag() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "status", value: "draft")
#expect(vm.tags.count == 1)
#expect(vm.tags.first?.name == "status")
#expect(vm.tags.first?.value == "draft")
}
@Test("addTag trims whitespace from name and value")
func addTagTrims() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: " topic ", value: " swift ")
#expect(vm.tags.first?.name == "topic")
#expect(vm.tags.first?.value == "swift")
}
@Test("addTag with empty name after trimming is ignored")
func addTagEmptyNameIgnored() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: " ")
#expect(vm.tags.isEmpty)
}
@Test("addTag prevents duplicate (same name + value) entries")
func addTagNoDuplicates() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "lang", value: "swift")
vm.addTag(name: "lang", value: "swift")
#expect(vm.tags.count == 1)
}
@Test("addTag allows same name with different value")
func addTagSameNameDifferentValue() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "env", value: "dev")
vm.addTag(name: "env", value: "prod")
#expect(vm.tags.count == 2)
}
@Test("removeTag removes the matching tag by id")
func removeTag() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "remove-me", value: "yes")
let tag = vm.tags[0]
vm.removeTag(tag)
#expect(vm.tags.isEmpty)
}
@Test("removeTag does not remove non-matching tags")
func removeTagKeepsOthers() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "keep", value: "")
vm.addTag(name: "remove", value: "")
let toRemove = vm.tags.first { $0.name == "remove" }!
vm.removeTag(toRemove)
#expect(vm.tags.count == 1)
#expect(vm.tags.first?.name == "keep")
}
}
// MARK: - uploadTargetPageId
@Suite("PageEditorViewModel uploadTargetPageId")
struct PageEditorViewModelUploadIDTests {
@Test("Create mode returns 0 as upload target")
func createModeUploadTarget() {
let vm = PageEditorViewModel(mode: .create(bookId: 5))
#expect(vm.uploadTargetPageId == 0)
}
@Test("Edit mode returns the existing page id")
func editModeUploadTarget() {
let page = makePageDTO()
let vm = PageEditorViewModel(mode: .edit(page: page))
#expect(vm.uploadTargetPageId == 42)
}
}
+108
View File
@@ -0,0 +1,108 @@
import Testing
@testable import bookstax
// MARK: - Recent Searches
@Suite("SearchViewModel Recent Searches", .serialized)
struct SearchViewModelRecentTests {
private let recentKey = "recentSearches"
init() {
// Start each test with a clean slate
UserDefaults.standard.removeObject(forKey: recentKey)
}
// MARK: addToRecent
@Test("Adding a query inserts it at position 0")
func addInsertsAtFront() {
let vm = SearchViewModel()
vm.addToRecent("swift")
vm.addToRecent("swiftui")
let recent = vm.recentSearches
#expect(recent.first == "swiftui")
#expect(recent[1] == "swift")
}
@Test("Adding duplicate moves it to front without creating duplicates")
func addDeduplicates() {
let vm = SearchViewModel()
vm.addToRecent("bookstack")
vm.addToRecent("wiki")
vm.addToRecent("bookstack") // duplicate
let recent = vm.recentSearches
#expect(recent.first == "bookstack")
#expect(recent.count == 2)
#expect(!recent.dropFirst().contains("bookstack"))
}
@Test("List is capped at 10 entries")
func cappedAtTen() {
let vm = SearchViewModel()
for i in 1...12 {
vm.addToRecent("query\(i)")
}
#expect(vm.recentSearches.count == 10)
}
@Test("Oldest entries are dropped when cap is exceeded")
func oldestDropped() {
let vm = SearchViewModel()
for i in 1...11 {
vm.addToRecent("query\(i)")
}
let recent = vm.recentSearches
// query1 was added first, so it falls off after 11 adds
#expect(!recent.contains("query1"))
#expect(recent.contains("query11"))
}
// MARK: clearRecentSearches
@Test("clearRecentSearches empties the list")
func clearResetsToEmpty() {
let vm = SearchViewModel()
vm.addToRecent("something")
vm.clearRecentSearches()
#expect(vm.recentSearches.isEmpty)
}
// MARK: Persistence
@Test("Recent searches persist across ViewModel instances")
func persistsAcrossInstances() {
let vm1 = SearchViewModel()
vm1.addToRecent("persistent")
let vm2 = SearchViewModel()
#expect(vm2.recentSearches.contains("persistent"))
}
}
// MARK: - Query Minimum Length
@Suite("SearchViewModel Query Logic")
struct SearchViewModelQueryTests {
@Test("Short query clears results without triggering search")
func shortQueryClearsResults() {
let vm = SearchViewModel()
vm.results = [SearchResultDTO(id: 1, name: "dummy", slug: "dummy",
type: .page, url: "", preview: nil)]
vm.query = "x" // only 1 character
vm.onQueryChanged()
#expect(vm.results.isEmpty)
}
@Test("Query with 2+ chars does not clear results immediately")
func sufficientQueryKeepsResults() {
let vm = SearchViewModel()
vm.query = "sw" // 2 characters triggers debounce but does not clear
vm.onQueryChanged()
// Results not cleared by onQueryChanged when query is long enough
// (actual search would require API; here we just verify results aren't wiped)
// Results were empty to start with, so we just confirm no crash
#expect(vm.results.isEmpty) // no API call, so still empty
}
}
+88
View File
@@ -0,0 +1,88 @@
import Testing
@testable import bookstax
@Suite("String strippingHTML")
struct StringHTMLTests {
// MARK: Basic tag removal
@Test("Simple tag is removed")
func simpleTag() {
#expect("<p>Hello</p>".strippingHTML == "Hello")
}
@Test("Bold tag is removed")
func boldTag() {
#expect("<b>Bold</b>".strippingHTML == "Bold")
}
@Test("Nested tags are fully stripped")
func nestedTags() {
#expect("<div><p><span>Deep</span></p></div>".strippingHTML == "Deep")
}
@Test("Self-closing tags are removed")
func selfClosingTag() {
let result = "Before<br/>After".strippingHTML
// NSAttributedString adds a newline for <br>, so just check both words are present
#expect(result.contains("Before"))
#expect(result.contains("After"))
}
// MARK: HTML entities
@Test("&amp; decodes to &")
func ampersandEntity() {
#expect("Cats &amp; Dogs".strippingHTML == "Cats & Dogs")
}
@Test("&lt; and &gt; decode to < and >")
func angleEntities() {
#expect("&lt;tag&gt;".strippingHTML == "<tag>")
}
@Test("&nbsp; is decoded (non-empty result)")
func nbspEntity() {
let result = "Hello&nbsp;World".strippingHTML
#expect(!result.isEmpty)
#expect(result.contains("Hello"))
#expect(result.contains("World"))
}
@Test("&quot; decodes to double quote")
func quotEntity() {
#expect("Say &quot;hi&quot;".strippingHTML == "Say \"hi\"")
}
// MARK: Edge cases
@Test("Empty string returns empty string")
func emptyString() {
#expect("".strippingHTML == "")
}
@Test("Plain text without HTML is returned unchanged")
func plainText() {
#expect("No tags here".strippingHTML == "No tags here")
}
@Test("Leading and trailing whitespace is trimmed")
func trimmingWhitespace() {
#expect("<p> hello </p>".strippingHTML == "hello")
}
@Test("HTML with attributes strips fully")
func tagsWithAttributes() {
let html = "<a href=\"https://example.com\" class=\"link\">Click here</a>"
#expect(html.strippingHTML == "Click here")
}
@Test("Complex real-world snippet is reduced to plain text")
func complexSnippet() {
let html = "<h1>Title</h1><p>First paragraph.</p><ul><li>Item 1</li></ul>"
let result = html.strippingHTML
#expect(result.contains("Title"))
#expect(result.contains("First paragraph"))
#expect(result.contains("Item 1"))
}
}