Files
Ymir/core/tests/world/TestEnvironmentTimeline.cpp
vinihcrosaandClaude Sonnet 4.6 53ce4a91f6 feat: environmental conditions, vessel instanceId, panel stability, regression tests
Environmental Conditions feature:
- C++ EnvironmentTimeline: time-series current/wind data, JSON serialization
- WASM bindings: loadEnvironment(), World integration
- TypeScript types: CurrentSeries, WindSeries, EnvironmentPayload
- Zustand environmentStore with React panel (EnvironmentConditionPanel)
- Mock WASM current drift physics: nautical→body-frame velocity applied in step()

Bug fixes:
- Vessel instanceId: unique auto-increment ID per scenario instance prevents duplicate
  vesselId crash when adding two vessels of same type
- vesselPanelStore: separate vesselTypeId field so config fetch uses correct type ID
  (not instanceId) after the instanceId/vesselId split
- VesselMarker stable eventHandlers: useMemo + useRef pattern prevents react-leaflet
  from re-registering Leaflet listeners every 50ms tick, fixing panel clicks during
  active simulation

Tests:
- Playwright regression suite (e2e/regression.spec.ts): 8 tests covering panel open
  during simulation, multi-click consistency, two VLCC no error, dual marker render,
  individual panel open, remove-one behavior, current drift, no-drift baseline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:29:16 -03:00

