|
10 | 10 | import sys |
11 | 11 | from pathlib import Path |
12 | 12 | from types import SimpleNamespace |
| 13 | +from unittest.mock import MagicMock |
13 | 14 |
|
14 | 15 | ROOT = Path(__file__).resolve().parents[1] |
15 | 16 | SRC = ROOT / "src" |
@@ -145,10 +146,25 @@ def __init__(self, args, **kwargs): |
145 | 146 | def wait(self): |
146 | 147 | pass |
147 | 148 |
|
| 149 | + def poll(self): |
| 150 | + return 0 |
| 151 | + |
| 152 | + def __enter__(self): |
| 153 | + return self |
| 154 | + |
| 155 | + def __exit__(self, exc_type, exc_val, exc_tb): |
| 156 | + pass |
| 157 | + |
148 | 158 | class FakePipe: |
149 | 159 | def readline(self): |
150 | 160 | return b"" |
151 | 161 |
|
| 162 | + def fileno(self): |
| 163 | + return 3 |
| 164 | + |
| 165 | + def read(self, size): |
| 166 | + return b"" |
| 167 | + |
152 | 168 | monkeypatch.setattr(flash_usb_module.subprocess, "Popen", FakeProcess) |
153 | 169 |
|
154 | 170 | flash_usb("/dev/nvme0n1p1", str(iso)) |
@@ -362,3 +378,132 @@ def test_find_dn_returns_device_node(self, monkeypatch): |
362 | 378 | ) |
363 | 379 |
|
364 | 380 | assert find_usb_module.find_device_node() == "/dev/sdd1" |
| 381 | + |
| 382 | +class TestFlashUsbProgress: |
| 383 | + def _fake_popen_factory(self, stderr_chunks, popen_calls, envs): |
| 384 | + class FakePopen: |
| 385 | + def __init__(self, cmd, **kwargs): |
| 386 | + popen_calls.append((cmd, kwargs)) |
| 387 | + envs.append(kwargs.get("env")) |
| 388 | + self.cmd = cmd |
| 389 | + self.kwargs = kwargs |
| 390 | + self.pid = 999 |
| 391 | + self.returncode = 0 |
| 392 | + self.stderr = MagicMock() |
| 393 | + self.stderr.fileno.return_value = 3 |
| 394 | + |
| 395 | + # Generator for non-blocking read simulator |
| 396 | + def chunk_generator(): |
| 397 | + for chunk in stderr_chunks: |
| 398 | + yield chunk.encode("utf-8") |
| 399 | + while True: |
| 400 | + yield b"" |
| 401 | + |
| 402 | + self.gen = chunk_generator() |
| 403 | + |
| 404 | + def poll(self): |
| 405 | + return self.returncode |
| 406 | + |
| 407 | + def wait(self): |
| 408 | + return self.returncode |
| 409 | + |
| 410 | + @property |
| 411 | + def stderr_mock(self): |
| 412 | + # This matches the read(4096) call in flash_usb |
| 413 | + def read_mock(size): |
| 414 | + try: |
| 415 | + val = next(self.gen) |
| 416 | + return val if val else None # simulate non-blocking "None" for empty |
| 417 | + except StopIteration: |
| 418 | + return b"" |
| 419 | + return read_mock |
| 420 | + |
| 421 | + return FakePopen |
| 422 | + |
| 423 | + def test_progress_cb_receives_milestones_and_scaled_dd_progress(self, monkeypatch): |
| 424 | + progress_values = [] |
| 425 | + status_messages = [] |
| 426 | + |
| 427 | + def progress_cb(progress): |
| 428 | + progress_values.append(progress) |
| 429 | + |
| 430 | + def status_cb(status): |
| 431 | + status_messages.append(status) |
| 432 | + |
| 433 | + # 100MB ISO |
| 434 | + iso_size = 100 * 1024 * 1024 |
| 435 | + stderr_chunks = [ |
| 436 | + "0+0 records in\r", |
| 437 | + "0+0 records out\r", |
| 438 | + "10485760 bytes (10 MB) copied\r", # 10% |
| 439 | + "52428800 bytes (52 MB) copied\r", # 50% |
| 440 | + "104857600 bytes (105 MB) copied\n", # 100% |
| 441 | + ] |
| 442 | + |
| 443 | + popen_calls = [] |
| 444 | + envs = [] |
| 445 | + |
| 446 | + # We need to mock os.path.getsize, check_iso_signature, detect_iso_type, os.set_blocking |
| 447 | + monkeypatch.setattr(flash_usb_module.os.path, "getsize", lambda p: iso_size) |
| 448 | + monkeypatch.setattr(flash_usb_module, "check_iso_signature", lambda p: True) |
| 449 | + monkeypatch.setattr(flash_usb_module, "detect_iso_type", lambda p: flash_usb_module.IsoType.LINUX) |
| 450 | + monkeypatch.setattr(flash_usb_module.os, "set_blocking", lambda fd, block: None) |
| 451 | + |
| 452 | + fake_popen_class = self._fake_popen_factory(stderr_chunks, popen_calls, envs) |
| 453 | + |
| 454 | + # We must patch subprocess.Popen where it is used in flash_usb_module |
| 455 | + monkeypatch.setattr(flash_usb_module.subprocess, "Popen", |
| 456 | + lambda cmd, **kwargs: (p := fake_popen_class(cmd, **kwargs), |
| 457 | + setattr(p.stderr, 'read', p.stderr_mock), p)[2]) |
| 458 | + |
| 459 | + import os |
| 460 | + original_environ = dict(os.environ) |
| 461 | + |
| 462 | + flash_usb("/dev/sdb", "/path/to/image.iso", progress_cb=progress_cb, status_cb=status_cb) |
| 463 | + |
| 464 | + assert envs[0].get("LC_ALL") == "C" |
| 465 | + assert dict(os.environ) == original_environ |
| 466 | + |
| 467 | + # Milestones: 2, 5, 8, 10 |
| 468 | + assert 2 in progress_values |
| 469 | + assert 5 in progress_values |
| 470 | + assert 8 in progress_values |
| 471 | + assert 10 in progress_values |
| 472 | + |
| 473 | + # 10% raw -> 10 + 10*0.85 = 18.5 -> 18 |
| 474 | + # 50% raw -> 10 + 50*0.85 = 52.5 -> 52 |
| 475 | + # 100% raw -> 10 + 100*0.85 = 95 |
| 476 | + assert 18 in progress_values |
| 477 | + assert 52 in progress_values |
| 478 | + assert 95 in progress_values |
| 479 | + |
| 480 | + assert any("10%" in msg for msg in status_messages) |
| 481 | + assert any("50%" in msg for msg in status_messages) |
| 482 | + |
| 483 | + def test_non_progress_and_unexpected_stderr_lines_handling(self, monkeypatch, caplog): |
| 484 | + def progress_cb(p): pass |
| 485 | + def status_cb(s): pass |
| 486 | + |
| 487 | + iso_size = 1000 |
| 488 | + stderr_chunks = [ |
| 489 | + "1+0 records in\n", |
| 490 | + "some unexpected warning from dd\n", |
| 491 | + ] |
| 492 | + |
| 493 | + popen_calls = [] |
| 494 | + envs = [] |
| 495 | + |
| 496 | + monkeypatch.setattr(flash_usb_module.os.path, "getsize", lambda p: iso_size) |
| 497 | + monkeypatch.setattr(flash_usb_module, "check_iso_signature", lambda p: True) |
| 498 | + monkeypatch.setattr(flash_usb_module, "detect_iso_type", lambda p: flash_usb_module.IsoType.LINUX) |
| 499 | + monkeypatch.setattr(flash_usb_module.os, "set_blocking", lambda fd, block: None) |
| 500 | + |
| 501 | + fake_popen_class = self._fake_popen_factory(stderr_chunks, popen_calls, envs) |
| 502 | + monkeypatch.setattr(flash_usb_module.subprocess, "Popen", |
| 503 | + lambda cmd, **kwargs: (p := fake_popen_class(cmd, **kwargs), |
| 504 | + setattr(p.stderr, 'read', p.stderr_mock), p)[2]) |
| 505 | + |
| 506 | + with caplog.at_level("WARNING"): |
| 507 | + flash_usb("/dev/sdb", "/path/to/image.iso", progress_cb=progress_cb, status_cb=status_cb) |
| 508 | + |
| 509 | + assert any("some unexpected warning from dd" in record.getMessage() for record in caplog.records) |
0 commit comments