fishertools 0.2.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. fishertools/__init__.py +16 -5
  2. fishertools/errors/__init__.py +11 -3
  3. fishertools/errors/exception_types.py +282 -0
  4. fishertools/errors/explainer.py +87 -1
  5. fishertools/errors/models.py +73 -1
  6. fishertools/errors/patterns.py +40 -0
  7. fishertools/examples/cli_example.py +156 -0
  8. fishertools/examples/learn_example.py +65 -0
  9. fishertools/examples/logger_example.py +176 -0
  10. fishertools/examples/menu_example.py +101 -0
  11. fishertools/examples/storage_example.py +175 -0
  12. fishertools/input_utils.py +185 -0
  13. fishertools/learn/__init__.py +19 -2
  14. fishertools/learn/examples.py +88 -1
  15. fishertools/learn/knowledge_engine.py +321 -0
  16. fishertools/learn/repl/__init__.py +19 -0
  17. fishertools/learn/repl/cli.py +31 -0
  18. fishertools/learn/repl/code_sandbox.py +229 -0
  19. fishertools/learn/repl/command_handler.py +544 -0
  20. fishertools/learn/repl/command_parser.py +165 -0
  21. fishertools/learn/repl/engine.py +479 -0
  22. fishertools/learn/repl/models.py +121 -0
  23. fishertools/learn/repl/session_manager.py +284 -0
  24. fishertools/learn/repl/test_code_sandbox.py +261 -0
  25. fishertools/learn/repl/test_code_sandbox_pbt.py +148 -0
  26. fishertools/learn/repl/test_command_handler.py +224 -0
  27. fishertools/learn/repl/test_command_handler_pbt.py +189 -0
  28. fishertools/learn/repl/test_command_parser.py +160 -0
  29. fishertools/learn/repl/test_command_parser_pbt.py +100 -0
  30. fishertools/learn/repl/test_engine.py +190 -0
  31. fishertools/learn/repl/test_session_manager.py +310 -0
  32. fishertools/learn/repl/test_session_manager_pbt.py +182 -0
  33. fishertools/learn/test_knowledge_engine.py +241 -0
  34. fishertools/learn/test_knowledge_engine_pbt.py +180 -0
  35. fishertools/patterns/__init__.py +46 -0
  36. fishertools/patterns/cli.py +175 -0
  37. fishertools/patterns/logger.py +140 -0
  38. fishertools/patterns/menu.py +99 -0
  39. fishertools/patterns/storage.py +127 -0
  40. fishertools/readme_transformer.py +631 -0
  41. fishertools/safe/__init__.py +6 -1
  42. fishertools/safe/files.py +329 -1
  43. fishertools/transform_readme.py +105 -0
  44. fishertools-0.4.0.dist-info/METADATA +104 -0
  45. fishertools-0.4.0.dist-info/RECORD +131 -0
  46. {fishertools-0.2.1.dist-info → fishertools-0.4.0.dist-info}/WHEEL +1 -1
  47. tests/test_documentation_properties.py +329 -0
  48. tests/test_documentation_structure.py +349 -0
  49. tests/test_errors/test_exception_types.py +446 -0
  50. tests/test_errors/test_exception_types_pbt.py +333 -0
  51. tests/test_errors/test_patterns.py +52 -0
  52. tests/test_input_utils/__init__.py +1 -0
  53. tests/test_input_utils/test_input_utils.py +65 -0
  54. tests/test_learn/test_examples.py +179 -1
  55. tests/test_learn/test_explain_properties.py +307 -0
  56. tests/test_patterns_cli.py +611 -0
  57. tests/test_patterns_docstrings.py +473 -0
  58. tests/test_patterns_logger.py +465 -0
  59. tests/test_patterns_menu.py +440 -0
  60. tests/test_patterns_storage.py +447 -0
  61. tests/test_readme_enhancements_v0_3_1.py +2036 -0
  62. tests/test_readme_transformer/__init__.py +1 -0
  63. tests/test_readme_transformer/test_readme_infrastructure.py +1023 -0
  64. tests/test_readme_transformer/test_transform_readme_integration.py +431 -0
  65. tests/test_safe/test_files.py +726 -1
  66. fishertools-0.2.1.dist-info/METADATA +0 -256
  67. fishertools-0.2.1.dist-info/RECORD +0 -81
  68. {fishertools-0.2.1.dist-info → fishertools-0.4.0.dist-info}/licenses/LICENSE +0 -0
  69. {fishertools-0.2.1.dist-info → fishertools-0.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1023 @@
1
+ """
2
+ Tests for README transformation infrastructure.
3
+
4
+ Tests the core components: ReadmeParser, BackupManager, ReadmeStructure,
5
+ and ReadmeTransformer.
6
+ """
7
+
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ import pytest
13
+
14
+ from fishertools.readme_transformer import (
15
+ BackupManager,
16
+ FeatureEntry,
17
+ ReadmeParser,
18
+ ReadmeSection,
19
+ ReadmeStructure,
20
+ ReadmeTransformer,
21
+ )
22
+
23
+
24
+ class TestReadmeParser:
25
+ """Tests for ReadmeParser class."""
26
+
27
+ def test_read_file_success(self, tmp_path: Path) -> None:
28
+ """Test successful reading of README file."""
29
+ readme_path = tmp_path / "README.md"
30
+ test_content = "# Test README\n\nThis is a test."
31
+ readme_path.write_text(test_content, encoding="utf-8")
32
+
33
+ parser = ReadmeParser(str(readme_path))
34
+ content = parser.read_file()
35
+
36
+ assert content == test_content
37
+
38
+ def test_read_file_not_found(self, tmp_path: Path) -> None:
39
+ """Test error when README file does not exist."""
40
+ readme_path = tmp_path / "nonexistent.md"
41
+
42
+ parser = ReadmeParser(str(readme_path))
43
+
44
+ with pytest.raises(FileNotFoundError):
45
+ parser.read_file()
46
+
47
+ def test_extract_first_sentence(self, tmp_path: Path) -> None:
48
+ """Test extraction of first introductory sentence."""
49
+ readme_path = tmp_path / "README.md"
50
+ test_content = "First sentence here\n\n# Heading\n\nMore content"
51
+ readme_path.write_text(test_content, encoding="utf-8")
52
+
53
+ parser = ReadmeParser(str(readme_path))
54
+ parser.read_file()
55
+ first_sentence = parser.extract_first_sentence()
56
+
57
+ assert first_sentence == "First sentence here"
58
+
59
+ def test_extract_first_sentence_with_heading(self, tmp_path: Path) -> None:
60
+ """Test extraction skips headings to find first sentence."""
61
+ readme_path = tmp_path / "README.md"
62
+ test_content = "# Title\n\nFirst real sentence\n\nMore content"
63
+ readme_path.write_text(test_content, encoding="utf-8")
64
+
65
+ parser = ReadmeParser(str(readme_path))
66
+ parser.read_file()
67
+ first_sentence = parser.extract_first_sentence()
68
+
69
+ assert first_sentence == "First real sentence"
70
+
71
+ def test_parse_sections(self, tmp_path: Path) -> None:
72
+ """Test parsing of README sections."""
73
+ readme_path = tmp_path / "README.md"
74
+ test_content = "# Heading 1\n\nContent 1\n\n## Heading 2\n\nContent 2"
75
+ readme_path.write_text(test_content, encoding="utf-8")
76
+
77
+ parser = ReadmeParser(str(readme_path))
78
+ parser.read_file()
79
+ sections = parser.parse_sections()
80
+
81
+ assert len(sections) >= 2
82
+ assert any(s.title == "Heading 1" for s in sections)
83
+ assert any(s.title == "Heading 2" for s in sections)
84
+
85
+ def test_parse_sections_preserves_content(self, tmp_path: Path) -> None:
86
+ """Test that parsing preserves section content."""
87
+ readme_path = tmp_path / "README.md"
88
+ test_content = "# Section\n\nThis is content\nWith multiple lines"
89
+ readme_path.write_text(test_content, encoding="utf-8")
90
+
91
+ parser = ReadmeParser(str(readme_path))
92
+ parser.read_file()
93
+ sections = parser.parse_sections()
94
+
95
+ assert any("This is content" in s.content for s in sections)
96
+
97
+ def test_extract_detailed_content(self, tmp_path: Path) -> None:
98
+ """Test extraction of detailed content after introduction."""
99
+ readme_path = tmp_path / "README.md"
100
+ test_content = "Introduction line\n\n# Section\n\nDetailed content here"
101
+ readme_path.write_text(test_content, encoding="utf-8")
102
+
103
+ parser = ReadmeParser(str(readme_path))
104
+ parser.read_file()
105
+ detailed = parser.extract_detailed_content()
106
+
107
+ assert "Detailed content here" in detailed
108
+ assert "Introduction line" not in detailed or detailed.count("Introduction") == 0
109
+
110
+ def test_identify_introduction_boundary(self, tmp_path: Path) -> None:
111
+ """Test identification of introduction boundary."""
112
+ readme_path = tmp_path / "README.md"
113
+ test_content = "First intro\n\n# Heading\n\nContent"
114
+ readme_path.write_text(test_content, encoding="utf-8")
115
+
116
+ parser = ReadmeParser(str(readme_path))
117
+ parser.read_file()
118
+ boundary = parser.identify_introduction_boundary()
119
+
120
+ assert boundary > 0
121
+
122
+ def test_identify_feature_list_section(self, tmp_path: Path) -> None:
123
+ """Test identification of feature list section."""
124
+ readme_path = tmp_path / "README.md"
125
+ test_content = "Intro\n\n## Основные возможности\n\nFeature 1\nFeature 2\n\n## Other"
126
+ readme_path.write_text(test_content, encoding="utf-8")
127
+
128
+ parser = ReadmeParser(str(readme_path))
129
+ parser.read_file()
130
+ feature_section = parser.identify_feature_list_section()
131
+
132
+ assert feature_section is not None
133
+ assert feature_section[0] >= 0
134
+ assert feature_section[1] > feature_section[0]
135
+
136
+
137
+ class TestBackupManager:
138
+ """Tests for BackupManager class."""
139
+
140
+ def test_create_backup_success(self, tmp_path: Path) -> None:
141
+ """Test successful backup creation."""
142
+ readme_path = tmp_path / "README.md"
143
+ readme_path.write_text("Test content", encoding="utf-8")
144
+
145
+ backup_manager = BackupManager(str(readme_path), str(tmp_path / "backups"))
146
+ backup_path = backup_manager.create_backup()
147
+
148
+ assert backup_path.exists()
149
+ assert backup_path.read_text(encoding="utf-8") == "Test content"
150
+
151
+ def test_create_backup_file_not_found(self, tmp_path: Path) -> None:
152
+ """Test error when README file does not exist."""
153
+ readme_path = tmp_path / "nonexistent.md"
154
+
155
+ backup_manager = BackupManager(str(readme_path), str(tmp_path / "backups"))
156
+
157
+ with pytest.raises(FileNotFoundError):
158
+ backup_manager.create_backup()
159
+
160
+ def test_list_backups(self, tmp_path: Path) -> None:
161
+ """Test listing of backup files."""
162
+ import time
163
+
164
+ readme_path = tmp_path / "README.md"
165
+ readme_path.write_text("Test content", encoding="utf-8")
166
+
167
+ backup_manager = BackupManager(str(readme_path), str(tmp_path / "backups"))
168
+
169
+ # Create multiple backups with delay to ensure different timestamps
170
+ backup_manager.create_backup()
171
+ time.sleep(1.1) # Sleep to ensure different timestamp
172
+ backup_manager.create_backup()
173
+
174
+ backups = backup_manager.list_backups()
175
+
176
+ assert len(backups) >= 2
177
+
178
+ def test_restore_backup(self, tmp_path: Path) -> None:
179
+ """Test restoration from backup."""
180
+ readme_path = tmp_path / "README.md"
181
+ original_content = "Original content"
182
+ readme_path.write_text(original_content, encoding="utf-8")
183
+
184
+ backup_manager = BackupManager(str(readme_path), str(tmp_path / "backups"))
185
+ backup_path = backup_manager.create_backup()
186
+
187
+ # Modify the README
188
+ readme_path.write_text("Modified content", encoding="utf-8")
189
+
190
+ # Restore from backup
191
+ backup_manager.restore_backup(backup_path)
192
+
193
+ assert readme_path.read_text(encoding="utf-8") == original_content
194
+
195
+ def test_restore_backup_not_found(self, tmp_path: Path) -> None:
196
+ """Test error when backup file does not exist."""
197
+ readme_path = tmp_path / "README.md"
198
+ readme_path.write_text("Test content", encoding="utf-8")
199
+
200
+ backup_manager = BackupManager(str(readme_path), str(tmp_path / "backups"))
201
+ nonexistent_backup = tmp_path / "backups" / "nonexistent.md"
202
+
203
+ with pytest.raises(FileNotFoundError):
204
+ backup_manager.restore_backup(nonexistent_backup)
205
+
206
+
207
+ class TestReadmeStructure:
208
+ """Tests for ReadmeStructure class."""
209
+
210
+ def test_set_introduction(self) -> None:
211
+ """Test setting introduction section."""
212
+ structure = ReadmeStructure()
213
+ intro = "This is the introduction"
214
+
215
+ structure.set_introduction(intro)
216
+
217
+ assert structure.introduction == intro
218
+
219
+ def test_set_installation_block_default(self) -> None:
220
+ """Test setting installation block with default command."""
221
+ structure = ReadmeStructure()
222
+
223
+ structure.set_installation_block()
224
+
225
+ assert "pip install fishertools" in structure.installation_block
226
+ assert structure.installation_block.startswith("```bash")
227
+ assert structure.installation_block.endswith("```")
228
+
229
+ def test_set_installation_block_custom(self) -> None:
230
+ """Test setting installation block with custom command."""
231
+ structure = ReadmeStructure()
232
+ custom_command = "pip install custom-package"
233
+
234
+ structure.set_installation_block(custom_command)
235
+
236
+ assert custom_command in structure.installation_block
237
+
238
+ def test_set_feature_table(self) -> None:
239
+ """Test setting feature table."""
240
+ structure = ReadmeStructure()
241
+ features = [
242
+ FeatureEntry("Объяснить ошибку", "explain_error(e)"),
243
+ FeatureEntry("Безопасно читать файл", "safe_read_file(path)"),
244
+ ]
245
+
246
+ structure.set_feature_table(features)
247
+
248
+ assert "Задача" in structure.feature_table
249
+ assert "Что вызвать" in structure.feature_table
250
+ assert "explain_error(e)" in structure.feature_table
251
+ assert "safe_read_file(path)" in structure.feature_table
252
+
253
+ def test_set_feature_table_format(self) -> None:
254
+ """Test that feature table uses proper markdown format."""
255
+ structure = ReadmeStructure()
256
+ features = [FeatureEntry("Task", "function()")]
257
+
258
+ structure.set_feature_table(features)
259
+
260
+ lines = structure.feature_table.split("\n")
261
+ assert lines[0].startswith("|")
262
+ assert lines[1].startswith("|")
263
+
264
+ def test_set_target_audience_default(self) -> None:
265
+ """Test setting target audience with default bullets."""
266
+ structure = ReadmeStructure()
267
+
268
+ structure.set_target_audience()
269
+
270
+ assert "Для кого эта библиотека" in structure.target_audience
271
+ assert "Ты только начал изучать Python" in structure.target_audience
272
+ assert "Сообщения об ошибках" in structure.target_audience
273
+ assert "нормальном русском" in structure.target_audience
274
+
275
+ def test_set_target_audience_custom(self) -> None:
276
+ """Test setting target audience with custom bullets."""
277
+ structure = ReadmeStructure()
278
+ custom_bullets = ["Bullet 1", "Bullet 2", "Bullet 3"]
279
+
280
+ structure.set_target_audience(bullets=custom_bullets)
281
+
282
+ for bullet in custom_bullets:
283
+ assert bullet in structure.target_audience
284
+
285
+ def test_set_existing_content(self) -> None:
286
+ """Test setting existing content."""
287
+ structure = ReadmeStructure()
288
+ content = "## Existing Section\n\nThis is existing content"
289
+
290
+ structure.set_existing_content(content)
291
+
292
+ assert structure.existing_content == content
293
+
294
+ def test_assemble_structure(self) -> None:
295
+ """Test assembling all sections into final content."""
296
+ structure = ReadmeStructure()
297
+ structure.set_introduction("Introduction")
298
+ structure.set_installation_block()
299
+ structure.set_feature_table([FeatureEntry("Task", "func()")])
300
+ structure.set_target_audience()
301
+ structure.set_existing_content("Existing content")
302
+
303
+ assembled = structure.assemble()
304
+
305
+ assert "Introduction" in assembled
306
+ assert "pip install fishertools" in assembled
307
+ assert "Task" in assembled
308
+ assert "Для кого эта библиотека" in assembled
309
+ assert "Existing content" in assembled
310
+
311
+
312
+ class TestReadmeTransformer:
313
+ """Tests for ReadmeTransformer class."""
314
+
315
+ def test_validate_readme_exists(self, tmp_path: Path) -> None:
316
+ """Test validation of README existence."""
317
+ readme_path = tmp_path / "README.md"
318
+ readme_path.write_text("Test", encoding="utf-8")
319
+
320
+ transformer = ReadmeTransformer(str(readme_path))
321
+
322
+ assert transformer.validate_readme_exists() is True
323
+
324
+ def test_validate_readme_not_exists(self, tmp_path: Path) -> None:
325
+ """Test validation when README does not exist."""
326
+ readme_path = tmp_path / "nonexistent.md"
327
+
328
+ transformer = ReadmeTransformer(str(readme_path))
329
+
330
+ assert transformer.validate_readme_exists() is False
331
+
332
+ def test_parse_readme(self, tmp_path: Path) -> None:
333
+ """Test parsing README file."""
334
+ readme_path = tmp_path / "README.md"
335
+ test_content = "# Title\n\nContent here"
336
+ readme_path.write_text(test_content, encoding="utf-8")
337
+
338
+ transformer = ReadmeTransformer(str(readme_path))
339
+ transformer.parse_readme()
340
+
341
+ assert transformer.parser.content == test_content
342
+
343
+ def test_extract_content(self, tmp_path: Path) -> None:
344
+ """Test extraction of introduction and existing content."""
345
+ readme_path = tmp_path / "README.md"
346
+ test_content = "First sentence\n\n# Heading\n\nMore content"
347
+ readme_path.write_text(test_content, encoding="utf-8")
348
+
349
+ transformer = ReadmeTransformer(str(readme_path))
350
+ introduction, existing = transformer.extract_content()
351
+
352
+ assert "First sentence" in introduction
353
+ assert "More content" in existing
354
+
355
+ def test_create_backup(self, tmp_path: Path) -> None:
356
+ """Test backup creation."""
357
+ readme_path = tmp_path / "README.md"
358
+ readme_path.write_text("Test content", encoding="utf-8")
359
+
360
+ transformer = ReadmeTransformer(str(readme_path))
361
+ backup_path = transformer.create_backup()
362
+
363
+ assert backup_path.exists()
364
+
365
+ def test_transform_basic(self, tmp_path: Path) -> None:
366
+ """Test basic README transformation."""
367
+ readme_path = tmp_path / "README.md"
368
+ test_content = "Introduction\n\n# Section\n\nContent"
369
+ readme_path.write_text(test_content, encoding="utf-8")
370
+
371
+ transformer = ReadmeTransformer(str(readme_path))
372
+ transformed = transformer.transform()
373
+
374
+ assert "Introduction" in transformed
375
+ assert "pip install fishertools" in transformed
376
+ assert "Для кого эта библиотека" in transformed
377
+
378
+ def test_transform_with_features(self, tmp_path: Path) -> None:
379
+ """Test transformation with custom features."""
380
+ readme_path = tmp_path / "README.md"
381
+ readme_path.write_text("Introduction\n\nContent", encoding="utf-8")
382
+
383
+ features = [
384
+ FeatureEntry("Feature 1", "func1()"),
385
+ FeatureEntry("Feature 2", "func2()"),
386
+ ]
387
+
388
+ transformer = ReadmeTransformer(str(readme_path))
389
+ transformed = transformer.transform(features=features)
390
+
391
+ assert "Feature 1" in transformed
392
+ assert "func1()" in transformed
393
+ assert "Feature 2" in transformed
394
+ assert "func2()" in transformed
395
+
396
+ def test_write_transformed_readme(self, tmp_path: Path) -> None:
397
+ """Test writing transformed README to file."""
398
+ readme_path = tmp_path / "README.md"
399
+ readme_path.write_text("Original", encoding="utf-8")
400
+
401
+ transformer = ReadmeTransformer(str(readme_path))
402
+ new_content = "Transformed content"
403
+ transformer.write_transformed_readme(new_content)
404
+
405
+ assert readme_path.read_text(encoding="utf-8") == new_content
406
+
407
+ def test_full_transformation_workflow(self, tmp_path: Path) -> None:
408
+ """Test complete transformation workflow."""
409
+ readme_path = tmp_path / "README.md"
410
+ original_content = "Original Introduction\n\n# Section\n\nOriginal content"
411
+ readme_path.write_text(original_content, encoding="utf-8")
412
+
413
+ transformer = ReadmeTransformer(str(readme_path))
414
+
415
+ # Create backup
416
+ backup_path = transformer.create_backup()
417
+ assert backup_path.exists()
418
+
419
+ # Transform
420
+ features = [FeatureEntry("Task", "function()")]
421
+ transformed = transformer.transform(features=features)
422
+
423
+ # Write
424
+ transformer.write_transformed_readme(transformed)
425
+
426
+ # Verify
427
+ result = readme_path.read_text(encoding="utf-8")
428
+ assert "pip install fishertools" in result
429
+ assert "Для кого эта библиотека" in result
430
+ assert "Task" in result
431
+
432
+
433
+ # Property-based tests using hypothesis
434
+ from hypothesis import given, strategies as st, settings, HealthCheck
435
+ import tempfile
436
+
437
+
438
+ class TestReadmeContentPreservation:
439
+ """Property-based tests for content preservation."""
440
+
441
+ @given(
442
+ intro=st.text(
443
+ min_size=1,
444
+ max_size=100,
445
+ alphabet=st.characters(
446
+ blacklist_categories=("Cc", "Cs"),
447
+ blacklist_characters="\x00\r",
448
+ ),
449
+ ),
450
+ content=st.text(
451
+ min_size=1,
452
+ max_size=500,
453
+ alphabet=st.characters(
454
+ blacklist_categories=("Cc", "Cs"),
455
+ blacklist_characters="\x00\r",
456
+ ),
457
+ ),
458
+ )
459
+ @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
460
+ def test_property_content_preservation(self, intro: str, content: str) -> None:
461
+ """
462
+ Property 5: Content Preservation
463
+
464
+ **Validates: Requirements 4.1, 4.3**
465
+
466
+ For any README transformation, the original introductory sentence
467
+ should remain as the first content and all existing detailed
468
+ documentation should be preserved without loss.
469
+ """
470
+ # Skip if intro or content are empty after stripping
471
+ if not intro.strip() or not content.strip():
472
+ return
473
+
474
+ with tempfile.TemporaryDirectory() as tmp_dir:
475
+ readme_path = Path(tmp_dir) / "README.md"
476
+ test_content = f"{intro}\n\n# Section\n\n{content}"
477
+ readme_path.write_text(test_content, encoding="utf-8")
478
+
479
+ transformer = ReadmeTransformer(str(readme_path))
480
+ transformed = transformer.transform()
481
+
482
+ # Verify that the introduction is preserved (or its first sentence)
483
+ intro_stripped = intro.strip()
484
+ first_sentence = intro_stripped.split(".")[0].split("!")[0].split("?")[0].strip()
485
+
486
+ assert (
487
+ intro_stripped in transformed
488
+ or first_sentence in transformed
489
+ ), f"Introduction '{intro_stripped}' not found in transformed content"
490
+
491
+ # Verify that the existing content is preserved
492
+ content_stripped = content.strip()
493
+ assert (
494
+ content_stripped in transformed
495
+ ), f"Content '{content_stripped}' not found in transformed content"
496
+
497
+
498
+ class TestInstallationBlockProperty:
499
+ """Property-based tests for installation block."""
500
+
501
+ @given(
502
+ intro=st.text(
503
+ min_size=1,
504
+ max_size=100,
505
+ alphabet=st.characters(
506
+ blacklist_categories=("Cc", "Cs"),
507
+ blacklist_characters="\x00\r",
508
+ ),
509
+ ),
510
+ content=st.text(
511
+ min_size=1,
512
+ max_size=500,
513
+ alphabet=st.characters(
514
+ blacklist_categories=("Cc", "Cs"),
515
+ blacklist_characters="\x00\r",
516
+ ),
517
+ ),
518
+ )
519
+ @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
520
+ def test_property_installation_block_content_and_format(
521
+ self, intro: str, content: str
522
+ ) -> None:
523
+ """
524
+ Property 2: Installation Block Content and Format
525
+
526
+ **Validates: Requirements 1.2, 1.3**
527
+
528
+ For any README file, the installation block should contain exactly
529
+ "pip install fishertools" and be formatted as a proper markdown code block.
530
+ """
531
+ # Skip if intro or content are empty after stripping
532
+ if not intro.strip() or not content.strip():
533
+ return
534
+
535
+ with tempfile.TemporaryDirectory() as tmp_dir:
536
+ readme_path = Path(tmp_dir) / "README.md"
537
+ test_content = f"{intro}\n\n# Section\n\n{content}"
538
+ readme_path.write_text(test_content, encoding="utf-8")
539
+
540
+ transformer = ReadmeTransformer(str(readme_path))
541
+ transformed = transformer.transform()
542
+
543
+ # Verify installation block contains the exact command
544
+ assert (
545
+ "pip install fishertools" in transformed
546
+ ), "Installation command not found in transformed content"
547
+
548
+ # Verify it's formatted as a code block
549
+ assert (
550
+ "```bash" in transformed
551
+ ), "Installation block not formatted as bash code block"
552
+ assert (
553
+ "```" in transformed
554
+ ), "Installation block missing closing code block marker"
555
+
556
+ # Verify the code block structure
557
+ lines = transformed.split("\n")
558
+ bash_block_start = None
559
+ bash_block_end = None
560
+
561
+ for i, line in enumerate(lines):
562
+ if line.strip() == "```bash":
563
+ bash_block_start = i
564
+ elif bash_block_start is not None and line.strip() == "```":
565
+ bash_block_end = i
566
+ break
567
+
568
+ assert (
569
+ bash_block_start is not None
570
+ ), "Opening bash code block marker not found"
571
+ assert (
572
+ bash_block_end is not None
573
+ ), "Closing code block marker not found"
574
+ assert (
575
+ bash_block_end > bash_block_start
576
+ ), "Code block end before start"
577
+
578
+ # Verify the command is between the markers
579
+ command_found = False
580
+ for i in range(bash_block_start + 1, bash_block_end):
581
+ if "pip install fishertools" in lines[i]:
582
+ command_found = True
583
+ break
584
+
585
+ assert (
586
+ command_found
587
+ ), "Installation command not found between code block markers"
588
+
589
+
590
+ class TestFeatureTableProperty:
591
+ """Property-based tests for feature table."""
592
+
593
+ @given(
594
+ intro=st.text(
595
+ min_size=1,
596
+ max_size=100,
597
+ alphabet=st.characters(
598
+ blacklist_categories=("Cc", "Cs"),
599
+ blacklist_characters="\x00\r|",
600
+ ),
601
+ ),
602
+ content=st.text(
603
+ min_size=1,
604
+ max_size=500,
605
+ alphabet=st.characters(
606
+ blacklist_categories=("Cc", "Cs"),
607
+ blacklist_characters="\x00\r|",
608
+ ),
609
+ ),
610
+ )
611
+ @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
612
+ def test_property_feature_table_structure_and_content(
613
+ self, intro: str, content: str
614
+ ) -> None:
615
+ """
616
+ Property 3: Feature Table Structure and Content
617
+
618
+ **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
619
+
620
+ For any README transformation, the feature table should have columns
621
+ "Задача" and "Что вызвать", use proper markdown table format, and
622
+ contain entries for "explain_error(e)" and "safe_read_file(path)".
623
+ """
624
+ # Skip if intro or content are empty after stripping
625
+ if not intro.strip() or not content.strip():
626
+ return
627
+
628
+ with tempfile.TemporaryDirectory() as tmp_dir:
629
+ readme_path = Path(tmp_dir) / "README.md"
630
+ test_content = f"{intro}\n\n# Section\n\n{content}"
631
+ readme_path.write_text(test_content, encoding="utf-8")
632
+
633
+ transformer = ReadmeTransformer(str(readme_path))
634
+ transformed = transformer.transform()
635
+
636
+ # Verify table headers are present
637
+ assert (
638
+ "Задача" in transformed
639
+ ), "Feature table header 'Задача' not found"
640
+ assert (
641
+ "Что вызвать" in transformed
642
+ ), "Feature table header 'Что вызвать' not found"
643
+
644
+ # Verify markdown table format (pipe characters)
645
+ lines = transformed.split("\n")
646
+
647
+ # Find table lines - they should have both headers and separator
648
+ header_line_idx = None
649
+ separator_line_idx = None
650
+
651
+ for i, line in enumerate(lines):
652
+ if "Задача" in line and "Что вызвать" in line:
653
+ header_line_idx = i
654
+ elif header_line_idx is not None and "-----" in line:
655
+ separator_line_idx = i
656
+ break
657
+
658
+ assert (
659
+ header_line_idx is not None
660
+ ), "Feature table header line not found"
661
+ assert (
662
+ separator_line_idx is not None
663
+ ), "Feature table separator line not found"
664
+ assert (
665
+ separator_line_idx > header_line_idx
666
+ ), "Separator line should come after header line"
667
+
668
+ # Verify required function entries
669
+ assert (
670
+ "explain_error(e)" in transformed
671
+ ), "Feature table missing 'explain_error(e)' entry"
672
+ assert (
673
+ "safe_read_file(path)" in transformed
674
+ ), "Feature table missing 'safe_read_file(path)' entry"
675
+
676
+ # Verify table structure: header and separator should have pipes
677
+ header_line = lines[header_line_idx]
678
+ separator_line = lines[separator_line_idx]
679
+
680
+ assert (
681
+ header_line.count("|") >= 2
682
+ ), f"Header line does not have proper structure: {header_line}"
683
+ assert (
684
+ separator_line.count("|") >= 2
685
+ ), f"Separator line does not have proper structure: {separator_line}"
686
+
687
+
688
+ class TestTargetAudienceSectionProperty:
689
+ """Property-based tests for target audience section."""
690
+
691
+ @given(
692
+ intro=st.text(
693
+ min_size=1,
694
+ max_size=100,
695
+ alphabet=st.characters(
696
+ blacklist_categories=("Cc", "Cs"),
697
+ blacklist_characters="\x00\r",
698
+ ),
699
+ ),
700
+ content=st.text(
701
+ min_size=1,
702
+ max_size=500,
703
+ alphabet=st.characters(
704
+ blacklist_categories=("Cc", "Cs"),
705
+ blacklist_characters="\x00\r",
706
+ ),
707
+ ),
708
+ )
709
+ @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
710
+ def test_property_target_audience_section_content(
711
+ self, intro: str, content: str
712
+ ) -> None:
713
+ """
714
+ Property 4: Target Audience Section Content
715
+
716
+ **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
717
+
718
+ For any README transformation, the target audience section should have
719
+ the title "Для кого эта библиотека", contain exactly 3 bullet points,
720
+ and include all three specified user descriptions.
721
+ """
722
+ # Skip if intro or content are empty after stripping
723
+ if not intro.strip() or not content.strip():
724
+ return
725
+
726
+ with tempfile.TemporaryDirectory() as tmp_dir:
727
+ readme_path = Path(tmp_dir) / "README.md"
728
+ test_content = f"{intro}\n\n# Section\n\n{content}"
729
+ readme_path.write_text(test_content, encoding="utf-8")
730
+
731
+ transformer = ReadmeTransformer(str(readme_path))
732
+ transformed = transformer.transform()
733
+
734
+ # Verify section title is present
735
+ assert (
736
+ "Для кого эта библиотека" in transformed
737
+ ), "Target audience section title not found"
738
+
739
+ # Verify section title is formatted as a heading
740
+ lines = transformed.split("\n")
741
+ title_found = False
742
+ for line in lines:
743
+ if "Для кого эта библиотека" in line and line.startswith("##"):
744
+ title_found = True
745
+ break
746
+
747
+ assert (
748
+ title_found
749
+ ), "Target audience section title not formatted as heading"
750
+
751
+ # Verify all three required bullet points are present
752
+ required_bullets = [
753
+ "Ты только начал изучать Python",
754
+ "Сообщения об ошибках кажутся страшными и непонятными",
755
+ "Хочешь, чтобы ошибки объяснялись на нормальном русском с примерами",
756
+ ]
757
+
758
+ for bullet in required_bullets:
759
+ assert (
760
+ bullet in transformed
761
+ ), f"Required bullet point '{bullet}' not found in target audience section"
762
+
763
+ # Verify exactly 3 bullet points in the section
764
+ # Find the target audience section
765
+ section_start = None
766
+ section_end = None
767
+
768
+ for i, line in enumerate(lines):
769
+ if "Для кого эта библиотека" in line:
770
+ section_start = i
771
+ elif section_start is not None and line.startswith("#") and i > section_start:
772
+ section_end = i
773
+ break
774
+
775
+ if section_end is None:
776
+ section_end = len(lines)
777
+
778
+ # Count bullet points in the section
779
+ bullet_count = 0
780
+ for i in range(section_start, section_end):
781
+ if lines[i].strip().startswith("- "):
782
+ bullet_count += 1
783
+
784
+ assert (
785
+ bullet_count == 3
786
+ ), f"Expected exactly 3 bullet points, found {bullet_count}"
787
+
788
+ # Verify bullet points are formatted correctly (start with "- ")
789
+ for i in range(section_start, section_end):
790
+ if any(bullet in lines[i] for bullet in required_bullets):
791
+ assert (
792
+ lines[i].strip().startswith("- ")
793
+ ), f"Bullet point not properly formatted: {lines[i]}"
794
+
795
+
796
+
797
+ class TestDocumentStructureOrderingProperty:
798
+ """Property-based tests for document structure ordering."""
799
+
800
+ @given(
801
+ intro=st.text(
802
+ min_size=10,
803
+ max_size=100,
804
+ alphabet=st.characters(
805
+ blacklist_categories=("Cc", "Cs"),
806
+ blacklist_characters="\x00\r#.",
807
+ ),
808
+ ),
809
+ content=st.text(
810
+ min_size=10,
811
+ max_size=500,
812
+ alphabet=st.characters(
813
+ blacklist_categories=("Cc", "Cs"),
814
+ blacklist_characters="\x00\r",
815
+ ),
816
+ ),
817
+ )
818
+ @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
819
+ def test_property_document_structure_ordering(
820
+ self, intro: str, content: str
821
+ ) -> None:
822
+ """
823
+ Property 1: Document Structure Ordering
824
+
825
+ **Validates: Requirements 1.1, 1.4, 2.6, 3.6, 4.2**
826
+
827
+ For any valid README transformation, the document sections should appear
828
+ in this exact order: introduction, installation block, feature table,
829
+ target audience section, then existing detailed content.
830
+ """
831
+ # Skip if intro or content are empty after stripping
832
+ if not intro.strip() or not content.strip():
833
+ return
834
+
835
+ with tempfile.TemporaryDirectory() as tmp_dir:
836
+ readme_path = Path(tmp_dir) / "README.md"
837
+ # Ensure intro ends with a period to form a valid sentence
838
+ intro_with_period = intro.strip() + "." if not intro.strip().endswith((".","!","?")) else intro.strip()
839
+ test_content = f"{intro_with_period}\n\n# Section\n\n{content}"
840
+ readme_path.write_text(test_content, encoding="utf-8")
841
+
842
+ transformer = ReadmeTransformer(str(readme_path))
843
+ transformed = transformer.transform()
844
+
845
+ lines = transformed.split("\n")
846
+
847
+ # Find indices of key sections
848
+ intro_idx = None
849
+ install_idx = None
850
+ feature_table_idx = None
851
+ target_audience_idx = None
852
+ existing_content_idx = None
853
+
854
+ for i, line in enumerate(lines):
855
+ # Introduction is the first non-empty, non-heading line
856
+ if intro_idx is None and line.strip() and not line.startswith("#"):
857
+ intro_idx = i
858
+
859
+ # Installation block starts with ```bash
860
+ if install_idx is None and "```bash" in line:
861
+ install_idx = i
862
+
863
+ # Feature table has both headers
864
+ if (
865
+ feature_table_idx is None
866
+ and "Задача" in line
867
+ and "Что вызвать" in line
868
+ ):
869
+ feature_table_idx = i
870
+
871
+ # Target audience section
872
+ if (
873
+ target_audience_idx is None
874
+ and "Для кого эта библиотека" in line
875
+ ):
876
+ target_audience_idx = i
877
+
878
+ # Existing content appears after target audience
879
+ if (
880
+ existing_content_idx is None
881
+ and target_audience_idx is not None
882
+ and i > target_audience_idx
883
+ and line.strip()
884
+ and not line.startswith("#")
885
+ and "Для кого" not in line
886
+ and "Ты только" not in line
887
+ and "Сообщения" not in line
888
+ and "Хочешь" not in line
889
+ ):
890
+ existing_content_idx = i
891
+
892
+ # Verify all sections are present
893
+ assert intro_idx is not None, "Introduction section not found"
894
+ assert install_idx is not None, "Installation block not found"
895
+ assert feature_table_idx is not None, "Feature table not found"
896
+ assert target_audience_idx is not None, "Target audience section not found"
897
+
898
+ # Verify ordering: introduction < installation < feature table < target audience
899
+ assert (
900
+ intro_idx < install_idx
901
+ ), f"Introduction ({intro_idx}) should come before installation block ({install_idx})"
902
+
903
+ assert (
904
+ install_idx < feature_table_idx
905
+ ), f"Installation block ({install_idx}) should come before feature table ({feature_table_idx})"
906
+
907
+ assert (
908
+ feature_table_idx < target_audience_idx
909
+ ), f"Feature table ({feature_table_idx}) should come before target audience ({target_audience_idx})"
910
+
911
+ # Verify markdown syntax is valid
912
+ is_valid, error_msg = transformer.validate_transformed_content(transformed)
913
+ assert (
914
+ is_valid
915
+ ), f"Transformed content has invalid markdown syntax: {error_msg}"
916
+
917
+
918
+
919
+ class TestLanguageConsistencyProperty:
920
+ """Property-based tests for language consistency."""
921
+
922
+ @given(
923
+ intro=st.text(
924
+ min_size=1,
925
+ max_size=100,
926
+ alphabet=st.characters(
927
+ blacklist_categories=("Cc", "Cs"),
928
+ blacklist_characters="\x00\r",
929
+ ),
930
+ ),
931
+ content=st.text(
932
+ min_size=1,
933
+ max_size=500,
934
+ alphabet=st.characters(
935
+ blacklist_categories=("Cc", "Cs"),
936
+ blacklist_characters="\x00\r",
937
+ ),
938
+ ),
939
+ )
940
+ @settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
941
+ def test_property_language_consistency(self, intro: str, content: str) -> None:
942
+ """
943
+ Property 6: Language Consistency
944
+
945
+ **Validates: Requirements 4.4**
946
+
947
+ For any new content added to the README, it should use Russian language
948
+ to match the existing documentation style.
949
+ """
950
+ # Skip if intro or content are empty after stripping
951
+ if not intro.strip() or not content.strip():
952
+ return
953
+
954
+ with tempfile.TemporaryDirectory() as tmp_dir:
955
+ readme_path = Path(tmp_dir) / "README.md"
956
+ test_content = f"{intro}\n\n# Section\n\n{content}"
957
+ readme_path.write_text(test_content, encoding="utf-8")
958
+
959
+ transformer = ReadmeTransformer(str(readme_path))
960
+ transformed = transformer.transform()
961
+
962
+ # Define Russian language markers that should be present in new content
963
+ russian_markers = [
964
+ "Задача", # Feature table header
965
+ "Что вызвать", # Feature table header
966
+ "Для кого эта библиотека", # Target audience section title
967
+ "Ты только начал изучать Python", # Target audience bullet 1
968
+ "Сообщения об ошибках", # Target audience bullet 2
969
+ "нормальном русском", # Target audience bullet 3
970
+ ]
971
+
972
+ # Verify all Russian language markers are present
973
+ for marker in russian_markers:
974
+ assert (
975
+ marker in transformed
976
+ ), f"Russian language marker '{marker}' not found in transformed content"
977
+
978
+ # Verify the installation block uses English (as it's a standard command)
979
+ assert (
980
+ "pip install fishertools" in transformed
981
+ ), "Installation command not found"
982
+
983
+ # Verify feature table entries use English function names
984
+ assert (
985
+ "explain_error(e)" in transformed
986
+ ), "Function name 'explain_error(e)' not found"
987
+ assert (
988
+ "safe_read_file(path)" in transformed
989
+ ), "Function name 'safe_read_file(path)' not found"
990
+
991
+ # Verify that new sections (not from original content) use Russian
992
+ lines = transformed.split("\n")
993
+
994
+ # Find the target audience section
995
+ target_audience_start = None
996
+ for i, line in enumerate(lines):
997
+ if "Для кого эта библиотека" in line:
998
+ target_audience_start = i
999
+ break
1000
+
1001
+ assert (
1002
+ target_audience_start is not None
1003
+ ), "Target audience section not found"
1004
+
1005
+ # Verify the section heading is in Russian
1006
+ assert (
1007
+ lines[target_audience_start].startswith("##")
1008
+ ), "Target audience should be a level 2 heading"
1009
+
1010
+ # Verify feature table headers are in Russian
1011
+ feature_table_found = False
1012
+ for i, line in enumerate(lines):
1013
+ if "Задача" in line and "Что вызвать" in line:
1014
+ feature_table_found = True
1015
+ # Verify it's a proper table header
1016
+ assert (
1017
+ "|" in line
1018
+ ), "Feature table header should use pipe characters"
1019
+ break
1020
+
1021
+ assert (
1022
+ feature_table_found
1023
+ ), "Feature table with Russian headers not found"