Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions tests/test_conftest_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
Unit tests for the `assert_segments_match` test helper in conftest.py.

This helper now tolerates minor floating point noise (rounding to 6 decimal
places) when comparing `start`/`end` values that are floats, to support the
new float-index test cases, while still requiring exact equality for
non-float values (e.g. integers, strings, dates).
"""

import pytest
from conftest import assert_segments_match


class TestAssertSegmentsMatchFloatTolerance:
"""Tests for the float-rounding tolerance added to assert_segments_match."""

def test_matches_exact_float_values(self):
"""Identical float values should match."""
detected = [{'direction': 'Up', 'start': 0.1, 'end': 0.2}]
expected = [{'direction': 'Up', 'start': 0.1, 'end': 0.2}]
assert_segments_match(detected, expected) # should not raise

def test_matches_float_within_rounding_tolerance(self):
"""Floating point noise beyond the 6th decimal place should still match."""
detected = [{'direction': 'Up', 'start': 0.1 + 1e-9, 'end': 0.2 - 1e-9}]
expected = [{'direction': 'Up', 'start': 0.1, 'end': 0.2}]
assert_segments_match(detected, expected) # should not raise

def test_mismatched_float_beyond_tolerance_raises(self):
"""Differences within the first 6 decimal places should still fail."""
detected = [{'direction': 'Up', 'start': 0.100002, 'end': 0.2}]
expected = [{'direction': 'Up', 'start': 0.1, 'end': 0.2}]
with pytest.raises(AssertionError):
assert_segments_match(detected, expected)

def test_non_float_values_use_exact_equality(self):
"""Integer start/end values should still require exact equality."""
detected = [{'direction': 'Up', 'start': 5, 'end': 10}]
expected = [{'direction': 'Up', 'start': 5, 'end': 10}]
assert_segments_match(detected, expected) # should not raise

mismatched_expected = [{'direction': 'Up', 'start': 5, 'end': 11}]
with pytest.raises(AssertionError):
assert_segments_match(detected, mismatched_expected)

def test_string_values_use_exact_equality(self):
"""String start/end labels should still require exact equality."""
detected = [{'direction': 'Up', 'start': 'Step 1', 'end': 'Step 5'}]
expected = [{'direction': 'Up', 'start': 'Step 1', 'end': 'Step 5'}]
assert_segments_match(detected, expected) # should not raise

mismatched_expected = [{'direction': 'Up', 'start': 'Step 1', 'end': 'Step 6'}]
with pytest.raises(AssertionError):
assert_segments_match(detected, mismatched_expected)

def test_direction_mismatch_still_raises(self):
"""Mismatched direction should raise regardless of float tolerance changes."""
detected = [{'direction': 'Up', 'start': 0.1, 'end': 0.2}]
expected = [{'direction': 'Down', 'start': 0.1, 'end': 0.2}]
with pytest.raises(AssertionError):
assert_segments_match(detected, expected)
134 changes: 134 additions & 0 deletions tests/test_index_dtype_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""
Tests for detect_trends's date_col dtype resolution logic.

`detect_trends` now inspects the dtype of `date_col` (or generates an implicit
integer index when `date_col` is None) to decide how segment boundaries should
be represented downstream: as dates, integers, floats, string labels, or
pre-parsed datetime64 values. These tests exercise each of those resolution
branches, including the fallback and error paths.
"""

import pytest
import pandas as pd
import numpy as np
import pytrendy as pt


class TestIndexDtypeResolution:
"""Tests for detect_trends's automatic detection of date_col dtype / index_type."""

@pytest.mark.core
def test_default_date_col_none_produces_integer_index(self):
"""When date_col is not supplied, an implicit integer index should be used."""
df = pt.load_data('series_synthetic')
results = pt.detect_trends(
df,
value_col='gradual',
plot=False,
)

assert results.index_type == 'integer'
assert all(isinstance(seg['start'], (int, np.integer)) for seg in results.segments)
assert all(isinstance(seg['end'], (int, np.integer)) for seg in results.segments)

@pytest.mark.core
def test_explicit_integer_date_col_matches_implicit_index(self):
"""Passing an explicit integer date_col should behave the same as date_col=None."""
df = pt.load_data('series_synthetic')
df['int_index'] = np.arange(len(df))
results = pt.detect_trends(
df,
value_col='gradual',
date_col='int_index',
plot=False,
)

assert results.index_type == 'integer'
assert results.segments[0]['direction'] == 'Up'
assert results.segments[0]['start'] == 1
assert results.segments[0]['end'] == 23

@pytest.mark.core
def test_datetime64_column_sets_datetime64_index_type(self):
"""A date_col already parsed to datetime64 should be classified as 'datetime64',
distinct from the 'date' index_type used for parseable string columns.
"""
df = pt.load_data('series_synthetic')
df['date'] = pd.to_datetime(df['date'])
results = pt.detect_trends(
df,
value_col='gradual',
date_col='date',
plot=False,
)

assert results.index_type == 'datetime64'
# Underlying detection is unaffected: boundaries match the date-string variant.
assert results.segments[0]['direction'] == 'Up'
assert pd.Timestamp(results.segments[0]['start']) == pd.Timestamp('2025-01-02')
assert pd.Timestamp(results.segments[0]['end']) == pd.Timestamp('2025-01-24')

@pytest.mark.core
def test_datetime64_column_with_plot_raises_not_implemented(self):
"""plot_pytrendy does not yet accept the 'datetime64' index_type, so requesting a
plot with a pre-parsed datetime64 date_col should surface a clear error rather
than silently mis-rendering.
"""
df = pt.load_data('series_synthetic')
df['date'] = pd.to_datetime(df['date'])

