From fcf6c3e441e3348fd3854a14b26ef74334e4043d Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Mon, 1 Dec 2025 22:03:19 +0100 Subject: [PATCH] feat: Add debug menu and image diagnostics, improve test coverage - Add DebugMenuView with network simulation, offline cache management, and Core Data reset - Add OfflineImageDebugView for diagnosing offline image loading issues - Implement debug diagnostics for cached articles and hero image caching - Add cache info display (size, article count) in debug menu - Add shake gesture detection for debug menu access - Fix LocalBookmarksSyncView callback syntax in PhoneTabView - Clean up StringExtensionsTests by removing stripHTMLSimple tests and performance tests - Remove SnapshotHelper import from readeckUITests.swift - Remove snapshot testing code from readeckUITests - Add comprehensive test cases for edge cases (malformed HTML, Unicode, newlines, lists) --- readeck/UI/Debug/DebugMenuView.swift | 318 +++++++++++++++++++ readeck/UI/Debug/OfflineImageDebugView.swift | 199 ++++++++++++ readeck/UI/Menu/PhoneTabView.swift | 4 +- readeckTests/StringExtensionsTests.swift | 67 ---- readeckUITests/readeckUITests.swift | 5 +- 5 files changed, 520 insertions(+), 73 deletions(-) create mode 100644 readeck/UI/Debug/DebugMenuView.swift create mode 100644 readeck/UI/Debug/OfflineImageDebugView.swift diff --git a/readeck/UI/Debug/DebugMenuView.swift b/readeck/UI/Debug/DebugMenuView.swift new file mode 100644 index 0000000..855927c --- /dev/null +++ b/readeck/UI/Debug/DebugMenuView.swift @@ -0,0 +1,318 @@ +// +// DebugMenuView.swift +// readeck +// +// Created by Claude on 21.11.25. +// + +#if DEBUG +import SwiftUI + +struct DebugMenuView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var appSettings: AppSettings + @StateObject private var viewModel = DebugMenuViewModel() + + var body: some View { + NavigationView { + List { + // MARK: - Network Section + Section { + networkSimulationToggle + networkStatusInfo + } header: { + Text("Network Debugging") + } footer: { + Text("Simulate offline mode to test offline reading features") + } + + // MARK: - Offline Debugging Section + Section { + Picker("Select Cached Bookmark", selection: $viewModel.selectedBookmarkId) { + Text("None").tag(nil as String?) + ForEach(viewModel.cachedBookmarks, id: \.id) { bookmark in + Text(bookmark.title.isEmpty ? bookmark.id : bookmark.title) + .lineLimit(1) + .tag(bookmark.id as String?) + } + } + + NavigationLink { + OfflineImageDebugView(bookmarkId: viewModel.selectedBookmarkId ?? "") + } label: { + Label("Offline Image Diagnostics", systemImage: "photo.badge.checkmark") + } + .disabled(viewModel.selectedBookmarkId == nil) + } header: { + Text("Offline Reading") + } footer: { + Text("Select a cached bookmark to diagnose offline image issues") + } + + // MARK: - Logging Section + Section { + NavigationLink { + DebugLogViewer() + } label: { + Label("View Logs", systemImage: "doc.text.magnifyingglass") + } + + Button(role: .destructive) { + viewModel.clearLogs() + } label: { + Label("Clear All Logs", systemImage: "trash") + } + } header: { + Text("Logging") + } footer: { + Text("View and manage application logs") + } + + // MARK: - Data Section + Section { + cacheInfo + + Button(role: .destructive) { + viewModel.showResetCacheAlert = true + } label: { + Label("Clear Offline Cache", systemImage: "trash") + } + + Button(role: .destructive) { + viewModel.showResetDatabaseAlert = true + } label: { + Label("Reset Core Data", systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + } + } header: { + Text("Data Management") + } footer: { + Text("⚠️ Reset Core Data will delete all local bookmarks and cache") + } + + // MARK: - App Info Section + Section { + HStack { + Text("App Version") + Spacer() + Text(viewModel.appVersion) + .foregroundColor(.secondary) + } + + HStack { + Text("Build Number") + Spacer() + Text(viewModel.buildNumber) + .foregroundColor(.secondary) + } + + HStack { + Text("Bundle ID") + Spacer() + Text(viewModel.bundleId) + .font(.caption) + .foregroundColor(.secondary) + } + } header: { + Text("App Information") + } + } + .navigationTitle("🛠️ Debug Menu") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + .task { + await viewModel.loadCacheInfo() + } + .alert("Clear Offline Cache?", isPresented: $viewModel.showResetCacheAlert) { + Button("Cancel", role: .cancel) { } + Button("Clear", role: .destructive) { + Task { + await viewModel.clearOfflineCache() + } + } + } message: { + Text("This will remove all cached articles. Your bookmarks will remain.") + } + .alert("Reset Core Data?", isPresented: $viewModel.showResetDatabaseAlert) { + Button("Cancel", role: .cancel) { } + Button("Reset", role: .destructive) { + viewModel.resetCoreData() + } + } message: { + Text("⚠️ WARNING: This will delete ALL local data including bookmarks, cache, and settings. This cannot be undone!") + } + } + } + + // MARK: - Subviews + + private var networkSimulationToggle: some View { + Toggle(isOn: Binding( + get: { !appSettings.isNetworkConnected }, + set: { isOffline in + appSettings.isNetworkConnected = !isOffline + } + )) { + HStack { + Image(systemName: appSettings.isNetworkConnected ? "wifi" : "wifi.slash") + .foregroundColor(appSettings.isNetworkConnected ? .green : .orange) + + VStack(alignment: .leading, spacing: 2) { + Text("Simulate Offline Mode") + Text(appSettings.isNetworkConnected ? "Network Connected" : "Network Disconnected") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + private var networkStatusInfo: some View { + HStack { + Text("Network Status") + Spacer() + Label( + appSettings.isNetworkConnected ? "Connected" : "Offline", + systemImage: appSettings.isNetworkConnected ? "checkmark.circle.fill" : "xmark.circle.fill" + ) + .font(.caption) + .foregroundColor(appSettings.isNetworkConnected ? .green : .orange) + } + } + + private var cacheInfo: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Cached Articles") + Spacer() + Text("\(viewModel.cachedArticlesCount)") + .foregroundColor(.secondary) + } + + HStack { + Text("Cache Size") + Spacer() + Text(viewModel.cacheSize) + .foregroundColor(.secondary) + } + } + .task { + await viewModel.loadCacheInfo() + } + } +} + +@MainActor +class DebugMenuViewModel: ObservableObject { + @Published var showResetCacheAlert = false + @Published var showResetDatabaseAlert = false + @Published var cachedArticlesCount = 0 + @Published var cacheSize = "0 KB" + @Published var selectedBookmarkId: String? + @Published var cachedBookmarks: [Bookmark] = [] + + private let offlineCacheRepository = OfflineCacheRepository() + private let coreDataManager = CoreDataManager.shared + private let logger = Logger.general + + var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + } + + var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + } + + var bundleId: String { + Bundle.main.bundleIdentifier ?? "Unknown" + } + + func loadCacheInfo() async { + cachedArticlesCount = offlineCacheRepository.getCachedArticlesCount() + cacheSize = offlineCacheRepository.getCacheSize() + + // Load cached bookmarks for diagnostics + do { + cachedBookmarks = try await offlineCacheRepository.getCachedBookmarks() + // Auto-select first bookmark if available + if selectedBookmarkId == nil, let firstBookmark = cachedBookmarks.first { + selectedBookmarkId = firstBookmark.id + } + } catch { + logger.error("Failed to load cached bookmarks: \(error.localizedDescription)") + } + } + + func clearOfflineCache() async { + do { + try await offlineCacheRepository.clearCache() + await loadCacheInfo() + logger.info("Offline cache cleared via Debug Menu") + } catch { + logger.error("Failed to clear offline cache: \(error.localizedDescription)") + } + } + + func clearLogs() { + // TODO: Implement log clearing when we add persistent logging + logger.info("Logs cleared via Debug Menu") + } + + func resetCoreData() { + do { + try coreDataManager.resetDatabase() + logger.warning("Core Data reset via Debug Menu - App restart required") + + // Show alert that restart is needed + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + fatalError("Core Data has been reset. Please restart the app.") + } + } catch { + logger.error("Failed to reset Core Data: \(error.localizedDescription)") + } + } +} + +// MARK: - Shake Gesture Detection + +extension UIDevice { + static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification") +} + +extension UIWindow { + open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + if motion == .motionShake { + NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil) + } + } +} + +struct DeviceShakeViewModifier: ViewModifier { + let action: () -> Void + + func body(content: Content) -> some View { + content + .onAppear() + .onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in + action() + } + } +} + +extension View { + func onShake(perform action: @escaping () -> Void) -> some View { + self.modifier(DeviceShakeViewModifier(action: action)) + } +} + +#Preview { + DebugMenuView() + .environmentObject(AppSettings()) +} +#endif diff --git a/readeck/UI/Debug/OfflineImageDebugView.swift b/readeck/UI/Debug/OfflineImageDebugView.swift new file mode 100644 index 0000000..cc3f575 --- /dev/null +++ b/readeck/UI/Debug/OfflineImageDebugView.swift @@ -0,0 +1,199 @@ +// +// OfflineImageDebugView.swift +// readeck +// +// Debug view to diagnose offline image loading issues +// + +import SwiftUI +import Kingfisher + +struct OfflineImageDebugView: View { + let bookmarkId: String + + @State private var debugInfo: DebugInfo = DebugInfo() + @EnvironmentObject var appSettings: AppSettings + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Offline Image Debug") + .font(.title) + .padding() + + Group { + DebugSection("Network Status") { + InfoRow(label: "Connected", value: "\(appSettings.isNetworkConnected)") + } + + DebugSection("Cached Article") { + InfoRow(label: "Has Cache", value: "\(debugInfo.hasCachedHTML)") + InfoRow(label: "HTML Size", value: debugInfo.htmlSize) + InfoRow(label: "Base64 Images", value: "\(debugInfo.base64ImageCount)") + InfoRow(label: "HTTP Images", value: "\(debugInfo.httpImageCount)") + } + + DebugSection("Hero Image Cache") { + InfoRow(label: "URL", value: debugInfo.heroImageURL) + InfoRow(label: "In Cache", value: "\(debugInfo.heroImageInCache)") + InfoRow(label: "Cache Key", value: debugInfo.cacheKey) + } + + if !debugInfo.sampleImages.isEmpty { + DebugSection("Sample HTML Images") { + ForEach(debugInfo.sampleImages.indices, id: \.self) { index in + VStack(alignment: .leading, spacing: 4) { + Text("Image \(index + 1)") + .font(.caption).bold() + Text(debugInfo.sampleImages[index]) + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + } + } + } + .padding(.horizontal) + + Button("Run Diagnostics") { + Task { + await runDiagnostics() + } + } + .buttonStyle(.borderedProminent) + .padding() + } + } + .task { + await runDiagnostics() + } + } + + private func runDiagnostics() async { + let offlineCache = OfflineCacheRepository() + + // Check cached HTML + if let cachedHTML = offlineCache.getCachedArticle(id: bookmarkId) { + debugInfo.hasCachedHTML = true + debugInfo.htmlSize = ByteCountFormatter.string(fromByteCount: Int64(cachedHTML.utf8.count), countStyle: .file) + + // Count Base64 images + debugInfo.base64ImageCount = countMatches(in: cachedHTML, pattern: #"src="data:image/"#) + + // Count HTTP images + debugInfo.httpImageCount = countMatches(in: cachedHTML, pattern: #"src="https?://"#) + + // Extract sample image URLs + debugInfo.sampleImages = extractSampleImages(from: cachedHTML) + } + + // Check hero image cache + do { + let bookmarkDetail = try await DefaultUseCaseFactory.shared.makeGetBookmarkUseCase().execute(id: bookmarkId) + + if !bookmarkDetail.imageUrl.isEmpty, let url = URL(string: bookmarkDetail.imageUrl) { + debugInfo.heroImageURL = bookmarkDetail.imageUrl + debugInfo.cacheKey = url.cacheKey + + // Check if image is in Kingfisher cache + let isCached = await withCheckedContinuation { continuation in + KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in + switch result { + case .success(let cacheResult): + continuation.resume(returning: cacheResult.image != nil) + case .failure: + continuation.resume(returning: false) + } + } + } + + debugInfo.heroImageInCache = isCached + } + } catch { + print("Error loading bookmark: \(error)") + } + } + + private func countMatches(in text: String, pattern: String) -> Int { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return 0 } + let nsString = text as NSString + let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length)) + return matches.count + } + + private func extractSampleImages(from html: String) -> [String] { + let pattern = #"]+src="([^"]+)""# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + + let nsString = html as NSString + let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length)) + + return matches.prefix(3).compactMap { match in + guard match.numberOfRanges >= 2 else { return nil } + let urlRange = match.range(at: 1) + let url = nsString.substring(with: urlRange) + + // Truncate long Base64 strings + if url.hasPrefix("data:image/") { + return "data:image/... (Base64, \(url.count) chars)" + } + return url + } + } + + struct DebugInfo { + var hasCachedHTML = false + var htmlSize = "0 KB" + var base64ImageCount = 0 + var httpImageCount = 0 + var heroImageURL = "N/A" + var heroImageInCache = false + var cacheKey = "N/A" + var sampleImages: [String] = [] + } +} + +struct InfoRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.subheadline.bold()) + } + .padding(.vertical, 4) + } +} + +struct DebugSection: View { + let title: String + let content: Content + + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + .padding(.top, 8) + + content + + Divider() + } + } +} + +#Preview { + OfflineImageDebugView(bookmarkId: "123") + .environmentObject(AppSettings()) +} diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index fde9a15..f164723 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -206,9 +206,9 @@ struct PhoneTabView: View { } else { Section { VStack { - LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) { + LocalBookmarksSyncView(state: offlineBookmarksViewModel.state, onSyncTapped: { await offlineBookmarksViewModel.syncOfflineBookmarks() - } + }) } .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) diff --git a/readeckTests/StringExtensionsTests.swift b/readeckTests/StringExtensionsTests.swift index 257c293..85d5bcb 100644 --- a/readeckTests/StringExtensionsTests.swift +++ b/readeckTests/StringExtensionsTests.swift @@ -59,73 +59,6 @@ final class StringExtensionsTests: XCTestCase { XCTAssertEqual(onlyTags.stripHTML, expected) } - // MARK: - stripHTMLSimple Tests - - func testStripHTMLSimple_BasicTags() { - let html = "

