dequoter/plugins_test.go
2026-09-07 22:57:44 +00:00

412 lines
12 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"ucl.lmika.dev/ucl"
"ucl.lmika.dev/ucl/builtins"
)
func newTestPlugins() (*PluginFilters, *ucl.Inst) {
plugins := newPluginFilters()
inst := ucl.New(
ucl.WithModule(plugins.Module()),
ucl.WithModule(builtins.Strs()),
)
return plugins, inst
}
func writePluginFile(t *testing.T, dir, name, content string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestPluginFilters_LoadDir(t *testing.T) {
ctx := context.Background()
t.Run("registers filter from upper-case example", func(t *testing.T) {
plugins, inst := newTestPlugins()
dir := t.TempDir()
writePluginFile(t, dir, "upper.ucl", `
d:filter "String: To Upper Case" { |in|
strs:to-upper $in
}
`)
plugins.LoadDir(ctx, inst, dir)
if len(plugins.Errors()) != 0 {
t.Fatalf("unexpected errors: %v", plugins.Errors())
}
proc, ok := plugins.filters["plugin:string-to-upper-case"]
if !ok {
t.Fatalf("filter not registered, have %v", plugins.filters)
}
if proc.Label != "String: To Upper Case" {
t.Errorf("label = %q", proc.Label)
}
res, err := proc.Filter(ctx, "abc")
if err != nil {
t.Fatal(err)
}
if res.Output != "ABC" || res.Append {
t.Errorf("got %+v", res)
}
})
t.Run("finds nested files and ignores non-ucl files", func(t *testing.T) {
plugins, inst := newTestPlugins()
dir := t.TempDir()
writePluginFile(t, dir, "sub/dir/nested.ucl", `d:filter "Nested" { |in| $in }`)
writePluginFile(t, dir, "notes.txt", `d:filter "Ignored" { |in| $in }`)
writePluginFile(t, dir, "README.md", `this is not ucl {`)
plugins.LoadDir(ctx, inst, dir)
if len(plugins.Errors()) != 0 {
t.Fatalf("unexpected errors: %v", plugins.Errors())
}
if _, ok := plugins.filters["plugin:nested"]; !ok {
t.Error("nested filter not registered")
}
if _, ok := plugins.filters["plugin:ignored"]; ok {
t.Error("non-ucl file should have been ignored")
}
})
t.Run("missing directory is not an error", func(t *testing.T) {
plugins, inst := newTestPlugins()
plugins.LoadDir(ctx, inst, filepath.Join(t.TempDir(), "does-not-exist"))
if len(plugins.Errors()) != 0 {
t.Fatalf("unexpected errors: %v", plugins.Errors())
}
if len(plugins.filters) != 0 {
t.Errorf("unexpected filters: %v", plugins.filters)
}
})
t.Run("bad file is reported and sibling still loads", func(t *testing.T) {
plugins, inst := newTestPlugins()
dir := t.TempDir()
writePluginFile(t, dir, "a-bad.ucl", `d:filter "Broken" { |in| `)
writePluginFile(t, dir, "b-good.ucl", `d:filter "Good" { |in| $in }`)
writePluginFile(t, dir, "c-bad-usage.ucl", `d:filter "Only Label"`)
plugins.LoadDir(ctx, inst, dir)
errs := plugins.Errors()
if len(errs) != 2 {
t.Fatalf("expected 2 errors, got %v", errs)
}
if !strings.Contains(errs[0].Error(), "a-bad.ucl") {
t.Errorf("first error should name the file: %v", errs[0])
}
if !strings.Contains(errs[1].Error(), "c-bad-usage.ucl") {
t.Errorf("second error should name the file: %v", errs[1])
}
if _, ok := plugins.filters["plugin:good"]; !ok {
t.Error("good filter not registered")
}
if _, ok := plugins.filters["plugin:broken"]; ok {
t.Error("broken filter should not be registered")
}
})
t.Run("later definition with same label wins", func(t *testing.T) {
plugins, inst := newTestPlugins()
dir := t.TempDir()
writePluginFile(t, dir, "01-first.ucl", `d:filter "Dup" { |in| "first" }`)
writePluginFile(t, dir, "02-second.ucl", `d:filter "Dup" { |in| "second" }`)
plugins.LoadDir(ctx, inst, dir)
if len(plugins.filters) != 1 {
t.Fatalf("expected 1 filter, got %v", plugins.filters)
}
res, err := plugins.filters["plugin:dup"].Filter(ctx, "x")
if err != nil {
t.Fatal(err)
}
if res.Output != "second" {
t.Errorf("output = %q", res.Output)
}
})
t.Run("procs defined in plugin files are reusable", func(t *testing.T) {
plugins, inst := newTestPlugins()
dir := t.TempDir()
writePluginFile(t, dir, "helpers.ucl", `
proc shout { |s| strs:to-upper $s }
d:filter "Shout" { |in| shout $in }
`)
plugins.LoadDir(ctx, inst, dir)
if len(plugins.Errors()) != 0 {
t.Fatalf("unexpected errors: %v", plugins.Errors())
}
res, err := plugins.filters["plugin:shout"].Filter(ctx, "hi")
if err != nil {
t.Fatal(err)
}
if res.Output != "HI" {
t.Errorf("output = %q", res.Output)
}
})
}
func TestPluginFilters_ProcResults(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
script string
input string
want string
wantErr string
}{
{
name: "string result used as-is",
script: `d:filter "F" { |in| strs:to-upper $in }`,
input: "abc",
want: "ABC",
},
{
name: "non-string result is stringified",
script: `d:filter "F" { |in| add 40 2 }`,
input: "ignored",
want: "42",
},
{
name: "nil result is an error",
script: `d:filter "F" { |in| }`,
input: "abc",
wantErr: "filter returned no value",
},
{
name: "error raised in proc propagates",
script: `d:filter "F" { |in| error "boom" }`,
input: "abc",
wantErr: "boom",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plugins, inst := newTestPlugins()
if _, err := inst.EvalString(ctx, tt.script); err != nil {
t.Fatal(err)
}
proc, ok := plugins.filters["plugin:f"]
if !ok {
t.Fatalf("filter not registered")
}
res, err := proc.Filter(ctx, tt.input)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v (res %+v)", tt.wantErr, err, res)
}
return
}
if err != nil {
t.Fatal(err)
}
if res.Output != tt.want {
t.Errorf("output = %q, want %q", res.Output, tt.want)
}
})
}
}
func TestPluginFilters_FilterBuiltinValidation(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
script string
}{
{name: "missing proc", script: `d:filter "Label"`},
{name: "no args", script: `d:filter`},
{name: "empty label", script: `d:filter " " { |in| $in }`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plugins, inst := newTestPlugins()
if _, err := inst.EvalString(ctx, tt.script); err == nil {
t.Fatal("expected an error")
}
if len(plugins.filters) != 0 {
t.Errorf("no filter should be registered, got %v", plugins.filters)
}
})
}
}
func TestSlugify(t *testing.T) {
tests := []struct {
in string
want string
}{
{"String: To Upper Case", "string-to-upper-case"},
{" Lines: Go Template… ", "lines-go-template"},
{"already-a-slug", "already-a-slug"},
{"MiXeD CaSe 123", "mixed-case-123"},
{"---", ""},
}
for _, tt := range tests {
if got := slugify(tt.in); got != tt.want {
t.Errorf("slugify(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestApp_PluginIntegration(t *testing.T) {
ctx := context.Background()
app := NewApp(nil)
dir := t.TempDir()
writePluginFile(t, dir, "upper.ucl", `d:filter "String: To Upper Case" { |in| strs:to-upper $in }`)
writePluginFile(t, dir, "rev.ucl", `d:filter "Custom: Echo" { |in| $in }`)
if n := app.loadPluginsFrom(dir); n != 2 {
t.Fatalf("expected 2 filters loaded, got %d", n)
}
_ = ctx
t.Run("ListProcessors shadows built-in by label", func(t *testing.T) {
resp := app.ListProcessors()
byLabel := map[string][]string{}
for _, r := range resp {
byLabel[r.Label] = append(byLabel[r.Label], r.Name)
}
if names := byLabel["String: To Upper Case"]; len(names) != 1 || names[0] != "plugin:string-to-upper-case" {
t.Errorf("expected only the plugin entry for the shadowed label, got %v", names)
}
if names := byLabel["Custom: Echo"]; len(names) != 1 || names[0] != "plugin:custom-echo" {
t.Errorf("expected plugin entry, got %v", names)
}
if names := byLabel["String: To Lower Case"]; len(names) != 1 || names[0] != "lower-case" {
t.Errorf("built-in should be unaffected, got %v", names)
}
for i := 1; i < len(resp); i++ {
if resp[i-1].Label > resp[i].Label {
t.Errorf("response not sorted by label at %d: %q > %q", i, resp[i-1].Label, resp[i].Label)
}
}
})
t.Run("lookupProcessor prefers plugin then falls back to built-in", func(t *testing.T) {
if proc, ok := app.lookupProcessor("plugin:custom-echo"); !ok || proc.Label != "Custom: Echo" {
t.Errorf("plugin lookup failed: %v %v", proc, ok)
}
if proc, ok := app.lookupProcessor("lower-case"); !ok || proc.Label != "String: To Lower Case" {
t.Errorf("built-in lookup failed: %v %v", proc, ok)
}
if _, ok := app.lookupProcessor("nope"); ok {
t.Error("unknown key should not be found")
}
})
}
func TestApp_ReloadPlugins(t *testing.T) {
ctx := context.Background()
app := NewApp(nil)
dir := t.TempDir()
writePluginFile(t, dir, "a.ucl", `
proc helper { |s| strs:to-upper $s }
d:filter "Filter A" { |in| helper $in }
`)
if n := app.loadPluginsFrom(dir); n != 1 {
t.Fatalf("expected 1 filter, got %d", n)
}
firstInst, firstPlugins, firstCtx := app.current()
if uclInstFromContext(firstCtx) != firstInst {
t.Fatal("context does not carry the current interpreter")
}
if res, err := firstInst.EvalString(ctx, `helper "x"`); err != nil || res != "X" {
t.Fatalf("helper proc should be defined: %v %v", res, err)
}
filterA, ok := firstPlugins.filters["plugin:filter-a"]
if !ok {
t.Fatal("Filter A not registered")
}
// Replace a.ucl with b.ucl and reload.
if err := os.Remove(filepath.Join(dir, "a.ucl")); err != nil {
t.Fatal(err)
}
writePluginFile(t, dir, "b.ucl", `d:filter "Filter B" { |in| strs:to-lower $in }`)
if n := app.loadPluginsFrom(dir); n != 1 {
t.Fatalf("expected 1 filter after reload, got %d", n)
}
secondInst, secondPlugins, secondCtx := app.current()
if secondInst == firstInst {
t.Error("interpreter should have been rebuilt")
}
if uclInstFromContext(secondCtx) != secondInst {
t.Error("context should carry the new interpreter")
}
if _, ok := secondPlugins.filters["plugin:filter-a"]; ok {
t.Error("Filter A should have been deregistered")
}
if _, ok := app.lookupProcessor("plugin:filter-a"); ok {
t.Error("lookupProcessor should no longer find Filter A")
}
if proc, ok := app.lookupProcessor("plugin:filter-b"); !ok || proc.Label != "Filter B" {
t.Errorf("Filter B should be registered, got %v %v", proc, ok)
}
if _, err := secondInst.EvalString(ctx, `helper "x"`); err == nil {
t.Error("helper proc from the removed file should no longer be defined")
}
t.Run("filter captured before reload still works", func(t *testing.T) {
res, err := filterA.Filter(ctx, "abc")
if err != nil {
t.Fatal(err)
}
if res.Output != "ABC" {
t.Errorf("output = %q", res.Output)
}
})
t.Run("reload with bad file reports fresh errors", func(t *testing.T) {
writePluginFile(t, dir, "c.ucl", `d:filter "Broken" { |in|`)
n := app.loadPluginsFrom(dir)
_, plugins, _ := app.current()
if n != 1 || len(plugins.filters) != 1 {
t.Errorf("expected only Filter B to survive, got n=%d filters=%v", n, plugins.filters)
}
if errs := plugins.Errors(); len(errs) != 1 || !strings.Contains(errs[0].Error(), "c.ucl") {
t.Errorf("expected one error naming c.ucl, got %v", errs)
}
if err := os.Remove(filepath.Join(dir, "c.ucl")); err != nil {
t.Fatal(err)
}
app.loadPluginsFrom(dir)
if _, plugins, _ := app.current(); len(plugins.Errors()) != 0 {
t.Errorf("errors should be cleared after a clean reload, got %v", plugins.Errors())
}
})
}