• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 29923911837

22 Jul 2026 01:26PM UTC coverage: 62.597% (-2.6%) from 65.151%
29923911837

push

github

tyevco
test(mcp): Update TestBridge mocks to current interface surface

MockTestBridge and MockCaptureController never implemented the members added
during the MCP Tools Expansion, so tests/KeenEyes.Mcp.TestBridge.Tests did not
compile on main. MockStateController had also drifted (GetComponentAsync and
EntitySnapshot.Components now use JsonElement).

- MockTestBridge: add Window, Time, Systems, Mutation, Profile, Snapshot, AI,
  Replay and InputContext, backed by new hand-written mock controllers plus
  KeenEyes.Testing's MockInputContext, mirroring the existing recording /
  canned-result mock style.
- MockCaptureController: add CaptureRegionAsync, GetRegionScreenshotBytesAsync
  and SaveRegionScreenshotAsync.
- MockStateController: return JsonElement from GetComponentAsync and store
  component data as JsonElement to match the current IStateController.
- Rename pre-existing PascalCase private fields in InputParsingTests to
  camelCase so the project passes dotnet format once it is in CI.

The project was MISSING from KeenEyes.slnx, which is why the compile break went
unnoticed (CI builds the solution). Adding it to the solution is itself the
cheap drift guard requested in the issue: CI now compiles the mocks against the
interfaces, so any future interface addition breaks the build immediately. All
80 tests pass under --max-parallel-test-modules 1.

Fixes #1025

8144 of 12399 branches covered (65.68%)

Branch coverage included in aggregate %.

