Initial commit - cleaned for CV

This commit is contained in:
Renato97
2026-03-31 01:28:25 -03:00
commit 2e7bb89d77
6413 changed files with 1069318 additions and 0 deletions

View File

@@ -0,0 +1,609 @@
import XCTest
import UIKit
@testable import MangaReader
/// Tests de integración para el flujo completo: scraper -> storage -> view
/// Pruebas de descarga de capítulos y navegación entre vistas
@MainActor
final class IntegrationTests: XCTestCase {
var scraper: ManhwaWebScraper!
var storageService: StorageService!
// MARK: - Setup & Teardown
override func setUp() async throws {
try await super.setUp()
// Limpiar estado antes de cada test
UserDefaults.standard.removeObject(forKey: "favoriteMangas")
UserDefaults.standard.removeObject(forKey: "readingProgress")
scraper = ManhwaWebScraper.shared
storageService = StorageService.shared
storageService.clearAllDownloads()
// Esperar un momento para asegurar limpieza completa
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 segundos
}
override func tearDown() async throws {
// Limpiar después de los tests
storageService.clearAllDownloads()
UserDefaults.standard.removeObject(forKey: "favoriteMangas")
UserDefaults.standard.removeObject(forKey: "readingProgress")
scraper = nil
storageService = nil
try await super.tearDown()
}
// MARK: - Complete Flow Tests: Scraper -> Storage
func testCompleteScrapingAndStorageFlow() async throws {
// Este test simula el flujo completo: scraper -> storage
// 1. Simular datos del scraper (capítulos)
let mockChapters = [
Chapter(number: 10, title: "Chapter 10", url: "url10", slug: "slug10"),
Chapter(number: 9, title: "Chapter 9", url: "url9", slug: "slug9"),
Chapter(number: 8, title: "Chapter 8", url: "url8", slug: "slug8")
]
// 2. Guardar progreso de lectura simulado
let progress = ReadingProgress(
mangaSlug: "test-manga",
chapterNumber: 9,
pageNumber: 5,
timestamp: Date()
)
storageService.saveReadingProgress(progress)
// 3. Verificar que el progreso se guardó
let retrievedProgress = storageService.getReadingProgress(
mangaSlug: "test-manga",
chapterNumber: 9
)
XCTAssertNotNil(retrievedProgress)
XCTAssertEqual(retrievedProgress?.pageNumber, 5)
// 4. Marcar manga como favorito
storageService.saveFavorite(mangaSlug: "test-manga")
// 5. Verificar favoritos
let favorites = storageService.getFavorites()
XCTAssertTrue(favorites.contains("test-manga"))
// 6. Simular guardado de capítulo descargado
let pages = mockChapters[0].number == 10 ? [
MangaPage(url: "page1.jpg", index: 0),
MangaPage(url: "page2.jpg", index: 1)
] : []
let downloadedChapter = DownloadedChapter(
mangaSlug: "test-manga",
mangaTitle: "Test Manga",
chapterNumber: 10,
pages: pages,
downloadedAt: Date()
)
storageService.saveDownloadedChapter(downloadedChapter)
// 7. Verificar capítulo descargado
XCTAssertTrue(storageService.isChapterDownloaded(
mangaSlug: "test-manga",
chapterNumber: 10
))
// 8. Obtener todos los datos relacionados
let allProgress = storageService.getAllReadingProgress()
let lastRead = storageService.getLastReadChapter(mangaSlug: "test-manga")
let downloadedChapters = storageService.getDownloadedChapters()
XCTAssertEqual(allProgress.count, 1)
XCTAssertNotNil(lastRead)
XCTAssertEqual(downloadedChapters.count, 1)
}
func testChapterDownloadFlow() async throws {
// Simular el flujo completo de descarga de un capítulo
// 1. Crear imágenes de prueba
let image1 = createTestImage(color: .red, size: CGSize(width: 800, height: 1200))
let image2 = createTestImage(color: .blue, size: CGSize(width: 800, height: 1200))
let image3 = createTestImage(color: .green, size: CGSize(width: 800, height: 1200))
// 2. Guardar imágenes
let url1 = try await storageService.saveImage(
image1,
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 0
)
let url2 = try await storageService.saveImage(
image2,
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 1
)
let url3 = try await storageService.saveImage(
image3,
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 2
)
// 3. Verificar que las imágenes se guardaron
XCTAssertTrue(FileManager.default.fileExists(atPath: url1.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: url2.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: url3.path))
// 4. Crear objeto de capítulo descargado
let pages = [
MangaPage(url: url1.absoluteString, index: 0, isCached: true),
MangaPage(url: url2.absoluteString, index: 1, isCached: true),
MangaPage(url: url3.absoluteString, index: 2, isCached: true)
]
let downloadedChapter = DownloadedChapter(
mangaSlug: "test-manga",
mangaTitle: "Test Manga",
chapterNumber: 1,
pages: pages,
downloadedAt: Date()
)
// 5. Guardar metadatos del capítulo
storageService.saveDownloadedChapter(downloadedChapter)
// 6. Verificar que el capítulo está marcado como descargado
XCTAssertTrue(storageService.isChapterDownloaded(
mangaSlug: "test-manga",
chapterNumber: 1
))
// 7. Recuperar el capítulo descargado
let retrieved = storageService.getDownloadedChapter(
mangaSlug: "test-manga",
chapterNumber: 1
)
XCTAssertNotNil(retrieved)
XCTAssertEqual(retrieved?.pages.count, 3)
// 8. Cargar imágenes desde disco
let loadedImage1 = storageService.loadImage(
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 0
)
let loadedImage2 = storageService.loadImage(
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 1
)
let loadedImage3 = storageService.loadImage(
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 2
)
XCTAssertNotNil(loadedImage1)
XCTAssertNotNil(loadedImage2)
XCTAssertNotNil(loadedImage3)
}
func testReadingProgressTrackingFlow() async throws {
// Simular el flujo de seguimiento de progreso de lectura
let mangaSlug = "tower-of-god"
// 1. Usuario comienza a leer capítulo 1
let progress1 = ReadingProgress(
mangaSlug: mangaSlug,
chapterNumber: 1,
pageNumber: 0,
timestamp: Date()
)
storageService.saveReadingProgress(progress1)
// 2. Usuario avanza a página 5
let progress2 = ReadingProgress(
mangaSlug: mangaSlug,
chapterNumber: 1,
pageNumber: 5,
timestamp: Date().addingTimeInterval(60) // 1 minuto después
)
storageService.saveReadingProgress(progress2)
// 3. Usuario cambia al capítulo 2
let progress3 = ReadingProgress(
mangaSlug: mangaSlug,
chapterNumber: 2,
pageNumber: 0,
timestamp: Date().addingTimeInterval(120) // 2 minutos después
)
storageService.saveReadingProgress(progress3)
// 4. Usuario lee capítulo 2 hasta página 10
let progress4 = ReadingProgress(
mangaSlug: mangaSlug,
chapterNumber: 2,
pageNumber: 10,
timestamp: Date().addingTimeInterval(300) // 5 minutos después
)
storageService.saveReadingProgress(progress4)
// 5. Verificar progreso del capítulo 1
let ch1Progress = storageService.getReadingProgress(
mangaSlug: mangaSlug,
chapterNumber: 1
)
XCTAssertEqual(ch1Progress?.pageNumber, 5)
// 6. Verificar progreso del capítulo 2
let ch2Progress = storageService.getReadingProgress(
mangaSlug: mangaSlug,
chapterNumber: 2
)
XCTAssertEqual(ch2Progress?.pageNumber, 10)
// 7. Verificar último capítulo leído
let lastRead = storageService.getLastReadChapter(mangaSlug: mangaSlug)
XCTAssertEqual(lastRead?.chapterNumber, 2)
// 8. Verificar que el capítulo se marca como completado
XCTAssertTrue(ch2Progress?.isCompleted ?? false)
}
func testFavoriteManagementFlow() {
// Simular el flujo de gestión de favoritos
let mangaSlugs = [
"solo-leveling",
"tower-of-god",
"the-beginning-after-the-end",
"omniscient-reader"
]
// 1. Agregar varios favoritos
mangaSlugs.forEach { slug in
storageService.saveFavorite(mangaSlug: slug)
}
// 2. Verificar que todos están en favoritos
let favorites = storageService.getFavorites()
XCTAssertEqual(favorites.count, 4)
mangaSlugs.forEach { slug in
XCTAssertTrue(storageService.isFavorite(mangaSlug: slug))
}
// 3. Remover uno
storageService.removeFavorite(mangaSlug: "tower-of-god")
// 4. Verificar que se eliminó
XCTAssertFalse(storageService.isFavorite(mangaSlug: "tower-of-god"))
XCTAssertEqual(storageService.getFavorites().count, 3)
// 5. Intentar agregar duplicado
storageService.saveFavorite(mangaSlug: "solo-leveling")
// 6. Verificar que no se duplicó
let updatedFavorites = storageService.getFavorites()
XCTAssertEqual(updatedFavorites.count, 3)
// 7. Contar ocurrencias
let soloLevelingCount = updatedFavorites.filter { $0 == "solo-leveling" }.count
XCTAssertEqual(soloLevelingCount, 1, "Should only appear once")
}
// MARK: - Multi-Manga Scenarios
func testMultipleMangasProgressTracking() async throws {
// Simular seguimiento de progreso para múltiples mangas
let mangas = [
"solo-leveling",
"tower-of-god",
"the-beginning-after-the-end"
]
// Agregar progreso para cada manga
for (index, manga) in mangas.enumerated() {
let progress = ReadingProgress(
mangaSlug: manga,
chapterNumber: index + 1,
pageNumber: (index + 1) * 10,
timestamp: Date().addingTimeInterval(Double(index * 100))
)
storageService.saveReadingProgress(progress)
}
// Verificar progreso individual
for (index, manga) in mangas.enumerated() {
let progress = storageService.getLastReadChapter(mangaSlug: manga)
XCTAssertNotNil(progress)
XCTAssertEqual(progress?.chapterNumber, index + 1)
}
// Verificar todo el progreso
let allProgress = storageService.getAllReadingProgress()
XCTAssertEqual(allProgress.count, 3)
}
func testMultipleChapterDownloads() async throws {
// Simular descarga de múltiples capítulos de diferentes mangas
let downloads = [
("manga1", 1),
("manga1", 2),
("manga2", 1),
("manga2", 3),
("manga3", 5)
]
// Crear y guardar capítulos descargados
for (manga, chapter) in downloads {
let chapter = DownloadedChapter(
mangaSlug: manga,
mangaTitle: "Manga \(manga)",
chapterNumber: chapter,
pages: [],
downloadedAt: Date()
)
storageService.saveDownloadedChapter(chapter)
}
// Verificar todos los capítulos descargados
let allDownloaded = storageService.getDownloadedChapters()
XCTAssertEqual(allDownloaded.count, 5)
// Verificar capítulos por manga
let manga1Chapters = allDownloaded.filter { $0.mangaSlug == "manga1" }
XCTAssertEqual(manga1Chapters.count, 2)
let manga2Chapters = allDownloaded.filter { $0.mangaSlug == "manga2" }
XCTAssertEqual(manga2Chapters.count, 2)
let manga3Chapters = allDownloaded.filter { $0.mangaSlug == "manga3" }
XCTAssertEqual(manga3Chapters.count, 1)
}
// MARK: - Error Handling Scenarios
func testDownloadFlowWithMissingImages() async throws {
// Simular descarga con imágenes faltantes
// Guardar solo algunas imágenes
let image1 = createTestImage(color: .red, size: CGSize(width: 800, height: 1200))
_ = try await storageService.saveImage(
image1,
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 0
)
// Intentar cargar imagen que no existe
let missingImage = storageService.loadImage(
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 1
)
XCTAssertNil(missingImage, "Missing image should return nil")
// Verificar que la imagen existente sí se carga
let existingImage = storageService.loadImage(
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 0
)
XCTAssertNotNil(existingImage, "Existing image should load successfully")
}
func testStorageCleanupFlow() async throws {
// Simular flujo de limpieza de almacenamiento
// 1. Llenar almacenamiento con datos
for i in 0..<5 {
let image = createTestImage(color: .blue, size: CGSize(width: 800, height: 1200))
_ = try await storageService.saveImage(
image,
mangaSlug: "test",
chapterNumber: i,
pageIndex: 0
)
let chapter = DownloadedChapter(
mangaSlug: "test",
mangaTitle: "Test",
chapterNumber: i,
pages: [],
downloadedAt: Date()
)
storageService.saveDownloadedChapter(chapter)
}
// Verificar que hay datos
XCTAssertGreaterThan(storageService.getStorageSize(), 0)
XCTAssertEqual(storageService.getDownloadedChapters().count, 5)
// 2. Limpiar todo
storageService.clearAllDownloads()
// 3. Verificar que todo se eliminó
XCTAssertEqual(storageService.getStorageSize(), 0)
XCTAssertEqual(storageService.getDownloadedChapters().count, 0)
}
// MARK: - Data Persistence Tests
func testDataPersistenceAcrossOperations() async throws {
// Verificar que los datos persisten a través de múltiples operaciones
// 1. Guardar datos iniciales
storageService.saveFavorite(mangaSlug: "persistent-manga")
let progress1 = ReadingProgress(
mangaSlug: "persistent-manga",
chapterNumber: 1,
pageNumber: 5,
timestamp: Date()
)
storageService.saveReadingProgress(progress1)
// 2. Verificar que existen
XCTAssertTrue(storageService.isFavorite(mangaSlug: "persistent-manga"))
XCTAssertNotNil(storageService.getReadingProgress(
mangaSlug: "persistent-manga",
chapterNumber: 1
))
// 3. Realizar operaciones intermedias
storageService.saveFavorite(mangaSlug: "temp-manga")
storageService.removeFavorite(mangaSlug: "temp-manga")
// 4. Verificar que los datos originales persistieron
XCTAssertTrue(storageService.isFavorite(mangaSlug: "persistent-manga"))
let originalProgress = storageService.getReadingProgress(
mangaSlug: "persistent-manga",
chapterNumber: 1
)
XCTAssertEqual(originalProgress?.pageNumber, 5)
}
// MARK: - Concurrent Operations Tests
func testConcurrentFavoriteOperations() {
// Probar operaciones concurrentes en favoritos
let expectations = (0..<10).map { _ in
XCTestExpectation(description: "Favorite operation")
}
let queue = DispatchQueue.global(qos: .userInitiated)
for i in 0..<10 {
queue.async {
self.storageService.saveFavorite(mangaSlug: "manga-\(i)")
expectations[i].fulfill()
}
}
wait(for: expectations, timeout: 5.0)
let favorites = storageService.getFavorites()
XCTAssertEqual(favorites.count, 10)
}
func testConcurrentProgressOperations() {
// Probar operaciones concurrentes de progreso
let expectations = (0..<10).map { _ in
XCTestExpectation(description: "Progress operation")
}
let queue = DispatchQueue.global(qos: .userInitiated)
for i in 0..<10 {
queue.async {
let progress = ReadingProgress(
mangaSlug: "manga-\(i % 3)",
chapterNumber: i,
pageNumber: i * 2,
timestamp: Date()
)
self.storageService.saveReadingProgress(progress)
expectations[i].fulfill()
}
}
wait(for: expectations, timeout: 5.0)
let allProgress = storageService.getAllReadingProgress()
XCTAssertEqual(allProgress.count, 10)
}
func testConcurrentImageOperations() async throws {
// Probar guardado concurrente de imágenes
await withTaskGroup(of: URL.self) { group in
for i in 0..<20 {
group.addTask {
let image = self.createTestImage(
color: .red,
size: CGSize(width: 800, height: 1200)
)
return try! await self.storageService.saveImage(
image,
mangaSlug: "concurrent-test",
chapterNumber: 1,
pageIndex: i
)
}
}
}
// Verificar que todas las imágenes se guardaron
for i in 0..<20 {
let image = storageService.loadImage(
mangaSlug: "concurrent-test",
chapterNumber: 1,
pageIndex: i
)
XCTAssertNotNil(image, "Image at index \(i) should exist")
}
}
// MARK: - Large Scale Tests
func testLargeScaleFavoriteOperations() {
// Probar con muchos favoritos
let count = 1000
measure {
for i in 0..<count {
storageService.saveFavorite(mangaSlug: "manga-\(i)")
}
}
let favorites = storageService.getFavorites()
XCTAssertEqual(favorites.count, count)
}
func testLargeScaleProgressOperations() {
// Probar con mucho progreso de lectura
let count = 500
measure {
for i in 0..<count {
let progress = ReadingProgress(
mangaSlug: "manga-\(i % 50)",
chapterNumber: i,
pageNumber: i,
timestamp: Date()
)
storageService.saveReadingProgress(progress)
}
}
let allProgress = storageService.getAllReadingProgress()
XCTAssertEqual(allProgress.count, count)
}
// MARK: - Helper Methods
private func createTestImage(color: UIColor, size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
color.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}
}

