""" test_dolphin_tui_log_tail.py Verifies the tail_log() method in DolphinDataFetcher: - Uses seek(-N, 2) to read only the last N bytes (not the full file) - Returns the correct last N lines from a large file - Does not load the entire file into memory Tests: - test_tail_log_returns_last_n_lines - test_tail_log_large_file_seek_not_full_read - test_tail_log_large_file_correctness - test_tail_log_file_not_found - test_tail_log_small_file - test_tail_log_empty_file - test_tail_log_n_param """ from __future__ import annotations import os import sys import tempfile import unittest import types from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- # Make sure the TUI module is importable from this directory # --------------------------------------------------------------------------- sys.path.insert(0, os.path.dirname(__file__)) # --------------------------------------------------------------------------- # Stub hazelcast so the import succeeds without the package # --------------------------------------------------------------------------- _hz_stub = types.ModuleType("hazelcast") _hz_stub.HazelcastClient = MagicMock() sys.modules.setdefault("hazelcast", _hz_stub) # --------------------------------------------------------------------------- # Stub textual so dolphin_tui imports cleanly without a terminal # --------------------------------------------------------------------------- for _mod in ["textual", "textual.app", "textual.containers", "textual.widgets"]: if _mod not in sys.modules: sys.modules[_mod] = types.ModuleType(_mod) _textual_app = sys.modules["textual.app"] _textual_app.App = object _textual_app.ComposeResult = object _textual_containers = sys.modules["textual.containers"] _textual_containers.Horizontal = object _textual_widgets = sys.modules["textual.widgets"] _textual_widgets.Static = object _textual_widgets.VerticalScroll = object # --------------------------------------------------------------------------- # Stub httpx # --------------------------------------------------------------------------- if "httpx" not in sys.modules: _httpx_stub = types.ModuleType("httpx") _httpx_stub.AsyncClient = MagicMock() sys.modules["httpx"] = _httpx_stub from dolphin_tui import DolphinDataFetcher, LOG_TAIL_CHUNK_BYTES # noqa: E402 # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- def _make_fetcher() -> DolphinDataFetcher: return DolphinDataFetcher(hz_host="localhost", hz_port=5701) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestTailLogReturnsLastNLines(unittest.TestCase): """test_tail_log_returns_last_n_lines Create a temp file with 1000 known lines, call tail_log(path, 50), verify exactly the last 50 lines are returned. """ def setUp(self): self.tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) for i in range(1000): self.tmp.write(f"Line {i}\n") self.tmp.close() def tearDown(self): os.unlink(self.tmp.name) def test_tail_log_returns_last_n_lines(self): fetcher = _make_fetcher() result = fetcher.tail_log(self.tmp.name, n=50) self.assertEqual(len(result), 50, f"Expected 50 lines, got {len(result)}") # The last 50 lines should be Line 950 .. Line 999 for i, line in enumerate(result): expected = f"Line {950 + i}" self.assertEqual(line, expected, f"Line {i}: expected {expected!r}, got {line!r}") class TestTailLogLargeFileSeekNotFullRead(unittest.TestCase): """test_tail_log_large_file_seek_not_full_read Verify that tail_log uses seek(-chunk, 2) and does NOT call read() with the full file size. """ def setUp(self): # Write a file that is clearly larger than the chunk size self.tmp = tempfile.NamedTemporaryFile(mode="wb", suffix=".log", delete=False) line = b"2026-01-01 00:00:00 [INFO] padding " + b"x" * 100 + b"\n" # Write enough to be > LOG_TAIL_CHUNK_BYTES total = 0 while total < LOG_TAIL_CHUNK_BYTES * 3: self.tmp.write(line) total += len(line) self.tmp.close() self.file_size = os.path.getsize(self.tmp.name) def tearDown(self): os.unlink(self.tmp.name) def test_tail_log_large_file_seek_not_full_read(self): fetcher = _make_fetcher() read_sizes = [] original_open = open def spy_open(path, mode="r", **kwargs): fh = original_open(path, mode, **kwargs) original_read = fh.read def tracking_read(size=-1): read_sizes.append(size) return original_read(size) fh.read = tracking_read return fh with patch("builtins.open", side_effect=spy_open): fetcher.tail_log(self.tmp.name, n=50) # read() must never be called with the full file size self.assertNotIn( self.file_size, read_sizes, f"read() was called with full file size {self.file_size} — full file was loaded", ) # At least one read() call must have happened self.assertTrue(len(read_sizes) > 0, "read() was never called") class TestTailLogLargeFileCorrectness(unittest.TestCase): """test_tail_log_large_file_correctness Create a temp file >10MB of repeated log lines, call tail_log, verify the returned lines match the actual last N lines of the file. """ def setUp(self): self.tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False, encoding="utf-8") line_template = "2026-01-01 00:00:00 [INFO] Line {i} padding " + "x" * 100 self.lines = [] total_bytes = 0 i = 0 while total_bytes < 10 * 1024 * 1024: # 10 MB line = line_template.format(i=i) self.tmp.write(line + "\n") self.lines.append(line) total_bytes += len(line) + 1 i += 1 self.tmp.close() def tearDown(self): os.unlink(self.tmp.name) def test_tail_log_large_file_correctness(self): fetcher = _make_fetcher() result = fetcher.tail_log(self.tmp.name, n=50) expected = self.lines[-50:] self.assertEqual(len(result), 50, f"Expected 50 lines, got {len(result)}") self.assertEqual(result, expected, "Returned lines do not match the actual last 50 lines") class TestTailLogFileNotFound(unittest.TestCase): """test_tail_log_file_not_found When path doesn't exist, returns ["Log not found: "]. """ def test_tail_log_file_not_found(self): fetcher = _make_fetcher() missing = "/tmp/this_file_does_not_exist_dolphin_test_xyz.log" result = fetcher.tail_log(missing, n=50) self.assertEqual(len(result), 1) self.assertEqual(result[0], f"Log not found: {missing}") class TestTailLogSmallFile(unittest.TestCase): """test_tail_log_small_file File smaller than the seek chunk still returns correct lines (seek hits start of file via OSError fallback). """ def setUp(self): self.tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) for i in range(20): self.tmp.write(f"SmallLine {i}\n") self.tmp.close() def tearDown(self): os.unlink(self.tmp.name) def test_tail_log_small_file(self): fetcher = _make_fetcher() result = fetcher.tail_log(self.tmp.name, n=50) # File only has 20 lines — should return all 20 self.assertEqual(len(result), 20, f"Expected 20 lines, got {len(result)}") for i, line in enumerate(result): self.assertEqual(line, f"SmallLine {i}") class TestTailLogEmptyFile(unittest.TestCase): """test_tail_log_empty_file Empty file returns empty list. """ def setUp(self): self.tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) self.tmp.close() def tearDown(self): os.unlink(self.tmp.name) def test_tail_log_empty_file(self): fetcher = _make_fetcher() result = fetcher.tail_log(self.tmp.name, n=50) self.assertEqual(result, [], f"Expected empty list for empty file, got {result!r}") class TestTailLogNParam(unittest.TestCase): """test_tail_log_n_param Calling with n=10 returns exactly 10 lines, n=100 returns 100 lines (when file has enough). """ def setUp(self): self.tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) for i in range(500): self.tmp.write(f"NParamLine {i}\n") self.tmp.close() def tearDown(self): os.unlink(self.tmp.name) def test_tail_log_n_10(self): fetcher = _make_fetcher() result = fetcher.tail_log(self.tmp.name, n=10) self.assertEqual(len(result), 10, f"Expected 10 lines, got {len(result)}") for i, line in enumerate(result): self.assertEqual(line, f"NParamLine {490 + i}") def test_tail_log_n_100(self): fetcher = _make_fetcher() result = fetcher.tail_log(self.tmp.name, n=100) self.assertEqual(len(result), 100, f"Expected 100 lines, got {len(result)}") for i, line in enumerate(result): self.assertEqual(line, f"NParamLine {400 + i}") if __name__ == "__main__": unittest.main()