diff --git a/IPCamLapse.Tests/IPCamLapse.Tests.csproj b/IPCamLapse.Tests/IPCamLapse.Tests.csproj
index faba667..7c4d3ca 100644
--- a/IPCamLapse.Tests/IPCamLapse.Tests.csproj
+++ b/IPCamLapse.Tests/IPCamLapse.Tests.csproj
@@ -25,4 +25,8 @@
+
+
+
+
diff --git a/IPCamLapse.Tests/TimelineDynamicCardTest.js b/IPCamLapse.Tests/TimelineDynamicCardTest.js
new file mode 100644
index 0000000..802c61a
--- /dev/null
+++ b/IPCamLapse.Tests/TimelineDynamicCardTest.js
@@ -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');
diff --git a/IPCamLapse.Tests/TimelinePageTests.cs b/IPCamLapse.Tests/TimelinePageTests.cs
index 9d5f2fd..432d227 100644
--- a/IPCamLapse.Tests/TimelinePageTests.cs
+++ b/IPCamLapse.Tests/TimelinePageTests.cs
@@ -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()
{
@@ -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}");
@@ -42,6 +73,13 @@ await File.WriteAllBytesAsync(
Assert.Contains("status.textContent = loadMoreError;", content, StringComparison.Ordinal);
Assert.Contains("download.innerHTML = '';", 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
{
diff --git a/IPCamLapse/Pages/Sessions/Details.cshtml b/IPCamLapse/Pages/Sessions/Details.cshtml
index caca132..bd08d7b 100644
--- a/IPCamLapse/Pages/Sessions/Details.cshtml
+++ b/IPCamLapse/Pages/Sessions/Details.cshtml
@@ -141,7 +141,7 @@
@@ -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}`;
@@ -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();
}
@@ -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)