• 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

95.65
/src/KeenEyes.Particles/Systems/ParticleRenderSystem.cs
1
using System.Numerics;
2
using KeenEyes.Common;
3
using KeenEyes.Graphics.Abstractions;
4
using KeenEyes.Particles.Components;
5
using KeenEyes.Particles.Data;
6

7
namespace KeenEyes.Particles.Systems;
8

9
/// <summary>
10
/// System that renders all particles.
11
/// </summary>
12
/// <remarks>
13
/// <para>
14
/// This system runs in the Render phase and draws all active particles
15
/// using the 2D renderer. Particles are grouped by blend mode for efficient
16
/// batching.
17
/// </para>
18
/// <para>
19
/// If a particle has a valid texture, it uses
20
/// <see cref="I2DRenderer.DrawTextureRotated(TextureHandle, in Rectangle, float, Vector2, Vector4?)"/>
21
/// (or the sprite-sheet overload when a texture sheet is configured).
22
/// Otherwise, it falls back to <see cref="I2DRenderer.FillCircle"/>.
23
/// </para>
24
/// </remarks>
25
public sealed class ParticleRenderSystem : SystemBase
26
{
27
    private ParticleManager? manager;
28
    private I2DRenderer? renderer;
29

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

38
        if (World.TryGetExtension<I2DRenderer>(out var r))
1✔
39
        {
40
            renderer = r;
1✔
41
        }
42
    }
1✔
43

44
    /// <inheritdoc/>
45
    public override void Update(float deltaTime)
46
    {
47
        var pm = manager;
1✔
48
        if (pm == null)
1✔
49
        {
50
            if (!World.TryGetExtension(out pm) || pm is null)
1✔
51
            {
52
                return;
1✔
53
            }
54
            manager = pm;
1✔
55
        }
56

57
        var r = renderer;
1✔
58
        if (r == null)
1✔
59
        {
60
            if (!World.TryGetExtension(out r) || r is null)
1✔
61
            {
62
                return;
1✔
63
            }
64
            renderer = r;
1✔
65
        }
66

67
        // Group emitters by blend mode for efficient batching
68
        // We'll render transparent first, then additive (so glow effects overlay)
69
        var transparentEmitters = new List<RenderEntry>();
1✔
70
        var additiveEmitters = new List<RenderEntry>();
1✔
71
        var multiplyEmitters = new List<RenderEntry>();
1✔
72
        var premultipliedEmitters = new List<RenderEntry>();
1✔
73

74
        foreach (var entity in World.Query<ParticleEmitter>())
1✔
75
        {
76
            ref readonly var emitter = ref World.Get<ParticleEmitter>(entity);
1✔
77
            var pool = pm.GetPool(entity);
1✔
78
            if (pool == null || pool.ActiveCount == 0)
1✔
79
            {
80
                continue;
81
            }
82

83
            // Local-space particles are stored relative to the emitter, so translate them
84
            // by the emitter's current position at render time. World-space particles use
85
            // absolute coordinates and need no offset.
86
            var offset = emitter.Space == ParticleSpace.Local
1✔
87
                ? ResolveEmitterPosition(entity)
1✔
88
                : Vector2.Zero;
1✔
89
            var entry = new RenderEntry(pool, emitter, offset);
1✔
90

91
            switch (emitter.BlendMode)
1✔
92
            {
93
                case BlendMode.Transparent:
94
                    transparentEmitters.Add(entry);
1✔
95
                    break;
1✔
96
                case BlendMode.Additive:
97
                    additiveEmitters.Add(entry);
1✔
98
                    break;
1✔
99
                case BlendMode.Multiply:
100
                    multiplyEmitters.Add(entry);
1✔
101
                    break;
1✔
102
                case BlendMode.Premultiplied:
103
                    premultipliedEmitters.Add(entry);
1✔
104
                    break;
105
            }
106
        }
107

108
        // Render in order: multiply -> transparent -> premultiplied -> additive
109
        // (This is a common ordering but can be adjusted based on desired visual results)
110
        RenderBatch(r, multiplyEmitters);
1✔
111
        RenderBatch(r, transparentEmitters);
1✔
112
        RenderBatch(r, premultipliedEmitters);
1✔
113
        RenderBatch(r, additiveEmitters);
1✔
114
    }
1✔
115

116
    private static void RenderBatch(I2DRenderer renderer, List<RenderEntry> emitters)
117
    {
118
        if (emitters.Count == 0)
1✔
119
        {
120
            return;
1✔
121
        }
122

123
        // Calculate total particles for batch hint
124
        var totalParticles = 0;
1✔
125
        foreach (var entry in emitters)
1✔
126
        {
127
            totalParticles += entry.Pool.ActiveCount;
1✔
128
        }
129

130
        renderer.Begin();
1✔
131
        renderer.SetBatchHint(totalParticles);
1✔
132

133
        foreach (var entry in emitters)
1✔
134
        {
135
            RenderPool(renderer, entry.Pool, in entry.Emitter, entry.Offset);
1✔
136
        }
137

138
        renderer.End();
1✔
139
    }