View File

@@ -0,0 +1,529 @@
import XCTest
import WebKit
@testable import MangaReader
/// Tests para el ManhwaWebScraper
/// Mock de WKWebView y pruebas de parsing de HTML/JavaScript
@MainActor
final class ManhwaWebScraperTests: XCTestCase {
var scraper: ManhwaWebScraper!
// MARK: - Setup & Teardown
override func setUp() async throws {
try await super.setUp()
scraper = ManhwaWebScraper.shared
}
override func tearDown() async throws {
scraper = nil
try await super.tearDown()
}
// MARK: - Scraper Error Tests
func testScrapingErrorDescriptions() {
// Test error descriptions
XCTAssertEqual(
ScrapingError.webViewNotInitialized.errorDescription,
"WebView no está inicializado"
)
XCTAssertEqual(
ScrapingError.pageLoadFailed.errorDescription,
"Error al cargar la página"
)
XCTAssertEqual(
ScrapingError.noContentFound.errorDescription,
"No se encontró contenido"
)
XCTAssertEqual(
ScrapingError.parsingError.errorDescription,
"Error al procesar el contenido"
)
}
func testScrapingErrorLocalizedError() {
let error = ScrapingError.pageLoadFailed
XCTAssertNotNil(error.errorDescription)
let nsError = error as NSError
XCTAssertNotNil(nsError.localizedDescription)
}
// MARK: - Mock WKWebView Tests
func testWebViewInitialization() {
// Test que el scraper se inicializa correctamente
XCTAssertNotNil(scraper)
}
// MARK: - Chapter Parsing Tests (Simulated)
func testChapterParsingFromJavaScriptResult() {
// Simular el resultado de JavaScript
let jsResult: [[String: Any]] = [
[
"number": 150,
"title": "The Final Battle",
"url": "https://manhwaweb.com/leer/solo-leveling/150",
"slug": "solo-leveling/150"
],
[
"number": 149,
"title": "The Beginning",
"url": "https://manhwaweb.com/leer/solo-leveling/149",
"slug": "solo-leveling/149"
],
[
"number": 148,
"title": "Preparation",
"url": "https://manhwaweb.com/leer/solo-leveling/148",
"slug": "solo-leveling/148"
]
]
// Parsear capítulos
let chapters = jsResult.compactMap { dict -> Chapter? in
guard let number = dict["number"] as? Int,
let title = dict["title"] as? String,
let url = dict["url"] as? String,
let slug = dict["slug"] as? String else {
return nil
}
return Chapter(number: number, title: title, url: url, slug: slug)
}
// Verificar parsing
XCTAssertEqual(chapters.count, 3)
XCTAssertEqual(chapters[0].number, 150)
XCTAssertEqual(chapters[0].title, "The Final Battle")
XCTAssertEqual(chapters[1].number, 149)
XCTAssertEqual(chapters[2].number, 148)
}
func testChapterParsingWithInvalidData() {
// Simular resultado con datos inválidos
let jsResult: [[String: Any]] = [
[
"number": "not-a-number",
"title": "Invalid Chapter",
"url": "https://manhwaweb.com/leer/test/1",
"slug": "test/1"
],
[
"number": 10,
"title": "",
"url": "https://manhwaweb.com/leer/test/10",
"slug": "test/10"
],
[
"number": 5,
"title": "Valid",
"url": "invalid-url",
"slug": "test/5"
]
]
// Parsear - debería fallar para todos los casos inválidos
let chapters = jsResult.compactMap { dict -> Chapter? in
guard let number = dict["number"] as? Int,
let title = dict["title"] as? String,
let url = dict["url"] as? String,
let slug = dict["slug"] as? String else {
return nil
}
return !title.isEmpty && !url.isEmpty ? Chapter(number: number, title: title, url: url, slug: slug) : nil
}
// Todos deberían ser nil por datos inválidos
XCTAssertTrue(chapters.isEmpty)
}
func testChapterDeduplication() {
// Simular duplicados en el resultado
let jsResult: [[String: Any]] = [
["number": 10, "title": "Chapter 10", "url": "url1", "slug": "slug1"],
["number": 10, "title": "Chapter 10 Duplicate", "url": "url2", "slug": "slug2"],
["number": 9, "title": "Chapter 9", "url": "url3", "slug": "slug3"],
["number": 9, "title": "Chapter 9 Duplicate", "url": "url4", "slug": "slug4"]
]
// Aplicar lógica de deduplicación
let unique = jsResult.filter { (chapter) in
jsResult.firstIndex(where: { ($0["number"] as? Int) == (chapter["number"] as? Int) }) ==
jsResult.firstIndex(of: chapter)
}
XCTAssertEqual(unique.count, 2, "Should have only 2 unique chapters")
}
func testChapterSorting() {
// Simular capítulos desordenados
let chapters = [
Chapter(number: 5, title: "Ch 5", url: "", slug: ""),
Chapter(number: 10, title: "Ch 10", url: "", slug: ""),
Chapter(number: 1, title: "Ch 1", url: "", slug: ""),
Chapter(number: 7, title: "Ch 7", url: "", slug: "")
]
// Ordenar descendente (como hace el scraper)
let sorted = chapters.sorted { $0.number > $1.number }
XCTAssertEqual(sorted[0].number, 10)
XCTAssertEqual(sorted[1].number, 7)
XCTAssertEqual(sorted[2].number, 5)
XCTAssertEqual(sorted[3].number, 1)
}
// MARK: - Image Parsing Tests (Simulated)
func testImageParsingFromJavaScriptResult() {
// Simular el resultado de JavaScript para imágenes
let jsResult: [String] = [
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
"https://example.com/image3.jpg",
"https://example.com/image1.jpg", // Duplicado
"https://example.com/avatar.jpg",
"https://example.com/logo.png",
"https://example.com/image4.jpg"
]
// Aplicar filtros del scraper
let filteredImages = jsResult.filter { url in
let lowercased = url.lowercased()
// Filtrar UI elements
let isUIElement =
lowercased.contains("avatar") ||
lowercased.contains("icon") ||
lowercased.contains("logo") ||
lowercased.contains("button")
return !isUIElement && lowercased.contains("http")
}
// Eliminar duplicados
let uniqueImages = Array(Set(filteredImages))
XCTAssertEqual(uniqueImages.count, 4, "Should have 4 unique non-UI images")
XCTAssertTrue(uniqueImages.contains("https://example.com/image1.jpg"))
XCTAssertFalse(uniqueImages.contains("https://example.com/avatar.jpg"))
XCTAssertFalse(uniqueImages.contains("https://example.com/logo.png"))
}
func testImageParsingWithEmptyArray() {
let jsResult: [String] = []
// Aplicar parsing
let images = jsResult.filter { $0.contains("http") }
XCTAssertTrue(images.isEmpty)
}
func testImageParsingWithInvalidURLs() {
let jsResult: [String] = [
"not-a-url",
"",
"ftp://invalid-protocol.com/image.jpg",
"https://valid.com/image.jpg"
]
let validImages = jsResult.filter { $0.hasPrefix("https://") }
XCTAssertEqual(validImages.count, 1)
XCTAssertEqual(validImages.first, "https://valid.com/image.jpg")
}
// MARK: - Manga Info Parsing Tests (Simulated)
func testMangaInfoParsingFromJavaScriptResult() {
// Simular el resultado de JavaScript
let jsResult: [String: Any] = [
"title": "Solo Leveling",
"description": "The weakest hunter becomes the strongest in a world where dungeons have appeared.",
"genres": ["Action", "Adventure", "Fantasy"],
"status": "PUBLICANDOSE",
"coverImage": "https://example.com/cover.jpg"
]
// Parsear información del manga
let title = jsResult["title"] as? String ?? "Unknown"
let description = jsResult["description"] as? String ?? ""
let genres = jsResult["genres"] as? [String] ?? []
let status = jsResult["status"] as? String ?? "UNKNOWN"
let coverImage = jsResult["coverImage"] as? String
let manga = Manga(
slug: "solo-leveling",
title: title,
description: description,
genres: genres,
status: status,
url: "https://manhwaweb.com/manga/solo-leveling",
coverImage: coverImage?.isEmpty == false ? coverImage : nil
)
XCTAssertEqual(manga.title, "Solo Leveling")
XCTAssertEqual(manga.genres.count, 3)
XCTAssertTrue(manga.genres.contains("Action"))
XCTAssertEqual(manga.status, "PUBLICANDOSE")
XCTAssertEqual(manga.coverImage, "https://example.com/cover.jpg")
}
func testMangaInfoParsingWithEmptyFields() {
let jsResult: [String: Any] = [
"title": "",
"description": "",
"genres": [],
"status": "",
"coverImage": ""
]
let title = jsResult["title"] as? String ?? "Unknown"
let description = jsResult["description"] as? String ?? ""
let genres = jsResult["genres"] as? [String] ?? []
let status = jsResult["status"] as? String ?? "UNKNOWN"
let coverImage = jsResult["coverImage"] as? String
let manga = Manga(
slug: "test",
title: title,
description: description,
genres: genres,
status: status,
url: "url",
coverImage: coverImage?.isEmpty == false ? coverImage : nil
)
XCTAssertEqual(manga.title, "Unknown")
XCTAssertTrue(manga.description.isEmpty)
XCTAssertTrue(manga.genres.isEmpty)
XCTAssertEqual(manga.status, "")
XCTAssertNil(manga.coverImage)
}
func testMangaStatusParsing() {
// Simular diferentes estados
let testCases: [(String, String)] = [
("PUBLICANDOSE", "PUBLICANDOSE"),
("FINALIZADO", "FINALIZADO"),
("EN PAUSA", "EN_PAUSA"),
("EN_ESPERA", "EN_ESPERA"),
("Unknown", "UNKNOWN")
]
for (input, expected) in testCases {
let normalized = input.uppercased().replacingOccurrences(of: " ", with: "_")
XCTAssertEqual(normalized, expected)
}
}
// MARK: - URL Construction Tests
func testMangaURLConstruction() {
let slug = "solo-leveling"
let expectedURL = "https://manhwaweb.com/manga/\(slug)"
// Simular construcción de URL
let url = URL(string: expectedURL)
XCTAssertNotNil(url)
XCTAssertEqual(url?.absoluteString, expectedURL)
}
func testChapterURLConstruction() {
let slug = "solo-leveling/150"
let expectedURL = "https://manhwaweb.com/leer/\(slug)"
let url = URL(string: expectedURL)
XCTAssertNotNil(url)
XCTAssertEqual(url?.absoluteString, expectedURL)
}
func testURLConstructionWithSpecialCharacters() {
let slug = "manga-with-special-chars"
let expectedURL = "https://manhwaweb.com/manga/\(slug)"
let url = URL(string: expectedURL)
XCTAssertNotNil(url)
XCTAssertEqual(url?.absoluteString, expectedURL)
}
// MARK: - Edge Cases Tests
func testChapterNumberExtraction() {
// Simular regex extraction de números de capítulo
let testCases: [(String, Int?)] = [
("https://manhwaweb.com/leer/solo-leveling/150", 150),
("https://manhwaweb.com/leer/solo-leveling/150/", 150),
("https://manhwaweb.com/leer/solo-leveling/50?page=2", 50),
("https://manhwaweb.com/leer/test", nil),
("", nil)
]
let pattern = "(\\d+)(?:\\/|\\?|\\s*$)"
for (url, expectedNumber) in testCases {
let regex = try? NSRegularExpression(pattern: pattern)
let range = NSRange(url.startIndex..., in: url)
let match = regex?.firstMatch(in: url, range: range)
if let match = match, let matchRange = Range(match.range(at: 1), in: url) {
let number = Int(String(url[matchRange]))
XCTAssertEqual(number, expectedNumber)
} else {
XCTAssertNil(expectedNumber, "URL \(url) should not extract a number")
}
}
}
func testChapterSlugExtraction() {
let href = "/leer/solo-leveling/150"
// Simular extracción de slug
let slug = href.replacingOccurrences(of: "/leer/", with: "").replacingOccurrences(of: "^/", with: "", options: .regularExpression)
XCTAssertEqual(slug, "solo-leveling/150")
}
func testDuplicateRemovalPreservingOrder() {
let chapters = [
["number": 10, "title": "Ch 10"],
["number": 9, "title": "Ch 9"],
["number": 10, "title": "Ch 10 Dup"],
["number": 8, "title": "Ch 8"],
["number": 9, "title": "Ch 9 Dup"]
]
// Eliminar duplicados preservando el orden de primera aparición
let seen = NSMutableSet()
let unique = chapters.filter { chapter in
let number = chapter["number"] as! Int
if seen.contains(number) {
return false
}
seen.add(number)
return true
}
XCTAssertEqual(unique.count, 3)
XCTAssertEqual((unique[0]["number"] as? Int), 10)
XCTAssertEqual((unique[1]["number"] as? Int), 9)
XCTAssertEqual((unique[2]["number"] as? Int), 8)
}
// MARK: - Async Behavior Tests
func testScraperIsMainActor() {
// Verificar que el scraper opera en MainActor
XCTAssertTrue(MainActor.isMainActor, "Scraper should run on main actor")
}
// MARK: - Performance Tests
func testChapterParsingPerformance() {
let jsResult: [[String: Any]] = (0..<1000).map { i in
[
"number": i,
"title": "Chapter \(i)",
"url": "https://manhwaweb.com/leer/test/\(i)",
"slug": "test/\(i)"
]
}
measure {
_ = jsResult.compactMap { dict -> Chapter? in
guard let number = dict["number"] as? Int,
let title = dict["title"] as? String,
let url = dict["url"] as? String,
let slug = dict["slug"] as? String else {
return nil
}
return Chapter(number: number, title: title, url: url, slug: slug)
}
}
}
func testImageFilteringPerformance() {
// Crear 10,000 URLs con algunos duplicados
var urls: [String] = []
for i in 0..<10000 {
urls.append("https://example.com/image\(i).jpg")
if i % 100 == 0 {
urls.append("https://example.com/avatar\(i).jpg")
urls.append("https://example.com/icon\(i).png")
}
}
measure {
let filtered = urls.filter { url in
let lowercased = url.lowercased()
return !lowercased.contains("avatar") &&
!lowercased.contains("icon") &&
!lowercased.contains("logo")
}
_ = Array(Set(filtered))
}
}
func testChapterSortingPerformance() {
var chapters: [Chapter] = []
for i in 0..<1000 {
chapters.append(Chapter(number: Int.random(in: 1...1000), title: "Ch", url: "", slug: ""))
}
measure {
_ = chapters.sorted { $0.number > $1.number }
}
}
// MARK: - Integration Simulation Tests
func testCompleteScrapingFlowSimulation() {
// Simular el flujo completo de scraping
// 1. Simular respuesta de capítulos
let chapterJSResponse: [[String: Any]] = [
["number": 10, "title": "Chapter 10", "url": "url10", "slug": "slug10"],
["number": 9, "title": "Chapter 9", "url": "url9", "slug": "slug9"]
]
let chapters = chapterJSResponse.compactMap { dict -> Chapter? in
guard let number = dict["number"] as? Int,
let title = dict["title"] as? String,
let url = dict["url"] as? String,
let slug = dict["slug"] as? String else {
return nil
}
return Chapter(number: number, title: title, url: url, slug: slug)
}
XCTAssertEqual(chapters.count, 2)
// 2. Simular respuesta de imágenes para el primer capítulo
let imagesJSResponse: [String] = [
"https://example.com/page1.jpg",
"https://example.com/page2.jpg",
"https://example.com/page3.jpg"
]
let images = imagesJSResponse.filter { $0.hasPrefix("https://") }
XCTAssertEqual(images.count, 3)
// 3. Crear páginas
let pages = images.enumerated().map { index, url in
MangaPage(url: url, index: index)
}
XCTAssertEqual(pages.count, 3)
XCTAssertEqual(pages[0].url, "https://example.com/page1.jpg")
XCTAssertEqual(pages[0].index, 0)
}
}

