Slice 4. 607 green, 0 warnings. Clips remain **off by default** and, while off, the ring is never constructed — so no reader thread and no encoder output exist until someone asks for the feature. - **F10** (`HotkeyId.SaveClip`, default F10, remappable in the hotkey dialog) snapshots the ring, writes the file, prunes to the retention count and toasts the name. Empty ring → "nothing to clip yet" instead of a zero-byte file; clips off → says so and points at Settings. - **Settings**: Enable, length (10 or 15, default 10), keep (3/4/5, default 3). Persisted in the layout store and **coerced on load** — a bad stored value falls back to a legal one, so the ring can never be sized for a length the UI cannot show. - The ring is sized from length × tier bitrate × 1.25 and rebuilt if either changes; it is created at session start and dropped at stop, so memory is only spent while a show is running. - The drawer states the boundary in the creator's terms: "A capture, not an editor: for trimming, captions or a library, use a video editor." Still to come: the replay layer itself (play once then hide in Live, loop the kept clips elsewhere).
126 lines
4.3 KiB
C#
126 lines
4.3 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Xunit;
|
|
using ytLive.Services;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 65 slice 3 — clips on disk, with retention and nothing else.
|
|
///
|
|
/// <para>The rule: write one file, delete the oldest past the count. No gallery, no prompt. The tests pin
|
|
/// the two ways that could go wrong — a zero-byte clip from an empty ring, and pruning taking something
|
|
/// that isn't ours.</para>
|
|
/// </summary>
|
|
public sealed class ClipLibraryTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), $"ytLlive-clips-{Guid.NewGuid():N}");
|
|
|
|
public ClipLibraryTests() => Directory.CreateDirectory(_root);
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
|
|
}
|
|
|
|
private ClipLibrary Library() => new(() => _root);
|
|
|
|
[Fact]
|
|
public void SaveWritesIntoClipsBesideTheRecordings()
|
|
{
|
|
var path = Library().Save(new byte[] { 1, 2, 3 }, DateTime.Now, keep: 3);
|
|
|
|
Assert.NotNull(path);
|
|
Assert.EndsWith(".mp4", path);
|
|
Assert.Equal(Path.Combine(_root, "clips"), Path.GetDirectoryName(path));
|
|
Assert.True(File.Exists(path));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnEmptySnapshotNeverBecomesAZeroByteFile()
|
|
{
|
|
Assert.Null(Library().Save(Array.Empty<byte>(), DateTime.Now, keep: 3));
|
|
Assert.False(Directory.Exists(Path.Combine(_root, "clips"))
|
|
&& Directory.EnumerateFiles(Path.Combine(_root, "clips")).Any());
|
|
}
|
|
|
|
[Fact]
|
|
public void RetentionKeepsTheNewestAndDeletesTheRest()
|
|
{
|
|
var library = Library();
|
|
var folder = library.EnsureFolder();
|
|
for (var i = 0; i < 6; i++)
|
|
File.WriteAllText(Path.Combine(folder, $"clip-20261110-12000{i}.mp4"), "x");
|
|
File.WriteAllText(Path.Combine(folder, "someone-elses-file.mp4"), "keep me");
|
|
|
|
var removed = library.Prune(keep: 3);
|
|
|
|
Assert.Equal(3, removed);
|
|
var remaining = library.Recent();
|
|
Assert.Equal(3, remaining.Count);
|
|
Assert.Contains("120005", remaining[0]); // newest first
|
|
Assert.True(File.Exists(Path.Combine(folder, "someone-elses-file.mp4")));
|
|
}
|
|
|
|
[Fact]
|
|
public void RingBytesFollowLengthAndBitrate_WithHeadroom()
|
|
{
|
|
var tenAt8 = ClipLibrary.RingBytesFor(10, 8000);
|
|
var fifteenAt8 = ClipLibrary.RingBytesFor(15, 8000);
|
|
|
|
Assert.True(tenAt8 > 10L * 8000 * 1000 / 8); // more than the exact wire size
|
|
Assert.True(fifteenAt8 > tenAt8);
|
|
Assert.True(tenAt8 < 15L * 1024 * 1024); // and still modest
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// TASK 65 slice 4 — the three settings behind the gear drawer. They persist, and the loader coerces
|
|
/// anything unexpected back into the allowed set: a bad value must never leave the user with a clip
|
|
/// length the ring cannot serve.
|
|
/// </summary>
|
|
public sealed class ClipSettingsTests : IDisposable
|
|
{
|
|
private readonly string _db = Path.Combine(Path.GetTempPath(), $"ytLlive-clipsettings-{Guid.NewGuid():N}.db");
|
|
|
|
public void Dispose()
|
|
{
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
try { File.Delete(_db); } catch { /* best effort */ }
|
|
}
|
|
|
|
[Fact]
|
|
public void SettingsPersistAndCoerce()
|
|
{
|
|
using (var store = new LayoutStore(_db))
|
|
{
|
|
Assert.False(store.LoadClipsEnabled()); // opt-in
|
|
Assert.Equal(10, store.LoadClipSeconds()); // default 10
|
|
Assert.Equal(3, store.LoadClipKeep()); // default 3
|
|
|
|
store.SaveClipsEnabled(true);
|
|
store.SaveClipSeconds(15);
|
|
store.SaveClipKeep(5);
|
|
}
|
|
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
using (var store = new LayoutStore(_db))
|
|
{
|
|
Assert.True(store.LoadClipsEnabled());
|
|
Assert.Equal(15, store.LoadClipSeconds());
|
|
Assert.Equal(5, store.LoadClipKeep());
|
|
|
|
store.SaveClipSeconds(7); // not a choice we offer
|
|
store.SaveClipKeep(9); // past the ceiling
|
|
}
|
|
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
using (var store = new LayoutStore(_db))
|
|
{
|
|
Assert.Equal(10, store.LoadClipSeconds());
|
|
Assert.Equal(3, store.LoadClipKeep());
|
|
}
|
|
}
|
|
}
|