diff --git a/cmd/any2anexoj-cli/main.go b/cmd/any2anexoj-cli/main.go index 11f50f3..b11a728 100644 --- a/cmd/any2anexoj-cli/main.go +++ b/cmd/any2anexoj-cli/main.go @@ -115,10 +115,7 @@ func runWithIO(ctx context.Context, stdin io.Reader, stdout io.Writer) error { if err != nil { return fmt.Errorf("create localizer: %w", err) } - // RenderHTML never writes to the printer's output writer, so - // io.Discard is safe here. - printer := table.NewPrinter(io.Discard, loc) - return htmlwriter.NewWriter(stdout, printer, loc, *lang).Render(writer) + return htmlwriter.NewWriter(stdout, loc, *lang).Render(writer) default: return fmt.Errorf("unsupported format %q: must be table, csv or html", *format) } diff --git a/cmd/any2anexoj-cli/main_test.go b/cmd/any2anexoj-cli/main_test.go index 77a0c21..d71cb85 100644 --- a/cmd/any2anexoj-cli/main_test.go +++ b/cmd/any2anexoj-cli/main_test.go @@ -159,6 +159,9 @@ func TestRunWithIO_HTMLFormat(t *testing.T) { t.Errorf("expected HTML output to start with ; got prefix: %q", got[:min(len(got), 64)]) } + if !strings.Contains(got, "

Tax report

") { + t.Errorf("expected output to contain the translated report title") + } if !strings.Contains(got, "table-responsive") { t.Errorf("expected output to contain Bootstrap table-responsive wrapper") } diff --git a/internal/writing/html/fragment.go b/internal/writing/html/fragment.go new file mode 100644 index 0000000..b4daf1a --- /dev/null +++ b/internal/writing/html/fragment.go @@ -0,0 +1,69 @@ +package html + +import ( + "fmt" + "strings" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef" +) + +// htmlCSSClass is the CSS class list put on the rendered report table. +const htmlCSSClass = "table table-striped table-hover table-bordered" + +// widthIndex is the weight of the auto-index (row number) column; the other +// column weights come from tabledef's width budgets. +const widthIndex = 6 + +// htmlColumnWeights lists one weight per rendered column, starting with the +// auto-index column, mirroring the column order of tabledef.Build so the +// HTML table keeps columns in the same strict proportion to each other as +// the terminal layout. +var htmlColumnWeights = []int{ + widthIndex, + tabledef.WidthCountry, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros, + tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthOther, tabledef.WidthEuros, + tabledef.WidthEuros, tabledef.WidthEuros, tabledef.WidthCountry, +} + +// renderTableFragment renders aw as the Bootstrap-styled HTML +// fragment embedded in the document. The table gets a fixed layout with +// strictly proportional column widths, so the browser — not this package — +// decides when a header or cell wraps: on wide screens everything fits on +// one line, on narrow ones it wraps automatically. +func (hw *Writer) renderTableFragment(aw *internal.AggregatorWriter) string { + t := tabledef.Build(hw.translator, aw) + t.Style().HTML = table.HTMLOptions{ + CSSClass: htmlCSSClass, + EscapeText: true, + Newline: "
", + EmptyColumn: " ", + } + html := t.RenderHTML() + + // go-pretty cannot emit table-level attributes or a , so splice + // them into its output: the fixed layout makes the colgroup's relative + // widths authoritative instead of content-driven column sizing. + openTag := `
` + fixedTag := `
` + "\n" + colgroupHTML() + return strings.Replace(html, openTag, fixedTag, 1) +} + +// colgroupHTML returns a with one per rendered column, each +// sized strictly in proportion to its weight. With the fixed table layout +// applied in renderTableFragment these widths are authoritative: wide +// viewports fit headers on a single line, narrow ones wrap automatically. +func colgroupHTML() string { + var total int + for _, w := range htmlColumnWeights { + total += w + } + var sb strings.Builder + sb.WriteString("") + for _, w := range htmlColumnWeights { + fmt.Fprintf(&sb, ``, float64(w)/float64(total)*100) + } + sb.WriteString("") + return sb.String() +} diff --git a/internal/writing/html/fragment_test.go b/internal/writing/html/fragment_test.go new file mode 100644 index 0000000..404de45 --- /dev/null +++ b/internal/writing/html/fragment_test.go @@ -0,0 +1,60 @@ +package html_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/nmoniz/any2anexoj/internal/writing" + htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html" +) + +// TestWriter_RenderTableFragment renders a Portuguese document and asserts +// the embedded report table fragment carries the shared structure: the +// translated headers, the totals footer, and the strictly proportional +// colgroup that keeps HTML columns in the same relation to each other as +// the terminal layout. +func TestWriter_RenderTableFragment(t *testing.T) { + aw := buildTestAggregator(t) + + localizer, err := writing.NewLocalizer("pt") + if err != nil { + t.Fatalf("failed to create localizer: %v", err) + } + + var buf bytes.Buffer + hw := htmlwriter.NewWriter(&buf, localizer, "pt") + + if err := hw.Render(aw); err != nil { + t.Fatalf("Render returned an error: %v", err) + } + got := buf.String() + + wantSubstrings := []string{ + `
`, + ``, + ``, + ``, + ``, + `
`, + ``, + ``, + ``, + ``, + ``, + ``, + "Realização", + "Aquisição", + "Soma", + } + for _, s := range wantSubstrings { + if !strings.Contains(got, s) { + t.Errorf("rendered document missing expected substring %q\nGot:\n%s", s, got) + } + } + + // The fragment must be embedded raw, not HTML-escaped. + if strings.Contains(got, "<table") { + t.Errorf("table fragment appears to be HTML-escaped; expected raw markup\nGot:\n%s", got) + } +} diff --git a/internal/writing/html/writer.go b/internal/writing/html/writer.go index 81c33ef..9cd8200 100644 --- a/internal/writing/html/writer.go +++ b/internal/writing/html/writer.go @@ -1,8 +1,8 @@ // Package html renders an internal.AggregatorWriter as a complete, -// standalone HTML document built around the same table fragment produced by -// 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. +// standalone HTML document: the report table fragment (built from the +// shared table definition in internal/writing/internal/tabledef, so it can +// never drift from the terminal layout) embedded in a Bootstrap-styled page +// with a translated title and generation timestamp. package html import ( @@ -13,15 +13,9 @@ import ( "time" "github.com/nmoniz/any2anexoj/internal" - "github.com/nmoniz/any2anexoj/internal/writing/table" + "github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef" ) -// 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" @@ -31,20 +25,17 @@ const bootstrapIntegrity = "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bR // Writer writes a complete HTML document for an AggregatorWriter. type Writer struct { output io.Writer - printer *table.Printer - translator translator + translator tabledef.Translator lang string } -// 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. 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 { +// NewWriter builds a Writer that writes a complete HTML document to w. The +// report table is rendered from the shared table definition via tr, and 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, tr tabledef.Translator, lang string) *Writer { return &Writer{ output: w, - printer: printer, translator: tr, lang: lang, } @@ -63,10 +54,7 @@ var documentTemplate = template.Must(template.New("html").Parse(documentTemplate // fragment from aw so the rendered count reflects aw's current items and // no rows are duplicated across invocations. func (hw *Writer) Render(aw *internal.AggregatorWriter) error { - if hw.printer == nil { - return fmt.Errorf("html: nil printer") - } - fragment := hw.printer.RenderHTML(aw) + fragment := hw.renderTableFragment(aw) title := hw.translator.Translate("report_title", 1, nil) data := struct { Lang string diff --git a/internal/writing/html/writer_test.go b/internal/writing/html/writer_test.go index 1fec216..42eab4f 100644 --- a/internal/writing/html/writer_test.go +++ b/internal/writing/html/writer_test.go @@ -6,10 +6,9 @@ import ( "testing" "time" - htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html" - "github.com/nmoniz/any2anexoj/internal/writing/table" - "github.com/nmoniz/any2anexoj/internal/writing" "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/writing" + htmlwriter "github.com/nmoniz/any2anexoj/internal/writing/html" "github.com/shopspring/decimal" ) @@ -64,9 +63,7 @@ func TestWriter_Render(t *testing.T) { } var buf bytes.Buffer - // RenderHTML never writes to the printer's output, so Discard is safe. - pp := table.NewPrinter(&buf, loc) - hw := htmlwriter.NewWriter(&buf, pp, loc, "en") + hw := htmlwriter.NewWriter(&buf, loc, "en") if err := hw.Render(buildTestAggregator(t)); err != nil { t.Fatalf("Render returned an error: %v", err) @@ -86,7 +83,8 @@ func TestWriter_Render(t *testing.T) { `
`, `

