Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions IPCamLapse.Tests/IPCamLapse.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<None Include="TimelineDynamicCardTest.js" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
57 changes: 57 additions & 0 deletions IPCamLapse.Tests/TimelineDynamicCardTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const fs = require('node:fs');
const vm = require('node:vm');

function functionSource(page, name) {
const start = page.indexOf(`function ${name}(`);
if (start < 0) throw new Error(`Could not find ${name}`);
const openingBrace = page.indexOf('{', start);
let depth = 0;
for (let index = openingBrace; index < page.length; index += 1) {
if (page[index] === '{') depth += 1;
if (page[index] === '}' && --depth === 0) return page.slice(start, index + 1);
}
throw new Error(`Could not parse ${name}`);
}

class Element {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
}

appendChild(child) {
this.children.push(child);
return child;
}

append(...children) {
children.forEach(child => this.appendChild(child));
}
}

const gallery = new Element('div');
global.document = {
createElement: tagName => new Element(tagName),
getElementById: id => id === 'frame-gallery' ? gallery : null
};

const page = fs.readFileSync(process.argv[2], 'utf8');
vm.runInThisContext(functionSource(page, 'roundToOneDecimal'));
vm.runInThisContext(functionSource(page, 'formatFrameSize'));
vm.runInThisContext(functionSource(page, 'addFrame'));

function dynamicLabelFor(sizeBytes) {
gallery.children = [];
addFrame({
number: 25,
capturedAt: '2026-09-04T12:00:00Z',
sizeBytes,
previewUrl: '/preview.jpg',
downloadUrl: '/download.jpg'
});
return gallery.children[0].children[1].children[0].textContent;
}

if (!dynamicLabelFor(2_560).endsWith('3 KB')) throw new Error('2.5 KiB dynamic card must render as 3 KB');
if (!dynamicLabelFor(1_572_864).endsWith('1.5 MB')) throw new Error('1.5 MiB dynamic card must render as 1.5 MB');
if (!dynamicLabelFor(1_310_720).endsWith('1.3 MB')) throw new Error('1.25 MiB dynamic card must render as 1.3 MB');
40 changes: 39 additions & 1 deletion IPCamLapse.Tests/TimelinePageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ namespace IPCamLapse.Tests;

public sealed class TimelinePageTests
{
[Fact]
public async Task DynamicFrameCardsUseTheSameMidpointFormattingContract()
{
var page = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "IPCamLapse", "Pages", "Sessions", "Details.cshtml"));
var script = Path.Combine(AppContext.BaseDirectory, "TimelineDynamicCardTest.js");
using var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo("node", $"\"{script}\" \"{page}\"")
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false
}
};

process.Start();
await process.WaitForExitAsync();
var output = await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();

Assert.True(process.ExitCode == 0, $"Dynamic card test failed: {error}{output}");
}

[Fact]
public async Task LoadMoreMarkupProvidesRetryStatusAndAccessibleDynamicIcons()
{
Expand All @@ -27,9 +50,17 @@ public async Task LoadMoreMarkupProvidesRetryStatusAndAccessibleDynamicIcons()
Directory.CreateDirectory(images);
for (var number = 1; number <= 25; number++)
{
var size = number switch
{
24 => 2_560,
25 => 1_572_864,
23 => 2_686_976,
22 => 1_310_720,
_ => 1
};
await File.WriteAllBytesAsync(
Path.Combine(images, $"frame_{number:0000}_20260904_120000.jpg"),
[0]);
new byte[size]);
}

var response = await client.GetAsync($"/Sessions/Details/{session.Id}");
Expand All @@ -42,6 +73,13 @@ await File.WriteAllBytesAsync(
Assert.Contains("status.textContent = loadMoreError;", content, StringComparison.Ordinal);
Assert.Contains("download.innerHTML = '<i class=\"bi bi-download\" aria-hidden=\"true\"></i>';", content, StringComparison.Ordinal);
Assert.DoesNotContain("firstElementChild.setAttribute", content, StringComparison.Ordinal);
Assert.Contains("3 KB", content, StringComparison.Ordinal);
Assert.Contains("1.5 MB", content, StringComparison.Ordinal);
Assert.Contains("2.6 MB", content, StringComparison.Ordinal);
Assert.Contains("1.3 MB", content, StringComparison.Ordinal);
Assert.Contains("${formatFrameSize(frame.sizeBytes)}", content, StringComparison.Ordinal);
Assert.Contains("roundToOneDecimal(bytes / 1024 ** 2).toFixed(1)", content, StringComparison.Ordinal);
Assert.Contains("Math.floor(bytes / 1024 + 0.5)", content, StringComparison.Ordinal);
}
finally
{
Expand Down
19 changes: 16 additions & 3 deletions IPCamLapse/Pages/Sessions/Details.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
<img src="@frame.PreviewUrl" alt="Frame @frame.Number captured @frame.CapturedAt.ToLocalTime().ToString("g")" loading="lazy" />
</a>
<div class="frame-meta">
<span>#@frame.Number · @frame.CapturedAt.ToLocalTime().ToString("HH:mm:ss")</span>
<span>#@frame.Number · @frame.CapturedAt.ToLocalTime().ToString("HH:mm:ss") · @FormatFrameSize(frame.SizeBytes)</span>
<a href="@frame.DownloadUrl" aria-label="Download frame @frame.Number"><i class="bi bi-download" aria-hidden="true"></i></a>
</div>
</article>
Expand Down Expand Up @@ -408,7 +408,7 @@
const meta = document.createElement('div');
meta.className = 'frame-meta';
const label = document.createElement('span');
label.textContent = `#${frame.number} · ${new Date(frame.capturedAt).toLocaleTimeString()}`;
label.textContent = `#${frame.number} · ${new Date(frame.capturedAt).toLocaleTimeString()} · ${formatFrameSize(frame.sizeBytes)}`;
const download = document.createElement('a');
download.href = frame.downloadUrl;
download.ariaLabel = `Download frame ${frame.number}`;
Expand All @@ -418,6 +418,15 @@
document.getElementById('frame-gallery').appendChild(article);
}

function formatFrameSize(bytes) {
if (bytes >= 1024 ** 2) return `${roundToOneDecimal(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${Math.floor(bytes / 1024 + 0.5)} KB`;
}

function roundToOneDecimal(value) {
return Math.floor(value * 10 + 0.5) / 10;
}

function updateStatus(data) {
if (data.sessionId === sessionId) location.reload();
}
Expand Down Expand Up @@ -522,9 +531,13 @@
{
if (bytes >= 1024L * 1024 * 1024) return $"{bytes / 1024d / 1024d / 1024d:F1} GB";
if (bytes >= 1024L * 1024) return $"{bytes / 1024d / 1024d:F1} MB";
return $"{bytes / 1024d:F0} KB";
return $"{Math.Round(bytes / 1024d, 0, MidpointRounding.AwayFromZero).ToString("0", System.Globalization.CultureInfo.InvariantCulture)} KB";
}

string FormatFrameSize(long bytes) => bytes >= 1024L * 1024
? $"{Math.Round(bytes / 1024d / 1024d, 1, MidpointRounding.AwayFromZero).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)} MB"
: $"{Math.Round(bytes / 1024d, 0, MidpointRounding.AwayFromZero).ToString("0", System.Globalization.CultureInfo.InvariantCulture)} KB";

string FormatPercent(double value) => value.ToString("F1", System.Globalization.CultureInfo.InvariantCulture);

string FormatDuration(double seconds)
Expand Down