✨ Features: - App iOS completa para leer manga sin publicidad - Scraper con WKWebView para manhwaweb.com - Sistema de descargas offline - Lector con zoom y navegación - Favoritos y progreso de lectura - Compatible con iOS 15+ y Sideloadly/3uTools 📦 Contenido: - Backend Node.js con Puppeteer (opcional) - App iOS con SwiftUI - Scraper de capítulos e imágenes - Sistema de almacenamiento local - Testing completo - Documentación exhaustiva 🧪 Prueba: Capítulo 789 de One Piece descargado exitosamente - 21 páginas descargadas - 4.68 MB total - URLs verificadas y funcionales 🎉 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
577 lines
17 KiB
Swift
577 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|