From 0e849271daebdc6e47cc52faeaeaef349d55e90c Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Tue, 8 Sep 2026 08:37:12 -0700 Subject: [PATCH 1/7] feat: show frame sizes in the timeline --- IPCamLapse.Tests/TimelinePageTests.cs | 13 ++++++++++++- IPCamLapse/Pages/Sessions/Details.cshtml | 13 +++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/IPCamLapse.Tests/TimelinePageTests.cs b/IPCamLapse.Tests/TimelinePageTests.cs index 9d5f2fd..c6eeb67 100644 --- a/IPCamLapse.Tests/TimelinePageTests.cs +++ b/IPCamLapse.Tests/TimelinePageTests.cs @@ -27,9 +27,15 @@ public async Task LoadMoreMarkupProvidesRetryStatusAndAccessibleDynamicIcons() Directory.CreateDirectory(images); for (var number = 1; number <= 25; number++) { + var size = number switch + { + 24 => 2_048, + 25 => 1_572_864, + _ => 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 +48,11 @@ 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("2 KB", content, StringComparison.Ordinal); + Assert.Contains("1.5 MB", content, StringComparison.Ordinal); + Assert.Contains("${formatFrameSize(frame.sizeBytes)}", content, StringComparison.Ordinal); + Assert.Contains("if (bytes >= 1024 ** 2)", content, StringComparison.Ordinal); + Assert.Contains("Math.round(bytes / 1024)", content, StringComparison.Ordinal); } finally { diff --git a/IPCamLapse/Pages/Sessions/Details.cshtml b/IPCamLapse/Pages/Sessions/Details.cshtml index caca132..cd2c4bf 100644 --- a/IPCamLapse/Pages/Sessions/Details.cshtml +++ b/IPCamLapse/Pages/Sessions/Details.cshtml @@ -141,7 +141,7 @@ Frame @frame.Number captured @frame.CapturedAt.ToLocalTime().ToString(
- #@frame.Number · @frame.CapturedAt.ToLocalTime().ToString("HH:mm:ss") + #@frame.Number · @frame.CapturedAt.ToLocalTime().ToString("HH:mm:ss") · @FormatFrameSize(frame.SizeBytes)
@@ -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,11 @@ document.getElementById('frame-gallery').appendChild(article); } + function formatFrameSize(bytes) { + if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + return `${Math.round(bytes / 1024)} KB`; + } + function updateStatus(data) { if (data.sessionId === sessionId) location.reload(); } @@ -525,6 +530,10 @@ return $"{bytes / 1024d:F0} KB"; } + string FormatFrameSize(long bytes) => bytes >= 1024L * 1024 + ? $"{bytes / 1024d / 1024d:F1} MB" + : $"{bytes / 1024d:F0} KB"; + string FormatPercent(double value) => value.ToString("F1", System.Globalization.CultureInfo.InvariantCulture); string FormatDuration(double seconds) From 0f2841155d922980b800e96b4d94c045d5697ae9 Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Wed, 9 Sep 2026 00:59:25 -0700 Subject: [PATCH 2/7] fix: align frame size rounding across render paths --- IPCamLapse.Tests/TimelinePageTests.cs | 6 ++++-- IPCamLapse/Pages/Sessions/Details.cshtml | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/IPCamLapse.Tests/TimelinePageTests.cs b/IPCamLapse.Tests/TimelinePageTests.cs index c6eeb67..25a2564 100644 --- a/IPCamLapse.Tests/TimelinePageTests.cs +++ b/IPCamLapse.Tests/TimelinePageTests.cs @@ -29,7 +29,7 @@ public async Task LoadMoreMarkupProvidesRetryStatusAndAccessibleDynamicIcons() { var size = number switch { - 24 => 2_048, + 24 => 2_560, 25 => 1_572_864, _ => 1 }; @@ -48,11 +48,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("2 KB", content, StringComparison.Ordinal); + Assert.Contains("3 KB", content, StringComparison.Ordinal); Assert.Contains("1.5 MB", content, StringComparison.Ordinal); Assert.Contains("${formatFrameSize(frame.sizeBytes)}", content, StringComparison.Ordinal); Assert.Contains("if (bytes >= 1024 ** 2)", content, StringComparison.Ordinal); Assert.Contains("Math.round(bytes / 1024)", content, StringComparison.Ordinal); + Assert.Contains("MidpointRounding.AwayFromZero", content, StringComparison.Ordinal); + Assert.Contains("CultureInfo.InvariantCulture", content, StringComparison.Ordinal); } finally { diff --git a/IPCamLapse/Pages/Sessions/Details.cshtml b/IPCamLapse/Pages/Sessions/Details.cshtml index cd2c4bf..daf5667 100644 --- a/IPCamLapse/Pages/Sessions/Details.cshtml +++ b/IPCamLapse/Pages/Sessions/Details.cshtml @@ -527,12 +527,12 @@ { 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 ? $"{bytes / 1024d / 1024d:F1} MB" - : $"{bytes / 1024d:F0} KB"; + : $"{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); From 0e18156b20f614e1d3ae1974eafeafe8c422d110 Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Wed, 9 Sep 2026 06:38:00 -0700 Subject: [PATCH 3/7] test: execute dynamic timeline frame formatting --- IPCamLapse.Tests/IPCamLapse.Tests.csproj | 4 ++ IPCamLapse.Tests/TimelineDynamicCardTest.js | 55 +++++++++++++++++++++ IPCamLapse.Tests/TimelinePageTests.cs | 23 +++++++++ IPCamLapse/Pages/Sessions/Details.cshtml | 2 +- 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 IPCamLapse.Tests/TimelineDynamicCardTest.js 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..93294d1 --- /dev/null +++ b/IPCamLapse.Tests/TimelineDynamicCardTest.js @@ -0,0 +1,55 @@ +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, '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'); diff --git a/IPCamLapse.Tests/TimelinePageTests.cs b/IPCamLapse.Tests/TimelinePageTests.cs index 25a2564..4c01838 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() { diff --git a/IPCamLapse/Pages/Sessions/Details.cshtml b/IPCamLapse/Pages/Sessions/Details.cshtml index daf5667..813f374 100644 --- a/IPCamLapse/Pages/Sessions/Details.cshtml +++ b/IPCamLapse/Pages/Sessions/Details.cshtml @@ -531,7 +531,7 @@ } string FormatFrameSize(long bytes) => bytes >= 1024L * 1024 - ? $"{bytes / 1024d / 1024d:F1} MB" + ? $"{(bytes / 1024d / 1024d).ToString("F1", 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); From 760bf88f21687e5c84a5bdb541fb002ac516eed6 Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Wed, 9 Sep 2026 06:40:56 -0700 Subject: [PATCH 4/7] test: remove obsolete timeline source assertions --- IPCamLapse.Tests/TimelinePageTests.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/IPCamLapse.Tests/TimelinePageTests.cs b/IPCamLapse.Tests/TimelinePageTests.cs index 4c01838..e7b6aa3 100644 --- a/IPCamLapse.Tests/TimelinePageTests.cs +++ b/IPCamLapse.Tests/TimelinePageTests.cs @@ -75,9 +75,6 @@ await File.WriteAllBytesAsync( Assert.Contains("1.5 MB", content, StringComparison.Ordinal); Assert.Contains("${formatFrameSize(frame.sizeBytes)}", content, StringComparison.Ordinal); Assert.Contains("if (bytes >= 1024 ** 2)", content, StringComparison.Ordinal); - Assert.Contains("Math.round(bytes / 1024)", content, StringComparison.Ordinal); - Assert.Contains("MidpointRounding.AwayFromZero", content, StringComparison.Ordinal); - Assert.Contains("CultureInfo.InvariantCulture", content, StringComparison.Ordinal); } finally { From a90d5f459afc15dc7ff08e3e6803ecf7364d9c9a Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Thu, 10 Sep 2026 05:47:13 -0700 Subject: [PATCH 5/7] test: cover 1.25 MiB frame-size midpoint --- IPCamLapse.Tests/TimelinePageTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/IPCamLapse.Tests/TimelinePageTests.cs b/IPCamLapse.Tests/TimelinePageTests.cs index e7b6aa3..0517615 100644 --- a/IPCamLapse.Tests/TimelinePageTests.cs +++ b/IPCamLapse.Tests/TimelinePageTests.cs @@ -54,6 +54,8 @@ public async Task LoadMoreMarkupProvidesRetryStatusAndAccessibleDynamicIcons() { 24 => 2_560, 25 => 1_572_864, + 23 => 2_686_976, + 22 => 1_310_720, _ => 1 }; await File.WriteAllBytesAsync( @@ -73,8 +75,11 @@ await File.WriteAllBytesAsync( 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("if (bytes >= 1024 ** 2)", 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 { From 4cd3633981b416bf97c2ddabfd2d37772b4e5e4f Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Thu, 10 Sep 2026 05:47:57 -0700 Subject: [PATCH 6/7] fix: align timeline frame-size rounding --- IPCamLapse/Pages/Sessions/Details.cshtml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/IPCamLapse/Pages/Sessions/Details.cshtml b/IPCamLapse/Pages/Sessions/Details.cshtml index 813f374..bd08d7b 100644 --- a/IPCamLapse/Pages/Sessions/Details.cshtml +++ b/IPCamLapse/Pages/Sessions/Details.cshtml @@ -419,8 +419,12 @@ } function formatFrameSize(bytes) { - if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; - return `${Math.round(bytes / 1024)} KB`; + 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) { @@ -531,7 +535,7 @@ } string FormatFrameSize(long bytes) => bytes >= 1024L * 1024 - ? $"{(bytes / 1024d / 1024d).ToString("F1", System.Globalization.CultureInfo.InvariantCulture)} MB" + ? $"{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); From b74887f3cd867d72defe78523c809621cd176e61 Mon Sep 17 00:00:00 2001 From: Igor Baranov Date: Thu, 10 Sep 2026 06:26:34 -0700 Subject: [PATCH 7/7] test: execute midpoint rounding helper --- IPCamLapse.Tests/TimelineDynamicCardTest.js | 2 ++ IPCamLapse.Tests/TimelinePageTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/IPCamLapse.Tests/TimelineDynamicCardTest.js b/IPCamLapse.Tests/TimelineDynamicCardTest.js index 93294d1..802c61a 100644 --- a/IPCamLapse.Tests/TimelineDynamicCardTest.js +++ b/IPCamLapse.Tests/TimelineDynamicCardTest.js @@ -36,6 +36,7 @@ global.document = { }; const page = fs.readFileSync(process.argv[2], 'utf8'); +vm.runInThisContext(functionSource(page, 'roundToOneDecimal')); vm.runInThisContext(functionSource(page, 'formatFrameSize')); vm.runInThisContext(functionSource(page, 'addFrame')); @@ -53,3 +54,4 @@ function dynamicLabelFor(sizeBytes) { 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 0517615..432d227 100644 --- a/IPCamLapse.Tests/TimelinePageTests.cs +++ b/IPCamLapse.Tests/TimelinePageTests.cs @@ -55,7 +55,7 @@ public async Task LoadMoreMarkupProvidesRetryStatusAndAccessibleDynamicIcons() 24 => 2_560, 25 => 1_572_864, 23 => 2_686_976, - 22 => 1_310_720, + 22 => 1_310_720, _ => 1 }; await File.WriteAllBytesAsync( @@ -76,7 +76,7 @@ await File.WriteAllBytesAsync( 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("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);