View File

@@ -0,0 +1,540 @@
import XCTest
@testable import MangaReader
/// Tests para los modelos de datos: Manga, Chapter, MangaPage, ReadingProgress, DownloadedChapter
final class ModelTests: XCTestCase {
// MARK: - Setup & Teardown
override func setUp() {
super.setUp()
// Configuración inicial antes de cada test
}
override func tearDown() {
// Limpieza después de cada test
super.tearDown()
}
// MARK: - Manga Model Tests
func testMangaInitialization() {
let manga = Manga(
slug: "tower-of-god",
title: "Tower of God",
description: "A young boy enters a mysterious tower",
genres: ["Action", "Fantasy", "Adventure"],
status: "PUBLICANDOSE",
url: "https://manhwaweb.com/manga/tower-of-god",
coverImage: "https://example.com/cover.jpg"
)
XCTAssertEqual(manga.slug, "tower-of-god")
XCTAssertEqual(manga.title, "Tower of God")
XCTAssertEqual(manga.genres.count, 3)
XCTAssertEqual(manga.id, "tower-of-god")
}
func testMangaCodableSerialization() {
let manga = Manga(
slug: "solo-leveling",
title: "Solo Leveling",
description: "The weakest hunter becomes the strongest",
genres: ["Action", "Adventure"],
status: "FINALIZADO",
url: "https://manhwaweb.com/manga/solo-leveling",
coverImage: nil
)
// Test encoding
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let jsonData = try encoder.encode(manga)
XCTAssertFalse(jsonData.isEmpty)
// Test decoding
let decoder = JSONDecoder()
let decodedManga = try decoder.decode(Manga.self, from: jsonData)
XCTAssertEqual(manga.slug, decodedManga.slug)
XCTAssertEqual(manga.title, decodedManga.title)
XCTAssertEqual(manga.description, decodedManga.description)
XCTAssertEqual(manga.genres, decodedManga.genres)
XCTAssertEqual(manga.status, decodedManga.status)
XCTAssertEqual(manga.url, decodedManga.url)
XCTAssertEqual(manga.coverImage, decodedManga.coverImage)
} catch {
XCTFail("Coding/decoding failed: \(error)")
}
}
func testMangaDisplayStatus() {
let publishingManga = Manga(
slug: "test1",
title: "Test",
description: "Desc",
genres: [],
status: "PUBLICANDOSE",
url: ""
)
XCTAssertEqual(publishingManga.displayStatus, "En publicación")
let finishedManga = Manga(
slug: "test2",
title: "Test",
description: "Desc",
genres: [],
status: "FINALIZADO",
url: ""
)
XCTAssertEqual(finishedManga.displayStatus, "Finalizado")
let pausedManga = Manga(
slug: "test3",
title: "Test",
description: "Desc",
genres: [],
status: "EN_PAUSA",
url: ""
)
XCTAssertEqual(pausedManga.displayStatus, "En pausa")
let waitingManga = Manga(
slug: "test4",
title: "Test",
description: "Desc",
genres: [],
status: "EN_ESPERA",
url: ""
)
XCTAssertEqual(waitingManga.displayStatus, "En pausa")
let unknownManga = Manga(
slug: "test5",
title: "Test",
description: "Desc",
genres: [],
status: "UNKNOWN_STATUS",
url: ""
)
XCTAssertEqual(unknownManga.displayStatus, "UNKNOWN_STATUS")
}
func testMangaHashable() {
let manga1 = Manga(
slug: "test",
title: "Test Manga",
description: "Description",
genres: ["Action"],
status: "PUBLICANDOSE",
url: "https://example.com",
coverImage: nil
)
let manga2 = Manga(
slug: "test",
title: "Different Title",
description: "Different Description",
genres: ["Drama"],
status: "FINALIZADO",
url: "https://different.com",
coverImage: "cover.jpg"
)
XCTAssertEqual(manga1, manga2)
XCTAssertEqual(manga1.hashValue, manga2.hashValue)
let set: Set<Manga> = [manga1, manga2]
XCTAssertEqual(set.count, 1, "Mangas with same slug should be considered equal")
}
// MARK: - Chapter Model Tests
func testChapterInitialization() {
let chapter = Chapter(
number: 150,
title: "The Final Battle",
url: "https://manhwaweb.com/leer/solo-leveling/150",
slug: "solo-leveling/150"
)
XCTAssertEqual(chapter.id, 150)
XCTAssertEqual(chapter.number, 150)
XCTAssertEqual(chapter.title, "The Final Battle")
XCTAssertEqual(chapter.isRead, false)
XCTAssertEqual(chapter.isDownloaded, false)
XCTAssertEqual(chapter.lastReadPage, 0)
}
func testChapterDisplayNumber() {
let chapter = Chapter(number: 42, title: "Test", url: "", slug: "")
XCTAssertEqual(chapter.displayNumber, "Capítulo 42")
}
func testChapterProgress() {
var chapter = Chapter(number: 1, title: "Test", url: "", slug: "")
XCTAssertEqual(chapter.progress, 0.0)
chapter.lastReadPage = 5
XCTAssertEqual(chapter.progress, 5.0)
chapter.lastReadPage = 100
XCTAssertEqual(chapter.progress, 100.0)
}
func testChapterCodableSerialization() {
let chapter = Chapter(
number: 50,
title: "Chapter 50",
url: "https://manhwaweb.com/leer/test/50",
slug: "test/50",
isRead: true,
isDownloaded: true,
lastReadPage: 25
)
// Test encoding
do {
let encoder = JSONEncoder()
let jsonData = try encoder.encode(chapter)
// Test decoding
let decoder = JSONDecoder()
let decodedChapter = try decoder.decode(Chapter.self, from: jsonData)
XCTAssertEqual(chapter.number, decodedChapter.number)
XCTAssertEqual(chapter.title, decodedChapter.title)
XCTAssertEqual(chapter.url, decodedChapter.url)
XCTAssertEqual(chapter.slug, decodedChapter.slug)
XCTAssertEqual(chapter.isRead, decodedChapter.isRead)
XCTAssertEqual(chapter.isDownloaded, decodedChapter.isDownloaded)
XCTAssertEqual(chapter.lastReadPage, decodedChapter.lastReadPage)
} catch {
XCTFail("Chapter coding/decoding failed: \(error)")
}
}
func testChapterHashable() {
let chapter1 = Chapter(
number: 10,
title: "Chapter 10",
url: "url1",
slug: "slug1",
isRead: true,
isDownloaded: false,
lastReadPage: 5
)
let chapter2 = Chapter(
number: 10,
title: "Different Title",
url: "url2",
slug: "slug2",
isRead: false,
isDownloaded: true,
lastReadPage: 10
)
XCTAssertEqual(chapter1, chapter2)
XCTAssertEqual(chapter1.hashValue, chapter2.hashValue)
}
// MARK: - MangaPage Model Tests
func testMangaPageInitialization() {
let page = MangaPage(url: "https://example.com/page1.jpg", index: 0)
XCTAssertEqual(page.id, "https://example.com/page1.jpg")
XCTAssertEqual(page.url, "https://example.com/page1.jpg")
XCTAssertEqual(page.index, 0)
XCTAssertEqual(page.isCached, false)
}
func testMangaPageThumbnailURL() {
let page = MangaPage(url: "https://example.com/high-res.jpg", index: 5)
XCTAssertEqual(page.thumbnailURL, "https://example.com/high-res.jpg")
}
func testMangaPageCodableSerialization() {
let page = MangaPage(
url: "https://example.com/page.jpg",
index: 10,
isCached: true
)
do {
let encoder = JSONEncoder()
let jsonData = try encoder.encode(page)
let decoder = JSONDecoder()
let decodedPage = try decoder.decode(MangaPage.self, from: jsonData)
XCTAssertEqual(page.url, decodedPage.url)
XCTAssertEqual(page.index, decodedPage.index)
XCTAssertEqual(page.isCached, decodedPage.isCached)
} catch {
XCTFail("MangaPage coding/decoding failed: \(error)")
}
}
func testMangaPageHashable() {
let page1 = MangaPage(url: "https://example.com/page.jpg", index: 0)
let page2 = MangaPage(url: "https://example.com/page.jpg", index: 5, isCached: true)
XCTAssertEqual(page1, page2)
XCTAssertEqual(page1.hashValue, page2.hashValue)
}
// MARK: - ReadingProgress Model Tests
func testReadingProgressInitialization() {
let progress = ReadingProgress(
mangaSlug: "solo-leveling",
chapterNumber: 50,
pageNumber: 10,
timestamp: Date()
)
XCTAssertEqual(progress.mangaSlug, "solo-leveling")
XCTAssertEqual(progress.chapterNumber, 50)
XCTAssertEqual(progress.pageNumber, 10)
}
func testReadingProgressIsCompleted() {
let incompleteProgress = ReadingProgress(
mangaSlug: "test",
chapterNumber: 1,
pageNumber: 3,
timestamp: Date()
)
XCTAssertFalse(incompleteProgress.isCompleted)
let completedProgress = ReadingProgress(
mangaSlug: "test",
chapterNumber: 1,
pageNumber: 6,
timestamp: Date()
)
XCTAssertTrue(completedProgress.isCompleted)
let boundaryProgress = ReadingProgress(
mangaSlug: "test",
chapterNumber: 1,
pageNumber: 5,
timestamp: Date()
)
XCTAssertFalse(boundaryProgress.isCompleted, "Exactly 5 pages should not be considered completed")
}
func testReadingProgressCodableSerialization() {
let timestamp = Date(timeIntervalSince1970: 1609459200) // 2021-01-01
let progress = ReadingProgress(
mangaSlug: "tower-of-god",
chapterNumber: 500,
pageNumber: 42,
timestamp: timestamp
)
do {
let encoder = JSONEncoder()
let jsonData = try encoder.encode(progress)
let decoder = JSONDecoder()
let decodedProgress = try decoder.decode(ReadingProgress.self, from: jsonData)
XCTAssertEqual(progress.mangaSlug, decodedProgress.mangaSlug)
XCTAssertEqual(progress.chapterNumber, decodedProgress.chapterNumber)
XCTAssertEqual(progress.pageNumber, decodedProgress.pageNumber)
// Compare timestamp with tolerance for encoding/decoding precision
let timeDifference = abs(progress.timestamp.timeIntervalSince(decodedProgress.timestamp))
XCTAssertLessThan(timeDifference, 0.001, "Timestamps should match within milliseconds")
} catch {
XCTFail("ReadingProgress coding/decoding failed: \(error)")
}
}
// MARK: - DownloadedChapter Model Tests
func testDownloadedChapterInitialization() {
let pages = [
MangaPage(url: "page1.jpg", index: 0),
MangaPage(url: "page2.jpg", index: 1)
]
let downloadedChapter = DownloadedChapter(
mangaSlug: "solo-leveling",
mangaTitle: "Solo Leveling",
chapterNumber: 100,
pages: pages,
downloadedAt: Date(),
totalSize: 1024000
)
XCTAssertEqual(downloadedChapter.id, "solo-leveling-chapter100")
XCTAssertEqual(downloadedChapter.mangaSlug, "solo-leveling")
XCTAssertEqual(downloadedChapter.pages.count, 2)
}
func testDownloadedChapterDisplayTitle() {
let chapter = DownloadedChapter(
mangaSlug: "test",
mangaTitle: "Test Manga",
chapterNumber: 25,
pages: [],
downloadedAt: Date()
)
XCTAssertEqual(chapter.displayTitle, "Test Manga - Capítulo 25")
}
func testDownloadedChapterCodableSerialization() {
let pages = [
MangaPage(url: "page1.jpg", index: 0, isCached: true),
MangaPage(url: "page2.jpg", index: 1, isCached: true)
]
let downloadDate = Date(timeIntervalSince1970: 1609459200)
let downloadedChapter = DownloadedChapter(
mangaSlug: "test-manga",
mangaTitle: "Test Manga",
chapterNumber: 10,
pages: pages,
downloadedAt: downloadDate,
totalSize: 2048000
)
do {
let encoder = JSONEncoder()
let jsonData = try encoder.encode(downloadedChapter)
let decoder = JSONDecoder()
let decodedChapter = try decoder.decode(DownloadedChapter.self, from: jsonData)
XCTAssertEqual(downloadedChapter.mangaSlug, decodedChapter.mangaSlug)
XCTAssertEqual(downloadedChapter.mangaTitle, decodedChapter.mangaTitle)
XCTAssertEqual(downloadedChapter.chapterNumber, decodedChapter.chapterNumber)
XCTAssertEqual(downloadedChapter.pages.count, decodedChapter.pages.count)
XCTAssertEqual(downloadedChapter.totalSize, decodedChapter.totalSize)
} catch {
XCTFail("DownloadedChapter coding/decoding failed: \(error)")
}
}
// MARK: - Edge Cases Tests
func testMangaWithEmptyGenres() {
let manga = Manga(
slug: "test",
title: "Test",
description: "Description",
genres: [],
status: "PUBLICANDOSE",
url: "url"
)
XCTAssertTrue(manga.genres.isEmpty)
XCTAssertEqual(manga.genres.count, 0)
}
func testMangaWithNilCoverImage() {
let manga1 = Manga(
slug: "test",
title: "Test",
description: "Desc",
genres: [],
status: "PUBLICANDOSE",
url: "url",
coverImage: nil
)
XCTAssertNil(manga1.coverImage)
let manga2 = Manga(
slug: "test",
title: "Test",
description: "Desc",
genres: [],
status: "PUBLICANDOSE",
url: "url",
coverImage: ""
)
XCTAssertNil(manga2.coverImage, "Empty string should be treated as nil")
}
func testChapterWithZeroNumber() {
let chapter = Chapter(number: 0, title: "Prologue", url: "url", slug: "slug")
XCTAssertEqual(chapter.id, 0)
XCTAssertEqual(chapter.displayNumber, "Capítulo 0")
}
func testChapterWithLargePageNumber() {
var chapter = Chapter(number: 1, title: "Test", url: "url", slug: "slug")
chapter.lastReadPage = 10000
XCTAssertEqual(chapter.progress, 10000.0)
}
func testMangaPageWithNegativeIndex() {
let page = MangaPage(url: "test.jpg", index: -1)
XCTAssertEqual(page.index, -1)
// Edge case: negative indices should still work
}
func testReadingProgressWithZeroPages() {
let progress = ReadingProgress(
mangaSlug: "test",
chapterNumber: 1,
pageNumber: 0,
timestamp: Date()
)
XCTAssertFalse(progress.isCompleted)
}
func testDownloadedChapterWithEmptyPages() {
let chapter = DownloadedChapter(
mangaSlug: "test",
mangaTitle: "Test",
chapterNumber: 1,
pages: [],
downloadedAt: Date()
)
XCTAssertTrue(chapter.pages.isEmpty)
XCTAssertEqual(chapter.totalSize, 0)
}
// MARK: - Performance Tests
func testMangaEncodingPerformance() {
let manga = Manga(
slug: "test-manga-with-very-long-slug",
title: "Test Manga Title That Is Quite Long",
description: "This is a very long description that contains a lot of text about the manga plot and characters",
genres: ["Action", "Adventure", "Comedy", "Drama", "Fantasy", "Horror", "Mystery", "Romance", "Sci-Fi", "Thriller"],
status: "PUBLICANDOSE",
url: "https://manhwaweb.com/manga/test-manga",
coverImage: "https://example.com/cover.jpg"
)
measure {
for _ in 0..<1000 {
_ = try? JSONEncoder().encode(manga)
}
}
}
func testChapterArrayEqualityPerformance() {
var chapters: [Chapter] = []
for i in 0..<1000 {
chapters.append(Chapter(number: i, title: "Chapter \(i)", url: "url\(i)", slug: "slug\(i)"))
}
let set = Set(chapters)
measure {
_ = set.contains(chapters[500])
}
}
}

