Share D&D cited source traversal
This commit is contained in:
36
internal/modules/dnd/shared/citations.go
Normal file
36
internal/modules/dnd/shared/citations.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
// CitedText resolves cited source ranges in document order, including each
|
||||
// source unit once, and joins the resulting text with newlines.
|
||||
func CitedText(doc *source.SourceDocument, refs []source.SourceRef) (string, error) {
|
||||
if doc == nil {
|
||||
return "", fmt.Errorf("source document must not be nil")
|
||||
}
|
||||
|
||||
included := make([]bool, len(doc.Units))
|
||||
for _, ref := range refs {
|
||||
if err := source.ValidateRef(doc, ref); err != nil {
|
||||
return "", fmt.Errorf("resolve cited source range: %w", err)
|
||||
}
|
||||
start, _ := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, _ := source.UnitIndex(doc, ref.EndUnitID)
|
||||
for index := start; index <= end; index++ {
|
||||
included[index] = true
|
||||
}
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(doc.Units))
|
||||
for index, unit := range doc.Units {
|
||||
if included[index] {
|
||||
parts = append(parts, unit.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
87
internal/modules/dnd/shared/citations_test.go
Normal file
87
internal/modules/dnd/shared/citations_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestCitedText(t *testing.T) {
|
||||
doc := citationDocument()
|
||||
tests := []struct {
|
||||
name string
|
||||
refs []source.SourceRef
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty references", refs: nil, want: ""},
|
||||
{name: "invalid source id", refs: []source.SourceRef{{SourceID: "other", StartUnitID: 10, EndUnitID: 10}}, wantErr: true},
|
||||
{name: "unknown start unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 10}}, wantErr: true},
|
||||
{name: "unknown end unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 99}}, wantErr: true},
|
||||
{name: "reversed document range", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 30, EndUnitID: 10}}, wantErr: true},
|
||||
{
|
||||
name: "disjoint ranges supplied out of order",
|
||||
refs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||
},
|
||||
want: "alpha\ngamma",
|
||||
},
|
||||
{
|
||||
name: "adjacent ranges",
|
||||
refs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 20},
|
||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 40},
|
||||
},
|
||||
want: "alpha\nbeta\ngamma\ndelta",
|
||||
},
|
||||
{
|
||||
name: "overlapping and duplicate ranges",
|
||||
refs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 30},
|
||||
{SourceID: "session", StartUnitID: 20, EndUnitID: 40},
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 30},
|
||||
},
|
||||
want: "alpha\nbeta\ngamma\ndelta",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
beforeUnits := append([]source.SourceUnit(nil), doc.Units...)
|
||||
refs := append([]source.SourceRef(nil), test.refs...)
|
||||
got, err := CitedText(doc, refs)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("CitedText() error = %v, want error = %t", err, test.wantErr)
|
||||
}
|
||||
if err == nil && got != test.want {
|
||||
t.Fatalf("CitedText() = %q, want %q", got, test.want)
|
||||
}
|
||||
if !reflect.DeepEqual(doc.Units, beforeUnits) || !reflect.DeepEqual(refs, test.refs) {
|
||||
t.Fatalf("CitedText() mutated document or references")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCitedTextRejectsNilDocument(t *testing.T) {
|
||||
if _, err := CitedText(nil, nil); err == nil {
|
||||
t.Fatal("CitedText() error = nil, want nil-document error")
|
||||
}
|
||||
}
|
||||
|
||||
func citationDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{
|
||||
ID: "session",
|
||||
Kind: "transcript",
|
||||
Format: "application/json",
|
||||
Digest: "sha256:session",
|
||||
Units: []source.SourceUnit{
|
||||
{ID: 10, Kind: "message", Text: "alpha"},
|
||||
{ID: 20, Kind: "message", Text: "beta"},
|
||||
{ID: 30, Kind: "message", Text: "gamma"},
|
||||
{ID: 40, Kind: "message", Text: "delta"},
|
||||
},
|
||||
}
|
||||
}
|
||||
43
internal/modules/dnd/shared/matching.go
Normal file
43
internal/modules/dnd/shared/matching.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
)
|
||||
|
||||
// NormalizedTokens returns identity-normalized alphanumeric tokens from a
|
||||
// D&D value.
|
||||
func NormalizedTokens(value string) []string {
|
||||
value = identity.ComparisonKey(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// ContainsTokenSequence reports whether value contains the complete normalized
|
||||
// query token sequence as consecutive tokens.
|
||||
func ContainsTokenSequence(value string, query string) bool {
|
||||
valueTokens := NormalizedTokens(value)
|
||||
queryTokens := NormalizedTokens(query)
|
||||
if len(queryTokens) == 0 || len(queryTokens) > len(valueTokens) {
|
||||
return false
|
||||
}
|
||||
for start := 0; start <= len(valueTokens)-len(queryTokens); start++ {
|
||||
matches := true
|
||||
for offset, token := range queryTokens {
|
||||
if valueTokens[start+offset] != token {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
25
internal/modules/dnd/shared/matching_test.go
Normal file
25
internal/modules/dnd/shared/matching_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package shared
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestContainsTokenSequence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
query string
|
||||
want bool
|
||||
}{
|
||||
{name: "unicode apostrophe and case", value: "O’rIn\u2003ThOrN advances", query: "o'rin thorn", want: true},
|
||||
{name: "multiword sequence", value: "Mira Thorn watches", query: "mira thorn", want: true},
|
||||
{name: "nonconsecutive words", value: "Mira watches Thorn", query: "mira thorn", want: false},
|
||||
{name: "short name is not substring", value: "A cart rolls past", query: "art", want: false},
|
||||
{name: "empty query", value: "anything", query: " ", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := ContainsTokenSequence(test.value, test.query); got != test.want {
|
||||
t.Fatalf("ContainsTokenSequence(%q, %q) = %t, want %t", test.value, test.query, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user