python2mobile 1.0.1__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 (50) hide show
  1. examples/example_ecommerce_app.py +189 -0
  2. examples/example_todo_app.py +159 -0
  3. p2m/__init__.py +31 -0
  4. p2m/cli.py +470 -0
  5. p2m/config.py +205 -0
  6. p2m/core/__init__.py +18 -0
  7. p2m/core/api.py +191 -0
  8. p2m/core/ast_walker.py +171 -0
  9. p2m/core/database.py +192 -0
  10. p2m/core/events.py +56 -0
  11. p2m/core/render_engine.py +597 -0
  12. p2m/core/runtime.py +128 -0
  13. p2m/core/state.py +51 -0
  14. p2m/core/validator.py +284 -0
  15. p2m/devserver/__init__.py +9 -0
  16. p2m/devserver/server.py +84 -0
  17. p2m/i18n/__init__.py +7 -0
  18. p2m/i18n/translator.py +74 -0
  19. p2m/imagine/__init__.py +35 -0
  20. p2m/imagine/agent.py +463 -0
  21. p2m/imagine/legacy.py +217 -0
  22. p2m/llm/__init__.py +20 -0
  23. p2m/llm/anthropic_provider.py +78 -0
  24. p2m/llm/base.py +42 -0
  25. p2m/llm/compatible_provider.py +120 -0
  26. p2m/llm/factory.py +72 -0
  27. p2m/llm/ollama_provider.py +89 -0
  28. p2m/llm/openai_provider.py +79 -0
  29. p2m/testing/__init__.py +41 -0
  30. p2m/ui/__init__.py +43 -0
  31. p2m/ui/components.py +301 -0
  32. python2mobile-1.0.1.dist-info/METADATA +238 -0
  33. python2mobile-1.0.1.dist-info/RECORD +50 -0
  34. python2mobile-1.0.1.dist-info/WHEEL +5 -0
  35. python2mobile-1.0.1.dist-info/entry_points.txt +2 -0
  36. python2mobile-1.0.1.dist-info/top_level.txt +3 -0
  37. tests/test_basic_engine.py +281 -0
  38. tests/test_build_generation.py +603 -0
  39. tests/test_build_test_gate.py +150 -0
  40. tests/test_carousel_modal.py +84 -0
  41. tests/test_config_system.py +272 -0
  42. tests/test_i18n.py +101 -0
  43. tests/test_ifood_app_integration.py +172 -0
  44. tests/test_imagine_cli.py +133 -0
  45. tests/test_imagine_command.py +341 -0
  46. tests/test_llm_providers.py +321 -0
  47. tests/test_new_apps_integration.py +588 -0
  48. tests/test_ollama_functional.py +329 -0
  49. tests/test_real_world_apps.py +228 -0
  50. tests/test_run_integration.py +776 -0