View File

@@ -0,0 +1,686 @@
import XCTest
import UIKit
@testable import MangaReader
/// Tests para el StorageService
/// Tests para guardar/cargar favoritos, progreso de lectura y capítulos descargados
final class StorageServiceTests: XCTestCase {
var storageService: StorageService!
var mockUserDefaults: UserDefaults!
// MARK: - Setup & Teardown
override func setUp() async throws {
try await super.setUp()
// Crear un UserDefaults aislado para los tests
mockUserDefaults = UserDefaults(suiteName: "test_manga_reader_\(UUID().uuidString)")!
// Inyectar el mock UserDefaults si fuera posible (requiere modificación del StorageService)
// Por ahora, limpiaremos UserDefaults después de cada test
// Limpiar UserDefaults antes de cada test
UserDefaults.standard.removeObject(forKey: "favoriteMangas")
UserDefaults.standard.removeObject(forKey: "readingProgress")
UserDefaults.standard.removeObject(forKey: "downloadedChaptersMetadata")
storageService = StorageService.shared
}
override func tearDown() async throws {
// Limpiar después de los tests
UserDefaults.standard.removeObject(forKey: "favoriteMangas")
UserDefaults.standard.removeObject(forKey: "readingProgress")
// Limpiar archivos de test
storageService.clearAllDownloads()
try await super.tearDown()
}
// MARK: - Favorites Tests
func testSaveFavorite() {
// Given
let mangaSlug = "solo-leveling"
// When
storageService.saveFavorite(mangaSlug: mangaSlug)
// Then
let favorites = storageService.getFavorites()
XCTAssertTrue(favorites.contains(mangaSlug))
XCTAssertEqual(favorites.count, 1)
}
func testSaveMultipleFavorites() {
// Given
let slugs = ["solo-leveling", "tower-of-god", "the-beginning-after-the-end"]
// When
slugs.forEach { storageService.saveFavorite(mangaSlug: $0) }
// Then
let favorites = storageService.getFavorites()
XCTAssertEqual(favorites.count, 3)
XCTAssertTrue(favorites.contains("solo-leveling"))
XCTAssertTrue(favorites.contains("tower-of-god"))
XCTAssertTrue(favorites.contains("the-beginning-after-the-end"))
}
func testSaveDuplicateFavorite() {
// Given
let mangaSlug = "solo-leveling"
// When
storageService.saveFavorite(mangaSlug: mangaSlug)
storageService.saveFavorite(mangaSlug: mangaSlug) // Intentar guardar duplicado
// Then
let favorites = storageService.getFavorites()
XCTAssertEqual(favorites.count, 1, "Duplicate favorites should not be added")
XCTAssertEqual(favorites.first, mangaSlug)
}
func testRemoveFavorite() {
// Given
let mangaSlug = "solo-leveling"
storageService.saveFavorite(mangaSlug: mangaSlug)
// When
storageService.removeFavorite(mangaSlug: mangaSlug)
// Then
let favorites = storageService.getFavorites()
XCTAssertFalse(favorites.contains(mangaSlug))
XCTAssertEqual(favorites.count, 0)
}
func testRemoveNonExistentFavorite() {
// Given
storageService.saveFavorite(mangaSlug: "manga1")
storageService.saveFavorite(mangaSlug: "manga2")
// When
storageService.removeFavorite(mangaSlug: "non-existent")
// Then
let favorites = storageService.getFavorites()
XCTAssertEqual(favorites.count, 2, "Removing non-existent favorite should not affect others")
}
func testIsFavorite() {
// Given
let favoriteSlug = "solo-leveling"
let nonFavoriteSlug = "tower-of-god"
storageService.saveFavorite(mangaSlug: favoriteSlug)
// When & Then
XCTAssertTrue(storageService.isFavorite(mangaSlug: favoriteSlug))
XCTAssertFalse(storageService.isFavorite(mangaSlug: nonFavoriteSlug))
}
func testGetFavoritesWhenEmpty() {
// When
let favorites = storageService.getFavorites()
// Then
XCTAssertTrue(favorites.isEmpty)
XCTAssertEqual(favorites.count, 0)
}
// MARK: - Reading Progress Tests
func testSaveReadingProgress() {
// Given
let progress = ReadingProgress(
mangaSlug: "solo-leveling",
chapterNumber: 50,
pageNumber: 10,
timestamp: Date()
)
// When
storageService.saveReadingProgress(progress)
// Then
let retrievedProgress = storageService.getReadingProgress(
mangaSlug: "solo-leveling",
chapterNumber: 50
)
XCTAssertNotNil(retrievedProgress)
XCTAssertEqual(retrievedProgress?.mangaSlug, "solo-leveling")
XCTAssertEqual(retrievedProgress?.chapterNumber, 50)
XCTAssertEqual(retrievedProgress?.pageNumber, 10)
}
func testSaveMultipleReadingProgress() {
// Given
let progress1 = ReadingProgress(
mangaSlug: "manga1",
chapterNumber: 1,
pageNumber: 5,
timestamp: Date()
)
let progress2 = ReadingProgress(
mangaSlug: "manga1",
chapterNumber: 2,
pageNumber: 15,
timestamp: Date()
)
let progress3 = ReadingProgress(
mangaSlug: "manga2",
chapterNumber: 1,
pageNumber: 20,
timestamp: Date()
)
// When
storageService.saveReadingProgress(progress1)
storageService.saveReadingProgress(progress2)
storageService.saveReadingProgress(progress3)
// Then
let allProgress = storageService.getAllReadingProgress()
XCTAssertEqual(allProgress.count, 3)
}
func testUpdateExistingReadingProgress() {
// Given
let initialProgress = ReadingProgress(
mangaSlug: "solo-leveling",
chapterNumber: 50,
pageNumber: 10,
timestamp: Date(timeIntervalSince1970: 1609459200)
)
let updatedProgress = ReadingProgress(
mangaSlug: "solo-leveling",
chapterNumber: 50,
pageNumber: 25,
timestamp: Date(timeIntervalSince1970: 1609459300)
)
// When
storageService.saveReadingProgress(initialProgress)
storageService.saveReadingProgress(updatedProgress)
// Then
let retrieved = storageService.getReadingProgress(
mangaSlug: "solo-leveling",
chapterNumber: 50
)
XCTAssertEqual(retrieved?.pageNumber, 25, "Progress should be updated")
XCTAssertEqual(retrieved?.timestamp.timeIntervalSince1970, 1609459300, accuracy: 0.001)
}
func testGetReadingProgressWhenNotExists() {
// When
let progress = storageService.getReadingProgress(
mangaSlug: "non-existent",
chapterNumber: 999
)
// Then
XCTAssertNil(progress)
}
func testGetLastReadChapter() {
// Given
let oldDate = Date(timeIntervalSince1970: 1609459200)
let newDate = Date(timeIntervalSince1970: 1609459300)
let progress1 = ReadingProgress(
mangaSlug: "manga1",
chapterNumber: 1,
pageNumber: 10,
timestamp: oldDate
)
let progress2 = ReadingProgress(
mangaSlug: "manga1",
chapterNumber: 2,
pageNumber: 5,
timestamp: newDate
)
// When
storageService.saveReadingProgress(progress1)
storageService.saveReadingProgress(progress2)
let lastRead = storageService.getLastReadChapter(mangaSlug: "manga1")
// Then
XCTAssertNotNil(lastRead)
XCTAssertEqual(lastRead?.chapterNumber, 2, "Should return the most recent chapter")
XCTAssertEqual(lastRead?.timestamp.timeIntervalSince1970, 1609459300, accuracy: 0.001)
}
func testGetLastReadChapterWhenNoProgress() {
// When
let lastRead = storageService.getLastReadChapter(mangaSlug: "non-existent")
// Then
XCTAssertNil(lastRead)
}
func testGetAllReadingProgressWhenEmpty() {
// When
let allProgress = storageService.getAllReadingProgress()
// Then
XCTAssertTrue(allProgress.isEmpty)
}
// MARK: - Downloaded Chapters Tests
func testSaveDownloadedChapter() {
// Given
let pages = [
MangaPage(url: "page1.jpg", index: 0),
MangaPage(url: "page2.jpg", index: 1)
]
let downloadedChapter = DownloadedChapter(
mangaSlug: "solo-leveling",
mangaTitle: "Solo Leveling",
chapterNumber: 50,
pages: pages,
downloadedAt: Date()
)
// When
storageService.saveDownloadedChapter(downloadedChapter)
// Then
let retrieved = storageService.getDownloadedChapter(
mangaSlug: "solo-leveling",
chapterNumber: 50
)
XCTAssertNotNil(retrieved)
XCTAssertEqual(retrieved?.mangaSlug, "solo-leveling")
XCTAssertEqual(retrieved?.chapterNumber, 50)
XCTAssertEqual(retrieved?.pages.count, 2)
}
func testIsChapterDownloaded() {
// Given
let chapter = DownloadedChapter(
mangaSlug: "test-manga",
mangaTitle: "Test",
chapterNumber: 10,
pages: [],
downloadedAt: Date()
)
// When
storageService.saveDownloadedChapter(chapter)
// Then
XCTAssertTrue(storageService.isChapterDownloaded(
mangaSlug: "test-manga",
chapterNumber: 10
))
XCTAssertFalse(storageService.isChapterDownloaded(
mangaSlug: "test-manga",
chapterNumber: 11
))
}
func testGetDownloadedChapters() {
// Given
let chapter1 = DownloadedChapter(
mangaSlug: "manga1",
mangaTitle: "Manga 1",
chapterNumber: 1,
pages: [],
downloadedAt: Date()
)
let chapter2 = DownloadedChapter(
mangaSlug: "manga2",
mangaTitle: "Manga 2",
chapterNumber: 5,
pages: [],
downloadedAt: Date()
)
// When
storageService.saveDownloadedChapter(chapter1)
storageService.saveDownloadedChapter(chapter2)
// Then
let downloaded = storageService.getDownloadedChapters()
XCTAssertEqual(downloaded.count, 2)
}
func testDeleteDownloadedChapter() {
// Given
let chapter = DownloadedChapter(
mangaSlug: "test-manga",
mangaTitle: "Test",
chapterNumber: 10,
pages: [],
downloadedAt: Date()
)
storageService.saveDownloadedChapter(chapter)
XCTAssertTrue(storageService.isChapterDownloaded(mangaSlug: "test-manga", chapterNumber: 10))
// When
storageService.deleteDownloadedChapter(mangaSlug: "test-manga", chapterNumber: 10)
// Then
XCTAssertFalse(storageService.isChapterDownloaded(mangaSlug: "test-manga", chapterNumber: 10))
}
func testDeleteNonExistentDownloadedChapter() {
// When - No debería lanzar error
storageService.deleteDownloadedChapter(mangaSlug: "non-existent", chapterNumber: 999)
// Then - Simplemente no hace nada
let downloaded = storageService.getDownloadedChapters()
XCTAssertTrue(downloaded.isEmpty)
}
// MARK: - Image Caching Tests
func testSaveAndLoadImage() async throws {
// Given
let testImage = createTestImage(size: CGSize(width: 800, height: 1200))
// When
let savedURL = try await storageService.saveImage(
testImage,
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 0
)
// Then
XCTAssertTrue(FileManager.default.fileExists(atPath: savedURL.path))
XCTAssertEqual(savedURL.lastPathComponent, "page_0.jpg")
let loadedImage = storageService.loadImage(
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 0
)
XCTAssertNotNil(loadedImage)
}
func testLoadNonExistentImage() {
// When
let image = storageService.loadImage(
mangaSlug: "non-existent",
chapterNumber: 999,
pageIndex: 999
)
// Then
XCTAssertNil(image)
}
func testGetImageURL() async throws {
// Given
let testImage = createTestImage(size: CGSize(width: 800, height: 1200))
// When
let savedURL = try await storageService.saveImage(
testImage,
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 0
)
let retrievedURL = storageService.getImageURL(
mangaSlug: "test-manga",
chapterNumber: 1,
pageIndex: 0
)
// Then
XCTAssertNotNil(retrievedURL)
XCTAssertEqual(retrievedURL, savedURL)
}
func testGetImageURLForNonExistentImage() {
// When
let url = storageService.getImageURL(
mangaSlug: "test",
chapterNumber: 1,
pageIndex: 0
)
// Then
XCTAssertNil(url)
}
// MARK: - Storage Management Tests
func testGetStorageSize() async throws {
// Given
let image1 = createTestImage(size: CGSize(width: 800, height: 1200))
let image2 = createTestImage(size: CGSize(width: 800, height: 1200))
// When
_ = try await storageService.saveImage(image1, mangaSlug: "test", chapterNumber: 1, pageIndex: 0)
_ = try await storageService.saveImage(image2, mangaSlug: "test", chapterNumber: 1, pageIndex: 1)
let size = storageService.getStorageSize()
// Then
XCTAssertGreaterThan(size, 0, "Storage size should be greater than 0")
}
func testClearAllDownloads() async throws {
// Given
let image = createTestImage(size: CGSize(width: 800, height: 1200))
_ = try await storageService.saveImage(image, mangaSlug: "test", chapterNumber: 1, pageIndex: 0)
let chapter = DownloadedChapter(
mangaSlug: "test",
mangaTitle: "Test",
chapterNumber: 1,
pages: [],
downloadedAt: Date()
)
storageService.saveDownloadedChapter(chapter)
// When
storageService.clearAllDownloads()
// Then
let downloaded = storageService.getDownloadedChapters()
XCTAssertTrue(downloaded.isEmpty)
let size = storageService.getStorageSize()
XCTAssertEqual(size, 0, "Storage size should be 0 after clearing")
}
func testFormatFileSize() {
// Given
let bytes: Int64 = 1536000 // ~1.5 MB
// When
let formatted = storageService.formatFileSize(bytes)
// Then
XCTAssertTrue(formatted.contains("MB") || formatted.contains("KB"))
}
func testFormatFileSizeWithVariousSizes() {
let tests: [(Int64, String)] = [
(500, "B"), // Bytes
(1024, "KB"), // 1 KB
(1048576, "MB"), // 1 MB
(1073741824, "GB") // 1 GB
]
for (size, expectedUnit) in {
let formatted = storageService.formatFileSize(size)
XCTAssertTrue(
formatted.contains(expectedUnit),
"Expected \(expectedUnit) in formatted string: \(formatted)"
)
}
}
// MARK: - Directory Management Tests
func testGetChapterDirectory() {
// When
let directory = storageService.getChapterDirectory(mangaSlug: "test-manga", chapterNumber: 10)
// Then
XCTAssertTrue(directory.path.contains("test-manga"))
XCTAssertTrue(directory.path.contains("Chapter10"))
}
func testChapterDirectoryCreation() async throws {
// Given
let image = createTestImage(size: CGSize(width: 100, height: 100))
// When
let savedURL = try await storageService.saveImage(
image,
mangaSlug: "new-manga",
chapterNumber: 1,
pageIndex: 0
)
// Then
let directory = savedURL.deletingLastPathComponent()
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.path))
}
// MARK: - Edge Cases Tests
func testSaveFavoriteWithEmptySlug() {
// When
storageService.saveFavorite(mangaSlug: "")
// Then
let favorites = storageService.getFavorites()
XCTAssertTrue(favorites.contains(""), "Empty slug should be saved")
}
func testSaveFavoriteWithSpecialCharacters() {
// Given
let specialSlug = "manga-with-special-chars-áéíóú-ñ-@#$"
// When
storageService.saveFavorite(mangaSlug: specialSlug)
// Then
XCTAssertTrue(storageService.isFavorite(mangaSlug: specialSlug))
}
func testReadingProgressWithZeroPage() {
// Given
let progress = ReadingProgress(
mangaSlug: "test",
chapterNumber: 1,
pageNumber: 0,
timestamp: Date()
)
// When
storageService.saveReadingProgress(progress)
// Then
let retrieved = storageService.getReadingProgress(mangaSlug: "test", chapterNumber: 1)
XCTAssertEqual(retrieved?.pageNumber, 0)
}
func testDownloadedChapterWithZeroChapterNumber() {
// Given
let chapter = DownloadedChapter(
mangaSlug: "test",
mangaTitle: "Test",
chapterNumber: 0,
pages: [],
downloadedAt: Date()
)
// When
storageService.saveDownloadedChapter(chapter)
// Then
XCTAssertTrue(storageService.isChapterDownloaded(mangaSlug: "test", chapterNumber: 0))
}
func testConcurrentImageSave() async throws {
// Given
let images = (0..<10).map { _ in createTestImage(size: CGSize(width: 800, height: 1200)) }
// When - Guardar imágenes concurrentemente
await withTaskGroup(of: URL.self) { group in
for (index, image) in images.enumerated() {
group.addTask {
try! await self.storageService.saveImage(
image,
mangaSlug: "concurrent-test",
chapterNumber: 1,
pageIndex: index
)
}
}
}
// Then - Verificar que todas se guardaron
for index in 0..<10 {
let image = storageService.loadImage(
mangaSlug: "concurrent-test",
chapterNumber: 1,
pageIndex: index
)
XCTAssertNotNil(image, "Image at index \(index) should exist")
}
}
// MARK: - Helper Methods
private func createTestImage(size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
UIColor.blue.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}
// MARK: - Performance Tests
func testSaveManyFavoritesPerformance() {
measure {
for i in 0..<1000 {
storageService.saveFavorite(mangaSlug: "manga-\(i)")
}
}
}
func testSaveManyReadingProgressPerformance() {
let progresses = (0..<100).map { i in
ReadingProgress(
mangaSlug: "manga-\(i % 10)",
chapterNumber: i,
pageNumber: i * 2,
timestamp: Date()
)
}
measure {
for progress in progresses {
storageService.saveReadingProgress(progress)
}
}
}
}

