feat(export): CSV export for the bet journal + forward-test ledger
Adds CSV export alongside the Excel snapshot export: two buttons on the Export hub write the bet journal and the paper-trading ledger to UTF-8 (BOM) .csv files in the configured export directory and toast the path — mirroring the Excel use case's write-and-return-path contract. CSV needs no third-party library, so it lives in the Application layer behind a pure RFC-4180 formatter. - Csv formatter (RFC 4180 escaping) + ExportToCsvUseCase (journal + ledger, batched title join, returns null when there's nothing to export); registered; Export hub buttons + en/ru resx. - 11 tests: formatter escaping/Document + use-case empty-state + real-file write to a temp dir.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Export;
|
||||
|
||||
namespace Marathon.Application.Tests.Export;
|
||||
|
||||
public sealed class CsvTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("plain", "plain")]
|
||||
[InlineData("", "")]
|
||||
[InlineData("a,b", "\"a,b\"")]
|
||||
[InlineData("he said \"hi\"", "\"he said \"\"hi\"\"\"")]
|
||||
[InlineData("line1\nline2", "\"line1\nline2\"")]
|
||||
public void Escape_QuotesOnlyWhenNeeded(string input, string expected)
|
||||
{
|
||||
Csv.Escape(input).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escape_Null_IsEmpty()
|
||||
{
|
||||
Csv.Escape(null).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Document_JoinsHeaderAndRows_WithCrlf_AndEscapes()
|
||||
{
|
||||
var csv = Csv.Document(
|
||||
new[] { "Name", "Note" },
|
||||
new[]
|
||||
{
|
||||
(IReadOnlyList<string>)new[] { "Kelly", "ok" },
|
||||
new[] { "Flat, fixed", "say \"hi\"" },
|
||||
});
|
||||
|
||||
csv.Should().Be(
|
||||
"Name,Note\r\n" +
|
||||
"Kelly,ok\r\n" +
|
||||
"\"Flat, fixed\",\"say \"\"hi\"\"\"\r\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Marathon.Application.Tests.UseCases;
|
||||
|
||||
public sealed class ExportToCsvUseCaseTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset T0 = new(2026, 5, 16, 12, 0, 0, Msk);
|
||||
|
||||
private readonly string _tempDir =
|
||||
Path.Combine(Path.GetTempPath(), "marathon_csv_" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
|
||||
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
|
||||
private ExportToCsvUseCase CreateSut()
|
||||
{
|
||||
var opts = Options.Create(new StorageOptions
|
||||
{
|
||||
DatabasePath = "x.db",
|
||||
ExportDirectory = _tempDir,
|
||||
SnapshotRetentionDays = 90,
|
||||
});
|
||||
return new ExportToCsvUseCase(_bets, _paperBets, _events, opts, NullLogger<ExportToCsvUseCase>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportJournalAsync_ReturnsNull_When_NoBets()
|
||||
{
|
||||
_bets.ListAsync(Arg.Any<CancellationToken>()).Returns(Array.Empty<PlacedBet>());
|
||||
|
||||
(await CreateSut().ExportJournalAsync()).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportJournalAsync_WritesFile_WithHeaderAndJoinedTitle()
|
||||
{
|
||||
var bet = new PlacedBet(
|
||||
Guid.NewGuid(),
|
||||
new EventId("evt-1"),
|
||||
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.0m)),
|
||||
100m, T0, BetOutcome.Pending, "note");
|
||||
_bets.ListAsync(Arg.Any<CancellationToken>()).Returns(new[] { bet });
|
||||
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<EventId, Event>
|
||||
{
|
||||
[new EventId("evt-1")] =
|
||||
new Event(new EventId("evt-1"), new SportCode(11), "GB", "L", "C", T0.AddDays(1), "Home", "Away"),
|
||||
});
|
||||
|
||||
var path = await CreateSut().ExportJournalAsync();
|
||||
|
||||
path.Should().NotBeNull();
|
||||
File.Exists(path!).Should().BeTrue();
|
||||
var content = await File.ReadAllTextAsync(path!);
|
||||
content.Should().StartWith("PlacedAt,Event,EventId");
|
||||
content.Should().Contain("Home vs Away");
|
||||
content.Should().Contain("evt-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportPaperLedgerAsync_ReturnsNull_When_NoBets()
|
||||
{
|
||||
_paperBets.ListAsync(Arg.Any<CancellationToken>()).Returns(Array.Empty<PaperBet>());
|
||||
|
||||
(await CreateSut().ExportPaperLedgerAsync()).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportPaperLedgerAsync_WritesFile_WithHeader()
|
||||
{
|
||||
var paper = PaperBet.Open(Guid.NewGuid(), new EventId("evt-2"), Side.Side2, 1.9m, 10m, T0);
|
||||
_paperBets.ListAsync(Arg.Any<CancellationToken>()).Returns(new[] { paper });
|
||||
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<EventId, Event>());
|
||||
|
||||
var path = await CreateSut().ExportPaperLedgerAsync();
|
||||
|
||||
path.Should().NotBeNull();
|
||||
var content = await File.ReadAllTextAsync(path!);
|
||||
content.Should().StartWith("OpenedAt,Event,EventId,PickedSide");
|
||||
content.Should().Contain("evt-2"); // title falls back to the raw id
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user