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 @@ 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,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)