View File

@@ -0,0 +1,415 @@
import XCTest
@testable import MangaReader
/// Ejemplos de cómo escribir tests para el proyecto MangaReader
/// Este archivo serve como guía de referencia para crear nuevos tests
// MARK: - Ejemplo 1: Test Unitario Básico
final class ExampleTests: XCTestCase {
// MARK: - Ejemplo: Test simple de modelo
func testEjemploModeloSimple() {
// Arrange: Preparar los datos
let manga = Manga(
slug: "test",
title: "Test Manga",
description: "Desc",
genres: ["Action"],
status: "PUBLICANDOSE",
url: "https://example.com"
)
// Act: Ejecutar la acción (si es necesario)
let displayTitle = manga.title
// Assert: Verificar el resultado
XCTAssertEqual(displayTitle, "Test Manga")
XCTAssertEqual(manga.slug, "test")
XCTAssertTrue(manga.genres.contains("Action"))
}
// MARK: - Ejemplo: Test con async/await
func testEjemploAsync() async throws {
// Arrange
let storageService = StorageService.shared
let image = createTestImage()
// Act
let url = try await storageService.saveImage(
image,
mangaSlug: "test",
chapterNumber: 1,
pageIndex: 0
)
// Assert
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
}
// MARK: - Ejemplo: Test de error
func testEjemploManejoDeError() async {
// Arrange
let storageService = StorageService.shared
// Act & Assert
do {
_ = try await storageService.saveImage(
UIImage(), // Imagen vacía
mangaSlug: "test",
chapterNumber: 1,
pageIndex: 0
)
XCTFail("Debería haber lanzado un error")
} catch {
XCTAssertNotNil(error)
}
}
// MARK: - Ejemplo: Test con helpers
func testEjemploConHelpers() {
// Usar TestDataFactory para crear datos de prueba
let manga = TestDataFactory.createManga(
slug: "mi-manga",
title: "Mi Manga",
genres: ["Action", "Fantasy"]
)
// Usar AssertionHelpers
AssertionHelpers.assertValidManga(manga)
XCTAssertTrue(manga.genres.count == 2)
}
// MARK: - Ejemplo: Test de múltiples escenarios
func testEjemploMultiplesEscenarios() {
// Escenario 1: Manga publicado
let publishedManga = TestDataFactory.createManga(
status: "PUBLICANDOSE"
)
XCTAssertEqual(publishedManga.displayStatus, "En publicación")
// Escenario 2: Manga finalizado
let finishedManga = TestDataFactory.createManga(
status: "FINALIZADO"
)
XCTAssertEqual(finishedManga.displayStatus, "Finalizado")
// Escenario 3: Manga en pausa
let pausedManga = TestDataFactory.createManga(
status: "EN_PAUSA"
)
XCTAssertEqual(pausedManga.displayStatus, "En pausa")
}
// MARK: - Ejemplo: Test de performance
func testEjemploPerformance() {
let manga = TestDataFactory.createManga()
// Medir cuánto tarda en codificar 1000 veces
measure {
for _ in 0..<1000 {
_ = try? JSONEncoder().encode(manga)
}
}
}
// MARK: - Ejemplo: Test con setup/teardown
var storageService: StorageService!
override func setUp() async throws {
try await super.setUp()
// Se ejecuta antes de cada test
storageService = StorageService.shared
StorageTestHelpers.clearAllStorage()
}
override func tearDown() async throws {
// Se ejecuta después de cada test
StorageTestHelpers.clearAllStorage()
storageService = nil
try await super.tearDown()
}
func testEjemploConSetup() {
// El storageService ya está inicializado y limpio
storageService.saveFavorite(mangaSlug: "test")
XCTAssertTrue(storageService.isFavorite(mangaSlug: "test"))
}
// MARK: - Ejemplo: Test de integración
func testEjemploIntegracion() async throws {
// Simular flujo completo del usuario
// 1. Usuario busca un manga
let manga = TestDataFactory.createManga(slug: "tower-of-god")
// 2. Usuario lo agrega a favoritos
storageService.saveFavorite(mangaSlug: manga.slug)
XCTAssertTrue(storageService.isFavorite(mangaSlug: manga.slug))
// 3. Usuario comienza a leer
let progress = ReadingProgress(
mangaSlug: manga.slug,
chapterNumber: 1,
pageNumber: 0,
timestamp: Date()
)
storageService.saveReadingProgress(progress)
// 4. Verificar que todo se guardó correctamente
let retrieved = storageService.getReadingProgress(
mangaSlug: manga.slug,
chapterNumber: 1
)
XCTAssertNotNil(retrieved)
XCTAssertEqual(retrieved?.pageNumber, 0)
}
// MARK: - Ejemplo: Test concurrente
func testEjemploConcurrencia() async throws {
// Crear múltiples tareas concurrentes
await withTaskGroup(of: Void.self) { group in
for i in 0..<10 {
group.addTask {
let manga = TestDataFactory.createManga(slug: "manga-\(i)")
self.storageService.saveFavorite(mangaSlug: manga.slug)
}
}
}
// Verificar que todas se guardaron
let favorites = storageService.getFavorites()
XCTAssertEqual(favorites.count, 10)
}
// MARK: - Ejemplo: Test con mock
func testEjemploConMock() {
// Mock de respuesta de JavaScript
let mockResponse: [[String: Any]] = [
["number": 1, "title": "Chapter 1", "url": "url1", "slug": "slug1"],
["number": 2, "title": "Chapter 2", "url": "url2", "slug": "slug2"]
]
// Parsear respuesta mock
let chapters = mockResponse.compactMap { dict -> Chapter? in
guard let number = dict["number"] as? Int,
let title = dict["title"] as? String,
let url = dict["url"] as? String,
let slug = dict["slug"] as? String else {
return nil
}
return Chapter(number: number, title: title, url: url, slug: slug)
}
// Verificar parsing
XCTAssertEqual(chapters.count, 2)
XCTAssertEqual(chapters[0].number, 1)
XCTAssertEqual(chapters[1].number, 2)
}
// MARK: - Ejemplo: Test de edge cases
func testEjemploEdgeCases() {
// Caso 1: String vacío
let emptyManga = TestDataFactory.createManga(title: "")
XCTAssertEqual(emptyManga.title, "")
// Caso 2: Array vacío
let noGenresManga = TestDataFactory.createManga(genres: [])
XCTAssertTrue(noGenresManga.genres.isEmpty)
// Caso 3: Valor negativo
let chapter = TestDataFactory.createChapter(number: -1)
XCTAssertEqual(chapter.number, -1)
// Caso 4: Caracteres especiales
let specialSlug = "manga-áéíóú-ñ-@#$"
storageService.saveFavorite(mangaSlug: specialSlug)
XCTAssertTrue(storageService.isFavorite(mangaSlug: specialSlug))
}
// MARK: - Ejemplo: Test con assertions personalizadas
func testEjemploAssertionsPersonalizadas() {
let manga1 = TestDataFactory.createManga(slug: "test")
let manga2 = TestDataFactory.createManga(slug: "test")
// Usar assert personalizado
XCTAssertEqual(manga1, manga2)
XCTAssertEqual(manga1.hashValue, manga2.hashValue)
// Verificar URL válida
AssertionHelpers.assertValidURL(manga1.url)
// Verificar manga válido
AssertionHelpers.assertValidManga(manga1)
}
// MARK: - Helpers para ejemplos
private func createTestImage() -> UIImage {
let size = CGSize(width: 800, height: 1200)
UIGraphicsBeginImageContext(size)
let context = UIGraphicsGetCurrentContext()
context?.setFillColor(UIColor.blue.cgColor)
context?.fill(CGRect(origin: .zero, size: size))
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image!
}
}
// MARK: - Plantillas de Tests
/// Plantilla para test unitario simple
/*
func test[NombreFuncionalidad]_[Condición]_[ResultadoEsperado]() {
// Arrange
let [input] = [valor]
// Act
let result = [función](input)
// Assert
XCTAssertEqual(result, [esperado])
}
*/
/// Plantilla para test asíncrono
/*
func test[NombreFuncionalidad]_Async() async throws {
// Arrange
let [input] = [valor]
// Act
let result = try await [funciónAsync](input)
// Assert
XCTAssertNotNil(result)
}
*/
/// Plantilla para test de error
/*
func test[NombreFuncionalidad]_ThrowsError() {
// Arrange
let [inputInvalido] = [valor]
// Act & Assert
XCTAssertThrowsError(
try [función](inputInvalido)
) { error in
XCTAssertEqual(error as! [TipoError], [errorEsperado])
}
}
*/
/// Plantilla para test de performance
/*
func test[NombreFuncionalidad]_Performance() {
let [data] = [crearDatosDePrueba]
measure {
_ = [función](data)
}
}
*/
/// Plantilla para test de integración
/*
func test[FlujoCompleto]_Integration() async throws {
// Paso 1: [acción inicial]
let [result1] = try await [función1]()
// Paso 2: [acción siguiente]
let [result2] = [función2](result1)
// Paso 3: [verificación final]
XCTAssertNotNil([resultFinal])
XCTAssertTrue([condición])
}
*/
// MARK: - Consejos para Escribir Tests
/*
BUENOS HÁBITOS:
1. Usa nombres descriptivos:
- testSaveFavorite_AddsNewFavorite_WhenNotExists
- testChapterProgress_ReturnsDouble_WhenSet
2. Un assert por test:
- Separa en múltiples tests si hay varios asserts
- Usa subtests si están relacionados
3. Arrange-Act-Assert:
- Arrange: Prepara los datos
- Act: Ejecuta la acción
- Assert: Verifica el resultado
4. Tests independientes:
- No dependen del orden de ejecución
- Limpian después de sí mismos
5. Usa helpers:
- TestDataFactory para crear objetos
- AssertionHelpers para verificar condiciones
MALOS HÁBITOS:
1. Tests con múltiples asserts no relacionados
2. Tests que dependen del orden
3. Tests que no limpian después
4. Tests con nombres ambiguos
5. Tests que llaman a APIs reales
*/
// MARK: - Referencias Rápidas
/*
COMUNES ASSERTIONS:
- XCTAssertEqual(a, b) - Verifica igualdad
- XCTAssertNotEqual(a, b) - Verifica desigualdad
- XCTAssertTrue(condición) - Verifica true
- XCTAssertFalse(condición) - Verifica false
- XCTAssertNil(valor) - Verifica nil
- XCTAssertNotNil(valor) - Verifica no nil
- XCTAssertThrowsError(expr) - Verifica que lanza error
- XCTAssertNoThrow(expr) - Verifica que NO lanza error
ASYNC/AWAIT:
- try await [función async] - Ejecutar función async
- try await Task.sleep(...) - Esperar
- await withTaskGroup {} - Tareas concurrentes
SETUP/TEARDOWN:
- override func setUp() - Antes de cada test
- override func tearDown() - Después de cada test
- override func setUpWithError() - Con errores
- override func tearDownWithError() - Con errores
PERFORMANCE:
- measure { } - Medir bloques de código
- measure(metrics: ...) { } - Métricas específicas
MOCKS:
- TestDataFactory - Crear objetos de prueba
- ImageTestHelpers - Crear imágenes
- FileSystemTestHelpers - Operaciones de archivos
*/

