From ebb471524ecac5d7ac7be1bfc4400eaf27384297 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 5 Oct 2026 12:19:21 +0100 Subject: [PATCH] addressed consistency --- cmd/any2anexoj-cli/main.go | 13 ++--- internal/writing/csv/writer.go | 8 +-- internal/writing/csv/writer_test.go | 4 +- internal/writing/html/document.tmpl | 18 ++++++ internal/writing/html/writer.go | 76 ++++++++++++-------------- internal/writing/html/writer_test.go | 26 ++++----- internal/writing/table/printer.go | 25 ++++++--- internal/writing/table/printer_test.go | 10 ++-- 8 files changed, 99 insertions(+), 81 deletions(-) create mode 100644 internal/writing/html/document.tmpl diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 84a744d..11f50f3 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -15,8 +15,8 @@ import ( "github.com/nmoniz/any2anexoj/internal/trading212" "github.com/nmoniz/any2anexoj/internal/writing" "github.com/nmoniz/any2anexoj/internal/writing/csv" - "github.com/nmoniz/any2anexoj/internal/writing/html" - "github.com/nmoniz/any2anexoj/internal/writing/pretty" + htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html" + "github.com/nmoniz/any2anexoj/internal/writing/table" "github.com/spf13/pflag" "golang.org/x/text/language" ) @@ -102,13 +102,13 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { switch *format { case "csv": - return csv.NewCSVWriter(stdout).Render(writer) + return csv.NewWriter(stdout).Render(writer) case "table": loc, err := writing.NewLocalizer(*lang) if err != nil { return fmt.Errorf("create localizer: %w", err) } - pretty.NewPrettyPrinter(stdout, loc).Render(writer) + table.NewPrinter(stdout, loc).Render(writer) return nil case "html": loc, err := writing.NewLocalizer(*lang) @@ -117,9 +117,8 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { } // RenderHTML never writes to the printer's output writer, so // io.Discard is safe here. - printer := pretty.NewPrettyPrinter(io.Discard, loc) - title := loc.Translate("report_title", 1, nil) - return html.NewHTMLWriter(stdout, printer, title, *lang).Render(writer) + printer := table.NewPrinter(io.Discard, loc) + return htmlwriter.NewWriter(stdout, printer, loc, *lang).Render(writer) default: return fmt.Errorf("unsupported format %q: must be table, csv or html", *format) } diff --git a/internal/writing/csv/writer.go b/internal/writing/csv/writer.go index 3468d47..10b1206 100644 --- a/internal/writing/csv/writer.go +++ b/internal/writing/csv/writer.go @@ -10,15 +10,15 @@ import ( "github.com/nmoniz/any2anexoj/internal" ) -type CSVWriter struct { +type Writer struct { w *csv.Writer } -func NewCSVWriter(w io.Writer) *CSVWriter { - return &CSVWriter{w: csv.NewWriter(w)} +func NewWriter(w io.Writer) *Writer { + return &Writer{w: csv.NewWriter(w)} } -func (cw *CSVWriter) Render(aw *internal.AggregatorWriter) error { +func (cw *Writer) Render(aw *internal.AggregatorWriter) error { err := cw.w.Write([]string{ "source_country", "code", "realization_year", "realization_month", "realization_day", "realization_value", diff --git a/internal/writing/csv/writer_test.go b/internal/writing/csv/writer_test.go index bb4e087..e010817 100644 --- a/internal/writing/csv/writer_test.go +++ b/internal/writing/csv/writer_test.go @@ -11,7 +11,7 @@ import ( "github.com/shopspring/decimal" ) -func TestCSVWriter_Render(t *testing.T) { +func TestWriter_Render(t *testing.T) { aw := internal.NewAggregatorWriter() ctx := t.Context() @@ -49,7 +49,7 @@ func TestCSVWriter_Render(t *testing.T) { } var buf bytes.Buffer - if err := csv.NewCSVWriter(&buf).Render(aw); err != nil { + if err := csv.NewWriter(&buf).Render(aw); err != nil { t.Fatalf("render: %v", err) } diff --git a/internal/writing/html/document.tmpl b/internal/writing/html/document.tmpl new file mode 100644 index 0000000..3e64de3 --- /dev/null +++ b/internal/writing/html/document.tmpl @@ -0,0 +1,18 @@ + + + + + +{{.Title}} + + + +
+

{{.Title}}

+
+{{.TableFragment}} +
+

Generated at {{.GeneratedAt}}

+
+ + diff --git a/internal/writing/html/writer.go b/internal/writing/html/writer.go index 720e642..81c33ef 100644 --- a/internal/writing/html/writer.go +++ b/internal/writing/html/writer.go @@ -1,79 +1,73 @@ // Package html renders an internal.AggregatorWriter as a complete, // standalone HTML document built around the same table fragment produced by -// pretty.PrettyPrinter.RenderHTML. The document includes the Bootstrap 5.3 +// table.Printer.RenderHTML. The document includes the Bootstrap 5.3 // stylesheet via CDN and embeds the raw table fragment without HTML-escaping // it so the result opens directly in a browser. package html import ( + _ "embed" "fmt" "html/template" "io" "time" "github.com/nmoniz/any2anexoj/internal" - "github.com/nmoniz/any2anexoj/internal/writing/pretty" + "github.com/nmoniz/any2anexoj/internal/writing/table" ) +// translator is the contract the HTML writer relies on for localized +// document text such as the page title. *writing.Localizer satisfies it. +type translator interface { + Translate(key string, count int, values map[string]any) string +} + // bootstrapCSSURL is the jsDelivr CDN URL for Bootstrap 5.3.3. const bootstrapCSSURL = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" // bootstrapIntegrity is the official SRI hash for Bootstrap 5.3.3 css. const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" -// HTMLWriter writes a complete HTML document for an AggregatorWriter. -type HTMLWriter struct { - output io.Writer - printer *pretty.PrettyPrinter - title string - lang string +// Writer writes a complete HTML document for an AggregatorWriter. +type Writer struct { + output io.Writer + printer *table.Printer + translator translator + lang string } -// NewHTMLWriter builds an HTMLWriter that writes a complete HTML document to +// NewWriter builds a Writer that writes a complete HTML document to // w. The printer is used purely as a source for the raw fragment via // RenderHTML; this writer never invokes Render or any method that writes to -// printer's own io.Writer. -func NewHTMLWriter(w io.Writer, printer *pretty.PrettyPrinter, title, lang string) *HTMLWriter { - return &HTMLWriter{ - output: w, - printer: printer, - title: title, - lang: lang, +// printer's own io.Writer. The document title is looked up from the +// "report_title" translation via tr, so callers never pass a pre-baked title. +func NewWriter(w io.Writer, printer *table.Printer, tr translator, lang string) *Writer { + return &Writer{ + output: w, + printer: printer, + translator: tr, + lang: lang, } } -// documentTemplate renders the full HTML page. The table fragment is typed as -// template.HTML so the template engine emits it verbatim without escaping -// the angle brackets in the
markup. -var documentTemplate = template.Must(template.New("html").Parse(` - - - - -{{.Title}} - - - -
-

{{.Title}}

-
-{{.TableFragment}} -
-

Generated at {{.GeneratedAt}}

-
- - -`)) +//go:embed document.tmpl +var documentTemplateBody string + +// documentTemplate renders the full HTML page from document.tmpl. The table +// fragment is typed as template.HTML so the template engine emits it +// verbatim without escaping the angle brackets in the
markup. +var documentTemplate = template.Must(template.New("html").Parse(documentTemplateBody)) // Render writes a complete standalone HTML document for aw to the configured -// output. The same HTMLWriter may be reused; each call rebuilds the table +// output. The same Writer may be reused; each call rebuilds the table // fragment from aw so the rendered count reflects aw's current items and // no rows are duplicated across invocations. -func (hw *HTMLWriter) Render(aw *internal.AggregatorWriter) error { +func (hw *Writer) Render(aw *internal.AggregatorWriter) error { if hw.printer == nil { return fmt.Errorf("html: nil printer") } fragment := hw.printer.RenderHTML(aw) + title := hw.translator.Translate("report_title", 1, nil) data := struct { Lang string Title string @@ -83,7 +77,7 @@ func (hw *HTMLWriter) Render(aw *internal.AggregatorWriter) error { GeneratedAt string }{ Lang: hw.lang, - Title: hw.title, + Title: title, CSSURL: bootstrapCSSURL, Integrity: bootstrapIntegrity, TableFragment: template.HTML(fragment), diff --git a/internal/writing/html/writer_test.go b/internal/writing/html/writer_test.go index 561bb0c..1fec216 100644 --- a/internal/writing/html/writer_test.go +++ b/internal/writing/html/writer_test.go @@ -7,7 +7,7 @@ import ( "time" htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html" - "github.com/nmoniz/any2anexoj/internal/writing/pretty" + "github.com/nmoniz/any2anexoj/internal/writing/table" "github.com/nmoniz/any2anexoj/internal/writing" "github.com/nmoniz/any2anexoj/internal" "github.com/shopspring/decimal" @@ -16,7 +16,7 @@ import ( // buildTestAggregator returns an AggregatorWriter seeded with two // deterministic ReportItems so every test can assert on a stable document // shape. Mirrors the fixture pattern used in -// internal/writing/pretty/printer_test.go. +// internal/writing/table/printer_test.go. func buildTestAggregator(t *testing.T) *internal.AggregatorWriter { t.Helper() aw := internal.NewAggregatorWriter() @@ -57,7 +57,7 @@ func buildTestAggregator(t *testing.T) *internal.AggregatorWriter { return aw } -func TestHTMLWriter_Render(t *testing.T) { +func TestWriter_Render(t *testing.T) { loc, err := writing.NewLocalizer("en") if err != nil { t.Fatalf("create localizer: %v", err) @@ -65,8 +65,8 @@ func TestHTMLWriter_Render(t *testing.T) { var buf bytes.Buffer // RenderHTML never writes to the printer's output, so Discard is safe. - pp := pretty.NewPrettyPrinter(&buf, loc) - hw := htmlwriter.NewHTMLWriter(&buf, pp, "Tax report", "en") + pp := table.NewPrinter(&buf, loc) + hw := htmlwriter.NewWriter(&buf, pp, loc, "en") if err := hw.Render(buildTestAggregator(t)); err != nil { t.Fatalf("Render returned an error: %v", err) @@ -123,18 +123,18 @@ func TestHTMLWriter_Render(t *testing.T) { } } -// TestHTMLWriter_NoRowDuplication verifies that calling Render twice on the +// TestWriter_NoRowDuplication verifies that calling Render twice on the // same writer produces documents with the same number of rows, just -// like pretty.PrettyPrinter's no-duplication guarantee. -func TestHTMLWriter_NoRowDuplication(t *testing.T) { +// like table.Printer's no-duplication guarantee. +func TestWriter_NoRowDuplication(t *testing.T) { loc, err := writing.NewLocalizer("en") if err != nil { t.Fatalf("create localizer: %v", err) } var buf bytes.Buffer - pp := pretty.NewPrettyPrinter(&buf, loc) - hw := htmlwriter.NewHTMLWriter(&buf, pp, "Tax report", "en") + pp := table.NewPrinter(&buf, loc) + hw := htmlwriter.NewWriter(&buf, pp, loc, "en") aw := buildTestAggregator(t) @@ -160,15 +160,15 @@ func TestHTMLWriter_NoRowDuplication(t *testing.T) { } } -func TestHTMLWriter_LangAttribute(t *testing.T) { +func TestWriter_LangAttribute(t *testing.T) { loc, err := writing.NewLocalizer("pt") if err != nil { t.Fatalf("create localizer: %v", err) } var buf bytes.Buffer - pp := pretty.NewPrettyPrinter(&buf, loc) - hw := htmlwriter.NewHTMLWriter(&buf, pp, "Relatório fiscal", "pt") + pp := table.NewPrinter(&buf, loc) + hw := htmlwriter.NewWriter(&buf, pp, loc, "pt") if err := hw.Render(buildTestAggregator(t)); err != nil { t.Fatalf("Render returned an error: %v", err) diff --git a/internal/writing/table/printer.go b/internal/writing/table/printer.go index 3a360e4..7326ee9 100644 --- a/internal/writing/table/printer.go +++ b/internal/writing/table/printer.go @@ -1,3 +1,6 @@ +// Package table renders an internal.AggregatorWriter as the human-readable +// Anexo J report table, for the terminal (Render) and for embedding in HTML +// documents (RenderHTML). package table import ( @@ -10,22 +13,22 @@ import ( "github.com/nmoniz/any2anexoj/internal" ) -// translator is the contract the pretty printer relies on for human-readable +// translator is the contract the table printer relies on for human-readable // column labels. *Localizer satisfies it. type translator interface { Translate(key string, count int, values map[string]any) string } -// PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each +// Printer writes a simple, human readable, table row to the provided io.Writer for each // ReportItem received. Render and RenderHTML each build a fresh table.Writer so that // invoking them (in any order or repeatedly) on the same instance never duplicates rows. -type PrettyPrinter struct { +type Printer struct { output io.Writer translator translator } -func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter { - return &PrettyPrinter{ +func NewPrinter(w io.Writer, tr translator) *Printer { + return &Printer{ output: w, translator: tr, } @@ -36,7 +39,7 @@ func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter { // multiple times without duplicating rows. The returned table is not wired to // pp.output via an output mirror; callers that want the rendered output // written to pp.output must set the mirror themselves before calling .Render(). -func (pp *PrettyPrinter) buildTable(aw *internal.AggregatorWriter) table.Writer { +func (pp *Printer) buildTable(aw *internal.AggregatorWriter) table.Writer { tw := table.NewWriter() tw.SetAutoIndex(true) tw.SetStyle(table.StyleLight) @@ -81,12 +84,16 @@ func (pp *PrettyPrinter) buildTable(aw *internal.AggregatorWriter) table.Writer } sumTxt := pp.translator.Translate("sum", 1, nil) - tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) + // The footer lists one cell per column (13) so it never relies on + // go-pretty's implicit short-row padding; the trailing empty cell is the + // counter-country column, which has no total. Totals are formatted with + // StringFixed(2) so they match the two-decimal data rows. + tw.AppendFooter(table.Row{sumTxt, sumTxt, sumTxt, sumTxt, sumTxt, aw.TotalEarned().StringFixed(2), "", "", "", aw.TotalSpent().StringFixed(2), aw.TotalFees().StringFixed(2), aw.TotalTaxes().StringFixed(2), ""}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) return tw } -func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { +func (pp *Printer) Render(aw *internal.AggregatorWriter) { t := pp.buildTable(aw) t.SetOutputMirror(pp.output) t.Render() @@ -94,7 +101,7 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { // RenderHTML builds a fresh table from aw and renders it as an HTML string // using go-pretty's HTMLOptions. -func (pp *PrettyPrinter) RenderHTML(aw *internal.AggregatorWriter) string { +func (pp *Printer) RenderHTML(aw *internal.AggregatorWriter) string { t := pp.buildTable(aw) t.Style().HTML = table.HTMLOptions{ CSSClass: "table table-striped table-hover table-bordered", diff --git a/internal/writing/table/printer_test.go b/internal/writing/table/printer_test.go index 415e49e..78d7834 100644 --- a/internal/writing/table/printer_test.go +++ b/internal/writing/table/printer_test.go @@ -12,7 +12,7 @@ import ( "github.com/shopspring/decimal" ) -func TestPrettyPrinter_Render(t *testing.T) { +func TestPrinter_Render(t *testing.T) { // Create test data aw := internal.NewAggregatorWriter() ctx := t.Context() @@ -58,7 +58,7 @@ func TestPrettyPrinter_Render(t *testing.T) { // Create writing printer with buffer var buf bytes.Buffer - pp := table.NewPrettyPrinter(&buf, localizer) + pp := table.NewPrinter(&buf, localizer) // Render the table pp.Render(aw) @@ -75,13 +75,13 @@ func TestPrettyPrinter_Render(t *testing.T) { │ 1 │ 840 - United States │ G01 │ 2023 │ 6 │ 20 │ 150.75 € │ 2023 │ 1 │ 15 │ 100.50 € │ 2.50 € │ 5.00 € │ 826 - United Kingdom │ │ 2 │ 840 - United States │ G20 │ 2023 │ 9 │ 5 │ 225.50 € │ 2023 │ 3 │ 10 │ 200.00 € │ 3.00 € │ 7.50 € │ 826 - United Kingdom │ ├───┼─────────────────────┴──────┴──────┴───────┴─────┼──────────────┼──────┴───────┴─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤ -│ │ SUM │ 376.25 € │ │ 300.5 € │ 5.5 € │ 12.5 € │ │ +│ │ SUM │ 376.25 € │ │ 300.50 € │ 5.50 € │ 12.50 € │ │ └───┴─────────────────────────────────────────────────┴──────────────┴────────────────────┴──────────────┴─────────────────┴─────────────────┴──────────────────────┘ ` // Compare output if got != want { - t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want) + t.Errorf("Printer.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want) } } @@ -115,7 +115,7 @@ func TestRenderHTML(t *testing.T) { // Output buffer used only to satisfy the constructor contract; RenderHTML // must not write to it. var buf bytes.Buffer - pp := table.NewPrettyPrinter(&buf, localizer) + pp := table.NewPrinter(&buf, localizer) html := pp.RenderHTML(aw)