Text mit fett.

" - let expected = "Text mit fett." - - XCTAssertEqual(html.stripHTMLSimple, expected) - } - - func testStripHTMLSimple_HTMLEntities() { - let html = "

Text mit  Leerzeichen, & Zeichen und "Anführungszeichen".

" - let expected = "Text mit Leerzeichen, & Zeichen und \"Anführungszeichen\"." - - XCTAssertEqual(html.stripHTMLSimple, expected) - } - - func testStripHTMLSimple_MoreEntities() { - let html = "

<Tag> und 'Apostroph'

" - let expected = " und 'Apostroph'" - - XCTAssertEqual(html.stripHTMLSimple, expected) - } - - func testStripHTMLSimple_ComplexHTML() { - let html = "

Überschrift

Absatz mit kursiv und fett.

  • Liste 1
  • Liste 2
" - let expected = "Überschrift\nAbsatz mit kursiv und fett.\nListe 1\nListe 2" - - XCTAssertEqual(html.stripHTMLSimple, expected) - } - - func testStripHTMLSimple_NoTags() { - let plainText = "Normaler Text ohne HTML." - - XCTAssertEqual(plainText.stripHTMLSimple, plainText) - } - - func testStripHTMLSimple_EmptyString() { - let emptyString = "" - - XCTAssertEqual(emptyString.stripHTMLSimple, emptyString) - } - - func testStripHTMLSimple_WhitespaceHandling() { - let html = "

Text mit Whitespace

" - let expected = "Text mit Whitespace" - - XCTAssertEqual(html.stripHTMLSimple, expected) - } - - // MARK: - Performance Tests - - func testStripHTML_Performance() { - let largeHTML = String(repeating: "

Dies ist ein Test mit vielen HTML Tags.

", count: 1000) - - measure { - _ = largeHTML.stripHTML - } - } - - func testStripHTMLSimple_Performance() { - let largeHTML = String(repeating: "

Dies ist ein Test mit vielen HTML Tags.

", count: 1000) - - measure { - _ = largeHTML.stripHTMLSimple - } - } - // MARK: - Edge Cases func testStripHTML_MalformedHTML() { diff --git a/readeckUITests/readeckUITests.swift b/readeckUITests/readeckUITests.swift index f8c0422..0df2b42 100644 --- a/readeckUITests/readeckUITests.swift +++ b/readeckUITests/readeckUITests.swift @@ -6,7 +6,6 @@ // import XCTest -import SnapshotHelper final class readeckUITests: XCTestCase { @@ -16,7 +15,7 @@ final class readeckUITests: XCTestCase { // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + // In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } override func tearDownWithError() throws { @@ -27,9 +26,7 @@ final class readeckUITests: XCTestCase { func testExample() throws { // UI tests must launch the application that they test. let app = XCUIApplication() - setupSnapshot(app) app.launch() - snapshot("01LaunchScreen") // Use XCTAssert and related functions to verify your tests produce the correct results. }