View File

@@ -0,0 +1,576 @@
import XCTest
import UIKit
@testable import MangaReader
/// Helpers y mocks para los tests
/// Proporciona métodos de utilidad para crear datos de prueba y configurar tests
// MARK: - Test Data Factory
class TestDataFactory {
/// Crea un manga de prueba
static func createManga(
slug: String = "test-manga",
title: String = "Test Manga",
description: String = "A test manga description",
genres: [String] = ["Action", "Adventure"],
status: String = "PUBLICANDOSE",
url: String = "https://manhwaweb.com/manga/test",
coverImage: String? = nil
) -> Manga {
return Manga(
slug: slug,
title: title,
description: description,
genres: genres,
status: status,
url: url,
coverImage: coverImage
)
}
/// Crea un capítulo de prueba
static func createChapter(
number: Int = 1,
title: String = "Chapter 1",
url: String = "https://manhwaweb.com/leer/test/1",
slug: String = "test/1",
isRead: Bool = false,
isDownloaded: Bool = false,
lastReadPage: Int = 0
) -> Chapter {
var chapter = Chapter(number: number, title: title, url: url, slug: slug)
chapter.isRead = isRead
chapter.isDownloaded = isDownloaded
chapter.lastReadPage = lastReadPage
return chapter
}
/// Crea una página de manga de prueba
static func createMangaPage(
url: String = "https://example.com/page.jpg",
index: Int = 0,
isCached: Bool = false
) -> MangaPage {
return MangaPage(url: url, index: index, isCached: isCached)
}
/// Crea un progreso de lectura de prueba
static func createReadingProgress(
mangaSlug: String = "test-manga",
chapterNumber: Int = 1,
pageNumber: Int = 0,
timestamp: Date = Date()
) -> ReadingProgress {
return ReadingProgress(
mangaSlug: mangaSlug,
chapterNumber: chapterNumber,
pageNumber: pageNumber,
timestamp: timestamp
)
}
/// Crea un capítulo descargado de prueba
static func createDownloadedChapter(
mangaSlug: String = "test-manga",
mangaTitle: String = "Test Manga",
chapterNumber: Int = 1,
pages: [MangaPage] = [],
downloadedAt: Date = Date(),
totalSize: Int64 = 0
) -> DownloadedChapter {
return DownloadedChapter(
mangaSlug: mangaSlug,
mangaTitle: mangaTitle,
chapterNumber: chapterNumber,
pages: pages,
downloadedAt: downloadedAt,
totalSize: totalSize
)
}
/// Crea múltiples capítulos de prueba
static func createChapters(count: Int, startingFrom: Int = 1) -> [Chapter] {
return (startingFrom..<(startingFrom + count)).map { i in
createChapter(
number: i,
title: "Chapter \(i)",
url: "https://manhwaweb.com/leer/test/\(i)",
slug: "test/\(i)"
)
}
}
/// Crea múltiples páginas de prueba
static func createPages(count: Int) -> [MangaPage] {
return (0..<count).map { i in
createMangaPage(
url: "https://example.com/page\(i).jpg",
index: i
)
}
}
}
// MARK: - Image Test Helpers
class ImageTestHelpers {
/// Crea una imagen de prueba con un color específico
static func createTestImage(
color: UIColor = .blue,
size: CGSize = CGSize(width: 800, height: 1200)
) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
color.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}
/// Crea una imagen de prueba con texto
static func createTestImageWithText(
text: String,
size: CGSize = CGSize(width: 800, height: 1200)
) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
UIColor.white.setFill()
context.fill(CGRect(origin: .zero, size: size))
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
let attrs: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 48),
.foregroundColor: UIColor.black,
.paragraphStyle: paragraphStyle
]
let string = NSAttributedString(string: text, attributes: attrs)
let rect = CGRect(origin: .zero, size: size)
string.draw(in: rect)
}
}
/// Compara dos imágenes para verificar si son iguales
static func compareImages(_ image1: UIImage?, _ image2: UIImage?) -> Bool {
guard let img1 = image1, let img2 = image2 else {
return image1 == nil && image2 == nil
}
return img1.pngData() == img2.pngData()
}
/// Verifica que una imagen no esté vacía
static func isImageNotEmpty(_ image: UIImage?) -> Bool {
guard let image = image else { return false }
return image.size.width > 0 && image.size.height > 0
}
}
// MARK: - File System Test Helpers
class FileSystemTestHelpers {
/// Crea un directorio temporal para tests
static func createTemporaryDirectory() throws -> URL {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: tempDir,
withIntermediateDirectories: true
)
return tempDir
}
/// Elimina un directorio temporal
static func removeTemporaryDirectory(at url: URL) throws {
try FileManager.default.removeItem(at: url)
}
/// Crea un archivo de prueba
static func createTestFile(at url: URL, content: Data) throws {
try content.write(to: url)
}
/// Verifica que un archivo existe
static func fileExists(at url: URL) -> Bool {
FileManager.default.fileExists(atPath: url.path)
}
/// Obtiene el tamaño de un archivo
static func fileSize(at url: URL) -> Int64? {
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
return attributes?[.size] as? Int64
}
/// Crea una estructura de directorios de prueba para capítulos
static func createTestChapterStructure(
mangaSlug: String,
chapterNumber: Int,
pageCount: Int,
in directory: URL
) throws {
let chapterDir = directory
.appendingPathComponent(mangaSlug)
.appendingPathComponent("Chapter\(chapterNumber)")
try FileManager.default.createDirectory(
at: chapterDir,
withIntermediateDirectories: true
)
// Crear archivos de prueba
for i in 0..<pageCount {
let imageData = Data(repeating: UInt8(i), count: 1024)
let fileURL = chapterDir.appendingPathComponent("page_\(i).jpg")
try imageData.write(to: fileURL)
}
}
}
// MARK: - Storage Test Helpers
class StorageTestHelpers {
/// Limpia todos los datos de almacenamiento
static func clearAllStorage() {
UserDefaults.standard.removeObject(forKey: "favoriteMangas")
UserDefaults.standard.removeObject(forKey: "readingProgress")
UserDefaults.standard.removeObject(forKey: "downloadedChaptersMetadata")
let storageService = StorageService.shared
storageService.clearAllDownloads()
}
/// Crea datos de prueba en el almacenamiento
static func seedTestData(
favoriteCount: Int = 5,
progressCount: Int = 10,
downloadedChapterCount: Int = 3
) {
let storageService = StorageService.shared
// Agregar favoritos
for i in 0..<favoriteCount {
storageService.saveFavorite(mangaSlug: "manga-\(i)")
}
// Agregar progreso
for i in 0..<progressCount {
let progress = TestDataFactory.createReadingProgress(
mangaSlug: "manga-\(i % 3)",
chapterNumber: i,
pageNumber: i * 5
)
storageService.saveReadingProgress(progress)
}
// Agregar capítulos descargados
for i in 0..<downloadedChapterCount {
let chapter = TestDataFactory.createDownloadedChapter(
mangaSlug: "manga-\(i % 2)",
mangaTitle: "Manga \(i % 2)",
chapterNumber: i,
pages: TestDataFactory.createPages(count: 5)
)
storageService.saveDownloadedChapter(chapter)
}
}
/// Verifica que el almacenamiento está vacío
static func assertStorageIsEmpty() {
let storageService = StorageService.shared
XCTAssertTrue(
storageService.getFavorites().isEmpty,
"Favorites should be empty"
)
XCTAssertTrue(
storageService.getAllReadingProgress().isEmpty,
"Reading progress should be empty"
)
XCTAssertTrue(
storageService.getDownloadedChapters().isEmpty,
"Downloaded chapters should be empty"
)
XCTAssertEqual(
storageService.getStorageSize(),
0,
"Storage size should be 0"
)
}
}
// MARK: - Async Test Helpers
class AsyncTestHelpers {
/// Ejecuta una operación asíncrona con timeout
static func executeWithTimeout<T>(
timeout: TimeInterval = 5.0,
operation: @escaping () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
return try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw TimeoutError()
}
let result = try await group.next()
group.cancelAll()
guard let result = result else {
throw TimeoutError()
}
return result
}
}
struct TimeoutError: Error {
let localizedDescription = "Operation timed out"
}
}
// MARK: - Scraper Test Helpers
class ScraperTestHelpers {
/// Simula una respuesta HTML para lista de capítulos
static func mockChapterListHTML(mangaSlug: String) -> String {
"""
<!DOCTYPE html>
<html>
<body>
<a href="/leer/\(mangaSlug)/150">Chapter 150</a>
<a href="/leer/\(mangaSlug)/149">Chapter 149</a>
<a href="/leer/\(mangaSlug)/148">Chapter 148</a>
</body>
</html>
"""
}
/// Simula una respuesta HTML para imágenes de capítulo
static func mockChapterImagesHTML() -> String {
"""
<!DOCTYPE html>
<html>
<body>
<img src="https://example.com/page1.jpg" alt="Page 1">
<img src="https://example.com/page2.jpg" alt="Page 2">
<img src="https://example.com/page3.jpg" alt="Page 3">
<img src="https://example.com/avatar.jpg" alt="Avatar">
<img src="https://example.com/logo.png" alt="Logo">
</body>
</html>
"""
}
/// Simula una respuesta HTML para información de manga
static func mockMangaInfoHTML(title: String) -> String {
"""
<!DOCTYPE html>
<html>
<head>
<title>\(title) - ManhwaWeb</title>
</head>
<body>
<h1>\(title)</h1>
<p>This is a long description of the manga that contains more than 100 characters to meet the minimum requirement for extraction.</p>
<a href="/genero/action">Action</a>
<a href="/genero/fantasy">Fantasy</a>
<div class="cover">
<img src="https://example.com/cover.jpg" alt="Cover">
</div>
<p>Estado: PUBLICANDOSE</p>
</body>
</html>
"""
}
/// Simula un resultado de JavaScript para capítulos
static func mockChapterJSResult() -> [[String: Any]] {
return [
[
"number": 150,
"title": "Chapter 150",
"url": "https://manhwaweb.com/leer/test/150",
"slug": "test/150"
],
[
"number": 149,
"title": "Chapter 149",
"url": "https://manhwaweb.com/leer/test/149",
"slug": "test/149"
]
]
}
/// Simula un resultado de JavaScript para imágenes
static func mockImagesJSResult() -> [String] {
return [
"https://example.com/page1.jpg",
"https://example.com/page2.jpg",
"https://example.com/page3.jpg"
]
}
/// Simula un resultado de JavaScript para info de manga
static func mockMangaInfoJSResult() -> [String: Any] {
return [
"title": "Test Manga",
"description": "A test manga description",
"genres": ["Action", "Fantasy"],
"status": "PUBLICANDOSE",
"coverImage": "https://example.com/cover.jpg"
]
}
}
// MARK: - Assertion Helpers
class AssertionHelpers {
/// Afirma que dos arrays son iguales independientemente del orden
static func assertArraysEqual<T: Equatable>(
_ array1: [T],
_ array2: [T],
file: StaticString = #file,
line: UInt = #line
) {
XCTAssertEqual(
array1.sorted(),
array2.sorted(),
"Arrays are not equal (order independent)",
file: file,
line: line
)
}
/// Afirma que un array contiene elementos específicos
static func assertArrayContains<T: Equatable>(
_ array: [T],
_ elements: [T],
file: StaticString = #file,
line: UInt = #line
) {
for element in elements {
XCTAssertTrue(
array.contains(element),
"Array does not contain expected element: \(element)",
file: file,
line: line
)
}
}
/// Afirma que una URL es válida
static func assertValidURL(
_ urlString: String,
file: StaticString = #file,
line: UInt = #line
) {
XCTAssertNotNil(
URL(string: urlString),
"URL string is not valid: \(urlString)",
file: file,
line: line
)
}
/// Afirma que un manga tiene datos válidos
static func assertValidManga(
_ manga: Manga,
file: StaticString = #file,
line: UInt = #line
) {
XCTAssertFalse(
manga.slug.isEmpty,
"Manga slug should not be empty",
file: file,
line: line
)
XCTAssertFalse(
manga.title.isEmpty,
"Manga title should not be empty",
file: file,
line: line
)
XCTAssertFalse(
manga.url.isEmpty,
"Manga URL should not be empty",
file: file,
line: line
)
AssertionHelpers.assertValidURL(manga.url, file: file, line: line)
}
/// Afirma que un capítulo tiene datos válidos
static func assertValidChapter(
_ chapter: Chapter,
file: StaticString = #file,
line: UInt = #line
) {
XCTAssertGreaterThan(
chapter.number,
0,
"Chapter number should be greater than 0",
file: file,
line: line
)
XCTAssertFalse(
chapter.title.isEmpty,
"Chapter title should not be empty",
file: file,
line: line
)
XCTAssertFalse(
chapter.url.isEmpty,
"Chapter URL should not be empty",
file: file,
line: line
)
}
}
// MARK: - Performance Test Helpers
class PerformanceTestHelpers {
/// Mide el tiempo de ejecución de una operación
static func measureTime(_ operation: () -> Void) -> TimeInterval {
let start = Date()
operation()
return Date().timeIntervalSince(start)
}
/// Mide el tiempo de ejecución de una operación asíncrona
static func measureAsyncTime(_ operation: () async throws -> Void) async throws -> TimeInterval {
let start = Date()
try await operation()
return Date().timeIntervalSince(start)
}
/// Ejecuta una operación múltiples veces y retorna el tiempo promedio
static func averageTime(
iterations: Int = 10,
operation: () -> Void
) -> TimeInterval {
var totalTime: TimeInterval = 0
for _ in 0..<iterations {
totalTime += measureTime(operation)
}
return totalTime / Double(iterations)
}
}

