Skip to content

Commit ab9500a

Browse files
committed
refactor #39,#40: implement application layer and improve test coverage
- Create application layer with RenderService, ValidateService, InfoService - Move orchestration logic (filterPages, collectFonts) from cmd to application - Simplify cmd/ to only handle CLI concerns (flags, I/O, user prompts) - Remove empty .gitkeep placeholders from vertical-slice application dirs - Add tests for CollectFontRefs, buildFilename, buildCSSURL, fallbackStyleKey, intrinsicSize, and crossOffset - Coverage: 55.0% → 64.3% Closes #39 Closes #40
1 parent d46f7ce commit ab9500a

File tree

21 files changed

+1101
-237
lines changed

21 files changed

+1101
-237
lines changed

cmd/info.go

Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
"sort"
77

88
"github.com/spf13/cobra"
9+
"github.com/vpedrosa/pen2pdf/internal/application"
910
parserInfra "github.com/vpedrosa/pen2pdf/internal/parser/infrastructure"
10-
shared "github.com/vpedrosa/pen2pdf/internal/shared/domain"
1111
)
1212

1313
var infoCmd = &cobra.Command{
@@ -31,74 +31,43 @@ func runInfo(cmd *cobra.Command, args []string) error {
3131
}
3232
defer inputFile.Close() //nolint:errcheck
3333

34-
parser := parserInfra.NewJSONParser()
35-
doc, err := parser.Parse(inputFile)
34+
svc := application.NewInfoService(parserInfra.NewJSONParser())
35+
36+
info, err := svc.GetInfo(inputFile)
3637
if err != nil {
37-
return fmt.Errorf("parse error: %w", err)
38+
return err
3839
}
3940

4041
cmd.Printf("File: %s\n", inputPath)
41-
cmd.Printf("Version: %s\n", doc.Version)
42+
cmd.Printf("Version: %s\n", info.Version)
4243
cmd.Println()
4344

44-
// Pages
45-
cmd.Printf("Pages (%d):\n", len(doc.Children))
46-
for _, child := range doc.Children {
47-
if frame, ok := child.(*shared.Frame); ok {
48-
cmd.Printf(" - %s (%.0fx%.0f)\n", frame.Name, frame.Width.Value, frame.Height.Value)
49-
}
45+
cmd.Printf("Pages (%d):\n", len(info.Pages))
46+
for _, page := range info.Pages {
47+
cmd.Printf(" - %s (%.0fx%.0f)\n", page.Name, page.Width, page.Height)
5048
}
5149
cmd.Println()
5250

53-
// Variables
54-
if len(doc.Variables) > 0 {
55-
cmd.Printf("Variables (%d):\n", len(doc.Variables))
56-
names := make([]string, 0, len(doc.Variables))
57-
for name := range doc.Variables {
51+
if len(info.Variables) > 0 {
52+
cmd.Printf("Variables (%d):\n", len(info.Variables))
53+
names := make([]string, 0, len(info.Variables))
54+
for name := range info.Variables {
5855
names = append(names, name)
5956
}
6057
sort.Strings(names)
6158
for _, name := range names {
62-
v := doc.Variables[name]
59+
v := info.Variables[name]
6360
cmd.Printf(" %-20s %s = %v\n", name, v.Type, v.Value)
6461
}
6562
cmd.Println()
6663
}
6764

68-
// Fonts
69-
fonts := collectFonts(doc.Children)
70-
if len(fonts) > 0 {
71-
cmd.Printf("Fonts (%d):\n", len(fonts))
72-
for _, f := range fonts {
65+
if len(info.Fonts) > 0 {
66+
cmd.Printf("Fonts (%d):\n", len(info.Fonts))
67+
for _, f := range info.Fonts {
7368
cmd.Printf(" - %s\n", f)
7469
}
7570
}
7671

7772
return nil
7873
}
79-
80-
// collectFonts walks the node tree and returns sorted unique font families.
81-
func collectFonts(nodes []shared.Node) []string {
82-
fontSet := make(map[string]bool)
83-
walkFonts(nodes, fontSet)
84-
85-
fonts := make([]string, 0, len(fontSet))
86-
for f := range fontSet {
87-
fonts = append(fonts, f)
88-
}
89-
sort.Strings(fonts)
90-
return fonts
91-
}
92-
93-
func walkFonts(nodes []shared.Node, fonts map[string]bool) {
94-
for _, node := range nodes {
95-
switch n := node.(type) {
96-
case *shared.Frame:
97-
walkFonts(n.Children, fonts)
98-
case *shared.Text:
99-
if n.FontFamily != "" {
100-
fonts[n.FontFamily] = true
101-
}
102-
}
103-
}
104-
}

cmd/info_test.go

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package cmd
22

33
import (
44
"testing"
5-
6-
shared "github.com/vpedrosa/pen2pdf/internal/shared/domain"
75
)
86

97
func TestInfoCommandRegistered(t *testing.T) {
@@ -25,60 +23,3 @@ func TestInfoCommandRequiresArg(t *testing.T) {
2523
t.Error("expected error for missing argument")
2624
}
2725
}
28-
29-
func TestCollectFonts(t *testing.T) {
30-
nodes := []shared.Node{
31-
&shared.Frame{
32-
ID: "f1", Name: "page",
33-
Children: []shared.Node{
34-
&shared.Text{ID: "t1", Name: "a", FontFamily: "Inter"},
35-
&shared.Text{ID: "t2", Name: "b", FontFamily: "Montserrat"},
36-
&shared.Text{ID: "t3", Name: "c", FontFamily: "Inter"}, // duplicate
37-
&shared.Frame{
38-
ID: "f2", Name: "inner",
39-
Children: []shared.Node{
40-
&shared.Text{ID: "t4", Name: "d", FontFamily: "Playfair Display"},
41-
},
42-
},
43-
},
44-
},
45-
}
46-
47-
fonts := collectFonts(nodes)
48-
if len(fonts) != 3 {
49-
t.Fatalf("expected 3 unique fonts, got %d: %v", len(fonts), fonts)
50-
}
51-
// Should be sorted
52-
expected := []string{"Inter", "Montserrat", "Playfair Display"}
53-
for i, f := range fonts {
54-
if f != expected[i] {
55-
t.Errorf("expected fonts[%d] '%s', got '%s'", i, expected[i], f)
56-
}
57-
}
58-
}
59-
60-
func TestCollectFontsEmpty(t *testing.T) {
61-
nodes := []shared.Node{
62-
&shared.Frame{ID: "f1", Name: "empty"},
63-
}
64-
65-
fonts := collectFonts(nodes)
66-
if len(fonts) != 0 {
67-
t.Errorf("expected 0 fonts, got %d", len(fonts))
68-
}
69-
}
70-
71-
func TestCollectFontsSkipsEmptyFamily(t *testing.T) {
72-
nodes := []shared.Node{
73-
&shared.Text{ID: "t1", Name: "a", FontFamily: ""},
74-
&shared.Text{ID: "t2", Name: "b", FontFamily: "Inter"},
75-
}
76-
77-
fonts := collectFonts(nodes)
78-
if len(fonts) != 1 {
79-
t.Fatalf("expected 1 font, got %d", len(fonts))
80-
}
81-
if fonts[0] != "Inter" {
82-
t.Errorf("expected 'Inter', got '%s'", fonts[0])
83-
}
84-
}

cmd/render.go

Lines changed: 30 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"github.com/spf13/cobra"
11+
"github.com/vpedrosa/pen2pdf/internal/application"
1112
assetInfra "github.com/vpedrosa/pen2pdf/internal/asset/infrastructure"
1213
layoutInfra "github.com/vpedrosa/pen2pdf/internal/layout/infrastructure"
1314
parserInfra "github.com/vpedrosa/pen2pdf/internal/parser/infrastructure"
@@ -47,88 +48,60 @@ func runRender(cmd *cobra.Command, args []string) error {
4748
output = strings.TrimSuffix(inputPath, ext) + ".pdf"
4849
}
4950

50-
// Parse
51-
inputFile, err := os.Open(inputPath)
52-
if err != nil {
53-
return fmt.Errorf("open input: %w", err)
54-
}
55-
defer inputFile.Close() //nolint:errcheck
56-
57-
parser := parserInfra.NewJSONParser()
58-
doc, err := parser.Parse(inputFile)
59-
if err != nil {
60-
return fmt.Errorf("parse: %w", err)
61-
}
62-
63-
// Resolve variables
64-
resolver := resolverInfra.NewVariableResolver()
65-
if err := resolver.Resolve(doc); err != nil {
66-
return fmt.Errorf("resolve: %w", err)
67-
}
68-
69-
// Filter pages if --pages flag is set
70-
if pagesFlag != "" {
71-
doc.Children, err = filterPages(doc.Children, pagesFlag)
72-
if err != nil {
73-
return err
74-
}
75-
}
76-
77-
// Set up asset loaders
51+
// Build infrastructure
7852
baseDir := filepath.Dir(inputPath)
79-
imageLoader := assetInfra.NewFSImageLoader(baseDir)
8053
fontsDir := filepath.Join(baseDir, "fonts")
8154

8255
fontDirs := []string{fontsDir, "/usr/share/fonts", "/usr/local/share/fonts"}
8356
if home, err := os.UserHomeDir(); err == nil {
8457
fontDirs = append(fontDirs, filepath.Join(home, ".local", "share", "fonts"))
8558
}
59+
60+
parser := parserInfra.NewJSONParser()
61+
resolver := resolverInfra.NewVariableResolver()
8662
fontLoader := assetInfra.NewFSFontLoader(fontDirs...)
63+
imageLoader := assetInfra.NewFSImageLoader(baseDir)
64+
measurer := layoutInfra.NewGopdfTextMeasurer(fontLoader)
65+
layoutEngine := layoutInfra.NewFlexboxEngine()
66+
renderer := rendererInfra.NewPDFRenderer(imageLoader, fontLoader)
8767

88-
// Check for missing fonts and offer to download
89-
if err := checkAndDownloadFonts(cmd, doc, fontLoader, fontsDir); err != nil {
90-
return err
68+
svc := application.NewRenderService(parser, resolver, fontLoader, imageLoader, layoutEngine, measurer, renderer)
69+
70+
// Check for missing fonts (interactive CLI concern)
71+
inputFile, err := os.Open(inputPath)
72+
if err != nil {
73+
return fmt.Errorf("open input: %w", err)
9174
}
9275

93-
// Layout
94-
measurer := layoutInfra.NewGopdfTextMeasurer(fontLoader)
95-
layoutEngine := layoutInfra.NewFlexboxEngine()
96-
pages, err := layoutEngine.Layout(doc, measurer)
76+
missing, doc, err := svc.DetectMissingFonts(inputFile)
77+
inputFile.Close() //nolint:errcheck
9778
if err != nil {
98-
return fmt.Errorf("layout: %w", err)
79+
return err
80+
}
81+
82+
if len(missing) > 0 {
83+
if err := promptAndDownloadFonts(cmd, missing, fontsDir); err != nil {
84+
return err
85+
}
9986
}
10087

101-
// Render
88+
// Render using the already-parsed document
10289
outputFile, err := os.Create(output)
10390
if err != nil {
10491
return fmt.Errorf("create output: %w", err)
10592
}
10693
defer outputFile.Close() //nolint:errcheck
10794

108-
renderer := rendererInfra.NewPDFRenderer(imageLoader, fontLoader)
109-
if err := renderer.Render(pages, outputFile); err != nil {
110-
return fmt.Errorf("render: %w", err)
95+
result, err := svc.RenderDocument(doc, outputFile, pagesFlag)
96+
if err != nil {
97+
return err
11198
}
11299

113-
cmd.Printf("PDF written to %s (%d pages)\n", output, len(pages))
100+
cmd.Printf("PDF written to %s (%d pages)\n", output, result.PageCount)
114101
return nil
115102
}
116103

117-
func checkAndDownloadFonts(cmd *cobra.Command, doc *shared.Document, fontLoader *assetInfra.FSFontLoader, fontsDir string) error {
118-
refs := shared.CollectFontRefs(doc)
119-
120-
var missing []shared.FontRef
121-
for _, ref := range refs {
122-
_, err := fontLoader.LoadFont(ref.Family, ref.Weight, ref.Style)
123-
if err != nil {
124-
missing = append(missing, ref)
125-
}
126-
}
127-
128-
if len(missing) == 0 {
129-
return nil
130-
}
131-
104+
func promptAndDownloadFonts(cmd *cobra.Command, missing []shared.FontRef, fontsDir string) error {
132105
cmd.Printf("Missing %d font(s):\n", len(missing))
133106
for _, ref := range missing {
134107
label := ref.Family + " " + ref.Weight
@@ -171,23 +144,3 @@ func checkAndDownloadFonts(cmd *cobra.Command, doc *shared.Document, fontLoader
171144

172145
return nil
173146
}
174-
175-
func filterPages(children []shared.Node, names string) ([]shared.Node, error) {
176-
nameList := strings.Split(names, ",")
177-
nameSet := make(map[string]bool, len(nameList))
178-
for _, n := range nameList {
179-
nameSet[strings.TrimSpace(n)] = true
180-
}
181-
182-
var filtered []shared.Node
183-
for _, child := range children {
184-
if nameSet[child.GetName()] {
185-
filtered = append(filtered, child)
186-
}
187-
}
188-
189-
if len(filtered) == 0 {
190-
return nil, fmt.Errorf("no pages match --pages %q", names)
191-
}
192-
return filtered, nil
193-
}

cmd/render_test.go

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package cmd
22

33
import (
44
"testing"
5-
6-
shared "github.com/vpedrosa/pen2pdf/internal/shared/domain"
75
)
86

97
func TestRenderCommandRegistered(t *testing.T) {
@@ -43,47 +41,9 @@ func TestRenderCommandRequiresArg(t *testing.T) {
4341
}
4442
}
4543

46-
func TestFilterPagesMatchSingle(t *testing.T) {
47-
children := []shared.Node{
48-
&shared.Frame{ID: "p1", Name: "Front"},
49-
&shared.Frame{ID: "p2", Name: "Back"},
50-
}
51-
52-
filtered, err := filterPages(children, "Front")
53-
if err != nil {
54-
t.Fatalf("unexpected error: %v", err)
55-
}
56-
if len(filtered) != 1 {
57-
t.Fatalf("expected 1 page, got %d", len(filtered))
58-
}
59-
if filtered[0].GetName() != "Front" {
60-
t.Errorf("expected 'Front', got '%s'", filtered[0].GetName())
61-
}
62-
}
63-
64-
func TestFilterPagesMatchMultiple(t *testing.T) {
65-
children := []shared.Node{
66-
&shared.Frame{ID: "p1", Name: "Front"},
67-
&shared.Frame{ID: "p2", Name: "Back"},
68-
&shared.Frame{ID: "p3", Name: "Extra"},
69-
}
70-
71-
filtered, err := filterPages(children, "Front,Back")
72-
if err != nil {
73-
t.Fatalf("unexpected error: %v", err)
74-
}
75-
if len(filtered) != 2 {
76-
t.Fatalf("expected 2 pages, got %d", len(filtered))
77-
}
78-
}
79-
80-
func TestFilterPagesNoMatch(t *testing.T) {
81-
children := []shared.Node{
82-
&shared.Frame{ID: "p1", Name: "Front"},
83-
}
84-
85-
_, err := filterPages(children, "NonExistent")
86-
if err == nil {
87-
t.Fatal("expected error for no matching pages")
44+
func TestRenderCommandHasNoPromptFlag(t *testing.T) {
45+
f := renderCmd.Flags().Lookup("no-prompt")
46+
if f == nil {
47+
t.Error("expected --no-prompt flag")
8848
}
8949
}

0 commit comments

Comments
 (0)