Files
Ymir/core/tests/world/TestWorldTimeline.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

166 lines
4.7 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <ymir/world/World.h>
#include <ymir/world/IDomain.h>
#include <ymir/world/Environment.h>
#include <ymir/world/CouplingRegistry.h>
#include <memory>
#include <string>
using Catch::Approx;
namespace {
// Minimal stub domain that records the environment current speed at step time.
class EnvCaptureDomain final : public ymir::IDomain {
public:
void onAddedToWorld(ymir::Environment& env, ymir::CouplingRegistry&) override
{
env_ = &env;
}
void step(double) override
{
capturedCurrentSpeed = env_->currentSpeed();
}
std::vector<ymir::BodyPosition> allBodyPositions() const override { return {}; }
ymir::BodyState bodyState(int) const override { return {}; }
std::string name() const override { return "capture"; }
double capturedCurrentSpeed = -1.0;
private:
ymir::Environment* env_ = nullptr;
};
} // namespace
// ---------------------------------------------------------------------------
// Empty timeline — no-op regression
// ---------------------------------------------------------------------------
TEST_CASE("World step with empty timeline: env.currentSpeed stays 0")
{
ymir::World world;
REQUIRE(world.timeline().empty());
world.step(0.1);
REQUIRE(world.environment().currentSpeed() == Approx(0.0));
}
TEST_CASE("World step with empty timeline does not throw")
{
ymir::World world;
REQUIRE_NOTHROW(world.step(1.0));
}
// ---------------------------------------------------------------------------
// Loaded timeline updates env before domain step
// ---------------------------------------------------------------------------
TEST_CASE("World step with single-keyframe current timeline: currentSpeed == 1.5 after step")
{
ymir::World world;
world.timeline().loadJson(R"({
"currentSeries": [[{"t": 0, "speed": 1.5, "dirNaut": 90}]],
"windSeries": [],
"waveSeries": []
})");
world.step(0.1);
REQUIRE(world.environment().currentSpeed() == Approx(1.5));
REQUIRE(world.environment().currentDirectionNaut() == Approx(90.0));
}
TEST_CASE("World step: timeline resolves env before domain receives it")
{
ymir::World world;
world.timeline().loadJson(R"({
"currentSeries": [[{"t": 0, "speed": 2.0, "dirNaut": 0}]],
"windSeries": [],
"waveSeries": []
})");
auto domain = std::make_unique<EnvCaptureDomain>();
auto* raw = domain.get();
world.addDomain(std::move(domain));
world.step(0.1);
// Domain must see the already-resolved current speed during its step().
REQUIRE(raw->capturedCurrentSpeed == Approx(2.0));
}
// ---------------------------------------------------------------------------
// timeline() getter
// ---------------------------------------------------------------------------
TEST_CASE("World::timeline() getter returns mutable reference usable for loadJson")
{
ymir::World world;
REQUIRE(world.timeline().empty());
world.timeline().loadJson(R"({
"currentSeries": [[{"t": 0, "speed": 0.5, "dirNaut": 0}]],
"windSeries": [],
"waveSeries": []
})");
REQUIRE_FALSE(world.timeline().empty());
}
TEST_CASE("World::timeline() const getter returns read-only reference")
{
ymir::World world;
const ymir::World& cw = world;
REQUIRE(cw.timeline().empty());
}
// ---------------------------------------------------------------------------
// Multiple steps — timeline resolves correct time each tick
// ---------------------------------------------------------------------------
TEST_CASE("World step: timeline interpolates at current world time across multiple steps")
{
// Two keyframes: t=0 speed=0, t=100 speed=2.0
// After 50 steps of dt=1 → world.time()=50 → speed=1.0
ymir::World world;
world.timeline().loadJson(R"({
"currentSeries": [[
{"t": 0, "speed": 0.0, "dirNaut": 0},
{"t": 100, "speed": 2.0, "dirNaut": 0}
]],
"windSeries": [],
"waveSeries": []
})");
for (int i = 0; i < 50; ++i)
world.step(1.0);
REQUIRE(world.environment().currentSpeed() == Approx(1.0));
}
// ---------------------------------------------------------------------------
// Existing world behaviour unchanged
// ---------------------------------------------------------------------------
TEST_CASE("World with timeline: time still accumulates correctly")
{
ymir::World world;
world.timeline().loadJson(R"({
"currentSeries": [[{"t": 0, "speed": 1.0, "dirNaut": 0}]],
"windSeries": [],
"waveSeries": []
})");
world.step(0.5);
world.step(0.5);
REQUIRE(world.time() == Approx(1.0));
}