|
8 | 8 | import types |
9 | 9 | import unittest |
10 | 10 | from unittest.mock import MagicMock, patch |
| 11 | +from urllib.parse import parse_qs, urlsplit |
11 | 12 |
|
| 13 | +import responses |
12 | 14 | from typer.testing import CliRunner |
13 | 15 |
|
14 | 16 | from roboflow.cli import app |
| 17 | +from roboflow.config import API_URL |
15 | 18 |
|
16 | 19 | runner = CliRunner() |
17 | 20 |
|
@@ -464,9 +467,101 @@ def test_start_help_shows_train_recipe_flag_only(self) -> None: |
464 | 467 | self.assertEqual(result.exit_code, 0) |
465 | 468 | output = _strip_ansi(result.output) |
466 | 469 | self.assertIn("--train-recipe", output) |
| 470 | + self.assertIn("Action Recognition", output) |
| 471 | + self.assertIn("trainingId", output) |
467 | 472 | self.assertNotIn("--hyperparameters", output) |
468 | 473 |
|
469 | 474 |
|
| 475 | +class TestActionRecognitionTrainCLI(unittest.TestCase): |
| 476 | + """Exercise the public train command through its real HTTP adapters.""" |
| 477 | + |
| 478 | + BASE = f"{API_URL}/audit-ws/audit-project/1" |
| 479 | + |
| 480 | + def _invoke(self, model_type, *, json_output=True, recipe=None): |
| 481 | + args = ["--api-key", "fixture-key", "--workspace", "audit-ws", "--quiet"] |
| 482 | + if json_output: |
| 483 | + args.append("--json") |
| 484 | + args.extend(["train", "start", "-p", "audit-project", "-v", "1", "-t", model_type, "--epochs", "5"]) |
| 485 | + if recipe is not None: |
| 486 | + args.extend(["--train-recipe", recipe]) |
| 487 | + return runner.invoke(app, args) |
| 488 | + |
| 489 | + def test_recipe_free_cosmos_exports_video_coco_and_returns_id_in_json_and_text(self): |
| 490 | + for json_output in (True, False): |
| 491 | + with self.subTest(json_output=json_output), responses.RequestsMock() as mocked, patch("time.sleep"): |
| 492 | + mocked.add(responses.GET, self.BASE, json={"version": {"generating": False, "exports": []}}) |
| 493 | + mocked.add(responses.GET, f"{self.BASE}/video-coco", json={"export": {"link": "https://fixture.test"}}) |
| 494 | + mocked.add(responses.GET, self.BASE, json={"version": {"generating": False, "exports": ["video-coco"]}}) |
| 495 | + mocked.add( |
| 496 | + responses.POST, |
| 497 | + f"{self.BASE}/v2/trainings", |
| 498 | + json={"trainingId": "audit-t1", "status": "queued", "jobId": "audit-j1"}, |
| 499 | + ) |
| 500 | + |
| 501 | + result = self._invoke("cosmos3-edge", json_output=json_output) |
| 502 | + |
| 503 | + self.assertEqual(result.exit_code, 0, result.output) |
| 504 | + self.assertIn("audit-t1", result.stdout) |
| 505 | + if json_output: |
| 506 | + self.assertEqual(json.loads(result.stdout)["trainingId"], "audit-t1") |
| 507 | + paths = [urlsplit(call.request.url).path for call in mocked.calls] |
| 508 | + self.assertEqual( |
| 509 | + paths, |
| 510 | + [ |
| 511 | + "/audit-ws/audit-project/1", |
| 512 | + "/audit-ws/audit-project/1/video-coco", |
| 513 | + "/audit-ws/audit-project/1", |
| 514 | + "/audit-ws/audit-project/1/v2/trainings", |
| 515 | + ], |
| 516 | + ) |
| 517 | + create = mocked.calls[-1].request |
| 518 | + self.assertEqual(parse_qs(urlsplit(create.url).query)["api_key"], ["fixture-key"]) |
| 519 | + self.assertEqual(json.loads(create.body), {"modelType": "cosmos3-edge", "epochs": 5}) |
| 520 | + |
| 521 | + def test_recipe_free_other_models_keep_legacy_route(self): |
| 522 | + for model_type in ("rfdetr-medium", "cosmos3-edge-vlm"): |
| 523 | + with self.subTest(model_type=model_type), responses.RequestsMock() as mocked: |
| 524 | + mocked.add( |
| 525 | + responses.GET, |
| 526 | + self.BASE, |
| 527 | + json={"version": {"generating": False, "exports": ["coco", "yolov5pytorch"]}}, |
| 528 | + ) |
| 529 | + mocked.add(responses.POST, f"{self.BASE}/train", status=204) |
| 530 | + |
| 531 | + result = self._invoke(model_type) |
| 532 | + |
| 533 | + self.assertEqual(result.exit_code, 0, result.output) |
| 534 | + self.assertEqual(json.loads(result.stdout)["status"], "training_started") |
| 535 | + self.assertEqual( |
| 536 | + [urlsplit(call.request.url).path for call in mocked.calls][-1], "/audit-ws/audit-project/1/train" |
| 537 | + ) |
| 538 | + self.assertEqual(json.loads(mocked.calls[-1].request.body)["modelType"], model_type) |
| 539 | + |
| 540 | + def test_explicit_recipe_still_folds_epochs_and_uses_v2(self): |
| 541 | + with responses.RequestsMock() as mocked: |
| 542 | + mocked.add(responses.GET, self.BASE, json={"version": {"generating": False, "exports": ["video-coco"]}}) |
| 543 | + mocked.add(responses.POST, f"{self.BASE}/v2/trainings", json={"trainingId": "audit-t2", "status": "queued"}) |
| 544 | + |
| 545 | + result = self._invoke("cosmos3-edge", recipe='{"schema_version":1,"hyperparameters":{"lr":0.1}}') |
| 546 | + |
| 547 | + self.assertEqual(result.exit_code, 0, result.output) |
| 548 | + self.assertEqual(json.loads(result.stdout)["trainingId"], "audit-t2") |
| 549 | + self.assertEqual( |
| 550 | + json.loads(mocked.calls[-1].request.body)["trainRecipe"]["hyperparameters"], |
| 551 | + {"lr": 0.1, "epochs": 5}, |
| 552 | + ) |
| 553 | + self.assertEqual(len(mocked.calls), 2) |
| 554 | + |
| 555 | + def test_malformed_explicit_recipe_does_not_start_training(self): |
| 556 | + for recipe in ("", "{invalid"): |
| 557 | + with self.subTest(recipe=recipe), responses.RequestsMock() as mocked: |
| 558 | + result = self._invoke("cosmos3-edge", recipe=recipe) |
| 559 | + |
| 560 | + self.assertEqual(result.exit_code, 1) |
| 561 | + self.assertIn("Invalid JSON", result.output) |
| 562 | + self.assertEqual(len(mocked.calls), 0) |
| 563 | + |
| 564 | + |
470 | 565 | class TestTrainSubcommandsRegister(unittest.TestCase): |
471 | 566 | """train cancel/stop/results subcommands register correctly.""" |
472 | 567 |
|
|
0 commit comments