Skip to content

Commit 9275b61

Browse files
committed
FileDownload unit tests
1 parent 3194090 commit 9275b61

File tree

1 file changed

+366
-0
lines changed

1 file changed

+366
-0
lines changed
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#nullable enable
5+
6+
using System.Collections.Concurrent;
7+
using System.Diagnostics;
8+
using Microsoft.AspNetCore.Components;
9+
using Microsoft.AspNetCore.Components.RenderTree;
10+
using Microsoft.AspNetCore.Components.Test.Helpers;
11+
using Microsoft.AspNetCore.Components.Web;
12+
using Microsoft.AspNetCore.Components.Web.Media;
13+
using Microsoft.JSInterop;
14+
using Xunit;
15+
16+
namespace Microsoft.AspNetCore.Components.Web.Media.Tests;
17+
18+
/// <summary>
19+
/// Unit tests for the FileDownload component covering behaviors unique to manual-download semantics.
20+
/// (Auto-load, cache key reuse, etc. are already covered by Image/Video tests.)
21+
/// </summary>
22+
public class FileDownloadTest
23+
{
24+
private static readonly byte[] SampleBytes = new byte[] { 1, 2, 3, 4, 5 };
25+
26+
[Fact]
27+
public async Task DoesNotAutoLoad_OnInitialRender()
28+
{
29+
var js = new FakeDownloadJsRuntime();
30+
using var renderer = CreateRenderer(js);
31+
var comp = (FileDownload)renderer.InstantiateComponent<FileDownload>();
32+
var id = renderer.AssignRootComponentId(comp);
33+
34+
var source = new MediaSource(SampleBytes, "application/octet-stream", cacheKey: "file-1");
35+
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
36+
{
37+
[nameof(FileDownload.Source)] = source,
38+
[nameof(FileDownload.FileName)] = "test.bin"
39+
}));
40+
41+
Assert.Equal(0, js.Count("Blazor._internal.BinaryMedia.downloadAsync"));
42+
43+
// Verify initial markup contains inert href and marker, no data-state
44+
var frames = renderer.GetCurrentRenderTreeFrames(id);
45+
MediaTestUtil.CurrentFrames = frames;
46+
var a = FindElement(frames, "a");
47+
Assert.True(a.HasValue);
48+
MediaTestUtil.CurrentFrames = frames;
49+
AssertAttribute(a.Value, "href", "javascript:void(0)");
50+
AssertAttribute(a.Value, "data-blazor-file-download", string.Empty);
51+
Assert.False(HasAttribute(a.Value, "data-state"));
52+
}
53+
54+
[Fact]
55+
public async Task ClickInvokesDownload_Success_NoErrorState()
56+
{
57+
var js = new FakeDownloadJsRuntime { Result = true };
58+
using var renderer = CreateRenderer(js);
59+
var comp = (FileDownload)renderer.InstantiateComponent<FileDownload>();
60+
var id = renderer.AssignRootComponentId(comp);
61+
var source = new MediaSource(SampleBytes, "application/octet-stream", cacheKey: "file-2");
62+
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
63+
{
64+
[nameof(FileDownload.Source)] = source,
65+
[nameof(FileDownload.FileName)] = "ok.bin",
66+
[nameof(FileDownload.Text)] = "Get it"
67+
}));
68+
69+
await ClickAnchorAsync(renderer, id);
70+
71+
Assert.Equal(1, js.Count("Blazor._internal.BinaryMedia.downloadAsync"));
72+
73+
var frames = renderer.GetCurrentRenderTreeFrames(id);
74+
MediaTestUtil.CurrentFrames = frames;
75+
var a = FindElement(frames, "a");
76+
Assert.True(a.HasValue);
77+
Assert.False(HasAttribute(a.Value, "data-state"));
78+
Assert.Equal("Get it", ReadInnerText(frames));
79+
}
80+
81+
[Fact]
82+
public async Task JsReturnsFalse_SetsErrorState()
83+
{
84+
var js = new FakeDownloadJsRuntime { Result = false };
85+
using var renderer = CreateRenderer(js);
86+
var comp = (FileDownload)renderer.InstantiateComponent<FileDownload>();
87+
var id = renderer.AssignRootComponentId(comp);
88+
var source = new MediaSource(SampleBytes, "application/octet-stream", cacheKey: "file-3");
89+
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
90+
{
91+
[nameof(FileDownload.Source)] = source,
92+
[nameof(FileDownload.FileName)] = "fail.bin"
93+
}));
94+
95+
await ClickAnchorAsync(renderer, id);
96+
97+
Assert.Equal(1, js.Count("Blazor._internal.BinaryMedia.downloadAsync"));
98+
99+
var a = FindElement(renderer.GetCurrentRenderTreeFrames(id), "a");
100+
MediaTestUtil.CurrentFrames = renderer.GetCurrentRenderTreeFrames(id);
101+
Assert.True(a.HasValue);
102+
AssertAttribute(a.Value, "data-state", "error");
103+
}
104+
105+
[Fact]
106+
public async Task JsThrows_SetsErrorState()
107+
{
108+
var js = new FakeDownloadJsRuntime { Throw = true };
109+
using var renderer = CreateRenderer(js);
110+
var comp = (FileDownload)renderer.InstantiateComponent<FileDownload>();
111+
var id = renderer.AssignRootComponentId(comp);
112+
var source = new MediaSource(SampleBytes, "application/octet-stream", cacheKey: "file-4");
113+
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
114+
{
115+
[nameof(FileDownload.Source)] = source,
116+
[nameof(FileDownload.FileName)] = "throw.bin"
117+
}));
118+
119+
await ClickAnchorAsync(renderer, id);
120+
121+
var a = FindElement(renderer.GetCurrentRenderTreeFrames(id), "a");
122+
MediaTestUtil.CurrentFrames = renderer.GetCurrentRenderTreeFrames(id);
123+
Assert.True(a.HasValue);
124+
AssertAttribute(a.Value, "data-state", "error");
125+
}
126+
127+
[Fact]
128+
public async Task AdditionalHref_IsIgnored_InertHrefUsed()
129+
{
130+
var js = new FakeDownloadJsRuntime();
131+
using var renderer = CreateRenderer(js);
132+
var comp = (FileDownload)renderer.InstantiateComponent<FileDownload>();
133+
var id = renderer.AssignRootComponentId(comp);
134+
var source = new MediaSource(SampleBytes, "application/octet-stream", cacheKey: "file-5");
135+
var additional = new Dictionary<string, object?>
136+
{
137+
["href"] = "https://example.com/should-not-navigate",
138+
["class"] = "btn"
139+
};
140+
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
141+
{
142+
[nameof(FileDownload.Source)] = source,
143+
[nameof(FileDownload.FileName)] = "test.bin",
144+
[nameof(FileDownload.AdditionalAttributes)] = additional
145+
}));
146+
147+
var frames = renderer.GetCurrentRenderTreeFrames(id);
148+
MediaTestUtil.CurrentFrames = frames;
149+
var a = FindElement(frames, "a");
150+
Assert.True(a.HasValue);
151+
AssertAttribute(a.Value, "href", "javascript:void(0)");
152+
AssertAttribute(a.Value, "class", "btn");
153+
}
154+
155+
[Fact]
156+
public async Task MissingFileName_SuppressesDownload()
157+
{
158+
var js = new FakeDownloadJsRuntime();
159+
using var renderer = CreateRenderer(js);
160+
var comp = (FileDownload)renderer.InstantiateComponent<FileDownload>();
161+
var id = renderer.AssignRootComponentId(comp);
162+
var source = new MediaSource(SampleBytes, "application/octet-stream", cacheKey: "file-6");
163+
// Purposely give whitespace filename
164+
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
165+
{
166+
[nameof(FileDownload.Source)] = source,
167+
[nameof(FileDownload.FileName)] = " "
168+
}));
169+
170+
await ClickAnchorAsync(renderer, id);
171+
172+
Assert.Equal(0, js.Count("Blazor._internal.BinaryMedia.downloadAsync"));
173+
}
174+
175+
[Fact]
176+
public async Task SecondClick_CancelsFirst()
177+
{
178+
var js = new FakeDownloadJsRuntime { DelayOnFirst = true };
179+
using var renderer = CreateRenderer(js);
180+
var comp = (FileDownload)renderer.InstantiateComponent<FileDownload>();
181+
var id = renderer.AssignRootComponentId(comp);
182+
var source = new MediaSource(SampleBytes, "application/octet-stream", cacheKey: "file-7");
183+
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
184+
{
185+
[nameof(FileDownload.Source)] = source,
186+
[nameof(FileDownload.FileName)] = "cancel.bin"
187+
}));
188+
189+
var click1 = ClickAnchorAsync(renderer, id);
190+
// Immediately second click
191+
await ClickAnchorAsync(renderer, id);
192+
await click1; // Ensure first completes after cancellation attempt
193+
194+
Assert.Equal(2, js.Count("Blazor._internal.BinaryMedia.downloadAsync"));
195+
// First token should be cancelled
196+
Assert.True(js.CapturedTokens.First().IsCancellationRequested);
197+
Assert.False(js.CapturedTokens.Last().IsCancellationRequested);
198+
}
199+
200+
private static async Task ClickAnchorAsync(TestRenderer renderer, int componentId)
201+
{
202+
var frames = renderer.GetCurrentRenderTreeFrames(componentId);
203+
var a = FindElement(frames, "a");
204+
Assert.True(a.HasValue, "Anchor element not found");
205+
ulong? handlerId = null;
206+
// Attributes immediately follow the element frame at index a.Value.Index
207+
for (var i = a.Value.Index + 1; i < frames.Count; i++)
208+
{
209+
ref readonly var frame = ref frames.Array[i];
210+
if (frame.FrameType == RenderTreeFrameType.Attribute)
211+
{
212+
if (frame.AttributeName == "onclick")
213+
{
214+
handlerId = frame.AttributeEventHandlerId;
215+
}
216+
continue;
217+
}
218+
break; // stop after attributes block
219+
}
220+
Assert.True(handlerId.HasValue, "onclick handler not found");
221+
await renderer.DispatchEventAsync(handlerId.Value, new MouseEventArgs());
222+
}
223+
224+
private static (int Index, int SequenceIndex, RenderTreeFrame Frame)? FindElement(ArrayRange<RenderTreeFrame> frames, string elementName)
225+
{
226+
for (var i = 0; i < frames.Count; i++)
227+
{
228+
ref readonly var frame = ref frames.Array[i];
229+
if (frame.FrameType == RenderTreeFrameType.Element && string.Equals(frame.ElementName, elementName, StringComparison.OrdinalIgnoreCase))
230+
{
231+
return (i, frame.Sequence, frame);
232+
}
233+
}
234+
return null;
235+
}
236+
237+
private static void AssertAttribute((int Index, int SequenceIndex, RenderTreeFrame Frame) element, string name, string? expectedValue)
238+
{
239+
var framesRange = MediaTestUtil.CurrentFrames ?? throw new InvalidOperationException("Current frames not set");
240+
for (var i = element.Index + 1; i < framesRange.Count; i++)
241+
{
242+
ref readonly var frame = ref framesRange.Array[i];
243+
if (frame.FrameType == RenderTreeFrameType.Attribute)
244+
{
245+
if (string.Equals(frame.AttributeName, name, StringComparison.Ordinal))
246+
{
247+
Assert.Equal(expectedValue, frame.AttributeValue?.ToString());
248+
return;
249+
}
250+
continue;
251+
}
252+
break; // end of attributes
253+
}
254+
Assert.Fail($"Attribute '{name}' not found on element '{element.Frame.ElementName}'.");
255+
}
256+
257+
private static bool HasAttribute((int Index, int SequenceIndex, RenderTreeFrame Frame) element, string name)
258+
{
259+
var framesRange = MediaTestUtil.CurrentFrames ?? throw new InvalidOperationException("Current frames not set");
260+
for (var i = element.Index + 1; i < framesRange.Count; i++)
261+
{
262+
ref readonly var frame = ref framesRange.Array[i];
263+
if (frame.FrameType == RenderTreeFrameType.Attribute)
264+
{
265+
if (string.Equals(frame.AttributeName, name, StringComparison.Ordinal))
266+
{
267+
return true;
268+
}
269+
continue;
270+
}
271+
break;
272+
}
273+
return false;
274+
}
275+
276+
private static string ReadInnerText(ArrayRange<RenderTreeFrame> frames)
277+
{
278+
for (var i = 0; i < frames.Count; i++)
279+
{
280+
ref readonly var frame = ref frames.Array[i];
281+
if (frame.FrameType == RenderTreeFrameType.Text)
282+
{
283+
return frame.TextContent;
284+
}
285+
}
286+
return string.Empty;
287+
}
288+
289+
private static TestRenderer CreateRenderer(IJSRuntime js)
290+
{
291+
var services = new TestServiceProvider();
292+
services.AddService<IJSRuntime>(js);
293+
var renderer = new InteractiveTestRenderer(services);
294+
return renderer;
295+
}
296+
297+
private sealed class InteractiveTestRenderer : TestRenderer
298+
{
299+
public InteractiveTestRenderer(IServiceProvider services) : base(services) { }
300+
protected internal override RendererInfo RendererInfo => new RendererInfo("Test", isInteractive: true);
301+
}
302+
303+
private sealed class FakeDownloadJsRuntime : IJSRuntime
304+
{
305+
private readonly ConcurrentQueue<Invocation> _invocations = new();
306+
public bool Result { get; set; } = true;
307+
public bool Throw { get; set; }
308+
public bool DelayOnFirst { get; set; }
309+
private int _calls;
310+
311+
public IReadOnlyList<CancellationToken> CapturedTokens => _invocations.Select(i => i.Token).ToList();
312+
313+
public int Count(string identifier) => _invocations.Count(i => i.Identifier == identifier);
314+
315+
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
316+
=> InvokeAsync<TValue>(identifier, CancellationToken.None, args ?? Array.Empty<object?>());
317+
318+
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
319+
{
320+
var invocation = new Invocation(identifier, cancellationToken, args ?? Array.Empty<object?>());
321+
_invocations.Enqueue(invocation);
322+
323+
if (identifier == "Blazor._internal.BinaryMedia.downloadAsync")
324+
{
325+
if (Throw)
326+
{
327+
return ValueTask.FromException<TValue>(new InvalidOperationException("Download failed"));
328+
}
329+
330+
if (DelayOnFirst && _calls == 0)
331+
{
332+
_calls++;
333+
return new ValueTask<TValue>(DelayAsync<TValue>(cancellationToken));
334+
}
335+
_calls++;
336+
object boxed = Result;
337+
return new ValueTask<TValue>((TValue)boxed);
338+
}
339+
340+
return ValueTask.FromException<TValue>(new InvalidOperationException("Unexpected identifier: " + identifier));
341+
}
342+
343+
private async Task<TValue> DelayAsync<TValue>(CancellationToken token)
344+
{
345+
try
346+
{
347+
await Task.Delay(50, token);
348+
}
349+
catch
350+
{
351+
// ignore cancellation
352+
}
353+
object boxed = Result;
354+
return (TValue)boxed;
355+
}
356+
357+
private record struct Invocation(string Identifier, CancellationToken Token, object?[] Args);
358+
}
359+
360+
// Utility to capture current frames for attribute search (simplified approach replaced after implementation refinement)
361+
private static class MediaTestUtil
362+
{
363+
[ThreadStatic]
364+
public static ArrayRange<RenderTreeFrame>? CurrentFrames;
365+
}
366+
}

0 commit comments

Comments
 (0)