0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
743 lines
25 KiB
Python
743 lines
25 KiB
Python
"""Comprehensive tests for log_compressor.py.
|
|
|
|
Tests cover:
|
|
1. Detection of different log formats (pytest, npm, cargo, make, jest, generic)
|
|
2. Line extraction and deduplication
|
|
3. Compression ratios
|
|
4. Edge cases
|
|
"""
|
|
|
|
from headroom.transforms.log_compressor import (
|
|
LogCompressionResult,
|
|
LogCompressor,
|
|
LogCompressorConfig,
|
|
LogFormat,
|
|
LogLevel,
|
|
LogLine,
|
|
)
|
|
|
|
|
|
class TestLogFormatDetection:
|
|
"""Tests for detecting different log formats."""
|
|
|
|
def test_detect_pytest_format(self):
|
|
"""Pytest output is detected correctly."""
|
|
content = """============================= test session starts ==============================
|
|
platform darwin -- Python 3.11.0
|
|
collected 15 items
|
|
|
|
tests/test_foo.py::test_basic PASSED [ 6%]
|
|
tests/test_foo.py::test_edge FAILED [ 13%]
|
|
|
|
=================================== FAILURES ===================================
|
|
tests/test_foo.py::test_edge - AssertionError
|
|
|
|
=========================== short test summary info ============================
|
|
FAILED tests/test_foo.py::test_edge
|
|
========================= 1 failed, 14 passed =========================
|
|
"""
|
|
compressor = LogCompressor()
|
|
lines = content.split("\n")
|
|
detected = compressor._detect_format(lines)
|
|
assert detected == LogFormat.PYTEST
|
|
|
|
def test_detect_npm_format(self):
|
|
"""npm output is detected correctly."""
|
|
content = """npm WARN deprecated package@1.0.0: This package is deprecated
|
|
npm WARN deprecated another@2.0.0: Obsolete
|
|
npm ERR! code ERESOLVE
|
|
npm ERR! ERESOLVE unable to resolve dependency tree
|
|
npm info using npm@9.0.0
|
|
> added 150 packages in 5s
|
|
"""
|
|
compressor = LogCompressor()
|
|
lines = content.split("\n")
|
|
detected = compressor._detect_format(lines)
|
|
assert detected == LogFormat.NPM
|
|
|
|
def test_detect_cargo_format(self):
|
|
"""Cargo/rustc output is detected correctly."""
|
|
content = """ Compiling myproject v0.1.0 (/path/to/project)
|
|
warning: unused variable: `x`
|
|
--> src/main.rs:5:9
|
|
|
|
|
5 | let x = 5;
|
|
| ^ help: if this is intentional, prefix it with an underscore: `_x`
|
|
|
|
|
= note: `#[warn(unused_variables)]` on by default
|
|
|
|
error[E0382]: borrow of moved value: `s`
|
|
Finished dev [unoptimized + debuginfo] target(s) in 0.50s
|
|
Running `target/debug/myproject`
|
|
"""
|
|
compressor = LogCompressor()
|
|
lines = content.split("\n")
|
|
detected = compressor._detect_format(lines)
|
|
assert detected == LogFormat.CARGO
|
|
|
|
def test_detect_make_format(self):
|
|
"""make/gcc output is detected correctly."""
|
|
content = """make[1]: Entering directory '/path/to/project'
|
|
gcc -c -o main.o main.c
|
|
gcc -c -o utils.o utils.c
|
|
make[1]: *** [Makefile:10: utils.o] Error 1
|
|
make: *** [Makefile:5: all] Error 2
|
|
g++ -Wall -o program main.cpp utils.cpp
|
|
"""
|
|
compressor = LogCompressor()
|
|
lines = content.split("\n")
|
|
detected = compressor._detect_format(lines)
|
|
assert detected == LogFormat.MAKE
|
|
|
|
def test_detect_jest_format(self):
|
|
"""Jest output is detected correctly."""
|
|
content = """PASS src/components/Button.test.js
|
|
FAIL src/utils/helpers.test.ts
|
|
Test Suites: 1 failed, 1 passed, 2 total
|
|
Tests: 2 failed, 10 passed, 12 total
|
|
"""
|
|
compressor = LogCompressor()
|
|
lines = content.split("\n")
|
|
detected = compressor._detect_format(lines)
|
|
assert detected == LogFormat.JEST
|
|
|
|
def test_detect_generic_format(self):
|
|
"""Generic log format is detected for unrecognized output."""
|
|
content = """INFO Starting application
|
|
DEBUG Initializing components
|
|
WARNING Low memory
|
|
ERROR Connection timeout
|
|
CRITICAL System failure
|
|
"""
|
|
compressor = LogCompressor()
|
|
lines = content.split("\n")
|
|
detected = compressor._detect_format(lines)
|
|
assert detected == LogFormat.GENERIC
|
|
|
|
def test_detect_empty_returns_generic(self):
|
|
"""Empty or minimal input returns GENERIC."""
|
|
compressor = LogCompressor()
|
|
assert compressor._detect_format([]) == LogFormat.GENERIC
|
|
assert compressor._detect_format(["random line"]) == LogFormat.GENERIC
|
|
|
|
|
|
class TestLogLevelDetection:
|
|
"""Tests for log level detection in lines."""
|
|
|
|
def test_detect_error_levels(self):
|
|
"""ERROR, FATAL, CRITICAL are detected."""
|
|
compressor = LogCompressor()
|
|
|
|
error_lines = [
|
|
"ERROR: something went wrong",
|
|
"error: file not found",
|
|
"Error: Invalid input",
|
|
"FATAL: system crash",
|
|
"fatal error occurred",
|
|
"CRITICAL: database down",
|
|
]
|
|
|
|
for line in error_lines:
|
|
log_lines = compressor._parse_lines([line])
|
|
assert log_lines[0].level == LogLevel.ERROR, f"Failed for: {line}"
|
|
|
|
def test_detect_fail_levels(self):
|
|
"""FAIL, FAILED are detected."""
|
|
compressor = LogCompressor()
|
|
|
|
fail_lines = [
|
|
"FAIL tests/test_foo.py",
|
|
"FAILED to connect",
|
|
"Test failed",
|
|
]
|
|
|
|
for line in fail_lines:
|
|
log_lines = compressor._parse_lines([line])
|
|
assert log_lines[0].level == LogLevel.FAIL, f"Failed for: {line}"
|
|
|
|
def test_detect_warn_levels(self):
|
|
"""WARN, WARNING are detected."""
|
|
compressor = LogCompressor()
|
|
|
|
warn_lines = [
|
|
"WARN: deprecated function",
|
|
"WARNING: low disk space",
|
|
"warning: unused variable",
|
|
]
|
|
|
|
for line in warn_lines:
|
|
log_lines = compressor._parse_lines([line])
|
|
assert log_lines[0].level == LogLevel.WARN, f"Failed for: {line}"
|
|
|
|
def test_detect_info_debug_trace(self):
|
|
"""INFO, DEBUG, TRACE are detected."""
|
|
compressor = LogCompressor()
|
|
|
|
test_cases = [
|
|
("INFO: starting process", LogLevel.INFO),
|
|
("info starting", LogLevel.INFO),
|
|
("DEBUG: variable x = 5", LogLevel.DEBUG),
|
|
("debug mode enabled", LogLevel.DEBUG),
|
|
("TRACE: entering function", LogLevel.TRACE),
|
|
]
|
|
|
|
for line, expected_level in test_cases:
|
|
log_lines = compressor._parse_lines([line])
|
|
assert log_lines[0].level == expected_level, f"Failed for: {line}"
|
|
|
|
def test_unknown_level_default(self):
|
|
"""Lines without level markers default to UNKNOWN."""
|
|
compressor = LogCompressor()
|
|
log_lines = compressor._parse_lines(["Just some regular text"])
|
|
assert log_lines[0].level == LogLevel.UNKNOWN
|
|
|
|
|
|
class TestStackTraceDetection:
|
|
"""Tests for stack trace detection."""
|
|
|
|
def test_detect_python_traceback(self):
|
|
"""Python traceback is detected."""
|
|
content = """Traceback (most recent call last):
|
|
File "main.py", line 42, in process
|
|
result = compute(data)
|
|
File "utils.py", line 15, in compute
|
|
return data / 0
|
|
ZeroDivisionError: division by zero
|
|
"""
|
|
compressor = LogCompressor()
|
|
log_lines = compressor._parse_lines(content.split("\n"))
|
|
|
|
# First several lines should be marked as stack trace
|
|
stack_trace_count = sum(1 for line in log_lines if line.is_stack_trace)
|
|
assert stack_trace_count > 0
|
|
|
|
def test_detect_javascript_stack_trace(self):
|
|
"""JavaScript stack trace is detected."""
|
|
content = """Error: Connection failed
|
|
at Connection.connect (src/db.js:42:15)
|
|
at async main (src/index.js:10:5)
|
|
"""
|
|
compressor = LogCompressor()
|
|
log_lines = compressor._parse_lines(content.split("\n"))
|
|
|
|
stack_trace_count = sum(1 for line in log_lines if line.is_stack_trace)
|
|
assert stack_trace_count > 0
|
|
|
|
def test_detect_rust_error_location(self):
|
|
"""Rust error location is detected."""
|
|
content = """error[E0382]: borrow of moved value: `s`
|
|
--> src/main.rs:5:13
|
|
|
|
|
3 | let s = String::from("hello");
|
|
| - move occurs
|
|
"""
|
|
compressor = LogCompressor()
|
|
log_lines = compressor._parse_lines(content.split("\n"))
|
|
|
|
stack_trace_count = sum(1 for line in log_lines if line.is_stack_trace)
|
|
assert stack_trace_count > 0
|
|
|
|
|
|
class TestLineDeduplication:
|
|
"""Tests for warning/line deduplication."""
|
|
|
|
def test_dedupe_identical_warnings(self):
|
|
"""Identical warnings are deduplicated."""
|
|
compressor = LogCompressor()
|
|
|
|
lines = [
|
|
LogLine(line_number=1, content="WARNING: unused variable 'x'", level=LogLevel.WARN),
|
|
LogLine(line_number=2, content="WARNING: unused variable 'x'", level=LogLevel.WARN),
|
|
LogLine(line_number=3, content="WARNING: unused variable 'x'", level=LogLevel.WARN),
|
|
]
|
|
|
|
deduped = compressor._dedupe_similar(lines)
|
|
assert len(deduped) == 1
|
|
|
|
def test_dedupe_similar_with_numbers(self):
|
|
"""Similar warnings with different numbers are deduplicated."""
|
|
compressor = LogCompressor()
|
|
|
|
lines = [
|
|
LogLine(line_number=1, content="WARNING: error at line 10", level=LogLevel.WARN),
|
|
LogLine(line_number=2, content="WARNING: error at line 20", level=LogLevel.WARN),
|
|
LogLine(line_number=3, content="WARNING: error at line 30", level=LogLevel.WARN),
|
|
]
|
|
|
|
deduped = compressor._dedupe_similar(lines)
|
|
# Numbers normalized to "N", so all three are treated as identical pattern
|
|
assert len(deduped) == 1
|
|
|
|
def test_dedupe_similar_with_paths(self):
|
|
"""Similar warnings with different paths are deduplicated.
|
|
|
|
Note: The path regex /[\\w/]+/ requires paths to end with '/'.
|
|
Paths like '/path/to/' will be normalized, but '/path/to/file' won't
|
|
be fully normalized because 'file' doesn't end with '/'.
|
|
"""
|
|
compressor = LogCompressor()
|
|
|
|
# Paths ending with / are normalized
|
|
lines = [
|
|
LogLine(line_number=1, content="WARNING: in /path/to/ error", level=LogLevel.WARN),
|
|
LogLine(line_number=2, content="WARNING: in /other/dir/ error", level=LogLevel.WARN),
|
|
LogLine(line_number=3, content="WARNING: in /another/path/ error", level=LogLevel.WARN),
|
|
]
|
|
|
|
deduped = compressor._dedupe_similar(lines)
|
|
# Paths normalized to /PATH/, so all three are treated as identical pattern
|
|
assert len(deduped) == 1
|
|
|
|
def test_keeps_different_warnings(self):
|
|
"""Different warnings are preserved."""
|
|
compressor = LogCompressor()
|
|
|
|
lines = [
|
|
LogLine(line_number=1, content="WARNING: unused variable", level=LogLevel.WARN),
|
|
LogLine(line_number=2, content="WARNING: deprecated function", level=LogLevel.WARN),
|
|
LogLine(line_number=3, content="WARNING: missing docstring", level=LogLevel.WARN),
|
|
]
|
|
|
|
deduped = compressor._dedupe_similar(lines)
|
|
assert len(deduped) == 3
|
|
|
|
|
|
class TestLineScoring:
|
|
"""Tests for line importance scoring."""
|
|
|
|
def test_error_lines_score_highest(self):
|
|
"""ERROR and FAIL lines get highest scores."""
|
|
compressor = LogCompressor()
|
|
|
|
error_line = LogLine(line_number=1, content="ERROR: critical", level=LogLevel.ERROR)
|
|
fail_line = LogLine(line_number=2, content="FAILED test", level=LogLevel.FAIL)
|
|
info_line = LogLine(line_number=3, content="INFO: normal", level=LogLevel.INFO)
|
|
|
|
error_score = compressor._score_line(error_line)
|
|
fail_score = compressor._score_line(fail_line)
|
|
info_score = compressor._score_line(info_line)
|
|
|
|
assert error_score > info_score
|
|
assert fail_score > info_score
|
|
|
|
def test_stack_trace_boost(self):
|
|
"""Stack trace lines get boosted score."""
|
|
compressor = LogCompressor()
|
|
|
|
regular = LogLine(line_number=1, content="some line", level=LogLevel.UNKNOWN)
|
|
stack_trace = LogLine(
|
|
line_number=2, content=" File 'x.py'", level=LogLevel.UNKNOWN, is_stack_trace=True
|
|
)
|
|
|
|
assert compressor._score_line(stack_trace) > compressor._score_line(regular)
|
|
|
|
def test_summary_line_boost(self):
|
|
"""Summary lines get boosted score."""
|
|
compressor = LogCompressor()
|
|
|
|
regular = LogLine(line_number=1, content="some line", level=LogLevel.UNKNOWN)
|
|
summary = LogLine(
|
|
line_number=2, content="10 passed, 2 failed", level=LogLevel.UNKNOWN, is_summary=True
|
|
)
|
|
|
|
assert compressor._score_line(summary) > compressor._score_line(regular)
|
|
|
|
|
|
class TestCompressionBehavior:
|
|
"""Tests for overall compression behavior."""
|
|
|
|
def test_small_log_passthrough(self):
|
|
"""Logs smaller than threshold pass through unchanged."""
|
|
content = "INFO: Starting\nINFO: Done"
|
|
|
|
compressor = LogCompressor(config=LogCompressorConfig(min_lines_for_ccr=100))
|
|
result = compressor.compress(content)
|
|
|
|
assert result.compression_ratio == 1.0
|
|
assert result.compressed == content
|
|
assert result.original_line_count == 2
|
|
|
|
def test_large_log_compressed(self):
|
|
"""Large logs are compressed."""
|
|
lines = [f"INFO: Processing item {i}" for i in range(200)]
|
|
lines.append("ERROR: Failed at item 100")
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=50,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
assert result.compression_ratio < 1.0
|
|
assert result.compressed_line_count < result.original_line_count
|
|
# Error is preserved
|
|
assert "ERROR: Failed" in result.compressed
|
|
|
|
def test_keeps_first_and_last_errors(self):
|
|
"""First and last errors are preserved."""
|
|
lines = [f"INFO: item {i}" for i in range(100)]
|
|
lines[10] = "ERROR: first error"
|
|
lines[50] = "ERROR: middle error"
|
|
lines[90] = "ERROR: last error"
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=50,
|
|
keep_first_error=True,
|
|
keep_last_error=True,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
assert "first error" in result.compressed
|
|
assert "last error" in result.compressed
|
|
|
|
def test_summary_lines_preserved(self):
|
|
"""Summary lines are always preserved."""
|
|
content = """INFO: test 1
|
|
INFO: test 2
|
|
========================================
|
|
TOTAL: 10 tests passed
|
|
Build succeeded in 5.2s
|
|
"""
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=2,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
assert "========" in result.compressed
|
|
assert "TOTAL:" in result.compressed or "Build succeeded" in result.compressed
|
|
|
|
def test_context_lines_added(self):
|
|
"""Context lines around errors are included."""
|
|
lines = [f"INFO: item {i}" for i in range(100)]
|
|
lines[50] = "ERROR: critical failure"
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=50,
|
|
error_context_lines=2,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should have context around the error
|
|
assert "item 48" in result.compressed or "item 49" in result.compressed
|
|
assert "item 51" in result.compressed or "item 52" in result.compressed
|
|
|
|
|
|
class TestCompressionRatios:
|
|
"""Tests for compression ratio calculations."""
|
|
|
|
def test_compression_ratio_calculation(self):
|
|
"""Compression ratio is calculated correctly."""
|
|
content = "a" * 1000 # 1000 chars
|
|
compressed = "b" * 100 # 100 chars
|
|
|
|
# Direct calculation: len(compressed) / len(content)
|
|
expected_ratio = 100 / 1000 # 0.1
|
|
|
|
# Result ratio is based on character counts
|
|
result = LogCompressionResult(
|
|
compressed=compressed,
|
|
original=content,
|
|
original_line_count=100,
|
|
compressed_line_count=10,
|
|
format_detected=LogFormat.GENERIC,
|
|
compression_ratio=len(compressed) / len(content),
|
|
)
|
|
|
|
assert result.compression_ratio == expected_ratio
|
|
|
|
def test_tokens_saved_estimate(self):
|
|
"""Token savings estimation works correctly."""
|
|
content = "a" * 400 # ~100 tokens
|
|
compressed = "b" * 40 # ~10 tokens
|
|
|
|
result = LogCompressionResult(
|
|
compressed=compressed,
|
|
original=content,
|
|
original_line_count=10,
|
|
compressed_line_count=1,
|
|
format_detected=LogFormat.GENERIC,
|
|
compression_ratio=0.1,
|
|
)
|
|
|
|
# (400 - 40) / 4 = 90 tokens saved
|
|
assert result.tokens_saved_estimate == 90
|
|
|
|
def test_lines_omitted_property(self):
|
|
"""Lines omitted property works correctly."""
|
|
result = LogCompressionResult(
|
|
compressed="test",
|
|
original="test\noriginal",
|
|
original_line_count=100,
|
|
compressed_line_count=10,
|
|
format_detected=LogFormat.GENERIC,
|
|
compression_ratio=0.1,
|
|
)
|
|
|
|
assert result.lines_omitted == 90
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Tests for edge cases and boundary conditions."""
|
|
|
|
def test_empty_input(self):
|
|
"""Empty input is handled gracefully."""
|
|
compressor = LogCompressor()
|
|
result = compressor.compress("")
|
|
|
|
assert result.compressed == ""
|
|
assert result.original_line_count == 1 # Empty string splits to one empty line
|
|
assert result.compression_ratio == 1.0
|
|
|
|
def test_single_line_input(self):
|
|
"""Single line input passes through."""
|
|
compressor = LogCompressor()
|
|
result = compressor.compress("Single line of text")
|
|
|
|
assert result.compressed == "Single line of text"
|
|
assert result.compression_ratio == 1.0
|
|
|
|
def test_all_errors_no_info(self):
|
|
"""Log with only errors is handled."""
|
|
lines = [f"ERROR: failure {i}" for i in range(100)]
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=50,
|
|
max_errors=5,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should limit to max_errors
|
|
assert result.compressed_line_count <= compressor.config.max_total_lines
|
|
|
|
def test_unicode_content(self):
|
|
"""Unicode characters are handled correctly."""
|
|
content = """INFO: Processing 日本語
|
|
ERROR: Failed with émoji 🚀
|
|
WARN: Über important
|
|
"""
|
|
compressor = LogCompressor()
|
|
result = compressor.compress(content)
|
|
|
|
# Should not crash and preserve unicode
|
|
assert (
|
|
"日本語" in result.compressed
|
|
or "émoji" in result.compressed
|
|
or "Über" in result.compressed
|
|
)
|
|
|
|
def test_very_long_lines(self):
|
|
"""Very long lines don't cause issues."""
|
|
long_line = "ERROR: " + "x" * 10000
|
|
lines = [f"INFO: line {i}" for i in range(100)]
|
|
lines[50] = long_line
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=50,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should complete without error
|
|
assert len(result.compressed) > 0
|
|
|
|
def test_mixed_line_endings(self):
|
|
"""Mixed line endings are handled."""
|
|
content = "INFO: line 1\r\nERROR: line 2\rINFO: line 3\n"
|
|
|
|
compressor = LogCompressor()
|
|
# Should not crash
|
|
result = compressor.compress(content)
|
|
assert result.compressed is not None
|
|
|
|
def test_binary_like_content(self):
|
|
"""Content with binary-like patterns doesn't crash."""
|
|
content = "INFO: data\x00\x01\x02ERROR: test"
|
|
|
|
compressor = LogCompressor()
|
|
result = compressor.compress(content)
|
|
assert result.compressed is not None
|
|
|
|
|
|
class TestConfigOptions:
|
|
"""Tests for configuration options."""
|
|
|
|
def test_max_errors_config(self):
|
|
"""max_errors configuration limits error selection."""
|
|
lines = [f"ERROR: error {i}" for i in range(50)]
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=10,
|
|
max_errors=3,
|
|
max_total_lines=50,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Count error lines in output (excluding summary line)
|
|
error_count = sum(1 for line in result.compressed.split("\n") if "ERROR:" in line)
|
|
assert error_count <= 3 + compressor.config.error_context_lines * 2
|
|
|
|
def test_max_warnings_config(self):
|
|
"""max_warnings configuration limits warning selection."""
|
|
lines = [f"WARN: warning {i}" for i in range(50)]
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=10,
|
|
max_warnings=2,
|
|
dedupe_warnings=False,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Warnings should be limited
|
|
warn_count = sum(1 for line in result.compressed.split("\n") if "WARN:" in line)
|
|
assert warn_count <= 2 + compressor.config.error_context_lines * 2
|
|
|
|
def test_max_total_lines_config(self):
|
|
"""max_total_lines configuration limits output."""
|
|
lines = [f"ERROR: error {i}" for i in range(200)]
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=50,
|
|
max_total_lines=20,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Output lines should be limited (plus summary line)
|
|
output_lines = [line for line in result.compressed.split("\n") if line.strip()]
|
|
assert len(output_lines) <= 21 # max_total_lines + 1 summary
|
|
|
|
def test_dedupe_warnings_disabled(self):
|
|
"""dedupe_warnings=False preserves duplicate warnings."""
|
|
lines = [
|
|
"WARN: same warning",
|
|
"WARN: same warning",
|
|
"WARN: same warning",
|
|
]
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=1,
|
|
dedupe_warnings=False,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# All warnings preserved when dedupe disabled
|
|
warn_count = sum(1 for line in result.compressed.split("\n") if "WARN:" in line)
|
|
assert warn_count == 3
|
|
|
|
|
|
class TestLogLineDataclass:
|
|
"""Tests for LogLine dataclass behavior."""
|
|
|
|
def test_equality_by_line_number(self):
|
|
"""LogLine equality is based on line_number."""
|
|
line1 = LogLine(line_number=10, content="foo")
|
|
line2 = LogLine(line_number=10, content="bar")
|
|
line3 = LogLine(line_number=20, content="foo")
|
|
|
|
assert line1 == line2
|
|
assert line1 != line3
|
|
|
|
def test_hash_by_line_number(self):
|
|
"""LogLine hash is based on line_number."""
|
|
line1 = LogLine(line_number=10, content="foo")
|
|
line2 = LogLine(line_number=10, content="bar")
|
|
|
|
assert hash(line1) == hash(line2)
|
|
|
|
# Can be used in sets
|
|
line_set = {line1, line2}
|
|
assert len(line_set) == 1
|
|
|
|
def test_default_values(self):
|
|
"""LogLine default values are correct."""
|
|
line = LogLine(line_number=1, content="test")
|
|
|
|
assert line.level == LogLevel.UNKNOWN
|
|
assert line.is_stack_trace is False
|
|
assert line.is_summary is False
|
|
assert line.score == 0.0
|
|
|
|
|
|
class TestOutputFormatting:
|
|
"""Tests for output formatting and stats."""
|
|
|
|
def test_format_output_includes_stats(self):
|
|
"""Format output includes category stats."""
|
|
lines = [
|
|
"ERROR: error 1",
|
|
"ERROR: error 2",
|
|
"WARN: warning 1",
|
|
"INFO: info 1",
|
|
"INFO: info 2",
|
|
"INFO: info 3",
|
|
] * 20 # Make it large enough to trigger compression
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=50,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Stats should be populated
|
|
assert "errors" in result.stats
|
|
assert "warnings" in result.stats
|
|
assert "info" in result.stats
|
|
assert result.stats["errors"] > 0
|
|
assert result.stats["warnings"] > 0
|
|
|
|
def test_format_output_summary_line(self):
|
|
"""Formatted output includes summary of omitted lines."""
|
|
lines = [f"INFO: message {i}" for i in range(200)]
|
|
lines.append("ERROR: critical")
|
|
content = "\n".join(lines)
|
|
|
|
compressor = LogCompressor(
|
|
config=LogCompressorConfig(
|
|
min_lines_for_ccr=50,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should have omission summary
|
|
assert "lines omitted" in result.compressed
|