Files
DOLPHIN/Observability/TUI/test_dolphin_tui_log_tail.py

289 lines
9.8 KiB
Python
Raw Normal View History

"""
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: <path>"].
"""
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()