From 62fe1a707694f3b78d9320c1c0d142e736fd1206 Mon Sep 17 00:00:00 2001 From: Natercio Moniz Date: Mon, 28 Sep 2026 21:50:01 +0100 Subject: [PATCH] writers in writing package --- cmd/any2anexoj-cli/main.go | 9 +- .../writing/csv/writer.go | 4 +- internal/writing/csv/writer_test.go | 78 ++++++++++++++++ .../writing}/localizer.go | 4 + .../writing}/localizer_test.go | 14 +++ .../writing/pretty/printer.go | 64 ++++++++++---- .../writing/pretty/printer_test.go | 88 ++++++++++++++++++- .../writing}/translations/en.json | 4 + .../writing}/translations/pt.json | 4 + 9 files changed, 248 insertions(+), 21 deletions(-) rename cmd/any2anexoj-cli/csv_writer.go => internal/writing/csv/writer.go (92%) create mode 100644 internal/writing/csv/writer_test.go rename {cmd/any2anexoj-cli => internal/writing}/localizer.go (88%) rename {cmd/any2anexoj-cli => internal/writing}/localizer_test.go (53%) rename cmd/any2anexoj-cli/pretty_printer.go => internal/writing/pretty/printer.go (60%) rename cmd/any2anexoj-cli/pretty_printer_test.go => internal/writing/pretty/printer_test.go (63%) rename {cmd/any2anexoj-cli => internal/writing}/translations/en.json (93%) rename {cmd/any2anexoj-cli => internal/writing}/translations/pt.json (93%) diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 3a46285..4de4204 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -13,6 +13,9 @@ import ( "github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal/ofigi" "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/pretty" "github.com/spf13/pflag" "golang.org/x/text/language" ) @@ -98,13 +101,13 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { switch *format { case "csv": - return NewCSVWriter(stdout).Render(writer) + return csv.NewCSVWriter(stdout).Render(writer) case "table": - loc, err := NewLocalizer(*lang) + loc, err := writing.NewLocalizer(*lang) if err != nil { return fmt.Errorf("create localizer: %w", err) } - NewPrettyPrinter(stdout, loc).Render(writer) + pretty.NewPrettyPrinter(stdout, loc).Render(writer) return nil default: return fmt.Errorf("unsupported format %q: must be table or csv", *format) diff --git a/cmd/any2anexoj-cli/csv_writer.go b/internal/writing/csv/writer.go similarity index 92% rename from cmd/any2anexoj-cli/csv_writer.go rename to internal/writing/csv/writer.go index 9f31464..3468d47 100644 --- a/cmd/any2anexoj-cli/csv_writer.go +++ b/internal/writing/csv/writer.go @@ -1,4 +1,6 @@ -package main +// Package csv renders an internal.AggregatorWriter as the CSV schema +// expected by Modelo 3 do Anexo J. +package csv import ( "encoding/csv" diff --git a/internal/writing/csv/writer_test.go b/internal/writing/csv/writer_test.go new file mode 100644 index 0000000..bb4e087 --- /dev/null +++ b/internal/writing/csv/writer_test.go @@ -0,0 +1,78 @@ +package csv_test + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/writing/csv" + "github.com/shopspring/decimal" +) + +func TestCSVWriter_Render(t *testing.T) { + aw := internal.NewAggregatorWriter() + ctx := t.Context() + + items := []internal.ReportItem{ + { + Symbol: "AAPL", + Nature: internal.NatureG01, + BrokerCountry: 826, // United Kingdom + AssetCountry: 840, // United States + BuyValue: decimal.NewFromFloat(100.50), + BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), + SellValue: decimal.NewFromFloat(150.75), + SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC), + Fees: decimal.NewFromFloat(2.50), + Taxes: decimal.NewFromFloat(5.00), + }, + { + Symbol: "VWRL", + Nature: internal.NatureG20, + BrokerCountry: 196, // Portugal equivalent demo + AssetCountry: 372, // Ireland + BuyValue: decimal.NewFromFloat(200.00), + BuyTimestamp: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + SellValue: decimal.NewFromFloat(250.25), + SellTimestamp: time.Date(2024, 7, 10, 0, 0, 0, 0, time.UTC), + Fees: decimal.NewFromFloat(1.00), + Taxes: decimal.NewFromFloat(0.00), + }, + } + + for _, ri := range items { + if err := aw.Write(ctx, ri); err != nil { + t.Fatalf("write report item: %v", err) + } + } + + var buf bytes.Buffer + if err := csv.NewCSVWriter(&buf).Render(aw); err != nil { + t.Fatalf("render: %v", err) + } + + out := buf.String() + + wantHeader := "source_country,code,realization_year,realization_month,realization_day,realization_value,acquisition_year,acquisition_month,acquisition_day,acquisition_value,expenses,foreign_tax_paid,counter_country" + if !strings.HasPrefix(out, wantHeader+"\n") { + t.Errorf("expected header row %q, got output:\n%s", wantHeader, out) + } + + wantRow1 := "840,G01,2023,6,20,150.75,2023,1,15,100.50,2.50,5.00,826" + if !strings.Contains(out, wantRow1+"\n") { + t.Errorf("expected row 1 %q in output:\n%s", wantRow1, out) + } + + wantRow2 := "372,G20,2024,7,10,250.25,2024,2,1,200.00,1.00,0.00,196" + if !strings.Contains(out, wantRow2+"\n") { + t.Errorf("expected row 2 %q in output:\n%s", wantRow2, out) + } + + // Make sure the header only appears once and we have exactly 1 header + 2 data rows. + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if got, want := len(lines), 3; got != want { + t.Errorf("expected %d lines (1 header + 2 rows), got %d:\n%s", want, got, out) + } +} diff --git a/cmd/any2anexoj-cli/localizer.go b/internal/writing/localizer.go similarity index 88% rename from cmd/any2anexoj-cli/localizer.go rename to internal/writing/localizer.go index a8cf756..0d2fe06 100644 --- a/cmd/any2anexoj-cli/localizer.go +++ b/internal/writing/localizer.go @@ -1,4 +1,8 @@ +<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer.go package main +======== +package writing +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer.go import ( "embed" diff --git a/cmd/any2anexoj-cli/localizer_test.go b/internal/writing/localizer_test.go similarity index 53% rename from cmd/any2anexoj-cli/localizer_test.go rename to internal/writing/localizer_test.go index 2bad62f..9381e3c 100644 --- a/cmd/any2anexoj-cli/localizer_test.go +++ b/internal/writing/localizer_test.go @@ -1,6 +1,16 @@ +<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer_test.go package main import "testing" +======== +package writing_test + +import ( + "testing" + + "github.com/nmoniz/any2anexoj/internal/writing" +) +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer_test.go func TestNewLocalizer(t *testing.T) { tests := []struct { @@ -15,7 +25,11 @@ func TestNewLocalizer(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { +<<<<<<<< HEAD:cmd/any2anexoj-cli/localizer_test.go _, err := NewLocalizer(tt.lang) +======== + _, err := writing.NewLocalizer(tt.lang) +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/localizer_test.go if err != nil { t.Fatalf("want success call but failed: %v", err) } diff --git a/cmd/any2anexoj-cli/pretty_printer.go b/internal/writing/pretty/printer.go similarity index 60% rename from cmd/any2anexoj-cli/pretty_printer.go rename to internal/writing/pretty/printer.go index f102573..56bcf58 100644 --- a/cmd/any2anexoj-cli/pretty_printer.go +++ b/internal/writing/pretty/printer.go @@ -1,4 +1,8 @@ +<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer.go package main +======== +package pretty +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer.go import ( "fmt" @@ -11,20 +15,35 @@ import ( ) // PrettyPrinter writes a simple, human readable, table row to the provided io.Writer for each -// ReportItem received. +// 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 { - table table.Writer output io.Writer translator Translator } +<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer.go type Translator interface { Translate(key string, count int, values map[string]any) string } func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter { +======== +func NewPrettyPrinter(w io.Writer, tr translator) *PrettyPrinter { + return &PrettyPrinter{ + output: w, + translator: tr, + } +} + +// buildTable constructs a fully configured table.Writer from the items in aw. +// Each call returns a new table.Writer so callers can render the same data +// 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 { +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer.go tw := table.NewWriter() - tw.SetOutputMirror(w) tw.SetAutoIndex(true) tw.SetStyle(table.StyleLight) tw.SetColumnConfigs([]table.ColumnConfig{ @@ -43,14 +62,6 @@ func NewPrettyPrinter(w io.Writer, tr Translator) *PrettyPrinter { colCountry(13), }) - return &PrettyPrinter{ - table: tw, - output: w, - translator: tr, - } -} - -func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { realizationTxt := pp.translator.Translate("realization", 1, nil) acquisitionTxt := pp.translator.Translate("acquisition", 1, nil) yearTxt := pp.translator.Translate("year", 1, nil) @@ -58,8 +69,8 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { dayTxt := pp.translator.Translate("day", 1, nil) valorTxt := pp.translator.Translate("value", 1, nil) - pp.table.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true}) - pp.table.AppendHeader(table.Row{ + tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true}) + tw.AppendHeader(table.Row{ pp.translator.Translate("source_country", 1, nil), pp.translator.Translate("code", 1, nil), yearTxt, monthTxt, dayTxt, valorTxt, yearTxt, monthTxt, dayTxt, valorTxt, @@ -67,7 +78,7 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { }) for ri := range aw.Iter() { - pp.table.AppendRow(table.Row{ + tw.AppendRow(table.Row{ ri.AssetCountry, ri.Nature, ri.SellTimestamp.Year(), int(ri.SellTimestamp.Month()), ri.SellTimestamp.Day(), ri.SellValue.StringFixed(2), ri.BuyTimestamp.Year(), int(ri.BuyTimestamp.Month()), ri.BuyTimestamp.Day(), ri.BuyValue.StringFixed(2), @@ -75,8 +86,29 @@ func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { }) } - pp.table.AppendFooter(table.Row{"SUM", "SUM", "SUM", "SUM", "SUM", aw.TotalEarned(), "", "", "", aw.TotalSpent(), aw.TotalFees(), aw.TotalTaxes()}, table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignRight}) - pp.table.Render() + 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}) + + return tw +} + +func (pp *PrettyPrinter) Render(aw *internal.AggregatorWriter) { + t := pp.buildTable(aw) + t.SetOutputMirror(pp.output) + t.Render() +} + +// 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 { + t := pp.buildTable(aw) + t.Style().HTML = table.HTMLOptions{ + CSSClass: "table table-striped table-hover table-bordered", + EscapeText: true, + Newline: "
", + EmptyColumn: " ", + } + return t.RenderHTML() } func colEuros(n int) table.ColumnConfig { diff --git a/cmd/any2anexoj-cli/pretty_printer_test.go b/internal/writing/pretty/printer_test.go similarity index 63% rename from cmd/any2anexoj-cli/pretty_printer_test.go rename to internal/writing/pretty/printer_test.go index 72db220..e0201fa 100644 --- a/cmd/any2anexoj-cli/pretty_printer_test.go +++ b/internal/writing/pretty/printer_test.go @@ -1,11 +1,21 @@ +<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go package main +======== +package pretty_test +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go import ( "bytes" + "strings" "testing" "time" "github.com/nmoniz/any2anexoj/internal" +<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go +======== + "github.com/nmoniz/any2anexoj/internal/writing" + "github.com/nmoniz/any2anexoj/internal/writing/pretty" +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go "github.com/shopspring/decimal" ) @@ -48,14 +58,22 @@ func TestPrettyPrinter_Render(t *testing.T) { } // Create English localizer +<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go localizer, err := NewLocalizer("en") +======== + localizer, err := writing.NewLocalizer("en") +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go if err != nil { t.Fatalf("failed to create localizer: %v", err) } - // Create pretty printer with buffer + // Create writing printer with buffer var buf bytes.Buffer +<<<<<<<< HEAD:cmd/any2anexoj-cli/pretty_printer_test.go pp := NewPrettyPrinter(&buf, localizer) +======== + pp := pretty.NewPrettyPrinter(&buf, localizer) +>>>>>>>> 6ba3d78 (writers in writing package):internal/writing/pretty/printer_test.go // Render the table pp.Render(aw) @@ -81,3 +99,71 @@ func TestPrettyPrinter_Render(t *testing.T) { t.Errorf("PrettyPrinter.Render() output doesn't match expected.\n\nGot:\n%s\n\nWant:\n%s", got, want) } } + +func TestRenderHTML(t *testing.T) { + // Create test data + aw := internal.NewAggregatorWriter() + ctx := t.Context() + + err := aw.Write(ctx, internal.ReportItem{ + Symbol: "AAPL", + Nature: internal.NatureG01, + BrokerCountry: 826, // United Kingdom + AssetCountry: 840, // United States + BuyValue: decimal.NewFromFloat(100.50), + BuyTimestamp: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), + SellValue: decimal.NewFromFloat(150.75), + SellTimestamp: time.Date(2023, 6, 20, 0, 0, 0, 0, time.UTC), + Fees: decimal.NewFromFloat(2.50), + Taxes: decimal.NewFromFloat(5.00), + }) + if err != nil { + t.Fatalf("failed to write report item: %v", err) + } + + // Create Portuguese localizer so the header text is in pt + localizer, err := writing.NewLocalizer("pt") + if err != nil { + t.Fatalf("failed to create localizer: %v", err) + } + + // Output buffer used only to satisfy the constructor contract; RenderHTML + // must not write to it. + var buf bytes.Buffer + pp := pretty.NewPrettyPrinter(&buf, localizer) + + html := pp.RenderHTML(aw) + + // RenderHTML must not side-write to pp.output. + if buf.Len() != 0 { + t.Errorf("RenderHTML should not write to pp.output, but got %d bytes: %q", buf.Len(), buf.String()) + } + + // Structural assertions. + wantSubstrings := []string{ + ``, + `
`, + ``, + ``, + ``, + ``, + ``, + ``, + "Realização", + "Aquisição", + "Soma", + } + for _, s := range wantSubstrings { + if !strings.Contains(html, s) { + t.Errorf("RenderHTML output missing expected substring %q\nGot:\n%s", s, html) + } + } + + // Calling RenderHTML twice on the same instance must not duplicate rows. + html2 := pp.RenderHTML(aw) + if strings.Count(html2, "):\n%s\nSecond (%d ):\n%s", + strings.Count(html, "