Tax report

`, `
`, - `
`, + `
`, + ``, `
`, ``, ``, @@ -133,8 +131,7 @@ func TestWriter_NoRowDuplication(t *testing.T) { } var buf bytes.Buffer - pp := table.NewPrinter(&buf, loc) - hw := htmlwriter.NewWriter(&buf, pp, loc, "en") + hw := htmlwriter.NewWriter(&buf, loc, "en") aw := buildTestAggregator(t) @@ -167,8 +164,7 @@ func TestWriter_LangAttribute(t *testing.T) { } var buf bytes.Buffer - pp := table.NewPrinter(&buf, loc) - hw := htmlwriter.NewWriter(&buf, pp, loc, "pt") + hw := htmlwriter.NewWriter(&buf, loc, "pt") if err := hw.Render(buildTestAggregator(t)); err != nil { t.Fatalf("Render returned an error: %v", err) diff --git a/internal/writing/internal/tabledef/table.go b/internal/writing/internal/tabledef/table.go new file mode 100644 index 0000000..c18958f --- /dev/null +++ b/internal/writing/internal/tabledef/table.go @@ -0,0 +1,154 @@ +// Package tabledef holds the single definition of the Anexo J report table +// — column configuration, translated headers, data rows and the totals +// footer — shared by every renderer under internal/writing. Living in a +// writing-internal package lets the table and html packages project the +// same structure into their own formats without any of them exposing +// go-pretty types in their public API. +package tabledef + +import ( + "fmt" + + "github.com/biter777/countries" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/nmoniz/any2anexoj/internal" +) + +// Translator is the contract the report table renderers rely on for +// human-readable column labels. *writing.Localizer satisfies it. +type Translator interface { + Translate(key string, count int, values map[string]any) string +} + +// Column width budgets enforced by the terminal renderer. The html package +// derives its proportional column widths from the same values, keeping both +// renderings in the same strict proportion to each other. +const ( + // WidthCountry is the width budget of a country column ("code - name"). + WidthCountry = 24 + // WidthOther is the width budget of a plain column (code, year, ...). + WidthOther = 12 + // WidthEuros is the width budget of a money column ("123.45 €"). + WidthEuros = 15 +) + +// Build constructs a fully configured go-pretty table.Writer from the items +// in aw: one column per report field, translated headers, data rows and the +// totals footer. Each call returns a fresh table so callers can render the +// same data multiple times without duplicating rows, and the returned table +// is not wired to any output. +// +// Change columns, headers or footers here so every renderer stays in sync. +func Build(tr Translator, aw *internal.AggregatorWriter) table.Writer { + tw := table.NewWriter() + tw.SetAutoIndex(true) + tw.SetStyle(table.StyleLight) + tw.SetColumnConfigs([]table.ColumnConfig{ + colCountry(1), + colOther(2), + colOther(3), + colOther(4), + colOther(5), + colEuros(6), + colOther(7), + colOther(8), + colOther(9), + colEuros(10), + colEuros(11), + colEuros(12), + colCountry(13), + }) + + realizationTxt := tr.Translate("realization", 1, nil) + acquisitionTxt := tr.Translate("acquisition", 1, nil) + yearTxt := tr.Translate("year", 1, nil) + monthTxt := tr.Translate("month", 1, nil) + dayTxt := tr.Translate("day", 1, nil) + valorTxt := tr.Translate("value", 1, nil) + + tw.AppendHeader(table.Row{"", "", realizationTxt, realizationTxt, realizationTxt, realizationTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, acquisitionTxt, "", "", ""}, table.RowConfig{AutoMerge: true}) + tw.AppendHeader(table.Row{ + tr.Translate("source_country", 1, nil), tr.Translate("code", 1, nil), + yearTxt, monthTxt, dayTxt, valorTxt, + yearTxt, monthTxt, dayTxt, valorTxt, + tr.Translate("expenses", 2, nil), tr.Translate("foreign_tax_paid", 1, nil), tr.Translate("counter_country", 1, nil), + }) + + for ri := range aw.Iter() { + 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), + ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry, + }) + } + + sumTxt := tr.Translate("sum", 1, nil) + // 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 colEuros(n int) table.ColumnConfig { + return table.ColumnConfig{ + Number: n, + Align: text.AlignRight, + AlignFooter: text.AlignRight, + AlignHeader: text.AlignRight, + WidthMin: WidthOther, + WidthMax: WidthEuros, + WidthMaxEnforcer: truncateHeader, + Transformer: func(val any) string { + return fmt.Sprintf("%v €", val) + }, + TransformerFooter: func(val any) string { + return fmt.Sprintf("%v €", val) + }, + } +} + +func colOther(n int) table.ColumnConfig { + return table.ColumnConfig{ + Number: n, + Align: text.AlignLeft, + AlignFooter: text.AlignLeft, + AlignHeader: text.AlignLeft, + WidthMax: WidthOther, + WidthMaxEnforcer: truncateHeader, + } +} + +func colCountry(n int) table.ColumnConfig { + return table.ColumnConfig{ + Number: n, + Align: text.AlignLeft, + AlignFooter: text.AlignLeft, + AlignHeader: text.AlignLeft, + WidthMax: WidthCountry, + WidthMaxEnforcer: text.Trim, + Transformer: func(val any) string { + countryCode := val.(int64) + return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name) + }, + } +} + +// truncateHeader enforces a column's WidthMax on terminal output by cutting +// the text exactly at the width limit — mid-word if that is where it lands — +// and marking the cut with an ellipsis: keeping as many leading runes +// readable beats a tidy word boundary. All indexing is rune-based so +// multibyte labels (e.g. Portuguese) truncate correctly. +func truncateHeader(val string, width int) string { + runes := []rune(val) + if len(runes) <= width { + return val + } + // leave room for the ellipsis + limit := max(width-1, 1) + return string(runes[:limit]) + "…" +} diff --git a/internal/writing/internal/tabledef/table_test.go b/internal/writing/internal/tabledef/table_test.go new file mode 100644 index 0000000..1bec9d2 --- /dev/null +++ b/internal/writing/internal/tabledef/table_test.go @@ -0,0 +1,24 @@ +package tabledef + +import "testing" + +func TestTruncateHeader(t *testing.T) { + tests := []struct { + name string + val string + width int + want string + }{ + {name: "fits unchanged", val: "TAX PAID ABROAD", width: 15, want: "TAX PAID ABROAD"}, + {name: "cut mid-word keeps the most runes", val: "EXPENSES AND CHARGES", width: 15, want: "EXPENSES AND C…"}, + {name: "single overlong word is hard cut", val: "ESTRANGEIRO", width: 8, want: "ESTRANG…"}, + {name: "cut ignores word boundaries", val: "IMPOSTO PAGO NO ESTRANGEIRO", width: 15, want: "IMPOSTO PAGO N…"}, + {name: "multibyte runes count as one", val: "PAÍS DA CONTRAPARTE", width: 12, want: "PAÍS DA CON…"}, + {name: "narrow width", val: "ABCD", width: 2, want: "A…"}, + } + for _, tt := range tests { + if got := truncateHeader(tt.val, tt.width); got != tt.want { + t.Errorf("%s: truncateHeader(%q, %d) = %q, want %q", tt.name, tt.val, tt.width, got, tt.want) + } + } +} diff --git a/internal/writing/table/printer.go b/internal/writing/table/printer.go index 7326ee9..5976cdc 100644 --- a/internal/writing/table/printer.go +++ b/internal/writing/table/printer.go @@ -1,155 +1,34 @@ // 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). +// Anexo J report table for the terminal. The shared table definition it (and +// the html package) renders from lives in internal/writing/internal/tabledef. package table import ( - "fmt" "io" - "github.com/biter777/countries" - "github.com/jedib0t/go-pretty/v6/table" - "github.com/jedib0t/go-pretty/v6/text" "github.com/nmoniz/any2anexoj/internal" + "github.com/nmoniz/any2anexoj/internal/writing/internal/tabledef" ) -// 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 -} - -// 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. +// Printer writes a simple, human readable, table to the provided io.Writer +// for the report items in an AggregatorWriter. Each Render call builds a +// fresh table from the aggregator, so invoking it repeatedly on the same +// instance never duplicates rows. type Printer struct { output io.Writer - translator translator + translator tabledef.Translator } -func NewPrinter(w io.Writer, tr translator) *Printer { +func NewPrinter(w io.Writer, tr tabledef.Translator) *Printer { return &Printer{ 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 *Printer) buildTable(aw *internal.AggregatorWriter) table.Writer { - tw := table.NewWriter() - tw.SetAutoIndex(true) - tw.SetStyle(table.StyleLight) - tw.SetColumnConfigs([]table.ColumnConfig{ - colCountry(1), - colOther(2), - colOther(3), - colOther(4), - colOther(5), - colEuros(6), - colOther(7), - colOther(8), - colOther(9), - colEuros(10), - colEuros(11), - colEuros(12), - colCountry(13), - }) - - realizationTxt := pp.translator.Translate("realization", 1, nil) - acquisitionTxt := pp.translator.Translate("acquisition", 1, nil) - yearTxt := pp.translator.Translate("year", 1, nil) - monthTxt := pp.translator.Translate("month", 1, nil) - dayTxt := pp.translator.Translate("day", 1, nil) - valorTxt := pp.translator.Translate("value", 1, nil) - - 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, - pp.translator.Translate("expenses", 2, nil), pp.translator.Translate("foreign_tax_paid", 1, nil), pp.translator.Translate("counter_country", 1, nil), - }) - - for ri := range aw.Iter() { - 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), - ri.Fees.StringFixed(2), ri.Taxes.StringFixed(2), ri.BrokerCountry, - }) - } - - sumTxt := pp.translator.Translate("sum", 1, nil) - // 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 -} - +// Render writes the terminal table for aw to the configured output. func (pp *Printer) Render(aw *internal.AggregatorWriter) { - t := pp.buildTable(aw) + t := tabledef.Build(pp.translator, 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 *Printer) 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 { - return table.ColumnConfig{ - Number: n, - Align: text.AlignRight, - AlignFooter: text.AlignRight, - AlignHeader: text.AlignRight, - WidthMin: 12, - WidthMax: 15, - Transformer: func(val any) string { - return fmt.Sprintf("%v €", val) - }, - TransformerFooter: func(val any) string { - return fmt.Sprintf("%v €", val) - }, - } -} - -func colOther(n int) table.ColumnConfig { - return table.ColumnConfig{ - Number: n, - Align: text.AlignLeft, - AlignFooter: text.AlignLeft, - AlignHeader: text.AlignLeft, - WidthMax: 12, - } -} - -func colCountry(n int) table.ColumnConfig { - return table.ColumnConfig{ - Number: n, - Align: text.AlignLeft, - AlignFooter: text.AlignLeft, - AlignHeader: text.AlignLeft, - WidthMax: 24, - WidthMaxEnforcer: text.Trim, - Transformer: func(val any) string { - countryCode := val.(int64) - return fmt.Sprintf("%v - %s", val, countries.ByNumeric(int(countryCode)).Info().Name) - }, - } -} diff --git a/internal/writing/table/printer_test.go b/internal/writing/table/printer_test.go index 78d7834..37f0845 100644 --- a/internal/writing/table/printer_test.go +++ b/internal/writing/table/printer_test.go @@ -2,7 +2,6 @@ package table_test import ( "bytes" - "strings" "testing" "time" @@ -69,8 +68,7 @@ func TestPrinter_Render(t *testing.T) { // Expected output want := `┌───┬────────────────────────────┬───────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────────────────────────┐ │ │ │ REALIZATION │ ACQUISITION │ │ -│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND CH │ TAX PAID ABROAD │ COUNTER COUNTRY │ -│ │ │ │ │ │ │ │ │ │ │ │ ARGES │ │ │ +│ │ SOURCE COUNTRY │ CODE │ YEAR │ MONTH │ DAY │ VALUE │ YEAR │ MONTH │ DAY │ VALUE │ EXPENSES AND C… │ TAX PAID ABROAD │ COUNTER COUNTRY │ ├───┼─────────────────────┼──────┼──────┼───────┼─────┼──────────────┼──────┼───────┼─────┼──────────────┼─────────────────┼─────────────────┼──────────────────────┤ │ 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 │ @@ -84,71 +82,3 @@ func TestPrinter_Render(t *testing.T) { t.Errorf("Printer.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 := table.NewPrinter(&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, "