ViewCoverageTests.swift

10.4 KB · raw

1import AppKit2import Foundation3import SwiftUI4import Testing56@testable import Haven78@Suite("SwiftUI view construction", .serialized)9@MainActor10struct ViewCoverageTests {11  private func render<V: View>(_ view: V) {12    let hostingView = NSHostingView(rootView: view)13    hostingView.frame = NSRect(x: 0, y: 0, width: 450, height: 640)14    hostingView.layoutSubtreeIfNeeded()15    hostingView.displayIfNeeded()16  }1718  @Test("Main and explore views render every feed selection")19  func mainAndExploreViews() async {20    let search = SearchStub()21    search.results = Array(22      repeating: .success(([], Meta(current_page: 1, last_page: 1, per_page: 24, total: 0))),23      count: 20)24    let state = AppState(api: search, observeChanges: false)25    for tab in [AppTab.explore, .local, .favorites, .history, .settings] {26      state.activeTab = tab27      render(MainView(appState: state))28    }2930    let explore = ExploreView(appState: state)31    render(explore.resolutionOption("Any"))32    render(explore.ratioOption("Any"))33    await explore.refresh()34    explore.refreshAction()35    await explore.loadMore()36    explore.loadMoreAction()3738    // Outage state: the Explore tab swaps the grid for the status panel, and39    // the panel itself renders with its retry action wired up.40    if let status = WallhavenStatus(41      error: WallhavenError.unavailable("Wallhaven is unavailable right now (HTTP 521)."))42    {43      state.wallhavenStatus = status44      render(explore)45      let panel = WallhavenStatusPanel(status: status) {}46      render(panel)47      panel.retry()48    }49    state.wallhavenStatus = nil5051    for mode in FeedMode.allCases {52      state.feedMode = mode53      render(explore)54      explore.select(mode)55      render(56        FeedModePill(mode: mode, isActive: true) {57          state.feedMode = mode58        })59      render(FeedModePill(mode: mode, isActive: false) {})60    }61  }6263  @Test("Grid, item, favorites, and history states render")64  func remoteWallpaperViews() async {65    let search = SearchStub()66    search.results = [.success(([], nil))]67    let wallpaperManager = WallpaperStub()68    let state = AppState(69      api: search, wallpaperManager: wallpaperManager, observeChanges: false)70    let wallpaper = makeWallpaper(id: "view")71    var actionCount = 07273    let item = WallpaperItem(74      wallpaper: wallpaper, action: { actionCount += 1 }, maxAttempts: 1,75      retryDelayNanoseconds: 0)76    render(item)77    render(item.thumbnail(for: .empty))78    render(item.thumbnail(for: .success(Image(systemName: "photo"))))79    render(item.thumbnail(for: .failure(URLError(.badServerResponse))))80    await item.retryAfterFailure()81    let exhausted = WallpaperItem(82      wallpaper: wallpaper, action: {}, attempt: 1, maxAttempts: 1,83      retryDelayNanoseconds: 0)84    render(exhausted.thumbnail(for: .failure(URLError(.badServerResponse))))85    await exhausted.retryAfterFailure()86    item.toggleFavorite()87    item.toggleFavorite()88    item.action()89    #expect(actionCount == 1)9091    // Exponential backoff: each successive attempt doubles the wait, and a92    // cancelled task bails out without advancing the attempt counter.93    let backoffItem = WallpaperItem(94      wallpaper: wallpaper, action: {}, maxAttempts: 4, retryDelayNanoseconds: 1)95    let schedule = (0..<4).map { backoffItem.backoffDelay(forAttempt: $0) }96    #expect(schedule == [1, 2, 4, 8])9798    let cancelled = WallpaperItem(99      wallpaper: wallpaper, action: {}, maxAttempts: 4, retryDelayNanoseconds: 1)100    let cancelTask = Task { await cancelled.retryAfterFailure() }101    cancelTask.cancel()102    await cancelTask.value103    #expect(cancelled.exposedAttempt == 0)104105    render(WallpaperGrid(wallpapers: [], appState: state, onEndReached: nil))106    let grid = WallpaperGrid(wallpapers: [wallpaper], appState: state, onEndReached: {})107    render(grid)108    state.wallpapers = [wallpaper]109    state.isLoading = true110    render(grid)111    state.isLoading = false112    await grid.set(wallpaper)113    await grid.refresh()114115    state.favoritesManager.favorites = []116    render(FavoritesView(appState: state, favoritesManager: state.favoritesManager))117    state.favoritesManager.favorites = [wallpaper]118    render(FavoritesView(appState: state, favoritesManager: state.favoritesManager))119120    state.historyManager.history = []121    render(HistoryView(appState: state, historyManager: state.historyManager))122    state.historyManager.history = [wallpaper]123    let history = HistoryView(appState: state, historyManager: state.historyManager)124    render(history)125    history.clearHistory()126    state.historyManager.history = [wallpaper]127    render(history)128  }129130  @Test("Local gallery renders enabled, disabled, empty, and populated states")131  func localViews() async throws {132    let defaults = UserDefaults.standard133    let oldEnabled = defaults.object(forKey: "saveToFolderEnabled")134    let oldPath = defaults.string(forKey: "saveFolderPath")135    defer {136      if let oldEnabled {137        defaults.set(oldEnabled, forKey: "saveToFolderEnabled")138      } else {139        defaults.removeObject(forKey: "saveToFolderEnabled")140      }141      if let oldPath {142        defaults.set(oldPath, forKey: "saveFolderPath")143      } else {144        defaults.removeObject(forKey: "saveFolderPath")145      }146    }147148    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)149    defer { try? FileManager.default.removeItem(at: root) }150    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)151    let fileURL = root.appendingPathComponent("local.png")152    let png = try #require(153      Data(154        base64Encoded:155          "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="156      )157    )158    try png.write(to: fileURL)159160    defaults.set(false, forKey: "saveToFolderEnabled")161    defaults.set(root.path, forKey: "saveFolderPath")162    let wallpaperManager = WallpaperStub()163    let state = AppState(wallpaperManager: wallpaperManager, observeChanges: false)164    state.saveFolderPath = root.path165    state.saveToFolderEnabled = false166    let manager = LocalWallpapersManager()167    let localView = LocalView(appState: state, localManager: manager)168    render(localView)169170    state.saveToFolderEnabled = true171    render(localView)172173    let local = LocalWallpaper(174      id: fileURL.path,175      url: fileURL,176      filename: fileURL.lastPathComponent,177      resolution: "2x2",178      fileSize: 100,179      createdAt: Date(),180      modifiedAt: Date()181    )182    manager.isLoading = true183    manager.wallpapers = []184    render(localView)185    manager.isLoading = false186    state.saveToFolderEnabled = false187    render(localView)188    state.saveToFolderEnabled = true189    manager.wallpapers = []190    render(localView)191    manager.wallpapers = [local]192    render(localView)193    var setCount = 0194    var deleteCount = 0195    render(196      LocalWallpaperGrid(197        wallpapers: [local],198        onSetWallpaper: { _ in setCount += 1 },199        onDeleteWallpaper: { _ in deleteCount += 1 }200      ))201    let item = LocalWallpaperItem(202      wallpaper: local,203      onSet: { setCount += 1 },204      onDelete: { deleteCount += 1 }205    )206    render(item)207    item.onSet()208    item.onDelete()209    #expect(setCount == 1)210    #expect(deleteCount == 1)211    render(212      LocalWallpaperItem(213        wallpaper: local, onSet: {}, onDelete: {},214        initialImage: NSImage(size: NSSize(width: 1, height: 1)), isLoadingImage: false))215    render(216      LocalWallpaperItem(217        wallpaper: local, onSet: {}, onDelete: {}, initialImage: nil,218        isLoadingImage: false))219    localView.scan()220    await localView.set(local)221    localView.requestDelete(local)222    render(localView.deleteMessage(local))223    render(localView.deleteActions(local))224    localView.cancelDelete()225    localView.delete(local)226    render(LoadingState())227  }228229  @Test("Settings and reusable components render and invoke actions")230  func settingsAndComponents() {231    var quitCalled = false232    let state = AppState(233      chooseFolder: { _, completion in completion(nil) },234      terminateApplication: { quitCalled = true }, observeChanges: false)235    state.saveToFolderEnabled = false236    let settings = SettingsView(appState: state)237    render(settings)238    state.saveToFolderEnabled = true239    render(settings)240    settings.selectFolder()241    settings.quit()242    #expect(quitCalled)243244    render(EmptyState(icon: "photo", text: "Empty"))245    var tab = AppTab.explore246    let tabButton = TabButton(247      icon: "heart", tab: .favorites,248      activeTab: Binding(get: { tab }, set: { tab = $0 }))249    render(tabButton)250    tabButton.select()251    #expect(tab == .favorites)252253    var invoked = false254    let button = GlossyButton(title: "Run", icon: "play", action: { invoked = true })255    render(button)256    button.action()257    #expect(invoked)258    render(GlossyButton(title: "Run", icon: nil, action: {}))259260    render(261      VisualEffectView(262        material: .headerView,263        blendingMode: .behindWindow264      ))265  }266267  @Suite("Local wallpaper thumbnail downsampling", .serialized)268  @MainActor269  struct LocalWallpaperItemThumbnailTests {270    private static let onePixelPNG = Data(271      base64Encoded:272        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="273    )!274275    @Test("makeThumbnail returns an NSImage and downsamples oversized PNGs")276    func thumbnailDecodeAndDownsample() throws {277      let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)278      defer { try? FileManager.default.removeItem(at: root) }279      try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)280      let png = root.appendingPathComponent("image.png")281      try Self.onePixelPNG.write(to: png)282283      let image = LocalWallpaperItem.makeThumbnail(url: png, maxPixel: 320)284      #expect(image != nil)285      // ImageIO preserves the original pixel dimensions for tiny sources, so286      // we only assert that decoding returned something usable.287      #expect(image?.size.width ?? 0 >= 1)288    }289290    @Test("makeThumbnail returns nil for a missing file rather than trapping")291    func missingFileReturnsNil() {292      let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)293      defer { try? FileManager.default.removeItem(at: root) }294      try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)295      let missing = root.appendingPathComponent("missing.png")296297      #expect(LocalWallpaperItem.makeThumbnail(url: missing, maxPixel: 320) == nil)298    }299  }300}