Nudge-Screen
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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("& decodes to &")
|
||||
func ampersandEntity() {
|
||||
#expect("Cats & Dogs".strippingHTML == "Cats & Dogs")
|
||||
}
|
||||
|
||||
@Test("< and > decode to < and >")
|
||||
func angleEntities() {
|
||||
#expect("<tag>".strippingHTML == "<tag>")
|
||||
}
|
||||
|
||||
@Test(" is decoded (non-empty result)")
|
||||
func nbspEntity() {
|
||||
let result = "Hello World".strippingHTML
|
||||
#expect(!result.isEmpty)
|
||||
#expect(result.contains("Hello"))
|
||||
#expect(result.contains("World"))
|
||||
}
|
||||
|
||||
@Test("" decodes to double quote")
|
||||
func quotEntity() {
|
||||
#expect("Say "hi"".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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user