diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8777cdd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: pip install poetry + + - name: Install dependencies + run: poetry install --with dev + + - name: Run tests + run: poetry run pytest -v --tb=short diff --git a/README.md b/README.md index 782592a..a0fbefb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,22 @@ This tool is useful when you need system metrics but can't or don't want to run Basically, if you can run `vmstat`, you can get charts. No agents, no daemons, no network dependencies. +## How does it differ from top / htop? + +`top` and `htop` are live, interactive monitors — they show what's happening *right now* and the data is gone when you close the terminal. `vmstat-visualizer` is a post-hoc analysis tool for recorded `vmstat` logs. + +| | `top` / `htop` | `vmstat-visualizer` | +|---|---|---| +| **When** | Live, interactive | Offline, after-the-fact | +| **Input** | Reads `/proc` directly | Reads saved `vmstat` log files | +| **Output** | Terminal UI | PNG/SVG plot images | +| **Granularity** | Per-process | System-wide aggregates | +| **Comparison** | No | Compare two captures side-by-side | +| **Shareability** | Screenshot or nothing | Generates artifacts for reports/tickets | +| **Persistence** | Ephemeral | Works on logs captured hours/days ago | + +Think of it as: `vmstat` is the data recorder, `top`/`htop` are the live dashboards, and `vmstat-visualizer` is the flight data recorder playback tool. + ## Requirements - Python 3.7+ diff --git a/poetry.lock b/poetry.lock index cddfae1..a89c040 100644 --- a/poetry.lock +++ b/poetry.lock @@ -21,12 +21,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -markers = "platform_system == \"Windows\"" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "contourpy" @@ -217,6 +217,18 @@ type1 = ["xattr"] unicode = ["unicodedata2 (>=15.1.0)"] woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "kiwisolver" version = "1.4.9" @@ -497,7 +509,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -628,6 +640,37 @@ tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "ole typing = ["typing-extensions"] xmp = ["defusedxml"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyparsing" version = "3.2.5" @@ -643,6 +686,28 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pytest" +version = "9.0.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -673,4 +738,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.13.2" -content-hash = "4152e70e965fdc8ef031e2c8aa262a2146d4d7ebf400a2219649eafcd0d157f5" +content-hash = "ea6021efa52f71018de4adf5b3c04cb8262b2d0d7868d26d33bcce1fedd1d796" diff --git a/pyproject.toml b/pyproject.toml index f2f0819..63a036b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,15 @@ pyparsing = "3.2.5" python-dateutil = "2.9.0.post0" six = "1.17.0" +[tool.poetry.group.dev.dependencies] +pytest = ">=8.0" + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: end-to-end CLI tests that produce file output", +] + [tool.setuptools_scm] write_to = "vmstat_visualizer/cli/__version__.py" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7e35b09 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +import os +import pytest + +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") + + +@pytest.fixture +def active_log(): + return os.path.join(FIXTURES_DIR, "vmstat_active.log") + + +@pytest.fixture +def standard_log(): + return os.path.join(FIXTURES_DIR, "vmstat_standard.log") + + +@pytest.fixture +def no_header_log(): + return os.path.join(FIXTURES_DIR, "vmstat_no_header.log") + + +@pytest.fixture +def empty_log(): + return os.path.join(FIXTURES_DIR, "vmstat_empty.log") + + +@pytest.fixture +def header_only_log(): + return os.path.join(FIXTURES_DIR, "vmstat_header_only.log") + + +@pytest.fixture +def example_log(): + return os.path.join( + os.path.dirname(__file__), "..", "examples", "example.log" + ) diff --git a/tests/fixtures/vmstat_active.log b/tests/fixtures/vmstat_active.log new file mode 100644 index 0000000..42eeb06 --- /dev/null +++ b/tests/fixtures/vmstat_active.log @@ -0,0 +1,7 @@ +procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu------- + r b swpd free inact active si so bi bo in cs us sy id wa st gu + 3 0 0 523 128 1059 0 0 4 1 71 111 0 0 99 0 0 0 2025-08-01 10:06:32 + 1 0 0 523 128 1059 0 0 0 0 1815 3452 4 1 96 0 0 0 2025-08-01 10:06:33 + 1 0 0 523 128 1059 0 0 0 16 2530 4711 4 3 93 0 0 0 2025-08-01 10:06:34 + 1 0 0 523 128 1059 0 0 0 0 2061 3776 4 2 94 0 0 0 2025-08-01 10:06:35 + 1 0 0 523 128 1059 0 0 0 0 2181 4079 4 3 94 0 0 0 2025-08-01 10:06:36 diff --git a/tests/fixtures/vmstat_empty.log b/tests/fixtures/vmstat_empty.log new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/vmstat_header_only.log b/tests/fixtures/vmstat_header_only.log new file mode 100644 index 0000000..1b4da58 --- /dev/null +++ b/tests/fixtures/vmstat_header_only.log @@ -0,0 +1,2 @@ +procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu------- + r b swpd free inact active si so bi bo in cs us sy id wa st gu diff --git a/tests/fixtures/vmstat_no_header.log b/tests/fixtures/vmstat_no_header.log new file mode 100644 index 0000000..a25381d --- /dev/null +++ b/tests/fixtures/vmstat_no_header.log @@ -0,0 +1,3 @@ + 3 0 0 523 128 1059 0 0 4 1 71 111 0 0 99 0 0 0 2025-08-01 10:06:32 + 1 0 0 523 128 1059 0 0 0 0 1815 3452 4 1 96 0 0 0 2025-08-01 10:06:33 + 1 0 0 523 128 1059 0 0 0 16 2530 4711 4 3 93 0 0 0 2025-08-01 10:06:34 diff --git a/tests/fixtures/vmstat_standard.log b/tests/fixtures/vmstat_standard.log new file mode 100644 index 0000000..8aa7b0f --- /dev/null +++ b/tests/fixtures/vmstat_standard.log @@ -0,0 +1,7 @@ +procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- + r b swpd free buff cache si so bi bo in cs us sy id wa st gu + 2 0 0 15234 512 8192 0 0 12 8 200 450 5 2 92 1 0 0 2025-09-15 14:00:01 + 1 0 0 15100 512 8200 0 0 0 4 180 400 3 1 95 1 0 0 2025-09-15 14:00:02 + 1 0 0 14900 514 8210 0 0 4 12 220 480 8 3 88 1 0 0 2025-09-15 14:00:03 + 3 1 256 14500 514 8220 2 4 20 40 350 600 12 5 80 3 0 0 2025-09-15 14:00:04 + 1 0 256 14700 516 8230 0 0 0 8 190 420 4 2 93 1 0 0 2025-09-15 14:00:05 diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..2b879e1 --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,51 @@ +from unittest.mock import patch, MagicMock +import subprocess +import pytest +from vmstat_visualizer.checks.check import check_vmstat_columns + + +class TestCheckVmstatColumns: + VMSTAT_HEADER_WITH_ST_GU = ( + "procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------\n" + " r b swpd free inact active si so bi bo in cs us sy id wa st gu\n" + " 1 0 0 15234 128 1059 0 0 4 1 71 111 0 0 99 0 0 0\n" + ) + VMSTAT_HEADER_WITHOUT_GU = ( + "procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----\n" + " r b swpd free inact active si so bi bo in cs us sy id wa st\n" + " 1 0 0 15234 128 1059 0 0 4 1 71 111 0 0 99 0 0\n" + ) + + @patch("vmstat_visualizer.checks.check.subprocess.run") + def test_returns_true_true_when_st_and_gu_present(self, mock_run): + mock_run.return_value = MagicMock(stdout=self.VMSTAT_HEADER_WITH_ST_GU) + has_st, has_gu = check_vmstat_columns() + assert has_st is True + assert has_gu is True + + @patch("vmstat_visualizer.checks.check.subprocess.run") + def test_returns_true_false_when_gu_absent(self, mock_run): + mock_run.return_value = MagicMock(stdout=self.VMSTAT_HEADER_WITHOUT_GU) + has_st, has_gu = check_vmstat_columns() + assert has_st is True + assert has_gu is False + + @patch("vmstat_visualizer.checks.check.subprocess.run") + def test_returns_false_false_on_short_output(self, mock_run): + mock_run.return_value = MagicMock(stdout="only one line") + has_st, has_gu = check_vmstat_columns() + assert has_st is False + assert has_gu is False + + @patch("vmstat_visualizer.checks.check.subprocess.run", side_effect=FileNotFoundError) + def test_returns_false_false_on_missing_vmstat(self, mock_run): + has_st, has_gu = check_vmstat_columns() + assert has_st is False + assert has_gu is False + + @patch("vmstat_visualizer.checks.check.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "vmstat", stderr="fail")) + def test_returns_false_false_on_process_error(self, mock_run): + has_st, has_gu = check_vmstat_columns() + assert has_st is False + assert has_gu is False diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..073361e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,114 @@ +import os +import glob +import pytest +from unittest.mock import patch +from click.testing import CliRunner +from vmstat_visualizer.cli.__main__ import cli +import vmstat_visualizer.cli.visualizer as viz + + +@pytest.fixture(autouse=True) +def _register_commands(): + """Ensure commands are registered before each test.""" + cli.add_command(viz.visualize) + cli.add_command(viz.compare) + + +class TestCLIHelp: + def test_main_help(self): + runner = CliRunner() + result = runner.invoke(cli, ['--help']) + assert result.exit_code == 0 + assert 'visualize' in result.output + assert 'compare' in result.output + + def test_visualize_help(self): + runner = CliRunner() + result = runner.invoke(cli, ['visualize', '--help']) + assert result.exit_code == 0 + assert '--output-prefix' in result.output + assert '--output-extension' in result.output + + def test_compare_help(self): + runner = CliRunner() + result = runner.invoke(cli, ['compare', '--help']) + assert result.exit_code == 0 + assert '--metric' in result.output + assert '--column' in result.output + + +class TestVisualizeCommand: + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_active_log(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "viz") + result = runner.invoke(cli, [ + 'visualize', active_log, '-o', prefix, '-e', 'png' + ]) + assert result.exit_code == 0 + assert '5 time series entries' in result.output + pngs = glob.glob(str(tmp_path / "viz_*.png")) + assert len(pngs) == 5 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_example_log(self, mock_check, example_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "ex") + result = runner.invoke(cli, [ + 'visualize', example_log, '-o', prefix + ]) + assert result.exit_code == 0 + assert '30 time series entries' in result.output + + def test_visualize_missing_file(self, tmp_path): + runner = CliRunner() + result = runner.invoke(cli, [ + 'visualize', str(tmp_path / 'nonexistent.log') + ]) + assert result.exit_code != 0 + + +class TestCompareCommand: + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_compare_cpu(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "cmp") + result = runner.invoke(cli, [ + 'compare', active_log, active_log, + '-m', 'cpu', '-o', prefix + ]) + assert result.exit_code == 0 + assert 'Comparing' in result.output + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_compare_all(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "all") + result = runner.invoke(cli, [ + 'compare', active_log, active_log, + '-m', 'all', '-o', prefix + ]) + assert result.exit_code == 0 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_compare_with_column_filter(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "filt") + result = runner.invoke(cli, [ + 'compare', active_log, active_log, + '-m', 'cpu', '-c', 'us', '-c', 'sy', '-o', prefix + ]) + assert result.exit_code == 0 + assert 'Filtering columns: us, sy' in result.output + + def test_compare_invalid_metric(self, active_log): + runner = CliRunner() + result = runner.invoke(cli, [ + 'compare', active_log, active_log, '-m', 'bogus' + ]) + assert result.exit_code != 0 diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..92a1aba --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,164 @@ +import pytest +from unittest.mock import patch +from vmstat_visualizer.parser.parser import Parser + + +def _mock_check_vmstat_columns(): + """Mock check_vmstat_columns to avoid running vmstat subprocess.""" + return True, True + + +class TestParserInit: + def test_initial_state(self): + p = Parser("dummy.log") + assert p.filename == "dummy.log" + assert p.timeseries == [] + assert p.enabled_columns == [] + + +class TestParseExampleLog: + """Parse the shipped example.log fixture (headerless, vmstat -a with gu).""" + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_entry_count(self, mock_check, example_log): + p = Parser(example_log) + p.parse() + assert len(p.timeseries) == 30 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_first_entry_values(self, mock_check, example_log): + p = Parser(example_log) + p.parse() + first = p.timeseries[0] + assert first.run_queue == '3' + assert first.blocked_processes == '0' + assert first.swapped_memory_kb == '0' + assert first.free_memory_kb == '523' + assert first.inactive_memory_kb == '128' + assert first.active_memory_kb == '1059' + assert first.swap_in_kb == '0' + assert first.swap_out_kb == '0' + assert first.blocks_in == '4' + assert first.blocks_out == '1' + assert first.user_cpu_percent == '0' + assert first.system_cpu_percent == '0' + assert first.idle_cpu_percent == '99' + assert first.wait_cpu_percent == '0' + assert first.steal_cpu_percent == '0' + assert first.guest_cpu_percent == '0' + assert first.time == '2025-08-01 10:06:32' + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_last_entry_time(self, mock_check, example_log): + p = Parser(example_log) + p.parse() + assert p.timeseries[-1].time == '2025-08-01 10:07:01' + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_timestamps_sequential(self, mock_check, example_log): + p = Parser(example_log) + p.parse() + times = [e.time for e in p.timeseries] + assert times == sorted(times) + + +class TestParseActiveFixture: + """Parse the vmstat -a fixture file that has header rows.""" + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_entry_count(self, mock_check, active_log): + p = Parser(active_log) + p.parse() + assert len(p.timeseries) == 5 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_skips_header_and_procs_lines(self, mock_check, active_log): + p = Parser(active_log) + p.parse() + for ts_entry in p.timeseries: + assert ts_entry.time is not None + assert ts_entry.run_queue is not None + + +class TestParseNoHeaderFixture: + """Parse a file with no header row (raw data only).""" + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_entry_count(self, mock_check, no_header_log): + p = Parser(no_header_log) + p.parse() + assert len(p.timeseries) == 3 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_first_entry(self, mock_check, no_header_log): + p = Parser(no_header_log) + p.parse() + first = p.timeseries[0] + assert first.run_queue == '3' + assert first.time == '2025-08-01 10:06:32' + + +class TestParseEdgeCases: + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_empty_file(self, mock_check, empty_log): + p = Parser(empty_log) + p.parse() + assert len(p.timeseries) == 0 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_header_only_file(self, mock_check, header_only_log): + p = Parser(header_only_log) + p.parse() + assert len(p.timeseries) == 0 + + +class TestPlotMetrics: + """Validate PlotMetrics correctly aggregates timeseries data.""" + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_absolute_time(self, mock_check, active_log): + p = Parser(active_log) + p.parse() + pm = p.PlotMetrics(p.timeseries) + assert len(pm.t) == 5 + assert pm.t[0] == '06:32' + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_relative_time(self, mock_check, active_log): + p = Parser(active_log) + p.parse() + pm = p.PlotMetrics(p.timeseries, relative_time=True) + assert pm.t == [0, 1, 2, 3, 4] + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_metric_lists_length(self, mock_check, active_log): + p = Parser(active_log) + p.parse() + pm = p.PlotMetrics(p.timeseries) + assert len(pm.run_queue) == 5 + assert len(pm.user_cpu_percent) == 5 + assert len(pm.blocks_in) == 5 + assert len(pm.swap_in_kb) == 5 + assert len(pm.inactive_memory_kb) == 5 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_metric_values(self, mock_check, example_log): + p = Parser(example_log) + p.parse() + pm = p.PlotMetrics(p.timeseries) + assert pm.run_queue[0] == '3' + assert pm.idle_cpu_percent[0] == '99' diff --git a/tests/test_plot.py b/tests/test_plot.py new file mode 100644 index 0000000..48f1256 --- /dev/null +++ b/tests/test_plot.py @@ -0,0 +1,85 @@ +import os +import glob +import pytest +import matplotlib +matplotlib.use('Agg') +from unittest.mock import patch +from vmstat_visualizer.parser.parser import Parser + + +class TestPlotOutput: + """Integration: parse a fixture and verify plot files are created.""" + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_creates_five_plots(self, mock_check, active_log, tmp_path): + p = Parser(active_log) + p.parse() + prefix = str(tmp_path / "test_plot") + p.plot(output_file_prefix=prefix, output_format='png') + + pngs = glob.glob(str(tmp_path / "test_plot_*.png")) + assert len(pngs) == 5 + expected_parts = ['system_load', 'memory', 'cpu', 'swap', 'io'] + basenames = [os.path.basename(f) for f in pngs] + for part in expected_parts: + assert any(part in b for b in basenames), f"Missing plot for '{part}'" + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_svg_output_format(self, mock_check, active_log, tmp_path): + p = Parser(active_log) + p.parse() + prefix = str(tmp_path / "svg_plot") + p.plot(output_file_prefix=prefix, output_format='svg') + svgs = glob.glob(str(tmp_path / "svg_plot_*.svg")) + assert len(svgs) == 5 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_plot_example_log(self, mock_check, example_log, tmp_path): + p = Parser(example_log) + p.parse() + prefix = str(tmp_path / "ex_plot") + p.plot(output_file_prefix=prefix, output_format='png') + pngs = glob.glob(str(tmp_path / "ex_plot_*.png")) + assert len(pngs) == 5 + + +class TestComparisonPlot: + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_comparison_cpu(self, mock_check, active_log, tmp_path): + p1 = Parser(active_log) + p1.parse() + p2 = Parser(active_log) + p2.parse() + prefix = str(tmp_path / "cmp") + p1.plot_comparison(p2, output_file_prefix=prefix, + output_format='png', metric='cpu') + pngs = glob.glob(str(tmp_path / "cmp_*.png")) + assert len(pngs) >= 1 + assert any('cpu_comparison' in os.path.basename(f) for f in pngs) + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_comparison_all(self, mock_check, active_log, tmp_path): + p1 = Parser(active_log) + p1.parse() + p2 = Parser(active_log) + p2.parse() + prefix = str(tmp_path / "cmp_all") + p1.plot_comparison(p2, output_file_prefix=prefix, + output_format='png', metric='all') + pngs = glob.glob(str(tmp_path / "cmp_all_*.png")) + assert len(pngs) == 5 + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_comparison_invalid_metric_raises(self, mock_check, active_log): + p1 = Parser(active_log) + p1.parse() + p2 = Parser(active_log) + p2.parse() + with pytest.raises(ValueError, match="Unknown metric"): + p1.plot_comparison(p2, metric='nonexistent') diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py new file mode 100644 index 0000000..c697203 --- /dev/null +++ b/tests/test_timeseries.py @@ -0,0 +1,110 @@ +import pytest +from vmstat_visualizer.parser.timeseries import Timeseries + + +class TestTimeseriesInit: + """All metric attributes start as None; raw_data starts as empty dict.""" + + METRIC_ATTRS = [ + 'time', 'run_queue', 'blocked_processes', + 'swapped_memory_kb', 'free_memory_kb', + 'inactive_memory_kb', 'active_memory_kb', + 'swap_in_kb', 'swap_out_kb', + 'blocks_in', 'blocks_out', + 'interrupts', 'context_switches', + 'user_cpu_percent', 'system_cpu_percent', + 'idle_cpu_percent', 'wait_cpu_percent', + 'steal_cpu_percent', 'guest_cpu_percent', + ] + + def test_all_metric_attributes_none(self): + ts = Timeseries() + for attr in self.METRIC_ATTRS: + assert getattr(ts, attr) is None, f"{attr} should be None" + + def test_raw_data_starts_empty(self): + ts = Timeseries() + assert ts.raw_data == {} + + +class TestAddDataPoint: + def test_stores_list(self): + ts = Timeseries() + data = ['3', '0', '0', '523', '128', '1059', '0', '0', + '4', '1', '71', '111', '0', '0', '99', '0', '0', '0', + '2025-08-01 10:06:32'] + ts.add_data_point(data) + assert ts.raw_data == data + + def test_replaces_previous_data(self): + ts = Timeseries() + ts.add_data_point(['a', 'b']) + ts.add_data_point(['c', 'd']) + assert ts.raw_data == ['c', 'd'] + + +class TestCommitRawData: + """commit_raw_data maps positional values from raw_data to named attributes + using the hardcoded vmstat -a header order: + r b swpd free inact active si so bi bo in cs us sy id wa st gu time + """ + + def _make_ts(self, data): + ts = Timeseries() + ts.add_data_point(data) + ts.commit_raw_data() + return ts + + def test_maps_all_19_columns(self): + data = ['3', '0', '0', '523', '128', '1059', '0', '0', + '4', '1', '71', '111', '0', '0', '99', '0', '0', '0', + '2025-08-01 10:06:32'] + ts = self._make_ts(data) + assert ts.run_queue == '3' + assert ts.blocked_processes == '0' + assert ts.swapped_memory_kb == '0' + assert ts.free_memory_kb == '523' + assert ts.inactive_memory_kb == '128' + assert ts.active_memory_kb == '1059' + assert ts.swap_in_kb == '0' + assert ts.swap_out_kb == '0' + assert ts.blocks_in == '4' + assert ts.blocks_out == '1' + assert ts.interrupts == '71' + assert ts.context_switches == '111' + assert ts.user_cpu_percent == '0' + assert ts.system_cpu_percent == '0' + assert ts.idle_cpu_percent == '99' + assert ts.wait_cpu_percent == '0' + assert ts.steal_cpu_percent == '0' + assert ts.guest_cpu_percent == '0' + assert ts.time == '2025-08-01 10:06:32' + + def test_cpu_values(self): + data = ['1', '0', '0', '500', '100', '2000', '0', '0', + '10', '5', '200', '300', '15', '2', '84', '0', '0', '0', + '2025-08-01 10:06:58'] + ts = self._make_ts(data) + assert ts.user_cpu_percent == '15' + assert ts.system_cpu_percent == '2' + assert ts.idle_cpu_percent == '84' + + +class TestRepr: + def test_repr_contains_key_fields(self): + ts = Timeseries() + r = repr(ts) + assert 'Timeseries(' in r + assert 'run_queue=None' in r + assert 'time=None' in r + assert 'raw_data={}' in r + + def test_repr_after_commit(self): + ts = Timeseries() + ts.add_data_point(['3', '0', '0', '523', '128', '1059', '0', '0', + '4', '1', '71', '111', '0', '0', '99', '0', '0', '0', + '2025-08-01 10:06:32']) + ts.commit_raw_data() + r = repr(ts) + assert "run_queue=3" in r + assert "time=2025-08-01 10:06:32" in r