395 lines
13 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <ymir/world/EnvironmentTimeline.h>
#include <ymir/world/Environment.h>
#include <cmath>
#include <stdexcept>
using Catch::Approx;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static std::string singleCurrentJson(double t, double speed, double dir)
{
// Single series, single keyframe — minimal valid payload.
return R"({"currentSeries":[[{"t":)" + std::to_string(t)
+ R"(,"speed":)" + std::to_string(speed)
+ R"(,"dirNaut":)" + std::to_string(dir)
+ R"(}]],"windSeries":[],"waveSeries":[]})";
}
static std::string emptyJson()
{
return R"({"currentSeries":[],"windSeries":[],"waveSeries":[]})";
}
// ---------------------------------------------------------------------------
// Empty timeline
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline default empty() is true")
{
ymir::EnvironmentTimeline tl;
REQUIRE(tl.empty());
}
TEST_CASE("EnvironmentTimeline empty timeline: advanceStep is a no-op")
{
ymir::EnvironmentTimeline tl;
ymir::Environment env;
tl.advanceStep(0.0, env);
REQUIRE(env.currentSpeed() == Approx(0.0));
REQUIRE(env.windSpeed() == Approx(0.0));
}
TEST_CASE("EnvironmentTimeline loadJson empty arrays: empty() stays true")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(emptyJson());
REQUIRE(tl.empty());
ymir::Environment env;
tl.advanceStep(0.0, env);
REQUIRE(env.currentSpeed() == Approx(0.0));
}
// ---------------------------------------------------------------------------
// Single keyframe — clamp behaviour
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline single current keyframe at t=0: clamps for t>0")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(singleCurrentJson(0.0, 1.0, 90.0));
ymir::Environment env;
tl.advanceStep(500.0, env);
REQUIRE(env.currentSpeed() == Approx(1.0));
REQUIRE(env.currentDirectionNaut() == Approx(90.0));
}
TEST_CASE("EnvironmentTimeline single keyframe: clamp before first keyframe (t<0)")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(singleCurrentJson(10.0, 2.0, 45.0));
ymir::Environment env;
tl.advanceStep(-10.0, env);
REQUIRE(env.currentSpeed() == Approx(2.0));
REQUIRE(env.currentDirectionNaut() == Approx(45.0));
}
TEST_CASE("EnvironmentTimeline single keyframe: clamp after last keyframe (t=999)")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [[
{"t": 0, "speed": 0.0, "dirNaut": 0},
{"t": 100, "speed": 2.0, "dirNaut": 0}
]],
"windSeries": [],
"waveSeries": []
})");
ymir::Environment env;
tl.advanceStep(999.0, env);
REQUIRE(env.currentSpeed() == Approx(2.0));
}
// ---------------------------------------------------------------------------
// Linear speed interpolation
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline two current keyframes: speed interpolates linearly at midpoint")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [[
{"t": 0, "speed": 0.0, "dirNaut": 0},
{"t": 100, "speed": 2.0, "dirNaut": 0}
]],
"windSeries": [],
"waveSeries": []
})");
ymir::Environment env;
tl.advanceStep(50.0, env);
REQUIRE(env.currentSpeed() == Approx(1.0));
}
// ---------------------------------------------------------------------------
// Angular wrap-around interpolation
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline angular wrap-around: 350 deg to 10 deg midpoint = 0 deg")
{
// Shortest-path: delta = +20 deg (not -340). Midpoint = 350 + 10 = 360 = 0 deg.
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [[
{"t": 0, "speed": 1.0, "dirNaut": 350},
{"t": 100, "speed": 1.0, "dirNaut": 10}
]],
"windSeries": [],
"waveSeries": []
})");
ymir::Environment env;
tl.advanceStep(50.0, env);
// Direction at midpoint should be near 0 (not 180).
const double dir = env.currentDirectionNaut();
REQUIRE((dir == Approx(0.0).margin(1e-9) || dir == Approx(360.0).margin(1e-9)));
}
// ---------------------------------------------------------------------------
// Vectorial composition — two current series
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline two current series, opposite directions: resultant speed = 0")
{
// From North + From South → cancel exactly.
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [
[{"t": 0, "speed": 1.0, "dirNaut": 0}],
[{"t": 0, "speed": 1.0, "dirNaut": 180}]
],
"windSeries": [],
"waveSeries": []
})");
ymir::Environment env;
tl.advanceStep(0.0, env);
REQUIRE(env.currentSpeed() == Approx(0.0).margin(1e-12));
}
TEST_CASE("EnvironmentTimeline two current series, same direction: resultant speed = sum")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [
[{"t": 0, "speed": 1.0, "dirNaut": 0}],
[{"t": 0, "speed": 1.5, "dirNaut": 0}]
],
"windSeries": [],
"waveSeries": []
})");
ymir::Environment env;
tl.advanceStep(0.0, env);
REQUIRE(env.currentSpeed() == Approx(2.5));
}
TEST_CASE("EnvironmentTimeline two perpendicular currents 1 m/s each: resultant = sqrt(2)")
{
// From North (flows South) + From East (flows West) → diagonal, speed = √2.
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [
[{"t": 0, "speed": 1.0, "dirNaut": 0}],
[{"t": 0, "speed": 1.0, "dirNaut": 90}]
],
"windSeries": [],
"waveSeries": []
})");
ymir::Environment env;
tl.advanceStep(0.0, env);
REQUIRE(env.currentSpeed() == Approx(std::sqrt(2.0)));
}
// ---------------------------------------------------------------------------
// Wave series
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline wave single keyframe: advanceStep does not throw")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [],
"windSeries": [],
"waveSeries": [
{"t": 0, "Hs": 2.0, "Tp": 10.0, "dirNaut": 270, "spectrum": "JONSWAP", "gamma": 3.3}
]
})");
ymir::Environment env;
// setSeaState must be called without assertion or exception.
REQUIRE_NOTHROW(tl.advanceStep(0.0, env));
// Unrelated fields remain at defaults.
REQUIRE(env.waterDepth() == Approx(100.0));
}
TEST_CASE("EnvironmentTimeline wave two keyframes: Hs interpolates linearly at midpoint")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [],
"windSeries": [],
"waveSeries": [
{"t": 0, "Hs": 1.0, "Tp": 8.0, "dirNaut": 0, "spectrum": "JONSWAP"},
{"t": 100, "Hs": 3.0, "Tp": 8.0, "dirNaut": 0, "spectrum": "JONSWAP"}
]
})");
ymir::Environment env;
// setSeaState is called with Hs=2.0 at t=50; no public getter, so verify no crash
// and that current/wind are untouched.
REQUIRE_NOTHROW(tl.advanceStep(50.0, env));
REQUIRE(env.currentSpeed() == Approx(0.0));
REQUIRE(env.windSpeed() == Approx(0.0));
}
TEST_CASE("EnvironmentTimeline wave: clamp before first keyframe")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [],
"windSeries": [],
"waveSeries": [
{"t": 10, "Hs": 2.0, "Tp": 8.0, "dirNaut": 0, "spectrum": "PIERSON"}
]
})");
ymir::Environment env;
REQUIRE_NOTHROW(tl.advanceStep(-5.0, env));
}
// ---------------------------------------------------------------------------
// loadJson — validation errors
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline loadJson throws on negative speed")
{
REQUIRE_THROWS_AS(
ymir::EnvironmentTimeline{}.loadJson(R"({
"currentSeries": [[{"t": 0, "speed": -1.0, "dirNaut": 0}]],
"windSeries": [],
"waveSeries": []
})"),
std::runtime_error);
}
TEST_CASE("EnvironmentTimeline loadJson throws on missing dirNaut in current keyframe")
{
REQUIRE_THROWS_AS(
ymir::EnvironmentTimeline{}.loadJson(R"({
"currentSeries": [[{"t": 0, "speed": 1.0}]],
"windSeries": [],
"waveSeries": []
})"),
std::runtime_error);
}
TEST_CASE("EnvironmentTimeline loadJson throws on invalid spectrum string")
{
REQUIRE_THROWS_AS(
ymir::EnvironmentTimeline{}.loadJson(R"({
"currentSeries": [],
"windSeries": [],
"waveSeries": [{"t": 0, "Hs": 1.0, "Tp": 8.0, "dirNaut": 0, "spectrum": "UNKNOWN"}]
})"),
std::runtime_error);
}
TEST_CASE("EnvironmentTimeline loadJson throws on malformed JSON")
{
REQUIRE_THROWS_AS(
ymir::EnvironmentTimeline{}.loadJson("not valid json {{"),
std::runtime_error);
}
TEST_CASE("EnvironmentTimeline loadJson throws on negative Hs")
{
REQUIRE_THROWS_AS(
ymir::EnvironmentTimeline{}.loadJson(R"({
"currentSeries": [],
"windSeries": [],
"waveSeries": [{"t": 0, "Hs": -1.0, "Tp": 8.0, "dirNaut": 0, "spectrum": "JONSWAP"}]
})"),
std::runtime_error);
}
TEST_CASE("EnvironmentTimeline loadJson throws on Tp <= 0")
{
REQUIRE_THROWS_AS(
ymir::EnvironmentTimeline{}.loadJson(R"({
"currentSeries": [],
"windSeries": [],
"waveSeries": [{"t": 0, "Hs": 1.0, "Tp": 0.0, "dirNaut": 0, "spectrum": "JONSWAP"}]
})"),
std::runtime_error);
}
TEST_CASE("EnvironmentTimeline loadJson throws on missing waveSeries top-level field")
{
REQUIRE_THROWS_AS(
ymir::EnvironmentTimeline{}.loadJson(R"({"currentSeries": [], "windSeries": []})"),
std::runtime_error);
}
// ---------------------------------------------------------------------------
// reset()
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline reset: empty() returns true, advanceStep is no-op")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(singleCurrentJson(0.0, 5.0, 90.0));
REQUIRE_FALSE(tl.empty());
tl.reset();
REQUIRE(tl.empty());
ymir::Environment env;
tl.advanceStep(0.0, env);
REQUIRE(env.currentSpeed() == Approx(0.0));
}
// ---------------------------------------------------------------------------
// Integration — full round-trip with current + wind + wave
// ---------------------------------------------------------------------------
TEST_CASE("EnvironmentTimeline integration: load current+wind+wave, advanceStep sets all env fields")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [[{"t": 0, "speed": 1.5, "dirNaut": 90}]],
"windSeries": [[{"t": 0, "speed": 8.0, "dirNaut": 270}]],
"waveSeries": [
{"t": 0, "Hs": 2.5, "Tp": 12.0, "dirNaut": 180, "spectrum": "JONSWAP", "gamma": 3.3}
]
})");
ymir::Environment env;
tl.advanceStep(0.0, env);
REQUIRE(env.currentSpeed() == Approx(1.5));
REQUIRE(env.currentDirectionNaut() == Approx(90.0));
REQUIRE(env.windSpeed() == Approx(8.0));
REQUIRE(env.windDirectionNaut() == Approx(270.0));
// seaState has no public getters; verify no crash and unrelated field unchanged.
REQUIRE(env.waterDepth() == Approx(100.0));
}
TEST_CASE("EnvironmentTimeline integration: loadJson is idempotent (reload replaces data)")
{
ymir::EnvironmentTimeline tl;
tl.loadJson(singleCurrentJson(0.0, 1.0, 0.0));
tl.loadJson(singleCurrentJson(0.0, 3.0, 90.0));
ymir::Environment env;
tl.advanceStep(0.0, env);
REQUIRE(env.currentSpeed() == Approx(3.0));
REQUIRE(env.currentDirectionNaut() == Approx(90.0));
}
TEST_CASE("EnvironmentTimeline integration: unsorted keyframes are sorted on load")
{
// Keyframes provided in reverse order; parser must sort by t.
ymir::EnvironmentTimeline tl;
tl.loadJson(R"({
"currentSeries": [[
{"t": 100, "speed": 2.0, "dirNaut": 0},
{"t": 0, "speed": 0.0, "dirNaut": 0}
]],
"windSeries": [],
"waveSeries": []
})");
ymir::Environment env;
tl.advanceStep(50.0, env);
REQUIRE(env.currentSpeed() == Approx(1.0));
}