48975 of 78850 relevant lines covered (62.11%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.17
/src/KeenEyes.Navigation/Systems/NavMeshAgentSystem.cs
1
using System.Numerics;
2
using KeenEyes.Common;
3
using KeenEyes.Navigation.Abstractions;
4
using KeenEyes.Navigation.Abstractions.Components;
5
using KeenEyes.Navigation.Events;
6

7
namespace KeenEyes.Navigation.Systems;
8

9
/// <summary>
10
/// System that moves agents along their computed paths.
11
/// </summary>
12
/// <remarks>
13
/// <para>
14
/// This system processes all entities with <see cref="NavMeshAgent"/> and
15
/// <see cref="Transform3D"/> components, moving them toward their destinations
16
/// along their computed paths.
17
/// </para>
18
/// <para>
19
/// The system:
20
/// </para>
21
/// <list type="bullet">
22
/// <item><description>Computes steering velocities based on path waypoints</description></item>
23
/// <item><description>Applies acceleration and speed limits</description></item>
24
/// <item><description>Advances to the next waypoint when close enough</description></item>
25
/// <item><description>Updates agent state (remaining distance, steering target, etc.)</description></item>
26
/// </list>
27
/// </remarks>
28
internal sealed class NavMeshAgentSystem : SystemBase
29
{
30
    private NavigationContext? context;
31
    private NavigationConfig? config;
32
    private bool crowdSupported;
33

34
    /// <inheritdoc/>
35
    protected override void OnInitialize()
36
    {
37
        if (!World.TryGetExtension(out NavigationContext? ctx) || ctx is null)
3✔
38
        {
39
            throw new InvalidOperationException("NavMeshAgentSystem requires NavigationContext extension.");
×
40
        }
41

42
        context = ctx;
3✔
43
        config = ctx.Config;
3✔
44

45
        // When the provider supports crowd simulation, entities with a
46
        // CrowdAgent component are steered by CrowdSteeringSystem instead.
47
        crowdSupported = ctx.Provider is ICrowdNavigationProvider;
3✔
48
    }
3✔
49

50
    /// <inheritdoc/>
51
    public override void Update(float deltaTime)
52
    {
53
        if (context == null || config == null)
3✔
54
        {
55
            return;
×
56
        }
57

58
        // Process all agents with paths
59
        foreach (var entity in World.Query<NavMeshAgent, Transform3D>())
3✔
60
        {
61
            // Crowd-simulated agents are handled by CrowdSteeringSystem
62
            if (crowdSupported && World.Has<CrowdAgent>(entity))
3✔
63
            {
64
                continue;
65
            }
66

67
            ref var agent = ref World.Get<NavMeshAgent>(entity);
3✔
68
            ref var transform = ref World.Get<Transform3D>(entity);
3✔
69

70
            // Skip stopped agents or those without paths
71
            if (agent.IsStopped || !agent.HasPath || agent.PathPending)
3✔
72
            {
73
                continue;
74
            }
75

76
            // Get navigation state
77
            if (!context.TryGetAgentState(entity, out var state) || !state.Path.IsValid)
3✔
78
            {
79
                continue;
80
            }
81

82
            // Process movement
83
            ProcessAgentMovement(entity, ref agent, ref transform, ref state, deltaTime);
3✔
84

85
            // Update state
86
            context.SetAgentState(entity, state);
3✔
87
        }
88
    }
3✔
89

90
    private void ProcessAgentMovement(
91
        Entity entity,
92
        ref NavMeshAgent agent,
93
        ref Transform3D transform,
94
        ref AgentNavigationState state,
95
        float deltaTime)
96
    {
97
        var path = state.Path;
3✔
98

99
        // Continue an in-progress off-mesh traversal before anything else
100
        if (state.IsTraversingOffMeshLink)
3✔
101
        {
102
            TraverseOffMeshLink(entity, ref agent, ref transform, ref state, deltaTime);
1✔
103
            return;
1✔
104
        }
105

106
        // Check if we've reached the end of the path
107
        if (state.CurrentWaypointIndex >= path.Count)
3✔
108
        {
109
            CompleteNavigation(ref agent);
×
110
            return;
×
111
        }
112

113
        // Get current waypoint position
114
        var targetWaypoint = path[state.CurrentWaypointIndex].Position;
3✔
115
        agent.SteeringTarget = targetWaypoint;
3✔
116

117
        // Calculate distance to waypoint
118
        var toWaypoint = targetWaypoint - transform.Position;
3✔
119
        float distanceToWaypoint = toWaypoint.Length();
3✔
120

121
        // Check if we've reached the waypoint
122
        float reachDistance = config!.WaypointReachDistance;
3✔
123
        if (distanceToWaypoint <= reachDistance)
3✔
124
        {
125
            // Reaching an off-mesh connection entry switches the agent into
126
            // link traversal instead of normal waypoint advancement.
127
            if (IsOffMeshEntry(path, state.CurrentWaypointIndex))
1✔
128
            {
129
                BeginOffMeshTraversal(entity, ref agent, ref state);
1✔
130
                return;
1✔
131
            }
132

133
            state.CurrentWaypointIndex++;
1✔
134

135
            // Check if this was the last waypoint
136
            if (state.CurrentWaypointIndex >= path.Count)
1✔
137
            {
138
                CompleteNavigation(ref agent);
×
139
                return;
×
140
            }
141

142
            // Update target to next waypoint
143
            targetWaypoint = path[state.CurrentWaypointIndex].Position;
1✔
144
            agent.SteeringTarget = targetWaypoint;
1✔
145
            toWaypoint = targetWaypoint - transform.Position;
1✔
146
            distanceToWaypoint = toWaypoint.Length();
1✔
147
        }
148

149
        // Calculate desired velocity
150
        Vector3 desiredVelocity;
151
        if (distanceToWaypoint > 0.001f)
3✔
152
        {
153
            var direction = toWaypoint / distanceToWaypoint;
3✔
154

155
            // Apply auto-braking near destination
156
            float speed = agent.Speed;
3✔
157
            if (agent.AutoBraking && state.CurrentWaypointIndex == path.Count - 1)
3✔
158
            {
159
                float brakingDistance = agent.StoppingDistance * 2f;
1✔
160
                if (distanceToWaypoint < brakingDistance)
1✔
161
                {
162
                    speed *= distanceToWaypoint / brakingDistance;
1✔
163
                }
164
            }
165

166
            desiredVelocity = direction * speed;
3✔
167
        }
168
        else
169
        {
170
            desiredVelocity = Vector3.Zero;
×
171
        }
172

173
        // Apply acceleration
174
        var velocityDiff = desiredVelocity - agent.DesiredVelocity;
3✔
175
        float maxChange = agent.Acceleration * deltaTime;
3✔
176
        if (velocityDiff.LengthSquared() > maxChange * maxChange)
3✔
177
        {
178
            velocityDiff = Vector3.Normalize(velocityDiff) * maxChange;
3✔
179
        }
180

181
        agent.DesiredVelocity += velocityDiff;
3✔
182

183
        // Move the agent
184
        var movement = agent.DesiredVelocity * deltaTime;
3✔
185
        transform.Position += movement;
3✔
186
        state.DistanceTraveled += movement.Length();
3✔
187

188
        // Update remaining distance
189
        agent.RemainingDistance = CalculateRemainingDistance(
3✔
190
            transform.Position,
3✔
191
            path,
3✔
192
            state.CurrentWaypointIndex);
3✔
193

194
        // Check if we've arrived at the final destination
195
        float distanceToFinal = Vector3.Distance(transform.Position, path.End.Position);
3✔
196
        if (distanceToFinal <= agent.StoppingDistance)
3✔
197
        {
198
            CompleteNavigation(ref agent);
1✔
199
        }
200
    }
3✔
201

202
    private static bool IsOffMeshEntry(Abstractions.NavPath path, int waypointIndex)
203
        => (path[waypointIndex].Properties & NavPointProperties.OffMeshConnection) != 0
1✔
204
            && waypointIndex + 1 < path.Count;
1✔
205

206
    private void BeginOffMeshTraversal(Entity entity, ref NavMeshAgent agent, ref AgentNavigationState state)
207
    {
208
        var entry = state.Path[state.CurrentWaypointIndex];
1✔
209
        var exit = state.Path[state.CurrentWaypointIndex + 1];
1✔
210

211
        state.IsTraversingOffMeshLink = true;
1✔
212
        state.OffMeshLinkStart = entry.Position;
1✔
213
        state.OffMeshLinkEnd = exit.Position;
1✔
214
        state.OffMeshLinkAreaType = entry.AreaType;
1✔
215
        state.OffMeshLinkProgress = 0f;
1✔
216
        state.OffMeshLinkCostModifier = ResolveCostModifier(entry.Position, exit.Position);
1✔
217

218
        agent.SteeringTarget = exit.Position;
1✔
219

220
        World.Send(new OffMeshLinkTraversalStarted(entity, entry.Position, exit.Position, entry.AreaType));
1✔
221
    }
1✔
222

223
    private void TraverseOffMeshLink(
224
        Entity entity,
225
        ref NavMeshAgent agent,
226
        ref Transform3D transform,
227
        ref AgentNavigationState state,
228
        float deltaTime)
229
    {
230
        var start = state.OffMeshLinkStart;
1✔
231
        var end = state.OffMeshLinkEnd;
1✔
232
        float length = Vector3.Distance(start, end);
1✔
233

234
        // Traversal speed is the agent's speed scaled down by the link's cost
235
        // modifier (a modifier of 2 makes the crossing take twice as long).
236
        float costModifier = MathF.Max(state.OffMeshLinkCostModifier, 0.001f);
1✔
237
        float traversalSpeed = agent.Speed / costModifier;
1✔
238

239
        if (length.IsApproximatelyZero())
1✔
240
        {
241
            state.OffMeshLinkProgress = 1f;
×
242
        }
243
        else
244
        {
245
            state.OffMeshLinkProgress += traversalSpeed * deltaTime / length;
1✔
246
            agent.DesiredVelocity = Vector3.Normalize(end - start) * traversalSpeed;
1✔
247
        }
248

249
        if (state.OffMeshLinkProgress >= 1f)
1✔
250
        {
251
            // Land exactly on the exit point and resume normal path following
252
            // from the waypoint after the landing point.
253
            var previousPosition = transform.Position;
1✔
254
            transform.Position = end;
1✔
255
            state.DistanceTraveled += Vector3.Distance(previousPosition, end);
1✔
256
            state.IsTraversingOffMeshLink = false;
1✔
257
            state.CurrentWaypointIndex += 2;
1✔
258

259
            World.Send(new OffMeshLinkTraversalCompleted(entity, start, end, state.OffMeshLinkAreaType));
1✔
260

261
            agent.RemainingDistance = CalculateRemainingDistance(
1✔
262
                transform.Position,
1✔
263
                state.Path,
1✔
264
                state.CurrentWaypointIndex);
1✔
265

266
            if (state.CurrentWaypointIndex >= state.Path.Count)
1✔
267
            {
268
                CompleteNavigation(ref agent);
×
269
            }
270

271
            return;
1✔
272
        }
273

274
        var newPosition = Vector3.Lerp(start, end, state.OffMeshLinkProgress);
1✔
275
        state.DistanceTraveled += Vector3.Distance(transform.Position, newPosition);
1✔
276
        transform.Position = newPosition;
1✔
277
        agent.RemainingDistance = Vector3.Distance(newPosition, end) + CalculateRemainingDistance(
1✔
278
            end,
1✔
279
            state.Path,
1✔
280
            state.CurrentWaypointIndex + 2);
1✔
281
    }
1✔
282

283
    /// <summary>
284
    /// Resolves the cost modifier for a traversal by matching the traversal
285
    /// endpoints against <see cref="OffMeshLink"/> components in the world.
286
    /// </summary>
287
    /// <remarks>
288
    /// Link endpoints are snapped onto the navigation mesh during the build, so
289
    /// matching uses a tolerance derived from each link's radius. Returns 1
290
    /// when no matching link entity exists (e.g., the mesh was baked from
291
    /// definitions that are not present in this world).
292
    /// </remarks>
293
    private float ResolveCostModifier(Vector3 start, Vector3 end)
294
    {
295
        foreach (var linkEntity in World.Query<OffMeshLink>())
1✔
296
        {
297
            ref readonly var link = ref World.Get<OffMeshLink>(linkEntity);
1✔
298

299
            float tolerance = link.Radius + config!.WaypointReachDistance;
1✔
300
            float toleranceSq = tolerance * tolerance;
1✔
301

302
            bool forward =
1✔
303
                Vector3.DistanceSquared(link.Start, start) <= toleranceSq &&
1✔
304
                Vector3.DistanceSquared(link.End, end) <= toleranceSq;
1✔
305
            bool reverse = link.Bidirectional &&
1✔
306
                Vector3.DistanceSquared(link.End, start) <= toleranceSq &&
1✔
307
                Vector3.DistanceSquared(link.Start, end) <= toleranceSq;
1✔
308

309
            if (forward || reverse)
1✔
310
            {
311
                return MathF.Max(link.CostModifier, 0.001f);
1✔
312
            }
313
        }
314

315
        return 1f;
×
316
    }
1✔
317

318
    private static void CompleteNavigation(ref NavMeshAgent agent)
319
    {
320
        agent.HasPath = false;
1✔
321
        agent.IsStopped = true;
1✔
322
        agent.DesiredVelocity = Vector3.Zero;
1✔
323
        agent.RemainingDistance = 0f;
1✔
324
    }
1✔
325

326
    private static float CalculateRemainingDistance(
327
        Vector3 currentPosition,
328
        Abstractions.NavPath path,
329
        int currentWaypointIndex)
330
    {
331
        if (currentWaypointIndex >= path.Count)
3✔
332
        {
333
            return 0f;
×
334
        }
335

336
        // Distance to current waypoint
337
        float distance = Vector3.Distance(currentPosition, path[currentWaypointIndex].Position);
3✔
338

339
        // Add distances between remaining waypoints
340
        for (int i = currentWaypointIndex + 1; i < path.Count; i++)
3✔
341
        {
342
            distance += path[i - 1].DistanceTo(path[i]);
3✔
343
        }
344

345
        return distance;
3✔
346
    }
347
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc