• 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

97.99
/src/KeenEyes.Particles/Systems/ParticleSpawnSystem.cs
1
using System.Numerics;
2
using KeenEyes.Common;
3
using KeenEyes.Particles.Components;
4
using KeenEyes.Particles.Data;
5

6
namespace KeenEyes.Particles.Systems;
7

8
/// <summary>
9
/// System that spawns new particles from emitters.
10
/// </summary>
11
/// <remarks>
12
/// <para>
13
/// This system runs in the Update phase and handles both continuous emission
14
/// (based on <see cref="ParticleEmitter.EmissionRate"/>) and burst emission
15
/// (based on <see cref="ParticleEmitter.BurstCount"/>).
16
/// </para>
17
/// <para>
18
/// Spawned particles are placed in the emitter's particle pool with random
19
/// initial properties within the configured min/max ranges.
20
/// </para>
21
/// </remarks>
22
public sealed class ParticleSpawnSystem : SystemBase
23
{
24
    private ParticleManager? manager;
25

26
    /// <inheritdoc/>
27
    protected override void OnInitialize()
28
    {
29
        if (World.TryGetExtension<ParticleManager>(out var pm))
1✔
30
        {
31
            manager = pm;
1✔
32
        }
33
    }
1✔
34

35
    /// <inheritdoc/>
36
    public override void Update(float deltaTime)
37
    {
38
        var pm = manager;
1✔
39
        if (pm == null)
1✔
40
        {
41
            if (!World.TryGetExtension(out pm) || pm is null)
1✔
42
            {
43
                return;
1✔
44
            }
45
            manager = pm;
1✔
46
        }
47

48
        // Query all emitters with Transform2D
49
        foreach (var entity in World.Query<ParticleEmitter, Transform2D>())
1✔
50
        {
51
            ref var emitter = ref World.Get<ParticleEmitter>(entity);
1✔
52
            if (!emitter.IsPlaying)
1✔
53
            {
54
                continue;
55
            }
56

57
            ref readonly var transform = ref World.Get<Transform2D>(entity);
1✔
58
            var pool = pm.GetPool(entity);
1✔
59
            if (pool == null)
1✔
60
            {
61
                continue;
62
            }
63

64
            var toSpawn = 0;
1✔
65

66
            // Continuous emission
67
            if (emitter.EmissionRate > 0)
1✔
68
            {
69
                emitter.EmissionAccumulator += emitter.EmissionRate * deltaTime;
1✔
70
                var continuousSpawn = (int)emitter.EmissionAccumulator;
1✔
71
                emitter.EmissionAccumulator -= continuousSpawn;
1✔
72
                toSpawn += continuousSpawn;
1✔
73
            }
74

75
            // Burst emission
76
            if (emitter.BurstCount > 0)
1✔
77
            {
78
                if (emitter.BurstInterval <= 0)
1✔
79
                {
80
                    // One-shot burst
81
                    if (!emitter.InitialBurstEmitted)
1✔
82
                    {
83
                        toSpawn += emitter.BurstCount;
1✔
84
                        emitter.InitialBurstEmitted = true;
1✔
85
                    }
86
                }
87
                else
88
                {
89
                    // Repeating burst
90
                    emitter.BurstTimer += deltaTime;
1✔
91
                    while (emitter.BurstTimer >= emitter.BurstInterval)
1✔
92
                    {
93
                        toSpawn += emitter.BurstCount;
1✔
94
                        emitter.BurstTimer -= emitter.BurstInterval;
1✔
95
                    }
96
                }
97
            }
98

99
            // Spawn particles. In Local space, positions are stored relative to the
100
            // emitter (origin zero) and translated by the emitter position at render time;
101
            // in World space, positions are anchored at the emitter's current position.
102
            var origin = emitter.Space == ParticleSpace.Local ? Vector2.Zero : transform.Position;
1✔
103
            for (var i = 0; i < toSpawn; i++)
1✔
104
            {
105
                SpawnParticle(pool, in emitter, origin, pm.Config.MaxParticlesPerEmitter);
1✔
106
            }
107
        }
108

109
        // Also query emitters with Transform3D (project to 2D)
110
        foreach (var entity in World.Query<ParticleEmitter, Transform3D>())
1✔
111
        {
112
            // Skip if also has Transform2D (already processed)
113
            if (World.Has<Transform2D>(entity))
1✔
114
            {
115
                continue;
116
            }
117

118
            ref var emitter = ref World.Get<ParticleEmitter>(entity);
1✔
119
            if (!emitter.IsPlaying)
1✔
120
            {
121
                continue;
122
            }
123

124
            ref readonly var transform3D = ref World.Get<Transform3D>(entity);
1✔
125
            var pool = pm.GetPool(entity);
1✔
126
            if (pool == null)
1✔
127
            {
128
                continue;
129
            }
130

131
            // Project to 2D
132
            var transform = new Transform2D(
1✔
133
                new Vector2(transform3D.Position.X, transform3D.Position.Y),
1✔
134
                0f,
1✔
135
                new Vector2(transform3D.Scale.X, transform3D.Scale.Y));
1✔
136

137
            var toSpawn = 0;
1✔
138

139
            // Continuous emission
140
            if (emitter.EmissionRate > 0)
1✔
141
            {
142
                emitter.EmissionAccumulator += emitter.EmissionRate * deltaTime;
1✔
143
                var continuousSpawn = (int)emitter.EmissionAccumulator;
1✔
144
                emitter.EmissionAccumulator -= continuousSpawn;
1✔
145
                toSpawn += continuousSpawn;
1✔
146
            }
147

148
            // Burst emission
149
            if (emitter.BurstCount > 0)
1✔
150
            {
151
                if (emitter.BurstInterval <= 0)
1✔
152
                {
153
                    if (!emitter.InitialBurstEmitted)
1✔
154
                    {
155
                        toSpawn += emitter.BurstCount;
1✔
156
                        emitter.InitialBurstEmitted = true;
1✔
157
                    }
158
                }
159
                else
160
                {
161
                    emitter.BurstTimer += deltaTime;
1✔
162
                    while (emitter.BurstTimer >= emitter.BurstInterval)
1✔
163
                    {
164
                        toSpawn += emitter.BurstCount;
1✔
165
                        emitter.BurstTimer -= emitter.BurstInterval;
1✔
166
                    }
167
                }
168
            }
169

170
            var origin = emitter.Space == ParticleSpace.Local ? Vector2.Zero : transform.Position;
1✔
171
            for (var i = 0; i < toSpawn; i++)
1✔
172
            {
173
                SpawnParticle(pool, in emitter, origin, pm.Config.MaxParticlesPerEmitter);
1✔
174
            }
175
        }
176
    }
1✔
177

178
    private void SpawnParticle(ParticlePool pool, in ParticleEmitter emitter, Vector2 origin, int maxParticles)
179
    {
180
        var index = pool.Allocate();
1✔
181
        if (index < 0)
1✔
182
        {
183
            // Pool full, try to grow
184
            pool.Grow(pool.Capacity * 2, maxParticles);
1✔
185
            index = pool.Allocate();
1✔
186
            if (index < 0)
1✔
187
            {
188
                return; // Still full
1✔
189
            }
190
        }
191

192
        // Calculate spawn position and direction based on shape
193
        var (posOffset, direction) = CalculateSpawnPosition(emitter.Shape);
1✔
194

195
        pool.PositionsX[index] = origin.X + posOffset.X;
1✔
196
        pool.PositionsY[index] = origin.Y + posOffset.Y;
1✔
197

198
        // Calculate velocity
199
        var speed = Lerp(emitter.StartSpeedMin, emitter.StartSpeedMax, World.NextFloat());
1✔
200
        pool.VelocitiesX[index] = direction.X * speed;
1✔
201
        pool.VelocitiesY[index] = direction.Y * speed;
1✔
202

203
        // Set visual properties
204
        pool.ColorsR[index] = emitter.StartColor.X;
1✔
205
        pool.ColorsG[index] = emitter.StartColor.Y;
1✔
206
        pool.ColorsB[index] = emitter.StartColor.Z;
1✔
207
        pool.ColorsA[index] = emitter.StartColor.W;
1✔
208

209
        var size = Lerp(emitter.StartSizeMin, emitter.StartSizeMax, World.NextFloat());
1✔
210
        pool.Sizes[index] = size;
1✔
211
        pool.InitialSizes[index] = size;
1✔
212

213
        pool.Rotations[index] = Lerp(emitter.StartRotationMin, emitter.StartRotationMax, World.NextFloat());
1✔
214
        pool.RotationSpeeds[index] = 0;
1✔
215

216
        // Set lifecycle
217
        pool.Ages[index] = 0;
1✔
218
        pool.Lifetimes[index] = Lerp(emitter.LifetimeMin, emitter.LifetimeMax, World.NextFloat());
1✔
219
        pool.NormalizedAges[index] = 0;
1✔
220
    }
1✔
221

222
    private (Vector2 Position, Vector2 Direction) CalculateSpawnPosition(EmissionShape shape)
223
    {
224
        switch (shape.Type)
1✔
225
        {
226
            case EmissionShapeType.Point:
227
                return (Vector2.Zero, RandomDirection());
1✔
228

229
            case EmissionShapeType.Sphere:
230
                var sphereDir = RandomDirection();
1✔
231
                var dist = World.NextFloat() * shape.Radius;
1✔
232
                return (sphereDir * dist, sphereDir);
1✔
233

234
            case EmissionShapeType.Cone:
235
                var baseDir = shape.Direction;
1✔
236
                if (baseDir == Vector2.Zero)
1✔
237
                {
238
                    baseDir = Vector2.UnitY;
×
239
                }
240
                var baseAngle = MathF.Atan2(baseDir.Y, baseDir.X);
1✔
241
                var halfAngle = shape.Angle / 2f;
1✔
242
                var angle = baseAngle + Lerp(-halfAngle, halfAngle, World.NextFloat());
1✔
243
                var coneDir = new Vector2(MathF.Cos(angle), MathF.Sin(angle));
1✔
244
                var coneDist = World.NextFloat() * shape.Radius;
1✔
245
                return (coneDir * coneDist, coneDir);
1✔
246

247
            case EmissionShapeType.Box:
248
                var x = Lerp(-shape.Size.X / 2, shape.Size.X / 2, World.NextFloat());
1✔
249
                var y = Lerp(-shape.Size.Y / 2, shape.Size.Y / 2, World.NextFloat());
1✔
250
                return (new Vector2(x, y), RandomDirection());
1✔
251

252
            case EmissionShapeType.Hemisphere:
253
                // 2D interpretation: a filled half-disc. Directions (and positions) span the
254
                // 180-degree arc centered on the shape direction, mirroring how Sphere fills a disc.
255
                var hemiBase = shape.Direction;
1✔
256
                if (hemiBase == Vector2.Zero)
1✔
257
                {
258
                    hemiBase = Vector2.UnitY;
×
259
                }
260
                var hemiBaseAngle = MathF.Atan2(hemiBase.Y, hemiBase.X);
1✔
261
                var hemiAngle = hemiBaseAngle + Lerp(-MathF.PI / 2f, MathF.PI / 2f, World.NextFloat());
1✔
262
                var hemiDir = new Vector2(MathF.Cos(hemiAngle), MathF.Sin(hemiAngle));
1✔
263
                var hemiDist = World.NextFloat() * shape.Radius;
1✔
264
                return (hemiDir * hemiDist, hemiDir);
1✔
265

266
            case EmissionShapeType.Edge:
267
                // Uniformly sample a point along the segment spanning -Size/2 .. +Size/2.
268
                var edgeT = World.NextFloat() - 0.5f;
1✔
269
                return (shape.Size * edgeT, RandomDirection());
1✔
270

271
            case EmissionShapeType.Circle:
272
                // Ring perimeter: position lies exactly on the circle, direction points outward.
273
                var circleDir = RandomDirection();
1✔
274
                return (circleDir * shape.Radius, circleDir);
1✔
275

276
            default:
277
                return (Vector2.Zero, Vector2.UnitY);
×
278
        }
279
    }
280

281
    private Vector2 RandomDirection()
282
    {
283
        var angle = World.NextFloat() * MathF.PI * 2f;
1✔
284
        return new Vector2(MathF.Cos(angle), MathF.Sin(angle));
1✔
285
    }
286

287
    private static float Lerp(float a, float b, float t) => a + (b - a) * t;
1✔
288
}
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