View File

@@ -0,0 +1,160 @@
import XCTest
#if !canImport(ObjectiveC)
return
#endif
/// Manifests para XCTest en Xcode
/// Este archivo ayuda a Xcode a descubrir y organizar los tests
// MARK: - Test Suites
final class ModelTestSuite: XCTestCase {
static let allTests = [
("testMangaInitialization", testMangaInitialization),
("testMangaCodableSerialization", testMangaCodableSerialization),
("testMangaDisplayStatus", testMangaDisplayStatus),
("testMangaHashable", testMangaHashable),
("testChapterInitialization", testChapterInitialization),
("testChapterDisplayNumber", testChapterDisplayNumber),
("testChapterProgress", testChapterProgress),
("testChapterCodableSerialization", testChapterCodableSerialization),
("testChapterHashable", testChapterHashable),
("testMangaPageInitialization", testMangaPageInitialization),
("testMangaPageThumbnailURL", testMangaPageThumbnailURL),
("testMangaPageCodableSerialization", testMangaPageCodableSerialization),
("testMangaPageHashable", testMangaPageHashable),
("testReadingProgressInitialization", testReadingProgressInitialization),
("testReadingProgressIsCompleted", testReadingProgressIsCompleted),
("testReadingProgressCodableSerialization", testReadingProgressCodableSerialization),
("testDownloadedChapterInitialization", testDownloadedChapterInitialization),
("testDownloadedChapterDisplayTitle", testDownloadedChapterDisplayTitle),
("testDownloadedChapterCodableSerialization", testDownloadedChapterCodableSerialization),
("testMangaWithEmptyGenres", testMangaWithEmptyGenres),
("testMangaWithNilCoverImage", testMangaWithNilCoverImage),
("testChapterWithZeroNumber", testChapterWithZeroNumber),
("testChapterWithLargePageNumber", testChapterWithLargePageNumber),
("testMangaPageWithNegativeIndex", testMangaPageWithNegativeIndex),
("testReadingProgressWithZeroPages", testReadingProgressWithZeroPages),
("testDownloadedChapterWithEmptyPages", testDownloadedChapterWithEmptyPages),
("testMangaEncodingPerformance", testMangaEncodingPerformance),
("testChapterArrayEqualityPerformance", testChapterArrayEqualityPerformance)
]
}
final class StorageServiceTestSuite: XCTestCase {
static let allTests = [
("testSaveFavorite", testSaveFavorite),
("testSaveMultipleFavorites", testSaveMultipleFavorites),
("testSaveDuplicateFavorite", testSaveDuplicateFavorite),
("testRemoveFavorite", testRemoveFavorite),
("testRemoveNonExistentFavorite", testRemoveNonExistentFavorite),
("testIsFavorite", testIsFavorite),
("testGetFavoritesWhenEmpty", testGetFavoritesWhenEmpty),
("testSaveReadingProgress", testSaveReadingProgress),
("testSaveMultipleReadingProgress", testSaveMultipleReadingProgress),
("testUpdateExistingReadingProgress", testUpdateExistingReadingProgress),
("testGetReadingProgressWhenNotExists", testGetReadingProgressWhenNotExists),
("testGetLastReadChapter", testGetLastReadChapter),
("testGetLastReadChapterWhenNoProgress", testGetGetLastReadChapterWhenNoProgress),
("testGetAllReadingProgressWhenEmpty", testGetAllReadingProgressWhenEmpty),
("testSaveDownloadedChapter", testSaveDownloadedChapter),
("testIsChapterDownloaded", testIsChapterDownloaded),
("testGetDownloadedChapters", testGetDownloadedChapters),
("testDeleteDownloadedChapter", testDeleteDownloadedChapter),
("testDeleteNonExistentDownloadedChapter", testDeleteNonExistentDownloadedChapter),
("testSaveAndLoadImage", testSaveAndLoadImage),
("testLoadNonExistentImage", testLoadNonExistentImage),
("testGetImageURL", testGetImageURL),
("testGetImageURLForNonExistentImage", testGetImageURLForNonExistentImage),
("testGetStorageSize", testGetStorageSize),
("testClearAllDownloads", testClearAllDownloads),
("testFormatFileSize", testFormatFileSize),
("testFormatFileSizeWithVariousSizes", testFormatFileSizeWithVariousSizes),
("testGetChapterDirectory", testGetChapterDirectory),
("testChapterDirectoryCreation", testChapterDirectoryCreation),
("testSaveFavoriteWithEmptySlug", testSaveFavoriteWithEmptySlug),
("testSaveFavoriteWithSpecialCharacters", testSaveFavoriteWithSpecialCharacters),
("testReadingProgressWithZeroPage", testReadingProgressWithZeroPage),
("testDownloadedChapterWithZeroChapterNumber", testDownloadedChapterWithZeroChapterNumber),
("testConcurrentImageSave", testConcurrentImageSave),
("testSaveManyFavoritesPerformance", testSaveManyFavoritesPerformance),
("testSaveManyReadingProgressPerformance", testSaveManyReadingProgressPerformance)
]
}
final class ManhwaWebScraperTestSuite: XCTestCase {
static let allTests = [
("testScrapingErrorDescriptions", testScrapingErrorDescriptions),
("testScrapingErrorLocalizedError", testScrapingErrorLocalizedError),
("testWebViewInitialization", testWebViewInitialization),
("testChapterParsingFromJavaScriptResult", testChapterParsingFromJavaScriptResult),
("testChapterParsingWithInvalidData", testChapterParsingWithInvalidData),
("testChapterDeduplication", testChapterDeduplication),
("testChapterSorting", testChapterSorting),
("testImageParsingFromJavaScriptResult", testImageParsingFromJavaScriptResult),
("testImageParsingWithEmptyArray", testImageParsingWithEmptyArray),
("testImageParsingWithInvalidURLs", testImageParsingWithInvalidURLs),
("testMangaInfoParsingFromJavaScriptResult", testMangaInfoParsingFromJavaScriptResult),
("testMangaInfoParsingWithEmptyFields", testMangaInfoParsingWithEmptyFields),
("testMangaStatusParsing", testMangaStatusParsing),
("testMangaURLConstruction", testMangaURLConstruction),
("testChapterURLConstruction", testChapterURLConstruction),
("testURLConstructionWithSpecialCharacters", testURLConstructionWithSpecialCharacters),
("testChapterNumberExtraction", testChapterNumberExtraction),
("testChapterSlugExtraction", testChapterSlugExtraction),
("testDuplicateRemovalPreservingOrder", testDuplicateRemovalPreservingOrder),
("testScraperIsMainActor", testScraperIsMainActor),
("testChapterParsingPerformance", testChapterParsingPerformance),
("testImageFilteringPerformance", testImageFilteringPerformance),
("testChapterSortingPerformance", testChapterSortingPerformance),
("testCompleteScrapingFlowSimulation", testCompleteScrapingFlowSimulation)
]
}
final class IntegrationTestSuite: XCTestCase {
static let allTests = [
("testCompleteScrapingAndStorageFlow", testCompleteScrapingAndStorageFlow),
("testChapterDownloadFlow", testChapterDownloadFlow),
("testReadingProgressTrackingFlow", testReadingProgressTrackingFlow),
("testFavoriteManagementFlow", testFavoriteManagementFlow),
("testMultipleMangasProgressTracking", testMultipleMangasProgressTracking),
("testMultipleChapterDownloads", testMultipleChapterDownloads),
("testDownloadFlowWithMissingImages", testDownloadFlowWithMissingImages),
("testStorageCleanupFlow", testStorageCleanupFlow),
("testDataPersistenceAcrossOperations", testDataPersistenceAcrossOperations),
("testConcurrentFavoriteOperations", testConcurrentFavoriteOperations),
("testConcurrentProgressOperations", testConcurrentProgressOperations),
("testConcurrentImageOperations", testConcurrentImageOperations),
("testLargeScaleFavoriteOperations", testLargeScaleFavoriteOperations),
("testLargeScaleProgressOperations", testLargeScaleProgressOperations)
]
}
// MARK: - All Tests Entry Point
#if DEBUG
/// Punto de entrada para ejecutar todos los tests
/// Esto es útil para debugging y para ejecutar tests programáticamente
final class AllTestsEntry {
static func runAllTests() {
print("🧪 MangaReader Test Suite")
print("=" * 50)
runTestSuite(ModelTestSuite.self)
runTestSuite(StorageServiceTestSuite.self)
runTestSuite(ManhwaWebScraperTestSuite.self)
runTestSuite(IntegrationTestSuite.self)
print("=" * 50)
print("✅ All tests completed!")
}
private static func runTestSuite(_ suiteClass: XCTestCase.Type) {
print("\n📋 Running \(suiteClass)...")
// La suite se ejecuta automáticamente por XCTest
}
}
#endif

View File

@@ -0,0 +1,388 @@
import XCTest
/// Extensiones y configuraciones adicionales para XCTest
/// Proporciona funcionalidades adicionales para los tests
extension XCTestCase {
/// Espera un periodo de tiempo específico (útil para operaciones asíncronas)
func wait(for duration: TimeInterval) async {
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
}
/// Ejecuta una operación y espera que complete
func waitForOperation<T>(
timeout: TimeInterval = 5.0,
operation: @escaping () -> T?,
file: StaticString = #file,
line: UInt = #line
) -> T? {
let expectation = self.expectation(description: "Operation completed")
var result: T?
DispatchQueue.global().async {
result = operation()
expectation.fulfill()
}
waitForExpectations(timeout: timeout) { error in
if let error = error {
XCTFail("Operation timed out: \(error.localizedDescription)", file: file, line: line)
}
}
return result
}
/// Verifica que una operación lanza un error específico
func assertThrowsError<T>(
_ error: Error,
in expression: () throws -> T,
file: StaticString = #file,
line: UInt = #line
) {
XCTAssertThrowsError(
try expression(),
file: file,
line: line
) { thrownError in
XCTAssertEqual(
thrownError as? Error,
error,
"Expected error does not match thrown error",
file: file,
line: line
)
}
}
/// Ejecuta un test multiple veces para detectar fallos intermitentes
func repeatTest(
_ count: Int = 10,
file: StaticString = #file,
line: UInt = #line,
test: () throws -> Void
) {
var failures = 0
for iteration in 1...count {
do {
try test()
} catch {
failures += 1
print("Test failed on iteration \(iteration): \(error)")
}
}
XCTAssertEqual(
failures,
0,
"Test failed \(failures) out of \(count) times",
file: file,
line: line
)
}
}
// MARK: - Custom Assertions
extension XCTestCase {
/// Afirma que un closure no lanza error
func assertNoThrow(
_ expression: () throws -> Void,
file: StaticString = #file,
line: UInt = #line
) {
do {
try expression()
} catch {
XCTFail(
"Unexpected error thrown: \(error.localizedDescription)",
file: file,
line: line
)
}
}
/// Afirma que dos fechas son aproximadamente iguales (dentro de un margen)
func assertDatesEqual(
_ date1: Date,
_ date2: Date,
precision: TimeInterval = 0.001,
file: StaticString = #file,
line: UInt = #line
) {
let difference = abs(date1.timeIntervalSince(date2))
XCTAssertLessThanOrEqual(
difference,
precision,
"Dates are not equal within \(precision)s",
file: file,
line: line
)
}
/// Afirma que un array contiene un número específico de elementos
func assertCount(
_ count: Int,
_ array: [Any],
file: StaticString = #file,
line: UInt = #line
) {
XCTAssertEqual(
array.count,
count,
"Array count mismatch",
file: file,
line: line
)
}
/// Afirma que una colección está vacía
func assertEmpty<T>(
_ collection: T,
file: StaticString = #file,
line: UInt = #line
) where T: Collection {
XCTAssertTrue(
collection.isEmpty,
"Collection should be empty but has \(collection.count) elements",
file: file,
line: line
)
}
/// Afirma que una colección no está vacía
func assertNotEmpty<T>(
_ collection: T,
file: StaticString = #file,
line: UInt = #line
) where T: Collection {
XCTAssertFalse(
collection.isEmpty,
"Collection should not be empty",
file: file,
line: line
)
}
}
// MARK: - Memory Leak Detection
extension XCTestCase {
/// Detecta memory leaks en un objeto
func assertNoMemoryLeak(
_ instance: AnyObject,
file: StaticString = #file,
line: UInt = #line
) {
addTeardownBlock { [weak instance] in
XCTAssertNil(
instance,
"Instance should be deallocated but still exists (potential memory leak)",
file: file,
line: line
)
}
}
}
// MARK: - Test Logging
extension XCTestCase {
/// Registra información de depuración durante los tests
func logTest(_ message: String, level: LogLevel = .info) {
let prefix = "[Test \(level.description)]"
print("\(prefix) \(message)")
#if DEBUG
let testRun = XCTRunLoop.current.currentTestRun
print("Test: \(testRun?.test.name ?? "Unknown") - \(message)")
#endif
}
enum LogLevel {
case info
case warning
case error
var description: String {
switch self {
case .info: return "INFO"
case .warning: return "WARNING"
case .error: return "ERROR"
}
}
}
}
// MARK: - Test Data Cleanup
extension XCTestCase {
/// Limpia todos los UserDefaults
func clearAllUserDefaults() {
let dictionary = UserDefaults.standard.dictionaryRepresentation()
dictionary.keys.forEach { key in
UserDefaults.standard.removeObject(forKey: key)
}
}
/// Limpia todos los archivos en el directorio temporal
func clearTemporaryDirectory() {
let tempDir = FileManager.default.temporaryDirectory
guard let contents = try? FileManager.default.contentsOfDirectory(
at: tempDir,
includingPropertiesForKeys: nil
) else { return }
for file in contents {
try? FileManager.default.removeItem(at: file)
}
}
}
// MARK: - Custom Test Runners
/// Configuración para ejecutar todos los tests
class AllTests {
static func runAllTests() {
print("🧪 Running MangaReader Test Suite")
print("=" * 50)
// Tests de Modelos
print("📦 Running Model Tests...")
// ModelTests se ejecutan automáticamente
// Tests de Storage
print("💾 Running Storage Service Tests...")
// StorageServiceTests se ejecutan automáticamente
// Tests de Scraper
print("🌐 Running Scraper Tests...")
// ManhwaWebScraperTests se ejecutan automáticamente
// Tests de Integración
print("🔗 Running Integration Tests...")
// IntegrationTests se ejecutan automáticamente
print("=" * 50)
print("✅ All tests completed!")
}
}
// MARK: - Test Metrics
extension XCTestCase {
/// Registra métricas de rendimiento
func recordMetric(_ name: String, value: Double, unit: String = "s") {
#if DEBUG
let metric = [
"name": name,
"value": value,
"unit": unit,
"timestamp": Date().timeIntervalSince1970
] as [String : Any]
print("📊 Metric: \(name) = \(value) \(unit)")
#endif
}
/// Compara métricas entre runs
func assertMetricImproved(
_ name: String,
currentValue: Double,
previousValue: Double,
file: StaticString = #file,
line: UInt = #line
) {
let improvement = ((previousValue - currentValue) / previousValue) * 100
XCTAssertGreaterThan(
improvement,
0,
"Metric '\(name)' did not improve. Previous: \(previousValue), Current: \(currentValue)",
file: file,
line: line
)
print("✨ Metric '\(name)' improved by \(String(format: "%.2f", improvement))%")
}
}
// MARK: - String Repetition Helper
extension String {
static func * (left: String, right: Int) -> String {
guard right > 0 else { return "" }
return String(repeating: left, count: right)
}
}
// MARK: - Test Documentation
/*
Guía de Ejecución de Tests:
1. Ejecutar todos los tests:
- Cmd + U en Xcode
- O seleccionar Product > Test
2. Ejecutar tests específicos:
- Click derecho en el test específico > Run
- Usar el Test Navigator (Cmd + 6)
3. Ejecutar tests con cobertura:
- Edit Scheme > Test > Options > Gather coverage
- Cmd + U
4. Tests Performance:
- Los tests de performance se marcan con "measure"
- Se ejecutan 10 veces por defecto
- Resultados en el Report Navigator
Estructura de Tests:
- ModelTests: Pruebas para modelos de datos (Manga, Chapter, etc.)
- StorageServiceTests: Pruebas para almacenamiento local
- ManhwaWebScraperTests: Pruebas para el web scraper
- IntegrationTests: Pruebas de integración completa
Mejores Prácticas:
1. Cada test debe ser independiente
2. Los tests deben poder ejecutarse en cualquier orden
3. Usar setUp/tearDown para limpieza
4. Usar nombres descriptivos para los tests
5. Un assert por test (cuando sea posible)
6. Mock de dependencias externas
7. Evitar llamadas de red reales en tests unitarios
Marcas de Tests:
- @MainActor: Tests que requieren MainActor
- async: Tests asíncronos
- throws: Tests que pueden lanzar errores
*/
// MARK: - Custom Test Constraints
#if DEBUG
/// Contenedor para configuración de tests
struct TestConfiguration {
static var isRunningTests: Bool {
return NSClassFromString("XCTest") != nil
}
static var testTimeout: TimeInterval = 10.0
static var useMockData: Bool = true
static var verboseLogging: Bool = true
}
#endif

255
ios-app/Tests/run_tests.sh Executable file
View File

@@ -0,0 +1,255 @@
#!/bin/bash
# Script para ejecutar los tests de MangaReader
# Usage: ./run_tests.sh [options]
#
# Options:
# --all Ejecutar todos los tests (default)
# --unit Solo tests unitarios
# --integration Solo tests de integración
# --coverage Ejecutar con cobertura de código
# --verbose Salida detallada
# --clean Limpiar build antes de testear
set -e
# Colores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Variables
PROJECT_DIR="/home/ren/ios/MangaReader/ios-app"
SCHEME="MangaReader"
DESTINATION="platform=iOS Simulator,name=iPhone 15"
TEST_TYPE="all"
COVERAGE="NO"
VERBOSE=""
# Funciones de logging
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Banner
print_banner() {
echo -e "${BLUE}"
echo "╔════════════════════════════════════════╗"
echo "║ MangaReader Test Suite Runner ║"
echo "╚════════════════════════════════════════╝"
echo -e "${NC}"
}
# Parsear argumentos
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
--all)
TEST_TYPE="all"
shift
;;
--unit)
TEST_TYPE="unit"
shift
;;
--integration)
TEST_TYPE="integration"
shift
;;
--coverage)
COVERAGE="YES"
shift
;;
--verbose)
VERBOSE="-verbose"
shift
;;
--clean)
log_info "Limpiando build..."
clean_build
shift
;;
--help)
show_help
exit 0
;;
*)
log_error "Opción desconocida: $1"
show_help
exit 1
;;
esac
done
}
# Mostrar ayuda
show_help() {
cat << EOF
Usage: ./run_tests.sh [options]
Options:
--all Ejecutar todos los tests (default)
--unit Solo tests unitarios
--integration Solo tests de integración
--coverage Ejecutar con cobertura de código
--verbose Salida detallada
--clean Limpiar build antes de testear
--help Mostrar esta ayuda
Examples:
./run_tests.sh --all --coverage
./run_tests.sh --unit --verbose
./run_tests.sh --clean --coverage
EOF
}
# Limpiar build
clean_build() {
cd "$PROJECT_DIR"
xcodebuild clean -scheme "$SCHEME" 2>&1 | grep -E "error|warning|clean"
}
# Ejecutar todos los tests
run_all_tests() {
log_info "Ejecutando todos los tests..."
xcodebuild test \
-scheme "$SCHEME" \
-destination "$DESTINATION" \
-enableCodeCoverage "$COVERAGE" \
$VERBOSE
}
# Ejecutar solo tests unitarios
run_unit_tests() {
log_info "Ejecutando tests unitarios..."
xcodebuild test \
-scheme "$SCHEME" \
-destination "$DESTINATION" \
-only-testing:MangaReaderTests/ModelTests \
-only-testing:MangaReaderTests/StorageServiceTests \
-only-testing:MangaReaderTests/ManhwaWebScraperTests \
-enableCodeCoverage "$COVERAGE" \
$VERBOSE
}
# Ejecutar solo tests de integración
run_integration_tests() {
log_info "Ejecutando tests de integración..."
xcodebuild test \
-scheme "$SCHEME" \
-destination "$DESTINATION" \
-only-testing:MangaReaderTests/IntegrationTests \
-enableCodeCoverage "$COVERAGE" \
$VERBOSE
}
# Generar reporte de cobertura
generate_coverage_report() {
if [ "$COVERAGE" = "YES" ]; then
log_info "Generando reporte de cobertura..."
# Buscar el archivo de cobertura más reciente
COVERAGE_FILE=$(find ~/Library/Developer/Xcode/DerivedData -name "*.profdata" -print0 | xargs -0 ls -t | head -n1)
if [ -n "$COVERAGE_FILE" ]; then
log_success "Archivo de cobertura: $COVERAGE_FILE"
# Generar reporte HTML (requiere xcrun)
# xcrun llvm-cov report "$COVERAGE_FILE" > coverage_report.txt
log_success "Reporte de cobertura generado"
else
log_warning "No se encontró archivo de cobertura"
fi
fi
}
# Verificar resultado del test
check_test_result() {
if [ $? -eq 0 ]; then
log_success "Todos los tests pasaron ✓"
if [ "$COVERAGE" = "YES" ]; then
generate_coverage_report
fi
echo ""
log_info "Resumen:"
echo " - Tests ejecutados: $TEST_TYPE"
echo " - Cobertura: $COVERAGE"
return 0
else
log_error "Algunos tests fallaron ✗"
return 1
fi
}
# Verificar dependencias
check_dependencies() {
log_info "Verificando dependencias..."
if ! command -v xcodebuild &> /dev/null; then
log_error "xcodebuild no encontrado. Asegúrate de tener Xcode instalado."
exit 1
fi
if [ ! -d "$PROJECT_DIR" ]; then
log_error "Directorio del proyecto no encontrado: $PROJECT_DIR"
exit 1
fi
log_success "Dependencias OK"
}
# Main
main() {
print_banner
parse_args "$@"
check_dependencies
echo ""
log_info "Configuración:"
echo " - Proyecto: $PROJECT_DIR"
echo " - Scheme: $SCHEME"
echo " - Destination: $DESTINATION"
echo " - Test Type: $TEST_TYPE"
echo " - Coverage: $COVERAGE"
echo ""
# Ejecutar tests según tipo
case $TEST_TYPE in
all)
run_all_tests
;;
unit)
run_unit_tests
;;
integration)
run_integration_tests
;;
esac
check_test_result
}
# Ejecutar
main "$@"