blob: dc869e6f048205dbe23db95f07fe1838d5639c90 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for the /api/datasets and /api/compare endpoints."""
import pytest
from fastapi.testclient import TestClient
from otava_test_data.web.main import app
client = TestClient(app)
def test_list_datasets():
r = client.get("/api/datasets")
assert r.status_code == 200
body = r.json()
assert "datasets" in body
names = [d["name"] for d in body["datasets"]]
assert "tigerbeetle" in names
def test_get_tigerbeetle():
r = client.get("/api/datasets/tigerbeetle")
assert r.status_code == 200
body = r.json()
assert body["name"] == "tigerbeetle"
assert len(body["series"]) == 365
def test_get_unknown_dataset_404():
r = client.get("/api/datasets/nope")
assert r.status_code == 404
def test_list_algorithms_includes_split():
r = client.get("/api/algorithms")
assert r.status_code == 200
body = r.json()
assert "split" in body["algorithms"]
# split must be available because apache-otava is a hard dependency.
assert body["algorithms"]["split"]["available"] is True
def test_compare_runs_split_on_tigerbeetle():
series = client.get("/api/datasets/tigerbeetle").json()["series"]
r = client.post(
"/api/compare?max_pvalue=0.001",
json={"data": series, "algorithms": ["split"]},
)
assert r.status_code == 200
body = r.json()
assert "split" in body["results"]
assert "indices" in body["results"]["split"]
# TigerBeetle has clear change points — split should detect at least one.
assert body["results"]["split"]["indices"]
def test_compare_rejects_empty_data():
r = client.post("/api/compare", json={"data": [], "algorithms": ["split"]})
assert r.status_code == 400
def test_compare_rejects_unknown_algorithm():
"""Unknown algorithm names should fail validation, not return 200 with an inline error."""
r = client.post(
"/api/compare",
json={"data": [1.0, 2.0, 1.0, 2.0, 1.0, 2.0], "algorithms": ["nonexistent"]},
)
assert r.status_code == 422
def test_compare_default_runs_all_available_algorithms():
"""When `algorithms` is omitted, every available algorithm in ALGORITHMS runs."""
available = {
name for name, info in client.get("/api/algorithms").json()["algorithms"].items()
if info["available"]
}
r = client.post(
"/api/compare",
json={"data": [1.0, 1.1, 1.0, 1.2, 5.0, 5.1, 5.0, 5.2, 5.3, 5.1]},
)
assert r.status_code == 200
assert set(r.json()["results"].keys()) == available
def test_compare_accepts_floats_and_round_trips_through_json():
"""Float series shouldn't blow up at JSON serialization."""
series = [1.5, 2.5, 1.7, 2.3, 1.4, 3.9, 4.1, 4.0, 4.2, 4.3, 4.1]
r = client.post("/api/compare", json={"data": series, "algorithms": ["split"]})
assert r.status_code == 200
body = r.json()
# Indices must be JSON-serializable plain ints, not numpy types.
for idx in body["results"]["split"]["indices"]:
assert isinstance(idx, int)
def test_index_exposes_dataset_mode():
"""The main page should expose the dataset-mode button and section."""
r = client.get("/")
assert r.status_code == 200
assert 'id="mode-dataset-btn"' in r.text
assert 'id="dataset-section"' in r.text
assert 'otava-algo-checkbox' in r.text
def test_generate_with_otava_algorithm_orig():
"""The /api/generate endpoint should accept otava_algorithm=orig."""
r = client.get(
"/api/generate/step_function",
params={
"length": 100, "seed": 42,
"run_otava": True, "otava_algorithm": "orig",
"max_pvalue": 0.001,
},
)
assert r.status_code == 200
body = r.json()
assert "otava" in body
# No error path expected here.
assert "error" not in body["otava"]
assert "parameters" in body["otava"], body["otava"]
assert body["otava"]["parameters"]["algorithm"] == "orig"
def test_generate_rejects_unknown_otava_algorithm():
r = client.get(
"/api/generate/step_function",
params={"length": 50, "seed": 42, "otava_algorithm": "nonexistent"},
)
assert r.status_code == 422
def test_analyze_accepts_otava_algorithm():
"""`/api/analyze` documents the otava_algorithm param — verify it actually works."""
r = client.get(
"/api/analyze/step_function",
params={
"length": 100, "seed": 42,
"otava_algorithm": "orig",
"max_pvalue": 0.001,
},
)
assert r.status_code == 200
body = r.json()
assert "otava" in body
assert "error" not in body["otava"]
assert body["otava"]["parameters"]["algorithm"] == "orig"
def test_compare_applies_per_algorithm_params():
"""`algorithm_params` should override the query-string defaults per algorithm,
and missing per-algorithm keys should fall back to those defaults."""
series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]
r = client.post(
"/api/compare?max_pvalue=0.5&window_len=40",
json={
"data": series,
"algorithms": ["split", "orig"],
"algorithm_params": {
"split": {"window_len": 20, "max_pvalue": 0.001},
"orig": {"max_pvalue": 0.01},
},
},
)
assert r.status_code == 200
body = r.json()
# The endpoint echoes back the resolved per-algorithm params so the UI can
# display "what was actually run."
assert body["algorithm_params"]["split"] == {
"window_len": 20, "max_pvalue": 0.001, "min_magnitude": 0.0,
}
# `orig` only overrode max_pvalue; window_len falls back to the query default.
assert body["algorithm_params"]["orig"] == {
"window_len": 40, "max_pvalue": 0.01, "min_magnitude": 0.0,
}
def test_compare_without_per_algorithm_params_uses_query_defaults():
"""`algorithm_params` is optional — falling back to query defaults must still work."""
series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]
r = client.post(
"/api/compare?max_pvalue=0.05",
json={"data": series, "algorithms": ["split"]},
)
assert r.status_code == 200
assert r.json()["algorithm_params"]["split"]["max_pvalue"] == 0.05
def test_detect_accepts_otava_algorithm():
"""`/api/detect` (used by mix mode) should respect the chosen algorithm."""
series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]
r = client.post(
"/api/detect?otava_algorithm=orig&max_pvalue=0.05",
json={"data": series},
)
assert r.status_code == 200
assert r.json()["parameters"]["algorithm"] == "orig"
def test_compare_returns_per_detection_details():
"""`/api/compare` results must carry per-detection stats so the frontend can
render the detected-change-points table without a second API call."""
series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]
r = client.post(
"/api/compare",
json={"data": series, "algorithms": ["split"]},
)
assert r.status_code == 200
split = r.json()["results"]["split"]
assert split["indices"] == [7]
# New fields required by the per-algorithm detected table:
assert "change_points" in split
assert len(split["change_points"]) == 1
cp = split["change_points"][0]
assert cp["index"] == 7
for k in ("mean_before", "mean_after", "pvalue"):
assert k in cp, f"missing {k} in {cp}"
assert isinstance(cp[k], float)
# Value checks: the step series [1.0]*7 + [5.0]*7 has clear segment means.
assert cp["mean_before"] == pytest.approx(1.0, abs=0.01)
assert cp["mean_after"] == pytest.approx(5.0, abs=0.01)
assert 0.0 <= cp["pvalue"] <= 1.0