all repos

clerk @ 974dc2dd8180350b310e155547485923b89ade3c

missing tooling for ledger/hledger

clerk/journal/lexer/lexer_test.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
tests: unify golden test api and usage, 1 month ago
1
package lexer
2
3
import (
4
	"testing"
5
6
	"olexsmir.xyz/clerk/internal/testutil/golden"
7
	"olexsmir.xyz/clerk/journal/token"
8
)
9
10
func TestLexer(t *testing.T) {
11
	tests := []struct {
12
		name  string
13
		input string
14
	}{
15
		{"simple transaction", `2024/01/01 groceries
16
    expenses:food  $10.00
17
    assets:checking
18
`},
19
		{"transaction, accounts with uppercase latters", `
20
2011/01/27 Book Store
21
    Expenses:Books                       $20.00
22
    Liabilities:MasterCard
23
`},
24
		{"cleared transaction", `2024/01/01 * groceries
25
    expenses:food  $10.00
26
    assets:checking
27
`},
28
		{"automated transaction", `= ^income
29
    (liabilities:tax)  *.33
30
31
= expenses:gifts
32
    budget:gifts  (amount * -1)
33
`},
34
		{"transaction with code", `2024/01/01 (123) groceries
35
    expenses:food  $10.00
36
    assets:checking
37
38
2024/01/02 (ABS) groceries
39
`},
40
		{"transaction with virtual accounts", `2024/01/01 * groceries
41
	(virtual:account)  1 PESO
42
	[something:else]   5 PESO
43
`},
44
		{"transaction with unicode commodity symbols", `2024/01/01 groceries
45
    expenses:food  €10.00
46
    expenses:food  £5.00
47
    expenses:food  ₹700.00
48
    expenses:food  40.00 гривні
49
    assets:cash
50
`},
51
		{"special chars in description", `
52
2024/01/01 * groceries + water| 1 + 2
53
2024/01/01 groceries * water | 1 * 2
54
2024/01/01 groceries ! water | 1 ! 2
55
2024/01/01 groceries # water | 1 # 2
56
2024/01/01 groceries % water | 1 % 2
57
2024/01/01 groceries ^ water | 1 ^ 2
58
2024/01/01 groceries & water | 1 & 2
59
2024/01/01 groceries ( water | 1 ( 2
60
2024/01/01 groceries ) water | 1 ) 2
61
2024/01/01 groceries [ water | 1 [ 2
62
2024/01/01 groceries ] water | 1 ] 2
63
2024/01/01 groceries { water | 1 { 2
64
2024/01/01 groceries } water | 1 } 2
65
2026-06-07 * payee !one | something *important*
66
    expenses:food  40.00
67
    assets:cash
68
`},
69
		{"date with secondary", `2024/01/01=2024/01/02 groceries`},
70
		{"better date", `2024-01-02`},
71
		{"comment line", `; this is a comment`},
72
		{"star comment", `* this is a comment`},
73
		{"hash comment", `# this is a comment`},
74
		{"account directive", `account expenses:food`},
75
		{"commodity directive", `commodity 1,000.00 UAH`},
76
		{"market price directive", "P 2024-01-01 USD 40.50 UAH\n"},
77
		{"market price directive with time", "P 2024-01-01 12:00:00 USD 40.50 UAH\n"},
78
		{"inline comment", `2024/01/01 groceries ; a note`},
79
		{"empty", ``},
80
		{"blank lines", "\n\n\n"},
81
		{"comment block directive", "comment\ncontent\nend\n"},
82
		{"comment block directive without end", "comment\ncontent\n"},
83
	}
84
	for _, tt := range tests {
85
		t.Run(tt.name, func(t *testing.T) {
86
			l := New("j", []byte(tt.input))
87
			golden.Assert(t, tt.name, l.Dump())
88
		})
89
	}
90
}
91
92
// token category bounds, ensures fuzzer never sees out-of-range token types.
93
const maxKnownTokenType = token.N
94
95
func FuzzLexer(f *testing.F) {
96
	f.Add([]byte("2024/01/01 groceries\n  expenses:food  $10.00\n  assets:checking\n"))
97
	f.Add([]byte("2024/01/01 * groceries\n  expenses:food  $10.00\n  assets:checking\n"))
98
	f.Add([]byte("2024/01/01 ! groceries\n  expenses:food  $10.00\n  assets:checking\n"))
99
	f.Add([]byte("2024/01/01 t ; inline comment\n  a  $10\n"))
100
	f.Add([]byte("2024/01/01 t\n  (a)  10 @@ $20\n  [b]  30\n"))
101
	f.Add([]byte("2008/06/03 * eat & shop\n    expenses:food      $1\n    expenses:supplies  $1\n    assets:cash\n"))
102
	f.Add([]byte("2015-01-03 * Money exchange office\n    Assets:Cash  -20 EUR @ 7.53 HRK\n    Assets:Cash  150.60 HRK\n"))
103
	f.Add([]byte("2024/01/01 ß\n  (ß)  10 ß\n"))
104
	f.Add([]byte("2024/01/01 t\n  (! a)  10\n"))
105
	f.Add([]byte("comment\nbody\nend\n"))
106
	f.Add([]byte("apply tag foo\nend\n"))
107
	f.Add([]byte("; a comment\n"))
108
	f.Add([]byte("# a comment\n"))
109
	f.Add([]byte("* a comment\n"))
110
	f.Add([]byte("account expenses:food\n"))
111
	f.Add([]byte("commodity 1,000.00 UAH\n"))
112
	f.Add([]byte("N $\n"))
113
	f.Add([]byte("P 2024-01-01 USD 41.50 UAH\n"))
114
	f.Add([]byte("P 2024-01-01 12:00:00 USD 41.50 UAH\n"))
115
	f.Add([]byte("P 2024-01-01 12:00 USD 41.50 UAH\n"))
116
	f.Add([]byte("~ monthly\n  a  $10\n  b\n"))
117
	f.Add([]byte("= /^Income/\n  expenses:food  $10\n"))
118
	f.Add([]byte("перевірка\n"))
119
	f.Add([]byte(""))
120
	f.Add([]byte("\n\n\n"))
121
	f.Add([]byte("@@@\n"))
122
	f.Add([]byte("   \n"))
123
	f.Add([]byte("0\n"))
124
	f.Add([]byte{0xff, 0xfe, 0x00})
125
126
	f.Fuzz(func(t *testing.T, data []byte) {
127
		// Pass 1: lex and validate token stream
128
		l := New("j", data)
129
		var tokens []token.Token
130
		maxTokens := max(len(data)*2, 16)
131
		prevEnd := -1
132
		for range maxTokens {
133
			tok := l.Next()
134
135
			// Monotonic span
136
			if tok.Span.Start.Offset < prevEnd {
137
				t.Fatalf("non-monotonic span: prevEnd=%d current=%s %d",
138
					prevEnd, tok.Type, tok.Span.Start.Offset)
139
			}
140
141
			// Token type in range (no garbage from memory corruption)
142
			if tok.Type < 0 || tok.Type > maxKnownTokenType {
143
				t.Fatalf("token type out of range: %d", tok.Type)
144
			}
145
146
			// Span in bounds (EOF/NEWLINE sentinels may extend one past input)
147
			maxEnd := len(data)
148
			if tok.Type == token.NEWLINE || tok.Type == token.EOF {
149
				maxEnd = len(data) + 1
150
			}
151
			if tok.Span.Start.Offset < 0 || tok.Span.End.Offset > maxEnd ||
152
				tok.Span.Start.Offset > tok.Span.End.Offset {
153
				t.Fatalf("span out of bounds: [%d,%d] for len=%d type=%s",
154
					tok.Span.Start.Offset, tok.Span.End.Offset, len(data), tok.Type)
155
			}
156
157
			if tok.Type == token.EOF {
158
				break
159
			}
160
161
			// Non-zero-length for non-EOF tokens (NEWLINE sentinel is exempt)
162
			if tok.Type != token.NEWLINE && tok.Span.End.Offset <= tok.Span.Start.Offset {
163
				t.Fatalf("non-progressing token: %s %q at %d:%d-%d:%d",
164
					tok.Type, tok.Literal,
165
					tok.Span.Start.Line, tok.Span.Start.Col,
166
					tok.Span.End.Line, tok.Span.End.Col)
167
			}
168
169
			tokens = append(tokens, tok)
170
			prevEnd = tok.Span.End.Offset
171
		}
172
173
		if prevEnd > len(data)+1 {
174
			t.Fatalf("token consumed beyond input: end=%d len=%d", prevEnd, len(data))
175
		}
176
177
		// Pass 2: re-lex the same input — token stream must be identical
178
		l2 := New("j", data)
179
		for _, expected := range tokens {
180
			tok := l2.Next()
181
			if tok.Type != expected.Type || tok.Literal != expected.Literal {
182
				t.Fatalf("re-lex mismatch at offset %d: expected (%s %q), got (%s %q)",
183
					expected.Span.Start.Offset, expected.Type, expected.Literal, tok.Type, tok.Literal)
184
			}
185
		}
186
	})
187
}