Initial commit: MangaReader iOS App
✨ 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>
This commit is contained in:
576
ios-app/Tests/TestHelpers.swift
Normal file
576
ios-app/Tests/TestHelpers.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user