Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

ahocorasick_test.gno

8.34 Kb · 260 lines
  1package ahocorasick
  2
  3import (
  4	"strings"
  5	"testing"
  6
  7	"gno.land/p/nt/uassert/v0"
  8)
  9
 10// fmtMatches renders a match slice as "start:end:pattern" joined by spaces, so
 11// a failing case prints the whole scan instead of the first difference.
 12func fmtMatches(ms []Match) string {
 13	parts := make([]string, 0, len(ms))
 14	for _, m := range ms {
 15		parts = append(parts, itoa(m.Start)+":"+itoa(m.End)+":"+m.Pattern)
 16	}
 17	return strings.Join(parts, " ")
 18}
 19
 20func itoa(n int) string {
 21	if n == 0 {
 22		return "0"
 23	}
 24	var b []byte
 25	for n > 0 {
 26		b = append([]byte{byte('0' + n%10)}, b...)
 27		n /= 10
 28	}
 29	return string(b)
 30}
 31
 32func TestFindAll(t *testing.T) {
 33	cases := []struct {
 34		name     string
 35		patterns []string
 36		text     string
 37		want     string
 38	}{
 39		{
 40			// The textbook automaton from the 1975 paper.
 41			name:     "ushers",
 42			patterns: []string{"he", "she", "his", "hers"},
 43			text:     "ushers",
 44			want:     "1:4:she 2:4:he 2:6:hers",
 45		},
 46		{
 47			// Every suffix of the longest pattern is also a pattern, so one
 48			// position must report four matches through the output links.
 49			name:     "nested suffixes",
 50			patterns: []string{"abcd", "bcd", "cd", "d"},
 51			text:     "abcd",
 52			want:     "0:4:abcd 1:4:bcd 2:4:cd 3:4:d",
 53		},
 54		{
 55			name:     "overlapping",
 56			patterns: []string{"a", "ab", "bab", "bc", "bca", "c", "caa"},
 57			text:     "abccab",
 58			want:     "0:1:a 0:2:ab 1:3:bc 2:3:c 3:4:c 4:5:a 4:6:ab",
 59		},
 60		{
 61			name:     "no match",
 62			patterns: []string{"xyz", "qq"},
 63			text:     "the quick brown fox",
 64			want:     "",
 65		},
 66		{
 67			name:     "empty text",
 68			patterns: []string{"a"},
 69			text:     "",
 70			want:     "",
 71		},
 72		{
 73			// A pattern longer than the text can never match, and must not
 74			// walk the automaton off its end trying.
 75			name:     "pattern longer than text",
 76			patterns: []string{"abcdef"},
 77			text:     "abc",
 78			want:     "",
 79		},
 80		{
 81			name:     "repeats",
 82			patterns: []string{"aa"},
 83			text:     "aaaa",
 84			want:     "0:2:aa 1:3:aa 2:4:aa",
 85		},
 86		{
 87			// The empty pattern matches everywhere and nowhere; it is dropped
 88			// rather than reported at every offset.
 89			name:     "empty pattern dropped",
 90			patterns: []string{"", "b"},
 91			text:     "abc",
 92			want:     "1:2:b",
 93		},
 94	}
 95	for _, tc := range cases {
 96		t.Run(tc.name, func(t *testing.T) {
 97			got := fmtMatches(New(tc.patterns).FindAll(tc.text))
 98			uassert.Equal(t, tc.want, got)
 99		})
100	}
101}
102
103// At one end offset the longest match comes first, because the scan reports
104// the node it reached before walking the output links to its shorter
105// suffixes. A caller taking the first match per offset gets the greedy one.
106func TestFindAllOrdersLongestFirstAtAnEndOffset(t *testing.T) {
107	got := New([]string{"he", "she"}).FindAll("she")
108	uassert.Equal(t, "0:3:she 1:3:he", fmtMatches(got))
109}
110
111// The index is the caller's slot in the pattern slice, not the automaton's
112// node number: that is what lets a caller map a match back to whatever it
113// stored alongside the pattern.
114func TestMatchIndexIsTheCallersSlot(t *testing.T) {
115	ms := New([]string{"zz", "he", "qq"}).FindAll("he")
116	uassert.Equal(t, 1, len(ms))
117	uassert.Equal(t, 1, ms[0].Index)
118	uassert.Equal(t, "he", ms[0].Pattern)
119}
120
121// A duplicated pattern is two slots, so it reports twice. Deduplicating here
122// would silently drop the second caller's payload.
123func TestDuplicatePatternsReportOnceperSlot(t *testing.T) {
124	ms := New([]string{"ab", "ab"}).FindAll("ab")
125	uassert.Equal(t, 2, len(ms))
126	uassert.Equal(t, 0, ms[0].Index)
127	uassert.Equal(t, 1, ms[1].Index)
128}
129
130func TestFindFirst(t *testing.T) {
131	m, ok := New([]string{"brown", "fox"}).FindFirst("the quick brown fox")
132	uassert.True(t, ok)
133	uassert.Equal(t, "brown", m.Pattern)
134	uassert.Equal(t, 10, m.Start)
135
136	_, ok = New([]string{"cat"}).FindFirst("the quick brown fox")
137	uassert.False(t, ok)
138}
139
140// FindFirst stops at the first match, so it must agree with FindAll's head
141// rather than with some other traversal order.
142func TestFindFirstAgreesWithFindAll(t *testing.T) {
143	m := New([]string{"he", "she", "hers"})
144	all := m.FindAll("ushers")
145	first, ok := m.FindFirst("ushers")
146	uassert.True(t, ok)
147	uassert.Equal(t, all[0].Start, first.Start)
148	uassert.Equal(t, all[0].Pattern, first.Pattern)
149}
150
151func TestContains(t *testing.T) {
152	m := New([]string{"spam", "eggs"})
153	uassert.True(t, m.Contains("green eggs and ham"))
154	uassert.False(t, m.Contains("green ham"))
155}
156
157// Matching is over BYTES. UTF-8 is self-synchronizing, so a pattern that is
158// itself valid UTF-8 can only match on a rune boundary of valid UTF-8 text.
159func TestValidUTF8PatternMatchesOnARuneBoundary(t *testing.T) {
160	ms := New([]string{"é"}).FindAll("café")
161	uassert.Equal(t, "3:5:é", fmtMatches(ms))
162}
163
164// The flip side of the same fact, and the reason the doc says bytes: a
165// pattern that is a bare continuation byte DOES match inside a rune. A caller
166// slicing on that offset splits the rune.
167func TestContinuationBytePatternMatchesInsideARune(t *testing.T) {
168	ms := New([]string{"\xa9"}).FindAll("café")
169	uassert.Equal(t, 1, len(ms))
170	uassert.Equal(t, 4, ms[0].Start)
171}
172
173// Refusals. The bounds exist so an on-chain caller cannot be handed an
174// unbounded build; a refusal is nil, and nil still answers every query.
175func TestNewRefusesWhatItCannotBound(t *testing.T) {
176	uassert.True(t, New(nil) == nil)
177	uassert.True(t, New([]string{}) == nil)
178	uassert.True(t, New([]string{"", ""}) == nil)
179
180	tooMany := make([]string, MaxPatterns+1)
181	for i := range tooMany {
182		tooMany[i] = "p" + itoa(i)
183	}
184	uassert.True(t, New(tooMany) == nil)
185
186	uassert.True(t, New([]string{strings.Repeat("a", MaxPatternLen+1)}) == nil)
187	uassert.True(t, New([]string{strings.Repeat("a", MaxPatternLen)}) != nil)
188}
189
190// A nil matcher is what a refused build returns, so every method has to
191// survive it: a demo that renders whatever New gave it must not panic.
192func TestNilMatcherAnswersEverything(t *testing.T) {
193	var m *Matcher
194	uassert.Equal(t, 0, len(m.FindAll("anything")))
195	uassert.False(t, m.Contains("anything"))
196	_, ok := m.FindFirst("anything")
197	uassert.False(t, ok)
198	uassert.Equal(t, 0, len(m.Patterns()))
199	uassert.Equal(t, 0, m.Nodes())
200}
201
202// Patterns returns the accepted set, empties dropped, in the caller's order,
203// and the returned slice is a copy: mutating it must not corrupt the
204// automaton's own view of what it matched.
205func TestPatternsIsACopyOfTheAcceptedSet(t *testing.T) {
206	m := New([]string{"he", "", "she"})
207	got := m.Patterns()
208	uassert.Equal(t, "he she", strings.Join(got, " "))
209	got[0] = "clobbered"
210	uassert.Equal(t, "he she", strings.Join(m.Patterns(), " "))
211}
212
213// The automaton is one node per distinct pattern prefix plus the root, which
214// is what makes the build linear in the total pattern length rather than in
215// the product of the set.
216func TestNodesCountsDistinctPrefixesPlusTheRoot(t *testing.T) {
217	// he, she, his, hers: prefixes h, he, her, hers, s, sh, she, hi, his = 9.
218	uassert.Equal(t, 10, New([]string{"he", "she", "his", "hers"}).Nodes())
219	// A single pattern is its own prefix chain.
220	uassert.Equal(t, 4, New([]string{"abc"}).Nodes())
221}
222
223// The guarantee worth having over a naive scan: the text cursor never moves
224// backwards, so a pathological set cannot be made to cost more than one pass.
225// 64 patterns that all share a prefix, over a text of the same letter.
226func TestPathologicalSetStaysOnePass(t *testing.T) {
227	patterns := make([]string, 0, 64)
228	for i := 1; i <= 64; i++ {
229		patterns = append(patterns, strings.Repeat("a", i))
230	}
231	ms := New(patterns).FindAll(strings.Repeat("a", 64))
232	// Every (start, length) pair with start+length <= 64: 64*65/2.
233	uassert.Equal(t, 2080, len(ms))
234}
235
236// The merged output lists are what the package doc calls its one departure
237// from the paper, so the shape that costs the most is pinned rather than
238// asserted in prose. A nested set of k patterns stores 1+2+...+k entries,
239// quadratic in k, where a dictionary of distinct words stores one per node.
240func TestMergedOutputsAreQuadraticOnANestedSet(t *testing.T) {
241	nested := make([]string, 0, 64)
242	for i := 1; i <= 64; i++ {
243		nested = append(nested, strings.Repeat("a", i))
244	}
245	uassert.Equal(t, 2080, totalOutputs(New(nested)))
246
247	// Four distinct words, no nesting: one entry per terminal node.
248	uassert.Equal(t, 4, totalOutputs(New([]string{"fox", "lazy", "over", "quick"})))
249
250	// he/she/his/hers nests once, "he" inside "she", so one node carries two.
251	uassert.Equal(t, 5, totalOutputs(New([]string{"he", "she", "his", "hers"})))
252}
253
254func totalOutputs(m *Matcher) int {
255	n := 0
256	for _, nd := range m.nodes {
257		n += len(nd.out)
258	}
259	return n
260}