Skip to content

Commit a39a140

Browse files
authored
fix(core): count sectioned outputs as outputs (#257)
CompiledReport.OutputCount returned Outputs.Count, excluding SectionedOutputs, and OutputFormats listed only the plain outputs' formats. Both collections produce a delivered artifact, so a report with one plain and one sectioned output looked single-output. The API's sync mode supports single-output reports only and guards on OutputCount, so it accepted such a report; the runner then wrote both files and the endpoint streamed artifacts[0] — the caller silently received one of the two, and which one depended on directory-enumeration order. GET /reports under-reported the same report's formats. Count and list both kinds, so the guard rejects what its own message says it rejects. Covered by a test verified to fail against the old accessor.
1 parent 5824a16 commit a39a140

4 files changed

Lines changed: 36 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
5858
the host — D20 — this is a nudge, not a behaviour change).
5959

6060
### Fixed
61+
- **Sectioned outputs are counted as outputs.** `CompiledReport.OutputCount` (and `OutputFormats`)
62+
omitted them, so a report with one plain and one sectioned output looked single-output: the API's
63+
sync mode — which supports single-output reports only — accepted it, the runner wrote both files,
64+
and the caller silently received whichever one directory enumeration yielded first. `GET /reports`
65+
under-reported such a report's formats for the same reason.
6166
- **Google Sheets no longer drops a column whose header cell isn't text.** Requests ask for
6267
`UNFORMATTED_VALUE`, so a year-numbered header (`2024`) or a `TRUE`/`FALSE` one arrives as a JSON
6368
number/boolean; the header index accepted strings only, while the data path decodes every kind. The

docs/STATUS-AND-BACKLOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,9 @@ rather than decided:
258258
error strings). The read- and write-failure paths in the same method scrub; the **upload** path does
259259
not — and the sync endpoint deliberately suppresses the very same string, so one route hides what
260260
the other returns verbatim.
261-
- **API: sync mode's single-output guard ignores sectioned outputs.** `OutputCount` counts only
261+
- ~~**API: sync mode's single-output guard ignores sectioned outputs.**~~ **FIXED**`OutputCount`
262+
and `OutputFormats` now include sectioned outputs, so the guard rejects the mixed report it always
263+
claimed to and the listing reports every format. Original description: `OutputCount` counts only
262264
`Outputs`, so a report with one plain and one sectioned output passes the guard, the runner writes
263265
two artifacts, and the caller silently receives **one** — which one decided by directory-enumeration
264266
order. The same undercount makes `GET /reports` under-report a sectioned report's formats.

src/NeoReports.Core/CompiledReport.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ internal CompiledReport(
5050
RowCountFactory = countRows;
5151
Deadline = deadline;
5252

53-
OutputFormats = outputs.Select(o => o.Factory.Format).ToArray();
53+
// Both collections produce a delivered artifact, so both belong in the count and the format
54+
// list. Counting only Outputs made a report with one plain and one sectioned output look
55+
// single-output: the API's sync guard let it through, the runner then wrote two files, and the
56+
// caller silently received whichever one directory enumeration happened to yield first.
57+
OutputFormats = outputs.Select(o => o.Factory.Format)
58+
.Concat(sectionedOutputs.Select(s => s.Spec.Factory.Format))
59+
.ToArray();
5460
DestinationTypes = destinations.Select(d => d.Factory.Type).ToArray();
5561
}
5662

@@ -64,7 +70,7 @@ internal CompiledReport(
6470
public int PageSize { get; }
6571

6672
/// <summary>Number of configured outputs (formats).</summary>
67-
public int OutputCount => Outputs.Count;
73+
public int OutputCount => Outputs.Count + SectionedOutputs.Count;
6874

6975
/// <summary>Format id of each configured output, in order (e.g. "csv", "xlsx").</summary>
7076
public IReadOnlyList<string> OutputFormats { get; }

tests/NeoReports.Core.UnitTests/MultiViewTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,26 @@ public void A_view_without_columns_and_no_report_columns_is_rejected()
9797
Should.Throw<ConfigurationException>(act).Message.ShouldContain("no columns");
9898
}
9999

100+
[Fact]
101+
public void Sectioned_outputs_are_counted_and_listed_alongside_plain_ones()
102+
{
103+
// Both kinds produce a delivered artifact. Counting only the plain ones made a report like
104+
// this one look single-output, so the API's sync guard — which promises "single-output reports
105+
// only" — let it through; the runner then wrote two files and the caller silently received
106+
// whichever one directory enumeration yielded first.
107+
var report = new ReportBuilder<Sale>("mixed")
108+
.From(new FakeBatchSource<Sale>(new[] { Page(1) }))
109+
.Column(v => v.Id, "Id")
110+
.To(new OutputSpec(new FakeWriterFactory()))
111+
.ToSections(new SectionedOutputSpec(new FakeSectionedWriterFactory()), s => s
112+
.Section("All", v => v.Where(x => x.Id > 0)))
113+
.Build();
114+
115+
report.OutputCount.ShouldBe(2);
116+
report.OutputFormats.ShouldContain("fake");
117+
report.OutputFormats.Count.ShouldBe(2);
118+
}
119+
100120
[Fact]
101121
public async Task Sectioned_output_projects_each_section_from_a_single_read()
102122
{

0 commit comments

Comments
 (0)