1✔
140

141
    private static void RenderPool(I2DRenderer renderer, ParticlePool pool, in ParticleEmitter emitter, Vector2 offset)
142
    {
143
        var texture = emitter.Texture;
1✔
144
        var hasTexture = texture.IsValid;
1✔
145

146
        // A texture sheet is only active when the grid describes more than one frame.
147
        var columns = Math.Max(1, emitter.TextureSheetColumns);
1✔
148
        var rows = Math.Max(1, emitter.TextureSheetRows);
1✔
149
        var frameCount = columns * rows;
1✔
150
        var animated = hasTexture && frameCount > 1;
1✔
151

152
        for (var i = 0; i < pool.Capacity; i++)
1✔
153
        {
154
            if (!pool.Alive[i])
1✔
155
            {
156
                continue;
157
            }
158

159
            var color = new Vector4(
1✔
160
                pool.ColorsR[i],
1✔
161
                pool.ColorsG[i],
1✔
162
                pool.ColorsB[i],
1✔
163
                pool.ColorsA[i]);
1✔
164

165
            var size = pool.Sizes[i];
1✔
166
            var halfSize = size / 2f;
1✔
167
            var posX = pool.PositionsX[i] + offset.X;
1✔
168
            var posY = pool.PositionsY[i] + offset.Y;
1✔
169

170
            if (hasTexture)
1✔
171
            {
172
                var destRect = new Rectangle(
1✔
173
                    posX - halfSize,
1✔
174
                    posY - halfSize,
1✔
175
                    size,
1✔
176
                    size);
1✔
177

178
                if (animated)
1✔
179
                {
180
                    var sourceRect = FrameSourceRect(pool.NormalizedAges[i], columns, rows, frameCount);
1✔
181
                    renderer.DrawTextureRotated(
1✔
182
                        texture,
1✔
183
                        in destRect,
1✔
184
                        in sourceRect,
1✔
185
                        pool.Rotations[i],
1✔
186
                        new Vector2(0.5f, 0.5f),
1✔
187
                        color);
1✔
188
                }
189
                else
190
                {
191
                    renderer.DrawTextureRotated(
1✔
192
                        texture,
1✔
193
                        in destRect,
1✔
194
                        pool.Rotations[i],
1✔
195
                        new Vector2(0.5f, 0.5f),
1✔
196
                        color);
1✔
197
                }
198
            }
199
            else
200
            {
201
                // Fallback: draw as filled circle
202
                renderer.FillCircle(
1✔
203
                    posX,
1✔
204
                    posY,
1✔
205
                    halfSize,
1✔
206
                    color,
1✔
207
                    segments: 8);
1✔
208
            }
209
        }
210
    }
1✔
211

212
    /// <summary>
213
    /// Computes the UV sub-rectangle for the sprite-sheet frame at the given normalized age.
214
    /// </summary>
215
    private static Rectangle FrameSourceRect(float normalizedAge, int columns, int rows, int frameCount)
216
    {
217
        var frame = (int)(normalizedAge * frameCount);
1✔
218
        if (frame < 0)
1✔
219
        {
220
            frame = 0;
×
221
        }
222
        else if (frame >= frameCount)
1✔
223
        {
224
            frame = frameCount - 1;
1✔
225
        }
226

227
        var col = frame % columns;
1✔
228
        var row = frame / columns;
1✔
229
        var frameWidth = 1f / columns;
1✔
230
        var frameHeight = 1f / rows;
1✔
231

232
        return new Rectangle(col * frameWidth, row * frameHeight, frameWidth, frameHeight);
1✔
233
    }
234

235
    private Vector2 ResolveEmitterPosition(Entity entity)
236
    {
237
        if (World.Has<Transform2D>(entity))
1✔
238
        {
239
            ref readonly var transform = ref World.Get<Transform2D>(entity);
1✔
240
            return transform.Position;
1✔
241
        }
242

243
        if (World.Has<Transform3D>(entity))
×
244
        {
245
            ref readonly var transform3D = ref World.Get<Transform3D>(entity);
×
246
            return new Vector2(transform3D.Position.X, transform3D.Position.Y);
×
247
        }
248

249
        return Vector2.Zero;
×
250
    }
251

252
    private readonly struct RenderEntry(ParticlePool pool, ParticleEmitter emitter, Vector2 offset)
253
    {
254
        public readonly ParticlePool Pool = pool;
1✔
255
        public readonly ParticleEmitter Emitter = emitter;
1✔
256
        public readonly Vector2 Offset = offset;
1✔
257
    }
258
}
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