with pytest.raises(NotImplementedError, match="Index Type datetime64 not yet implemented"):
pt.detect_trends(
df,
value_col='gradual',
date_col='date',
plot=True,
)

@pytest.mark.core
def test_partially_parseable_string_falls_back_to_string_index(self, capsys):
"""If not every value in a string date_col parses to a date, pytrendy should fall
back to treating the column as a string lookup rather than raising.
"""
df = pt.load_data('series_synthetic')
df.loc[0, 'date'] = 'not-a-date' # breaks full-column date parsing

results = pt.detect_trends(
df,
value_col='gradual',
date_col='date',
plot=False,
)

assert results.index_type == 'string'
captured = capsys.readouterr()
assert 'treating as string lookup' in captured.out

@pytest.mark.core
def test_unsupported_dtype_raises_not_implemented_error(self):
"""An unsupported dtype for date_col (e.g. boolean) should raise NotImplementedError."""
df = pt.load_data('series_synthetic')
df['flag_col'] = [i % 2 == 0 for i in range(len(df))]

with pytest.raises(NotImplementedError, match="unimplimented dtype"):
pt.detect_trends(
df,
value_col='gradual',
date_col='flag_col',
plot=False,
)

@pytest.mark.core
def test_float_date_col_sets_float_index_type(self):
"""A float date_col should be classified as the 'float' index_type."""
df = pt.load_data('series_synthetic')
df['float_lookup'] = np.linspace(0, 1, len(df))
results = pt.detect_trends(
df,
value_col='gradual',
date_col='float_lookup',
plot=False,
)

assert results.index_type == 'float'
assert all(isinstance(seg['start'], float) for seg in results.segments)
92 changes: 89 additions & 3 deletions tests/test_io_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,37 @@ def zeros_signal():
return df


@pytest.fixture
def integer_index_results():
"""
Fixture to create results from gradual synthetic data using the implicit
integer index (no date_col supplied).
"""
df = pt.load_data('series_synthetic')
results = pt.detect_trends(
df,
value_col='gradual',
plot=False
)
return results


@pytest.fixture
def string_index_results():
"""
Fixture to create results from gradual synthetic data using a string-label index.
"""
df = pt.load_data('series_synthetic')
df['label'] = [f"Step {i}" for i in range(len(df))]
results = pt.detect_trends(
df,
value_col='gradual',
date_col='label',
plot=False
)
return results


@pytest.fixture
def outlier_signal():
"""
Expand Down Expand Up @@ -401,6 +432,7 @@ def test_filter_segments_by_direction_flat(self, gradual_results):
@pytest.mark.core
def test_filter_segments_by_direction_noise(self, outlier_signal):
"""Test filtering segments by 'Noise' direction."""

results = pt.detect_trends(
outlier_signal,
date_col='date',
Expand All @@ -415,8 +447,11 @@ def test_filter_segments_by_direction_noise(self, outlier_signal):
assert len(noise_segments) == 1

# Expected Noise segment from outlier signal
expected_noise = [
{'direction': 'Noise', 'start': '2025-02-19', 'end': '2025-02-21'},
expected_noise = [{
'direction': 'Noise',
'start': pd.to_datetime('2025-02-19'),
'end': pd.to_datetime('2025-02-21')
},
]

assert_segments_match(noise_segments, expected_noise)
Expand Down Expand Up @@ -712,7 +747,58 @@ class TestResultsDataStructures:
def test_segments_have_required_fields(self, gradual_results):
"""Test that all segments have required fields."""
required_fields = ['direction', 'start', 'end', 'time_index', 'days']

for i, segment in enumerate(gradual_results.segments):
for field in required_fields:
assert field in segment, f"Segment {i} missing field: {field}"


class TestResultsIndexTypeAwareness:
"""Tests for PyTrendyResults handling of the new `index_type` parameter."""

@pytest.mark.core
def test_default_index_type_is_date(self):
"""PyTrendyResults defaults to 'date' index_type when not specified."""
from pytrendy.io.results_pytrendy import PyTrendyResults
results = PyTrendyResults([])
assert results.index_type == 'date'

@pytest.mark.core
def test_index_type_stored_from_detect_trends(self, gradual_results, integer_index_results, string_index_results):
"""detect_trends should populate index_type based on the resolved date_col dtype."""
assert gradual_results.index_type == 'date'
assert integer_index_results.index_type == 'integer'
assert string_index_results.index_type == 'string'

@pytest.mark.core
def test_summary_uses_days_for_date_index(self, gradual_results):
"""Date-indexed results keep the 'days' column name in the summary."""
assert 'days' in gradual_results.df_summary.columns
assert 'index steps' not in gradual_results.df_summary.columns

@pytest.mark.core
def test_summary_uses_index_steps_for_integer_index(self, integer_index_results):
"""Integer-indexed results rename the 'days' column to 'index steps' in the summary."""
assert 'index steps' in integer_index_results.df_summary.columns
assert 'days' not in integer_index_results.df_summary.columns

@pytest.mark.core
def test_print_summary_descriptor_for_date_index(self, gradual_results, capsys):
"""print_summary describes the best segment using 'dates' for date-indexed results."""
gradual_results.print_summary()
captured = capsys.readouterr()
assert 'between dates' in captured.out

@pytest.mark.core
def test_print_summary_descriptor_for_integer_index(self, integer_index_results, capsys):
"""print_summary describes the best segment using 'indexes' for integer-indexed results."""
integer_index_results.print_summary()
captured = capsys.readouterr()
assert 'between indexes' in captured.out

@pytest.mark.core
def test_print_summary_descriptor_for_string_index(self, string_index_results, capsys):
"""print_summary describes the best segment using 'labels' for string-indexed results."""
string_index_results.print_summary()
captured = capsys.readouterr()
assert 'between labels' in captured.out
Loading
Loading