@@ -0,0 +1,603 @@
1
+ """
2
+ Tests for p2m build code generation.
3
+
4
+ Validates that CodeGenerator produces correct output files for all supported
5
+ platforms (Flutter, React Native, Web, Android, iOS) using the real test apps
6
+ from tests-p2m/.
7
+
8
+ Coverage:
9
+ - Project file loading from multi-directory app structures
10
+ - Output file existence and structure for each target
11
+ - Key content assertions per platform
12
+ - generate() dispatcher with valid/invalid targets
13
+ - All 5 targets against both todo_app and ecommerce_app
14
+ """
15
+
16
+ import sys
17
+ import json
18
+ import tempfile
19
+ from pathlib import Path
20
+ from unittest.mock import MagicMock, patch
21
+ import pytest
22
+
23
+ # Framework path
24
+ FRAMEWORK_PATH = Path(__file__).parent.parent
25
+ TESTS_P2M_PATH = FRAMEWORK_PATH.parent / "tests-p2m"
26
+
27
+ if str(FRAMEWORK_PATH) not in sys.path:
28
+ sys.path.insert(0, str(FRAMEWORK_PATH))
29
+
30
+ from p2m.build.generator import CodeGenerator
31
+
32
+
33
+ # ──────────────────────────────────────────────────────────────────────────────
34
+ # Fixtures
35
+ # ──────────────────────────────────────────────────────────────────────────────
36
+
37
+ @pytest.fixture
38
+ def generator():
39
+ """CodeGenerator instance with a mocked LLM provider (not used for generation)."""
40
+ with patch("p2m.build.generator.LLMFactory") as mock_factory:
41
+ mock_factory.create.return_value = MagicMock()
42
+ config = MagicMock()
43
+ config.llm.provider = "openai"
44
+ config.llm.api_key = "test-key"
45
+ config.llm.model = "gpt-4o"
46
+ config.llm.base_url = ""
47
+ config.llm.x_api_key = ""
48
+ gen = CodeGenerator(config)
49
+ return gen
50
+
51
+
52
+ @pytest.fixture
53
+ def todo_files(generator):
54
+ """Python files loaded from tests-p2m/todo_app."""
55
+ return generator.load_project_files(str(TESTS_P2M_PATH / "todo_app"))
56
+
57
+
58
+ @pytest.fixture
59
+ def ecommerce_files(generator):
60
+ """Python files loaded from tests-p2m/ecommerce_app."""
61
+ return generator.load_project_files(str(TESTS_P2M_PATH / "ecommerce_app"))
62
+
63
+
64
+ @pytest.fixture
65
+ def outdir():
66
+ """Fresh temporary directory for each test's build output."""
67
+ with tempfile.TemporaryDirectory() as tmpdir:
68
+ yield Path(tmpdir)
69
+
70
+
71
+ # ──────────────────────────────────────────────────────────────────────────────
72
+ # Project file loading
73
+ # ──────────────────────────────────────────────────────────────────────────────
74
+
75
+ class TestProjectFileLoading:
76
+ """load_project_files() behavior."""
77
+
78
+ def test_loads_main_py_todo(self, generator):
79
+ """todo_app: main.py at the project root is loaded."""
80
+ files = generator.load_project_files(str(TESTS_P2M_PATH / "todo_app"))
81
+ assert "main.py" in files
82
+
83
+ def test_loads_main_py_ecommerce(self, generator):
84
+ """ecommerce_app: main.py at the project root is loaded."""
85
+ files = generator.load_project_files(str(TESTS_P2M_PATH / "ecommerce_app"))
86
+ assert "main.py" in files
87
+
88
+ def test_todo_main_has_create_view(self, generator):
89
+ """todo_app/main.py contains the create_view function."""
90
+ files = generator.load_project_files(str(TESTS_P2M_PATH / "todo_app"))
91
+ assert "create_view" in files["main.py"]
92
+
93
+ def test_todo_main_has_event_handlers(self, generator):
94
+ """todo_app/main.py registers handlers: add_todo, clear_done, nav_go."""
95
+ files = generator.load_project_files(str(TESTS_P2M_PATH / "todo_app"))
96
+ assert "add_todo" in files["main.py"]
97
+ assert "clear_done" in files["main.py"]
98
+ assert "nav_go" in files["main.py"]
99
+
100
+ def test_ecommerce_main_has_handlers(self, generator):
101
+ """ecommerce_app/main.py registers search, cart, checkout handlers."""
102
+ files = generator.load_project_files(str(TESTS_P2M_PATH / "ecommerce_app"))
103
+ assert "search_products" in files["main.py"]
104
+ assert "confirm_order" in files["main.py"]
105
+ assert "nav_checkout" in files["main.py"]
106
+
107
+ def test_returns_dict_of_strings(self, generator):
108
+ """load_project_files returns Dict[str, str]."""
109
+ files = generator.load_project_files(str(TESTS_P2M_PATH / "todo_app"))
110
+ assert isinstance(files, dict)
111
+ for key, value in files.items():
112
+ assert isinstance(key, str)
113
+ assert isinstance(value, str)
114
+
115
+ def test_ignores_test_prefix_files(self, generator, tmp_path):
116
+ """Files starting with 'test_' are excluded."""
117
+ (tmp_path / "main.py").write_text("# app")
118
+ (tmp_path / "test_unit.py").write_text("# test")
119
+ files = generator.load_project_files(str(tmp_path))
120
+ assert "main.py" in files
121
+ assert "test_unit.py" not in files
122
+
123
+ def test_loads_subdirectory_py_files(self, generator):
124
+ """
125
+ load_project_files recursively loads all .py files from subdirectories
126
+ including state/, views/, and components/.
127
+ """
128
+ files = generator.load_project_files(str(TESTS_P2M_PATH / "todo_app"))
129
+ # main.py at root plus subdirectory module files
130
+ assert "main.py" in files
131
+ # At least one subdirectory file should be loaded
132
+ subdirectory_files = [k for k in files.keys() if "/" in k or "\\" in k]
133
+ assert len(subdirectory_files) > 0, "Expected subdirectory .py files to be loaded"
134
+
135
+ def test_ecommerce_loads_subdirectory_files(self, generator):
136
+ """ecommerce_app subdirectory files (state/, views/, components/) are loaded."""
137
+ files = generator.load_project_files(str(TESTS_P2M_PATH / "ecommerce_app"))
138
+ assert "main.py" in files
139
+ subdirectory_files = [k for k in files.keys() if "/" in k or "\\" in k]
140
+ assert len(subdirectory_files) > 0, "Expected subdirectory .py files to be loaded"
141
+
142
+ def test_multiple_root_py_files(self, generator, tmp_path):
143
+ """Multiple root-level .py files are all loaded."""
144
+ (tmp_path / "main.py").write_text("# main")
145
+ (tmp_path / "utils.py").write_text("# utils")
146
+ (tmp_path / "config.py").write_text("# config")
147
+ files = generator.load_project_files(str(tmp_path))
148
+ assert "main.py" in files
149
+ assert "utils.py" in files
150
+ assert "config.py" in files
151
+
152
+ def test_empty_project_dir(self, generator, tmp_path):
153
+ """Empty directory returns an empty dict."""
154
+ files = generator.load_project_files(str(tmp_path))
155
+ assert files == {}
156
+
157
+
158
+ # ──────────────────────────────────────────────────────────────────────────────
159
+ # Flutter generation
160
+ # ──────────────────────────────────────────────────────────────────────────────
161
+
162
+ class TestFlutterGeneration:
163
+ """generate_flutter() / generate('flutter', ...) output validation."""
164
+
165
+ def test_creates_pubspec_yaml(self, generator, todo_files, outdir):
166
+ generator.generate_flutter(todo_files, str(outdir))
167
+ assert (outdir / "pubspec.yaml").exists()
168
+
169
+ def test_creates_lib_main_dart(self, generator, todo_files, outdir):
170
+ generator.generate_flutter(todo_files, str(outdir))
171
+ assert (outdir / "lib" / "main.dart").exists()
172
+
173
+ def test_pubspec_has_flutter_sdk(self, generator, todo_files, outdir):
174
+ generator.generate_flutter(todo_files, str(outdir))
175
+ content = (outdir / "pubspec.yaml").read_text()
176
+ assert "flutter:" in content
177
+ assert "sdk: flutter" in content
178
+
179
+ def test_pubspec_has_http(self, generator, todo_files, outdir):
180
+ generator.generate_flutter(todo_files, str(outdir))
181
+ content = (outdir / "pubspec.yaml").read_text()
182
+ assert "http:" in content
183
+
184
+ def test_pubspec_has_shared_preferences(self, generator, todo_files, outdir):
185
+ generator.generate_flutter(todo_files, str(outdir))
186
+ content = (outdir / "pubspec.yaml").read_text()
187
+ assert "shared_preferences:" in content
188
+
189
+ def test_pubspec_has_valid_app_name(self, generator, todo_files, outdir):
190
+ generator.generate_flutter(todo_files, str(outdir))
191
+ content = (outdir / "pubspec.yaml").read_text()
192
+ assert "name: p2m_app" in content
193
+
194
+ def test_main_dart_imports_flutter(self, generator, todo_files, outdir):
195
+ generator.generate_flutter(todo_files, str(outdir))
196
+ content = (outdir / "lib" / "main.dart").read_text()
197
+ assert "import 'package:flutter/material.dart'" in content
198
+
199
+ def test_main_dart_has_main_entry(self, generator, todo_files, outdir):
200
+ generator.generate_flutter(todo_files, str(outdir))
201
+ content = (outdir / "lib" / "main.dart").read_text()
202
+ assert "void main()" in content
203
+ assert "runApp" in content
204
+
205
+ def test_main_dart_has_material_app(self, generator, todo_files, outdir):
206
+ generator.generate_flutter(todo_files, str(outdir))
207
+ content = (outdir / "lib" / "main.dart").read_text()
208
+ assert "MaterialApp" in content
209
+
210
+ def test_main_dart_has_stateful_widget(self, generator, todo_files, outdir):
211
+ generator.generate_flutter(todo_files, str(outdir))
212
+ content = (outdir / "lib" / "main.dart").read_text()
213
+ assert "StatefulWidget" in content
214
+
215
+ def test_main_dart_has_build_method(self, generator, todo_files, outdir):
216
+ generator.generate_flutter(todo_files, str(outdir))
217
+ content = (outdir / "lib" / "main.dart").read_text()
218
+ assert "Widget build(BuildContext context)" in content
219
+
220
+ def test_flutter_ecommerce(self, generator, ecommerce_files, outdir):
221
+ """Flutter generation works for ecommerce_app too."""
222
+ generator.generate_flutter(ecommerce_files, str(outdir))
223
+ assert (outdir / "pubspec.yaml").exists()
224
+ assert (outdir / "lib" / "main.dart").exists()
225
+
226
+ def test_output_dir_created_if_missing(self, generator, todo_files, tmp_path):
227
+ """generate_flutter creates the output directory if it does not exist."""
228
+ nested = tmp_path / "deep" / "nested" / "build"
229
+ generator.generate_flutter(todo_files, str(nested))
230
+ assert nested.exists()
231
+
232
+
233
+ # ──────────────────────────────────────────────────────────────────────────────
234
+ # React Native generation
235
+ # ──────────────────────────────────────────────────────────────────────────────
236
+
237
+ class TestReactNativeGeneration:
238
+ """generate_react_native() / generate('react-native', ...) output validation."""
239
+
240
+ def test_creates_package_json(self, generator, todo_files, outdir):
241
+ generator.generate_react_native(todo_files, str(outdir))
242
+ assert (outdir / "package.json").exists()
243
+
244
+ def test_creates_app_tsx(self, generator, todo_files, outdir):
245
+ generator.generate_react_native(todo_files, str(outdir))
246
+ assert (outdir / "App.tsx").exists()
247
+
248
+ def test_package_json_valid_json(self, generator, todo_files, outdir):
249
+ generator.generate_react_native(todo_files, str(outdir))
250
+ content = json.loads((outdir / "package.json").read_text())
251
+ assert isinstance(content, dict)
252
+
253
+ def test_package_json_has_react_native(self, generator, todo_files, outdir):
254
+ generator.generate_react_native(todo_files, str(outdir))
255
+ content = json.loads((outdir / "package.json").read_text())
256
+ assert "react-native" in content["dependencies"]
257
+
258
+ def test_package_json_has_react(self, generator, todo_files, outdir):
259
+ generator.generate_react_native(todo_files, str(outdir))
260
+ content = json.loads((outdir / "package.json").read_text())
261
+ assert "react" in content["dependencies"]
262
+
263
+ def test_package_json_has_typescript_devdep(self, generator, todo_files, outdir):
264
+ generator.generate_react_native(todo_files, str(outdir))
265
+ content = json.loads((outdir / "package.json").read_text())
266
+ assert "typescript" in content["devDependencies"]
267
+
268
+ def test_package_json_scripts(self, generator, todo_files, outdir):
269
+ generator.generate_react_native(todo_files, str(outdir))
270
+ content = json.loads((outdir / "package.json").read_text())
271
+ assert "start" in content["scripts"]
272
+ assert "android" in content["scripts"]
273
+ assert "ios" in content["scripts"]
274
+
275
+ def test_app_tsx_imports_react(self, generator, todo_files, outdir):
276
+ generator.generate_react_native(todo_files, str(outdir))
277
+ content = (outdir / "App.tsx").read_text()
278
+ assert "import React" in content
279
+
280
+ def test_app_tsx_imports_react_native(self, generator, todo_files, outdir):
281
+ generator.generate_react_native(todo_files, str(outdir))
282
+ content = (outdir / "App.tsx").read_text()
283
+ assert "react-native" in content
284
+
285
+ def test_app_tsx_default_export(self, generator, todo_files, outdir):
286
+ generator.generate_react_native(todo_files, str(outdir))
287
+ content = (outdir / "App.tsx").read_text()
288
+ assert "export default" in content
289
+
290
+ def test_app_tsx_safe_area_view(self, generator, todo_files, outdir):
291
+ generator.generate_react_native(todo_files, str(outdir))
292
+ content = (outdir / "App.tsx").read_text()
293
+ assert "SafeAreaView" in content
294
+
295
+ def test_rn_ecommerce(self, generator, ecommerce_files, outdir):
296
+ """React Native generation works for ecommerce_app."""
297
+ generator.generate_react_native(ecommerce_files, str(outdir))
298
+ assert (outdir / "App.tsx").exists()
299
+ content = json.loads((outdir / "package.json").read_text())
300
+ assert "react-native" in content["dependencies"]
301
+
302
+
303
+ # ──────────────────────────────────────────────────────────────────────────────
304
+ # Web generation
305
+ # ──────────────────────────────────────────────────────────────────────────────
306
+
307
+ class TestWebGeneration:
308
+ """generate_web() / generate('web', ...) output validation."""
309
+
310
+ def test_creates_index_html(self, generator, todo_files, outdir):
311
+ generator.generate_web(todo_files, str(outdir))
312
+ assert (outdir / "index.html").exists()
313
+
314
+ def test_index_html_doctype(self, generator, todo_files, outdir):
315
+ generator.generate_web(todo_files, str(outdir))
316
+ content = (outdir / "index.html").read_text()
317
+ assert "<!DOCTYPE html>" in content
318
+
319
+ def test_index_html_structure(self, generator, todo_files, outdir):
320
+ generator.generate_web(todo_files, str(outdir))
321
+ content = (outdir / "index.html").read_text()
322
+ assert "<html" in content
323
+ assert "</html>" in content
324
+ assert "<head" in content
325
+ assert "<body" in content
326
+
327
+ def test_index_html_charset(self, generator, todo_files, outdir):
328
+ generator.generate_web(todo_files, str(outdir))
329
+ content = (outdir / "index.html").read_text()
330
+ assert "charset" in content.lower()
331
+
332
+ def test_index_html_viewport_meta(self, generator, todo_files, outdir):
333
+ generator.generate_web(todo_files, str(outdir))
334
+ content = (outdir / "index.html").read_text()
335
+ assert "viewport" in content
336
+
337
+ def test_index_html_python2mobile_brand(self, generator, todo_files, outdir):
338
+ generator.generate_web(todo_files, str(outdir))
339
+ content = (outdir / "index.html").read_text()
340
+ assert "Python2Mobile" in content
341
+
342
+ def test_index_html_has_script(self, generator, todo_files, outdir):
343
+ generator.generate_web(todo_files, str(outdir))
344
+ content = (outdir / "index.html").read_text()
345
+ assert "<script" in content
346
+
347
+ def test_index_html_has_style(self, generator, todo_files, outdir):
348
+ generator.generate_web(todo_files, str(outdir))
349
+ content = (outdir / "index.html").read_text()
350
+ assert "<style" in content
351
+
352
+ def test_web_ecommerce(self, generator, ecommerce_files, outdir):
353
+ """Web generation works for ecommerce_app."""
354
+ generator.generate_web(ecommerce_files, str(outdir))
355
+ assert (outdir / "index.html").exists()
356
+
357
+
358
+ # ──────────────────────────────────────────────────────────────────────────────
359
+ # Android generation
360
+ # ──────────────────────────────────────────────────────────────────────────────
361
+
362
+ class TestAndroidGeneration:
363
+ """generate_android() / generate('android', ...) output validation."""
364
+
365
+ def test_creates_build_gradle(self, generator, todo_files, outdir):
366
+ generator.generate_android(todo_files, str(outdir))
367
+ assert (outdir / "build.gradle").exists()
368
+
369
+ def test_creates_android_manifest(self, generator, todo_files, outdir):
370
+ generator.generate_android(todo_files, str(outdir))
371
+ assert (outdir / "AndroidManifest.xml").exists()
372
+
373
+ def test_creates_main_activity_kt(self, generator, todo_files, outdir):
374
+ generator.generate_android(todo_files, str(outdir))
375
+ assert (outdir / "MainActivity.kt").exists()
376
+
377
+ def test_creates_activity_main_xml(self, generator, todo_files, outdir):
378
+ generator.generate_android(todo_files, str(outdir))
379
+ assert (outdir / "activity_main.xml").exists()
380
+
381
+ def test_manifest_has_internet_permission(self, generator, todo_files, outdir):
382
+ generator.generate_android(todo_files, str(outdir))
383
+ content = (outdir / "AndroidManifest.xml").read_text()
384
+ assert "android.permission.INTERNET" in content
385
+
386
+ def test_manifest_has_main_activity(self, generator, todo_files, outdir):
387
+ generator.generate_android(todo_files, str(outdir))
388
+ content = (outdir / "AndroidManifest.xml").read_text()
389
+ assert "MainActivity" in content
390
+ assert "android.intent.action.MAIN" in content
391
+
392
+ def test_manifest_has_package(self, generator, todo_files, outdir):
393
+ generator.generate_android(todo_files, str(outdir))
394
+ content = (outdir / "AndroidManifest.xml").read_text()
395
+ assert 'package="com.p2m.app"' in content
396
+
397
+ def test_main_activity_package(self, generator, todo_files, outdir):
398
+ generator.generate_android(todo_files, str(outdir))
399
+ content = (outdir / "MainActivity.kt").read_text()
400
+ assert "package com.p2m.app" in content
401
+
402
+ def test_main_activity_class(self, generator, todo_files, outdir):
403
+ generator.generate_android(todo_files, str(outdir))
404
+ content = (outdir / "MainActivity.kt").read_text()
405
+ assert "class MainActivity" in content
406
+ assert "AppCompatActivity" in content
407
+
408
+ def test_main_activity_on_create(self, generator, todo_files, outdir):
409
+ generator.generate_android(todo_files, str(outdir))
410
+ content = (outdir / "MainActivity.kt").read_text()
411
+ assert "onCreate" in content
412
+
413
+ def test_build_gradle_kotlin_android(self, generator, todo_files, outdir):
414
+ generator.generate_android(todo_files, str(outdir))
415
+ content = (outdir / "build.gradle").read_text()
416
+ assert "kotlin-android" in content
417
+
418
+ def test_build_gradle_compile_sdk(self, generator, todo_files, outdir):
419
+ generator.generate_android(todo_files, str(outdir))
420
+ content = (outdir / "build.gradle").read_text()
421
+ assert "compileSdk" in content
422
+
423
+ def test_build_gradle_appcompat(self, generator, todo_files, outdir):
424
+ generator.generate_android(todo_files, str(outdir))
425
+ content = (outdir / "build.gradle").read_text()
426
+ assert "appcompat" in content
427
+
428
+ def test_layout_xml_is_valid(self, generator, todo_files, outdir):
429
+ generator.generate_android(todo_files, str(outdir))
430
+ content = (outdir / "activity_main.xml").read_text()
431
+ assert '<?xml version="1.0"' in content
432
+ assert "layout_width" in content
433
+
434
+ def test_android_ecommerce(self, generator, ecommerce_files, outdir):
435
+ """Android generation works for ecommerce_app."""
436
+ generator.generate_android(ecommerce_files, str(outdir))
437
+ assert (outdir / "MainActivity.kt").exists()
438
+ assert (outdir / "AndroidManifest.xml").exists()
439
+
440
+
441
+ # ──────────────────────────────────────────────────────────────────────────────
442
+ # iOS generation
443
+ # ──────────────────────────────────────────────────────────────────────────────
444
+
445
+ class TestIOSGeneration:
446
+ """generate_ios() / generate('ios', ...) output validation."""
447
+
448
+ def test_creates_package_swift(self, generator, todo_files, outdir):
449
+ generator.generate_ios(todo_files, str(outdir))
450
+ assert (outdir / "Package.swift").exists()
451
+
452
+ def test_creates_content_view_swift(self, generator, todo_files, outdir):
453
+ generator.generate_ios(todo_files, str(outdir))
454
+ assert (outdir / "ContentView.swift").exists()
455
+
456
+ def test_creates_app_swift(self, generator, todo_files, outdir):
457
+ generator.generate_ios(todo_files, str(outdir))
458
+ assert (outdir / "App.swift").exists()
459
+
460
+ def test_package_swift_targets_ios14(self, generator, todo_files, outdir):
461
+ generator.generate_ios(todo_files, str(outdir))
462
+ content = (outdir / "Package.swift").read_text()
463
+ assert ".iOS(.v14)" in content
464
+
465
+ def test_package_swift_has_swift_tools_version(self, generator, todo_files, outdir):
466
+ generator.generate_ios(todo_files, str(outdir))
467
+ content = (outdir / "Package.swift").read_text()
468
+ assert "swift-tools-version" in content
469
+
470
+ def test_content_view_imports_swiftui(self, generator, todo_files, outdir):
471
+ generator.generate_ios(todo_files, str(outdir))
472
+ content = (outdir / "ContentView.swift").read_text()
473
+ assert "import SwiftUI" in content
474
+
475
+ def test_content_view_struct(self, generator, todo_files, outdir):
476
+ generator.generate_ios(todo_files, str(outdir))
477
+ content = (outdir / "ContentView.swift").read_text()
478
+ assert "struct ContentView" in content
479
+
480
+ def test_content_view_body_property(self, generator, todo_files, outdir):
481
+ generator.generate_ios(todo_files, str(outdir))
482
+ content = (outdir / "ContentView.swift").read_text()
483
+ assert "var body: some View" in content
484
+
485
+ def test_content_view_has_state(self, generator, todo_files, outdir):
486
+ generator.generate_ios(todo_files, str(outdir))
487
+ content = (outdir / "ContentView.swift").read_text()
488
+ assert "@State" in content
489
+
490
+ def test_app_swift_has_main_attribute(self, generator, todo_files, outdir):
491
+ generator.generate_ios(todo_files, str(outdir))
492
+ content = (outdir / "App.swift").read_text()
493
+ assert "@main" in content
494
+
495
+ def test_app_swift_imports_swiftui(self, generator, todo_files, outdir):
496
+ generator.generate_ios(todo_files, str(outdir))
497
+ content = (outdir / "App.swift").read_text()
498
+ assert "import SwiftUI" in content
499
+
500
+ def test_app_swift_window_group(self, generator, todo_files, outdir):
501
+ generator.generate_ios(todo_files, str(outdir))
502
+ content = (outdir / "App.swift").read_text()
503
+ assert "WindowGroup" in content
504
+
505
+ def test_ios_ecommerce(self, generator, ecommerce_files, outdir):
506
+ """iOS generation works for ecommerce_app."""
507
+ generator.generate_ios(ecommerce_files, str(outdir))
508
+ assert (outdir / "ContentView.swift").exists()
509
+ assert (outdir / "App.swift").exists()
510
+
511
+
512
+ # ──────────────────────────────────────────────────────────────────────────────
513
+ # generate() dispatcher
514
+ # ──────────────────────────────────────────────────────────────────────────────
515
+
516
+ class TestGenerateDispatcher:
517
+ """The generate() method routes to the correct target generator."""
518
+
519
+ def test_flutter(self, generator, todo_files, outdir):
520
+ generator.generate("flutter", todo_files, str(outdir))
521
+ assert (outdir / "pubspec.yaml").exists()
522
+
523
+ def test_react_native(self, generator, todo_files, outdir):
524
+ generator.generate("react-native", todo_files, str(outdir))
525
+ assert (outdir / "App.tsx").exists()
526
+
527
+ def test_web(self, generator, todo_files, outdir):
528
+ generator.generate("web", todo_files, str(outdir))
529
+ assert (outdir / "index.html").exists()
530
+
531
+ def test_android(self, generator, todo_files, outdir):
532
+ generator.generate("android", todo_files, str(outdir))
533
+ assert (outdir / "MainActivity.kt").exists()
534
+
535
+ def test_ios(self, generator, todo_files, outdir):
536
+ generator.generate("ios", todo_files, str(outdir))
537
+ assert (outdir / "ContentView.swift").exists()
538
+
539
+ def test_invalid_target_raises_value_error(self, generator, todo_files, outdir):
540
+ with pytest.raises(ValueError, match="Unsupported target"):
541
+ generator.generate("xamarin", todo_files, str(outdir))
542
+
543
+ def test_unsupported_target_message_includes_target(self, generator, todo_files, outdir):
544
+ with pytest.raises(ValueError, match="xamarin"):
545
+ generator.generate("xamarin", todo_files, str(outdir))
546
+
547
+ def test_case_insensitive_flutter(self, generator, todo_files, outdir):
548
+ """Target strings are lowercased before dispatch."""
549
+ generator.generate("Flutter", todo_files, str(outdir))
550
+ assert (outdir / "pubspec.yaml").exists()
551
+
552
+ def test_case_insensitive_android(self, generator, todo_files, outdir):
553
+ generator.generate("ANDROID", todo_files, str(outdir))
554
+ assert (outdir / "MainActivity.kt").exists()
555
+
556
+ def test_supported_targets_constant(self):
557
+ """SUPPORTED_TARGETS lists all 5 expected platforms."""
558
+ assert set(CodeGenerator.SUPPORTED_TARGETS) == {
559
+ "flutter", "react-native", "web", "android", "ios"
560
+ }
561
+
562
+
563
+ # ──────────────────────────────────────────────────────────────────────────────
564
+ # All targets × both apps
565
+ # ──────────────────────────────────────────────────────────────────────────────
566
+
567
+ class TestAllTargetsBothApps:
568
+ """Generate all 5 targets for both todo_app and ecommerce_app."""
569
+
570
+ @pytest.mark.parametrize("target", CodeGenerator.SUPPORTED_TARGETS)
571
+ def test_todo_app_all_targets(self, generator, todo_files, target):
572
+ """todo_app generates without error for every supported target."""
573
+ with tempfile.TemporaryDirectory() as tmpdir:
574
+ generator.generate(target, todo_files, tmpdir)
575
+ assert len(list(Path(tmpdir).iterdir())) > 0, (
576
+ f"No output files created for target '{target}'"
577
+ )
578
+
579
+ @pytest.mark.parametrize("target", CodeGenerator.SUPPORTED_TARGETS)
580
+ def test_ecommerce_app_all_targets(self, generator, ecommerce_files, target):
581
+ """ecommerce_app generates without error for every supported target."""
582
+ with tempfile.TemporaryDirectory() as tmpdir:
583
+ generator.generate(target, ecommerce_files, tmpdir)
584
+ assert len(list(Path(tmpdir).iterdir())) > 0, (
585
+ f"No output files created for target '{target}'"
586
+ )
587
+
588
+ @pytest.mark.parametrize("target,expected_file", [
589
+ ("flutter", "pubspec.yaml"),
590
+ ("react-native", "App.tsx"),
591
+ ("web", "index.html"),
592
+ ("android", "MainActivity.kt"),
593
+ ("ios", "ContentView.swift"),
594
+ ])
595
+ def test_key_output_file_per_target(self, generator, todo_files, target, expected_file):
596
+ """Each target produces its primary output file for todo_app."""
597
+ with tempfile.TemporaryDirectory() as tmpdir:
598
+ generator.generate(target, todo_files, tmpdir)
599
+ # Flutter nests main.dart under lib/
600
+ if target == "flutter":
601
+ assert (Path(tmpdir) / "lib" / "main.dart").exists()
602
+ else:
603
+ assert (Path(tmpdir) / expected_file).exists()