|
|
@@ -0,0 +1,448 @@
|
|
|
+"""
|
|
|
+Unit tests for uploader module.
|
|
|
+"""
|
|
|
+
|
|
|
+import pytest
|
|
|
+from datetime import datetime
|
|
|
+
|
|
|
+from src.uploader import (
|
|
|
+ CUCalculator,
|
|
|
+ ContentChecker,
|
|
|
+ MockUploadClient,
|
|
|
+ UploadManager,
|
|
|
+ create_mock_upload_manager,
|
|
|
+ ChapterUploadData,
|
|
|
+ UploadResult,
|
|
|
+ UploadStatus,
|
|
|
+ CheckStatus,
|
|
|
+ QuotaInfo
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+class TestCUCalculator:
|
|
|
+ """Test suite for CUCalculator."""
|
|
|
+
|
|
|
+ def test_calculate_cu_basic(self):
|
|
|
+ """Test basic CU calculation."""
|
|
|
+ calc = CUCalculator()
|
|
|
+
|
|
|
+ # Minimum CU (1-100 chars = 1 CU)
|
|
|
+ assert calc.calculate_cu("A" * 50) == 1
|
|
|
+ assert calc.calculate_cu("A" * 100) == 1
|
|
|
+
|
|
|
+ # Second CU (101-200 chars = 2 CU)
|
|
|
+ assert calc.calculate_cu("A" * 101) == 2
|
|
|
+ assert calc.calculate_cu("A" * 200) == 2
|
|
|
+
|
|
|
+ # Third CU (201-300 chars = 3 CU)
|
|
|
+ assert calc.calculate_cu("A" * 201) == 3
|
|
|
+ assert calc.calculate_cu("A" * 300) == 3
|
|
|
+
|
|
|
+ def test_calculate_cu_examples(self):
|
|
|
+ """Test examples from the spec."""
|
|
|
+ calc = CUCalculator()
|
|
|
+
|
|
|
+ # "123字" → 2 CU (123/100=1.23→向上取整2)
|
|
|
+ assert calc.calculate_cu("A" * 123) == 2
|
|
|
+
|
|
|
+ # "300字" → 3 CU
|
|
|
+ assert calc.calculate_cu("A" * 300) == 3
|
|
|
+
|
|
|
+ # "99字" → 1 CU
|
|
|
+ assert calc.calculate_cu("A" * 99) == 1
|
|
|
+
|
|
|
+ def test_calculate_cu_empty(self):
|
|
|
+ """Test CU calculation for empty content."""
|
|
|
+ calc = CUCalculator()
|
|
|
+ assert calc.calculate_cu("") == 0
|
|
|
+
|
|
|
+ def test_calculate_cu_from_count(self):
|
|
|
+ """Test CU calculation from character count."""
|
|
|
+ calc = CUCalculator()
|
|
|
+ assert calc.calculate_cu_from_count(0) == 0
|
|
|
+ assert calc.calculate_cu_from_count(150) == 2
|
|
|
+ assert calc.calculate_cu_from_count(350) == 4
|
|
|
+
|
|
|
+ def test_calculate_batch(self):
|
|
|
+ """Test batch CU calculation."""
|
|
|
+ calc = CUCalculator()
|
|
|
+
|
|
|
+ # (50/100→1) + (150/100→2) = 3
|
|
|
+ result = calc.calculate_batch(["A" * 50, "A" * 150])
|
|
|
+ assert result == 3
|
|
|
+
|
|
|
+ def test_estimate_cu(self):
|
|
|
+ """Test CU estimation."""
|
|
|
+ calc = CUCalculator()
|
|
|
+
|
|
|
+ # 10 chapters × 150 chars each = 10 × 2 CU = 20 CU
|
|
|
+ result = calc.estimate_cu(10, 150)
|
|
|
+ assert result == 20
|
|
|
+
|
|
|
+ def test_get_breakdown(self):
|
|
|
+ """Test CU calculation breakdown."""
|
|
|
+ calc = CUCalculator()
|
|
|
+ breakdown = calc.get_breakdown("A" * 123)
|
|
|
+
|
|
|
+ assert breakdown["character_count"] == 123
|
|
|
+ assert breakdown["cu_cost"] == 2
|
|
|
+ assert breakdown["efficiency_percentage"] == pytest.approx(61.5, 0.1)
|
|
|
+ assert breakdown["remaining_characters_in_cu"] == 77
|
|
|
+
|
|
|
+ def test_reverse_calculate_max_chars(self):
|
|
|
+ """Test reverse calculation of max chars."""
|
|
|
+ calc = CUCalculator()
|
|
|
+
|
|
|
+ assert calc.reverse_calculate_max_chars(1) == 100
|
|
|
+ assert calc.reverse_calculate_max_chars(5) == 500
|
|
|
+
|
|
|
+ def test_calculate_cost_summary(self):
|
|
|
+ """Test cost summary calculation."""
|
|
|
+ calc = CUCalculator()
|
|
|
+
|
|
|
+ contents = ["A" * 50, "A" * 150, "A" * 250]
|
|
|
+ summary = calc.calculate_cost_summary(contents)
|
|
|
+
|
|
|
+ assert summary["total_chapters"] == 3
|
|
|
+ assert summary["total_cu"] == 6 # 1 + 2 + 3 = 6
|
|
|
+ assert summary["min_cu"] == 1
|
|
|
+ assert summary["max_cu"] == 3
|
|
|
+
|
|
|
+ def test_custom_divisor(self):
|
|
|
+ """Test calculator with custom divisor."""
|
|
|
+ calc = CUCalculator(divisor=50)
|
|
|
+
|
|
|
+ # With divisor=50: 1-50=1 CU, 51-100=2 CU
|
|
|
+ assert calc.calculate_cu("A" * 25) == 1
|
|
|
+ assert calc.calculate_cu("A" * 50) == 1
|
|
|
+ assert calc.calculate_cu("A" * 51) == 2
|
|
|
+ assert calc.calculate_cu("A" * 100) == 2
|
|
|
+
|
|
|
+
|
|
|
+class TestContentChecker:
|
|
|
+ """Test suite for ContentChecker."""
|
|
|
+
|
|
|
+ def test_check_normal_content(self):
|
|
|
+ """Test checking normal, compliant content."""
|
|
|
+ checker = ContentChecker()
|
|
|
+ result = checker.check_chapter("Test Title", "This is normal content.")
|
|
|
+
|
|
|
+ assert result.passed is True
|
|
|
+ assert result.status == CheckStatus.PASSED
|
|
|
+ assert len(result.issues) == 0
|
|
|
+
|
|
|
+ def test_check_empty_title(self):
|
|
|
+ """Test checking chapter with empty title."""
|
|
|
+ checker = ContentChecker()
|
|
|
+ result = checker.check_chapter("", "Content here")
|
|
|
+
|
|
|
+ assert result.passed is False
|
|
|
+ assert len(result.issues) > 0
|
|
|
+ assert result.issues[0].type == "empty_title"
|
|
|
+
|
|
|
+ def test_check_sensitive_words(self):
|
|
|
+ """Test checking for sensitive words."""
|
|
|
+ checker = ContentChecker()
|
|
|
+ result = checker.check_chapter("Normal Title", "This contains 暴力 content.")
|
|
|
+
|
|
|
+ assert result.passed is False
|
|
|
+ assert any(i.type == "sensitive_word" for i in result.issues)
|
|
|
+
|
|
|
+ def test_check_content_too_short(self):
|
|
|
+ """Test checking content that's too short."""
|
|
|
+ checker = ContentChecker(min_chapter_length=50)
|
|
|
+ result = checker.check_chapter("Title", "Short")
|
|
|
+
|
|
|
+ assert result.passed is False
|
|
|
+ assert any(i.type == "content_too_short" for i in result.issues)
|
|
|
+
|
|
|
+ def test_check_content_too_long(self):
|
|
|
+ """Test checking content that's too long."""
|
|
|
+ checker = ContentChecker(max_chapter_length=100)
|
|
|
+ result = checker.check_chapter("Title", "A" * 101)
|
|
|
+
|
|
|
+ assert result.passed is False
|
|
|
+ assert any(i.type == "content_too_long" for i in result.issues)
|
|
|
+
|
|
|
+ def test_check_placeholders(self):
|
|
|
+ """Test checking for unreplaced placeholders."""
|
|
|
+ checker = ContentChecker()
|
|
|
+ result = checker.check_chapter("Title", "This has [TERM_test] placeholder.")
|
|
|
+
|
|
|
+ # Bracket-style placeholders are checked
|
|
|
+ assert len(result.warnings) > 0 or len(result.issues) > 0
|
|
|
+
|
|
|
+ def test_check_strict_mode(self):
|
|
|
+ """Test strict mode where warnings become errors."""
|
|
|
+ checker = ContentChecker()
|
|
|
+ result = checker.check_chapter("Title", "This has [TERM_test] placeholder.", strict_mode=True)
|
|
|
+
|
|
|
+ # In strict mode, warnings become errors
|
|
|
+ assert result.passed is False or len(result.issues) > 0
|
|
|
+
|
|
|
+ def test_check_all_chapters(self):
|
|
|
+ """Test checking multiple chapters."""
|
|
|
+ checker = ContentChecker()
|
|
|
+
|
|
|
+ chapters = [
|
|
|
+ {"title": "Chapter 1", "content": "Normal content."},
|
|
|
+ {"title": "Chapter 2", "content": "Content with 暴力."}
|
|
|
+ ]
|
|
|
+
|
|
|
+ results = checker.check_all_chapters(chapters)
|
|
|
+
|
|
|
+ assert len(results) == 2
|
|
|
+ assert results["ch_0"].passed is True
|
|
|
+ assert results["ch_1"].passed is False
|
|
|
+
|
|
|
+ def test_add_sensitive_word(self):
|
|
|
+ """Test adding custom sensitive word."""
|
|
|
+ checker = ContentChecker()
|
|
|
+ checker.add_sensitive_word("testword")
|
|
|
+
|
|
|
+ assert "testword" in checker.sensitive_words
|
|
|
+
|
|
|
+ def test_remove_sensitive_word(self):
|
|
|
+ """Test removing sensitive word."""
|
|
|
+ checker = ContentChecker()
|
|
|
+ checker.remove_sensitive_word("暴力")
|
|
|
+
|
|
|
+ assert "暴力" not in checker.sensitive_words
|
|
|
+
|
|
|
+ def test_set_sensitive_words(self):
|
|
|
+ """Test replacing sensitive words list."""
|
|
|
+ checker = ContentChecker()
|
|
|
+ checker.set_sensitive_words({"custom1", "custom2"})
|
|
|
+
|
|
|
+ assert "custom1" in checker.sensitive_words
|
|
|
+ assert "暴力" not in checker.sensitive_words
|
|
|
+
|
|
|
+
|
|
|
+class TestMockUploadClient:
|
|
|
+ """Test suite for MockUploadClient."""
|
|
|
+
|
|
|
+ def test_upload_single_chapter(self):
|
|
|
+ """Test uploading a single chapter."""
|
|
|
+ client = MockUploadClient()
|
|
|
+
|
|
|
+ result = client.upload_chapter(
|
|
|
+ "work123",
|
|
|
+ {
|
|
|
+ "title": "Chapter 1",
|
|
|
+ "content": "A" * 150,
|
|
|
+ "index": 0
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result.success is True
|
|
|
+ assert result.cu_consumed == 2 # 150/100 → 2 CU
|
|
|
+
|
|
|
+ def test_batch_upload(self):
|
|
|
+ """Test batch upload."""
|
|
|
+ client = MockUploadClient()
|
|
|
+
|
|
|
+ chapters = [
|
|
|
+ {"title": "Ch1", "content": "A" * 50, "index": 0},
|
|
|
+ {"title": "Ch2", "content": "A" * 150, "index": 1}
|
|
|
+ ]
|
|
|
+
|
|
|
+ result = client.batch_upload("work123", chapters)
|
|
|
+
|
|
|
+ assert result.total_chapters == 2
|
|
|
+ assert result.successful == 2
|
|
|
+ assert result.failed == 0
|
|
|
+ assert result.status == UploadStatus.COMPLETED
|
|
|
+
|
|
|
+ def test_check_quota(self):
|
|
|
+ """Test quota checking."""
|
|
|
+ client = MockUploadClient()
|
|
|
+ quota = client.check_quota()
|
|
|
+
|
|
|
+ assert quota.total_cu > 0
|
|
|
+ assert quota.remaining_cu > 0
|
|
|
+
|
|
|
+ def test_health_check(self):
|
|
|
+ """Test health check."""
|
|
|
+ client = MockUploadClient()
|
|
|
+ assert client.health_check() is True
|
|
|
+
|
|
|
+
|
|
|
+class TestUploadManager:
|
|
|
+ """Test suite for UploadManager."""
|
|
|
+
|
|
|
+ def test_upload_workflow_dry_run(self):
|
|
|
+ """Test upload workflow in dry run mode."""
|
|
|
+ mock_client = MockUploadClient()
|
|
|
+ manager = UploadManager(mock_client, dry_run=True)
|
|
|
+
|
|
|
+ chapters = [
|
|
|
+ {"title": "Chapter 1", "content": "This is chapter one.", "index": 0},
|
|
|
+ {"title": "Chapter 2", "content": "This is chapter two.", "index": 1}
|
|
|
+ ]
|
|
|
+
|
|
|
+ result = manager.upload_workflow("work123", chapters)
|
|
|
+
|
|
|
+ assert result.status == UploadStatus.COMPLETED
|
|
|
+ assert len(result.upload_results) == 2
|
|
|
+
|
|
|
+ def test_upload_workflow_with_content_check_fail(self):
|
|
|
+ """Test workflow with content check failure."""
|
|
|
+ mock_client = MockUploadClient()
|
|
|
+ manager = UploadManager(mock_client, strict_mode=True)
|
|
|
+
|
|
|
+ # Content with sensitive word will fail
|
|
|
+ chapters = [
|
|
|
+ {"title": "", "content": "Content with empty title causes failure", "index": 0}
|
|
|
+ ]
|
|
|
+
|
|
|
+ result = manager.upload_workflow("work123", chapters)
|
|
|
+
|
|
|
+ # Empty title causes content check failure in strict mode
|
|
|
+ assert result.status == UploadStatus.FAILED or len(result.upload_results) == 0
|
|
|
+
|
|
|
+ def test_estimate_cu(self):
|
|
|
+ """Test CU estimation."""
|
|
|
+ mock_client = MockUploadClient()
|
|
|
+ manager = UploadManager(mock_client)
|
|
|
+
|
|
|
+ chapters = [
|
|
|
+ {"content": "A" * 50},
|
|
|
+ {"content": "A" * 150}
|
|
|
+ ]
|
|
|
+
|
|
|
+ estimate = manager.estimate_cu(chapters)
|
|
|
+
|
|
|
+ assert estimate["total_cu"] == 3 # 1 + 2
|
|
|
+
|
|
|
+ def test_preview_upload(self):
|
|
|
+ """Test upload preview."""
|
|
|
+ mock_client = MockUploadClient()
|
|
|
+ manager = UploadManager(mock_client)
|
|
|
+
|
|
|
+ chapters = [
|
|
|
+ {"title": "Ch1", "content": "Content 1"},
|
|
|
+ {"title": "Ch2", "content": "Content 2"}
|
|
|
+ ]
|
|
|
+
|
|
|
+ preview = manager.preview_upload("work123", chapters)
|
|
|
+
|
|
|
+ assert preview["total_chapters"] == 2
|
|
|
+ assert "content_check" in preview
|
|
|
+ assert "cu_estimate" in preview
|
|
|
+
|
|
|
+ def test_validate_before_upload(self):
|
|
|
+ """Test pre-upload validation."""
|
|
|
+ mock_client = MockUploadClient()
|
|
|
+ manager = UploadManager(mock_client)
|
|
|
+
|
|
|
+ # Invalid chapters (missing title, empty content)
|
|
|
+ chapters = [
|
|
|
+ {"content": "Content without title"},
|
|
|
+ {"title": "Title without content", "content": ""}
|
|
|
+ ]
|
|
|
+
|
|
|
+ validation = manager.validate_before_upload(chapters)
|
|
|
+
|
|
|
+ # Should have errors or warnings
|
|
|
+ assert validation["has_errors"] is True or validation["has_warnings"] is True
|
|
|
+ assert len(validation["errors"]) + len(validation["warnings"]) >= 2
|
|
|
+
|
|
|
+ def test_check_quota(self):
|
|
|
+ """Test checking quota through manager."""
|
|
|
+ mock_client = MockUploadClient()
|
|
|
+ manager = UploadManager(mock_client)
|
|
|
+
|
|
|
+ quota_info = manager.check_quota()
|
|
|
+
|
|
|
+ assert "total_cu" in quota_info
|
|
|
+ assert "remaining_cu" in quota_info
|
|
|
+
|
|
|
+
|
|
|
+class TestChapterUploadData:
|
|
|
+ """Test suite for ChapterUploadData model."""
|
|
|
+
|
|
|
+ def test_content_length(self):
|
|
|
+ """Test content_length property."""
|
|
|
+ data = ChapterUploadData(
|
|
|
+ "ch1",
|
|
|
+ "work1",
|
|
|
+ "Title",
|
|
|
+ "Content here",
|
|
|
+ 0
|
|
|
+ )
|
|
|
+
|
|
|
+ assert data.content_length == 12
|
|
|
+
|
|
|
+ def test_word_count(self):
|
|
|
+ """Test word_count property."""
|
|
|
+ data = ChapterUploadData(
|
|
|
+ "ch1",
|
|
|
+ "work1",
|
|
|
+ "Title",
|
|
|
+ "Content here",
|
|
|
+ 0
|
|
|
+ )
|
|
|
+
|
|
|
+ # word_count removes newlines and spaces
|
|
|
+ assert data.word_count > 0 # Should count non-whitespace characters
|
|
|
+
|
|
|
+
|
|
|
+class TestUploadResult:
|
|
|
+ """Test suite for UploadResult model."""
|
|
|
+
|
|
|
+ def test_success_result(self):
|
|
|
+ """Test successful upload result."""
|
|
|
+ result = UploadResult(
|
|
|
+ success=True,
|
|
|
+ chapter_id="ch1",
|
|
|
+ server_chapter_id="srv_ch1",
|
|
|
+ cu_consumed=2
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result.success is True
|
|
|
+ assert result.cu_consumed == 2
|
|
|
+
|
|
|
+ def test_failed_result(self):
|
|
|
+ """Test failed upload result."""
|
|
|
+ result = UploadResult(
|
|
|
+ success=False,
|
|
|
+ chapter_id="ch1",
|
|
|
+ error_message="Upload failed"
|
|
|
+ )
|
|
|
+
|
|
|
+ assert result.success is False
|
|
|
+ assert result.error_message == "Upload failed"
|
|
|
+
|
|
|
+
|
|
|
+class TestQuotaInfo:
|
|
|
+ """Test suite for QuotaInfo model."""
|
|
|
+
|
|
|
+ def test_quota_calculations(self):
|
|
|
+ """Test quota calculation properties."""
|
|
|
+ quota = QuotaInfo(
|
|
|
+ total_cu=1000,
|
|
|
+ used_cu=300,
|
|
|
+ remaining_cu=700
|
|
|
+ )
|
|
|
+
|
|
|
+ assert quota.usage_percentage == 0.3
|
|
|
+ assert quota.is_exceeded is False
|
|
|
+
|
|
|
+ def test_quota_exceeded(self):
|
|
|
+ """Test exceeded quota."""
|
|
|
+ quota = QuotaInfo(
|
|
|
+ total_cu=1000,
|
|
|
+ used_cu=1000,
|
|
|
+ remaining_cu=0
|
|
|
+ )
|
|
|
+
|
|
|
+ assert quota.is_exceeded is True
|
|
|
+
|
|
|
+
|
|
|
+def test_create_mock_upload_manager():
|
|
|
+ """Test factory function for mock upload manager."""
|
|
|
+ manager = create_mock_upload_manager()
|
|
|
+
|
|
|
+ assert isinstance(manager, UploadManager)
|
|
|
+ # Check that it uses a mock client (has upload_count attribute)
|
|
|
+ assert hasattr(manager.client, "upload_count")
|