Reorganize GSP module layout
This commit is contained in:
103
toolkit/internal/gsp/graph.go
Normal file
103
toolkit/internal/gsp/graph.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package gsp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (p *Project) Graph(id string, depth int) Graph {
|
||||
if id != "" {
|
||||
flattened := p.Flatten(id, depth, Filter{})
|
||||
return p.graphForUnits(flattened.Units)
|
||||
}
|
||||
return p.graphForUnits(p.Units)
|
||||
}
|
||||
|
||||
func (p *Project) graphForUnits(units []*Unit) Graph {
|
||||
nodeMap := map[string]GraphNode{}
|
||||
edgeMap := map[string]GraphEdge{}
|
||||
include := map[string]bool{}
|
||||
for _, unit := range units {
|
||||
include[unit.ID] = true
|
||||
nodeMap[unit.ID] = GraphNode{ID: unit.ID, Type: unit.Type, Resolution: unit.Resolution, File: unit.File}
|
||||
}
|
||||
for _, unit := range units {
|
||||
if unit.Refines != "" {
|
||||
addEdge(edgeMap, unit.ID, unit.Refines, "refines")
|
||||
if !include[unit.Refines] {
|
||||
nodeMap[unit.Refines] = GraphNode{ID: unit.Refines, Missing: p.ByID[unit.Refines] == nil}
|
||||
}
|
||||
}
|
||||
for _, rel := range unit.With {
|
||||
addEdge(edgeMap, unit.ID, rel.ID, "with")
|
||||
if !include[rel.ID] {
|
||||
nodeMap[rel.ID] = GraphNode{ID: rel.ID, Missing: p.ByID[rel.ID] == nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes := make([]GraphNode, 0, len(nodeMap))
|
||||
for _, node := range nodeMap {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
return nodes[i].ID < nodes[j].ID
|
||||
})
|
||||
edges := make([]GraphEdge, 0, len(edgeMap))
|
||||
for _, edge := range edgeMap {
|
||||
edges = append(edges, edge)
|
||||
}
|
||||
sort.Slice(edges, func(i, j int) bool {
|
||||
if edges[i].From == edges[j].From {
|
||||
if edges[i].To == edges[j].To {
|
||||
return edges[i].Kind < edges[j].Kind
|
||||
}
|
||||
return edges[i].To < edges[j].To
|
||||
}
|
||||
return edges[i].From < edges[j].From
|
||||
})
|
||||
return Graph{Nodes: nodes, Edges: edges}
|
||||
}
|
||||
|
||||
func addEdge(edges map[string]GraphEdge, from, to, kind string) {
|
||||
key := from + "\x00" + to + "\x00" + kind
|
||||
edges[key] = GraphEdge{From: from, To: to, Kind: kind}
|
||||
}
|
||||
|
||||
var mermaidIDPattern = regexp.MustCompile(`[^A-Za-z0-9_]`)
|
||||
|
||||
func (g Graph) Mermaid() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("graph TD\n")
|
||||
if len(g.Nodes) == 0 {
|
||||
return builder.String()
|
||||
}
|
||||
for _, node := range g.Nodes {
|
||||
label := node.ID
|
||||
if node.Missing {
|
||||
label += " (missing)"
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf(" %s[\"%s\"]\n", mermaidID(node.ID), escapeMermaid(label)))
|
||||
}
|
||||
for _, edge := range g.Edges {
|
||||
builder.WriteString(fmt.Sprintf(" %s -- %s --> %s\n", mermaidID(edge.From), edge.Kind, mermaidID(edge.To)))
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func mermaidID(id string) string {
|
||||
value := mermaidIDPattern.ReplaceAllString(id, "_")
|
||||
if value == "" {
|
||||
return "node"
|
||||
}
|
||||
if value[0] >= '0' && value[0] <= '9' {
|
||||
value = "n_" + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func escapeMermaid(value string) string {
|
||||
value = strings.ReplaceAll(value, `"`, `\"`)
|
||||
return value
|
||||
}
|
||||
92
toolkit/internal/gsp/load.go
Normal file
92
toolkit/internal/gsp/load.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package gsp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var ignoredDirs = map[string]bool{
|
||||
".git": true,
|
||||
".gsp": true,
|
||||
"node_modules": true,
|
||||
"dist": true,
|
||||
"build": true,
|
||||
"bin": true,
|
||||
}
|
||||
|
||||
func LoadProject(root string) (*Project, error) {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
project := &Project{
|
||||
Root: absRoot,
|
||||
ByID: map[string]*Unit{},
|
||||
Duplicates: map[string][]*Unit{},
|
||||
}
|
||||
|
||||
var files []string
|
||||
err = filepath.WalkDir(absRoot, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
project.LoadIssues = append(project.LoadIssues, Issue{Level: "error", Code: "walk_error", File: path, Message: walkErr.Error()})
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if ignoredDirs[entry.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(entry.Name()), ".gsp") {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, file := range files {
|
||||
unit, err := readUnit(absRoot, file)
|
||||
if err != nil {
|
||||
project.LoadIssues = append(project.LoadIssues, Issue{Level: "error", Code: "parse_error", File: relPath(absRoot, file), Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
project.Units = append(project.Units, unit)
|
||||
if unit.ID == "" {
|
||||
continue
|
||||
}
|
||||
if existing, ok := project.ByID[unit.ID]; ok {
|
||||
project.Duplicates[unit.ID] = append(project.Duplicates[unit.ID], existing, unit)
|
||||
continue
|
||||
}
|
||||
project.ByID[unit.ID] = unit
|
||||
}
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func readUnit(root, file string) (*Unit, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var unit Unit
|
||||
if err := yaml.Unmarshal(data, &unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unit.File = relPath(root, file)
|
||||
return &unit, nil
|
||||
}
|
||||
|
||||
func relPath(root, path string) string {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
179
toolkit/internal/gsp/model.go
Normal file
179
toolkit/internal/gsp/model.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package gsp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var resolutionRank = map[string]int{
|
||||
"": 0,
|
||||
"L0": 0,
|
||||
"L1": 1,
|
||||
"L2": 2,
|
||||
"L3": 3,
|
||||
"L4": 4,
|
||||
"L5": 5,
|
||||
}
|
||||
|
||||
type Unit struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Context string `json:"context,omitempty" yaml:"context"`
|
||||
Resolution string `json:"resolution,omitempty" yaml:"resolution"`
|
||||
With Relations `json:"with,omitempty" yaml:"with"`
|
||||
Refines string `json:"refines,omitempty" yaml:"refines"`
|
||||
Type string `json:"type,omitempty" yaml:"type"`
|
||||
File string `json:"file,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
type Relation struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Context string `json:"context,omitempty" yaml:"context"`
|
||||
}
|
||||
|
||||
type Relations []Relation
|
||||
|
||||
func (r *Relations) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind == 0 || value.Tag == "!!null" {
|
||||
*r = nil
|
||||
return nil
|
||||
}
|
||||
if value.Kind != yaml.SequenceNode {
|
||||
return fmt.Errorf("with must be a list")
|
||||
}
|
||||
out := make([]Relation, 0, len(value.Content))
|
||||
for _, item := range value.Content {
|
||||
switch item.Kind {
|
||||
case yaml.ScalarNode:
|
||||
if item.Value == "" {
|
||||
return fmt.Errorf("with item cannot be empty")
|
||||
}
|
||||
out = append(out, Relation{ID: item.Value})
|
||||
case yaml.MappingNode:
|
||||
var rel Relation
|
||||
if err := item.Decode(&rel); err != nil {
|
||||
return err
|
||||
}
|
||||
if rel.ID == "" {
|
||||
return fmt.Errorf("with object item requires id")
|
||||
}
|
||||
out = append(out, rel)
|
||||
default:
|
||||
return fmt.Errorf("with item must be a string or object")
|
||||
}
|
||||
}
|
||||
*r = out
|
||||
return nil
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
Level string `json:"level"`
|
||||
Code string `json:"code"`
|
||||
ID string `json:"id,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
OK bool `json:"ok"`
|
||||
Errors []Issue `json:"errors,omitempty"`
|
||||
Warnings []Issue `json:"warnings,omitempty"`
|
||||
Notices []Issue `json:"notices,omitempty"`
|
||||
}
|
||||
|
||||
func (r *Report) addError(code, id, file, message string) {
|
||||
r.Errors = append(r.Errors, Issue{Level: "error", Code: code, ID: id, File: file, Message: message})
|
||||
r.OK = false
|
||||
}
|
||||
|
||||
func (r *Report) addWarning(code, id, file, message string) {
|
||||
r.Warnings = append(r.Warnings, Issue{Level: "warning", Code: code, ID: id, File: file, Message: message})
|
||||
}
|
||||
|
||||
func (r *Report) addNotice(code, id, file, message string) {
|
||||
r.Notices = append(r.Notices, Issue{Level: "notice", Code: code, ID: id, File: file, Message: message})
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
Root string
|
||||
Units []*Unit
|
||||
ByID map[string]*Unit
|
||||
Duplicates map[string][]*Unit
|
||||
LoadIssues []Issue
|
||||
}
|
||||
|
||||
type IndexEntry struct {
|
||||
ID string `json:"id"`
|
||||
File string `json:"file"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
With []string `json:"with,omitempty"`
|
||||
Refines string `json:"refines,omitempty"`
|
||||
}
|
||||
|
||||
type FlattenResult struct {
|
||||
Entry string `json:"entry"`
|
||||
Depth int `json:"depth"`
|
||||
Units []*Unit `json:"units"`
|
||||
Warnings []Issue `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type TraceResult struct {
|
||||
Entry string `json:"entry"`
|
||||
Depth int `json:"depth"`
|
||||
Nodes []*Unit `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Warnings []Issue `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type PackResult struct {
|
||||
Entry string `json:"entry"`
|
||||
Depth int `json:"depth"`
|
||||
Budget int `json:"budget,omitempty"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Units []*Unit `json:"units"`
|
||||
ApproxChars int `json:"approxChars"`
|
||||
Warnings []Issue `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type Graph struct {
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
}
|
||||
|
||||
type GraphNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
type GraphEdge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
IncludeTypes map[string]bool
|
||||
ExcludeTypes map[string]bool
|
||||
}
|
||||
|
||||
func (f Filter) Allows(unit *Unit) bool {
|
||||
if unit == nil {
|
||||
return false
|
||||
}
|
||||
if len(f.IncludeTypes) > 0 && !f.IncludeTypes[unit.Type] {
|
||||
return false
|
||||
}
|
||||
if len(f.ExcludeTypes) > 0 && f.ExcludeTypes[unit.Type] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolutionValue(value string) (int, bool) {
|
||||
rank, ok := resolutionRank[value]
|
||||
return rank, ok
|
||||
}
|
||||
219
toolkit/internal/gsp/project.go
Normal file
219
toolkit/internal/gsp/project.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package gsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func (p *Project) Validate(stage string) Report {
|
||||
report := Report{OK: true}
|
||||
for _, issue := range p.LoadIssues {
|
||||
report.Errors = append(report.Errors, issue)
|
||||
report.OK = false
|
||||
}
|
||||
for _, unit := range p.Units {
|
||||
if unit.ID == "" {
|
||||
report.addError("missing_id", "", unit.File, "GSP requires id")
|
||||
}
|
||||
if unit.Resolution != "" {
|
||||
if _, ok := resolutionValue(unit.Resolution); !ok {
|
||||
report.addError("invalid_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %q is not allowed", unit.Resolution))
|
||||
}
|
||||
}
|
||||
for _, rel := range unit.With {
|
||||
if _, ok := p.ByID[rel.ID]; !ok {
|
||||
report.addError("missing_with", unit.ID, unit.File, fmt.Sprintf("with references missing GSP %q", rel.ID))
|
||||
}
|
||||
}
|
||||
if unit.Refines != "" {
|
||||
if _, ok := p.ByID[unit.Refines]; !ok {
|
||||
report.addError("missing_refines", unit.ID, unit.File, fmt.Sprintf("refines references missing GSP %q", unit.Refines))
|
||||
}
|
||||
}
|
||||
if unit.Context == "" {
|
||||
report.addNotice("placeholder", unit.ID, unit.File, "GSP has no context and is treated as placeholder")
|
||||
}
|
||||
}
|
||||
for id, units := range p.Duplicates {
|
||||
files := make([]string, 0, len(units))
|
||||
seen := map[string]bool{}
|
||||
for _, unit := range units {
|
||||
if !seen[unit.File] {
|
||||
files = append(files, unit.File)
|
||||
seen[unit.File] = true
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
report.addError("duplicate_id", id, "", fmt.Sprintf("id %q is defined more than once: %v", id, files))
|
||||
}
|
||||
if stage != "" {
|
||||
stageReport := p.StageCheck(stage)
|
||||
report.Errors = append(report.Errors, stageReport.Errors...)
|
||||
report.Warnings = append(report.Warnings, stageReport.Warnings...)
|
||||
if len(stageReport.Errors) > 0 {
|
||||
report.OK = false
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func (p *Project) Index() []IndexEntry {
|
||||
entries := make([]IndexEntry, 0, len(p.Units))
|
||||
for _, unit := range p.Units {
|
||||
with := make([]string, 0, len(unit.With))
|
||||
for _, rel := range unit.With {
|
||||
with = append(with, rel.ID)
|
||||
}
|
||||
sort.Strings(with)
|
||||
entries = append(entries, IndexEntry{
|
||||
ID: unit.ID,
|
||||
File: unit.File,
|
||||
Type: unit.Type,
|
||||
Resolution: unit.Resolution,
|
||||
With: with,
|
||||
Refines: unit.Refines,
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].ID < entries[j].ID
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func (p *Project) StageCheck(stage string) Report {
|
||||
report := Report{OK: true}
|
||||
required, ok := map[string]string{
|
||||
"design": "L0",
|
||||
"integrate": "L2",
|
||||
"implement": "L3",
|
||||
"bind": "L4",
|
||||
"release": "L5",
|
||||
}[stage]
|
||||
if !ok {
|
||||
report.addError("unknown_stage", "", "", fmt.Sprintf("unknown stage %q", stage))
|
||||
return report
|
||||
}
|
||||
minRank, _ := resolutionValue(required)
|
||||
for _, unit := range p.Units {
|
||||
rank, ok := resolutionValue(unit.Resolution)
|
||||
if !ok {
|
||||
report.addError("invalid_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %q is not allowed", unit.Resolution))
|
||||
continue
|
||||
}
|
||||
if rank < minRank {
|
||||
report.addError("low_resolution", unit.ID, unit.File, fmt.Sprintf("resolution %s is below %s for stage %s", displayResolution(unit.Resolution), required, stage))
|
||||
}
|
||||
}
|
||||
if len(report.Errors) == 0 {
|
||||
report.OK = true
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func displayResolution(value string) string {
|
||||
if value == "" {
|
||||
return "L0"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (p *Project) Trace(id string, depth int, filter Filter) TraceResult {
|
||||
flatten := p.Flatten(id, depth, filter)
|
||||
graph := p.graphForUnits(flatten.Units)
|
||||
return TraceResult{
|
||||
Entry: id,
|
||||
Depth: depth,
|
||||
Nodes: flatten.Units,
|
||||
Edges: graph.Edges,
|
||||
Warnings: flatten.Warnings,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Project) Flatten(id string, depth int, filter Filter) FlattenResult {
|
||||
walker := &walker{
|
||||
project: p,
|
||||
depth: depth,
|
||||
filter: filter,
|
||||
seen: map[string]bool{},
|
||||
stack: map[string]bool{},
|
||||
}
|
||||
walker.visit(id, 0)
|
||||
return FlattenResult{
|
||||
Entry: id,
|
||||
Depth: depth,
|
||||
Units: walker.units,
|
||||
Warnings: walker.warnings,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Project) Pack(id string, depth, budget int, filter Filter) PackResult {
|
||||
flattened := p.Flatten(id, depth, filter)
|
||||
units := make([]*Unit, 0, len(flattened.Units))
|
||||
approx := 0
|
||||
truncated := false
|
||||
for _, unit := range flattened.Units {
|
||||
candidate := append(units, unit)
|
||||
data, _ := json.Marshal(candidate)
|
||||
if budget > 0 && len(data) > budget && len(units) > 0 {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
units = candidate
|
||||
approx = len(data)
|
||||
}
|
||||
return PackResult{
|
||||
Entry: id,
|
||||
Depth: depth,
|
||||
Budget: budget,
|
||||
Truncated: truncated,
|
||||
Units: units,
|
||||
ApproxChars: approx,
|
||||
Warnings: flattened.Warnings,
|
||||
}
|
||||
}
|
||||
|
||||
type walker struct {
|
||||
project *Project
|
||||
depth int
|
||||
filter Filter
|
||||
seen map[string]bool
|
||||
stack map[string]bool
|
||||
units []*Unit
|
||||
warnings []Issue
|
||||
}
|
||||
|
||||
func (w *walker) visit(id string, currentDepth int) {
|
||||
if w.depth >= 0 && currentDepth > w.depth {
|
||||
return
|
||||
}
|
||||
if w.stack[id] {
|
||||
w.warnings = append(w.warnings, Issue{Level: "warning", Code: "cycle", ID: id, Message: fmt.Sprintf("cycle detected at %q", id)})
|
||||
return
|
||||
}
|
||||
unit, ok := w.project.ByID[id]
|
||||
if !ok {
|
||||
w.warnings = append(w.warnings, Issue{Level: "warning", Code: "missing", ID: id, Message: fmt.Sprintf("missing GSP %q", id)})
|
||||
return
|
||||
}
|
||||
if w.seen[id] {
|
||||
return
|
||||
}
|
||||
w.seen[id] = true
|
||||
if currentDepth == 0 || w.filter.Allows(unit) {
|
||||
w.units = append(w.units, unit)
|
||||
}
|
||||
w.stack[id] = true
|
||||
if unit.Refines != "" {
|
||||
w.visit(unit.Refines, currentDepth+1)
|
||||
}
|
||||
withIDs := make([]string, 0, len(unit.With))
|
||||
for _, rel := range unit.With {
|
||||
withIDs = append(withIDs, rel.ID)
|
||||
}
|
||||
sort.Strings(withIDs)
|
||||
for _, relID := range withIDs {
|
||||
w.visit(relID, currentDepth+1)
|
||||
}
|
||||
delete(w.stack, id)
|
||||
}
|
||||
122
toolkit/internal/gsp/project_test.go
Normal file
122
toolkit/internal/gsp/project_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package gsp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadValidateAndFlatten(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "page.gsp", `id: page.lottery.main
|
||||
type: page
|
||||
resolution: L3
|
||||
context: Lottery page.
|
||||
with:
|
||||
- id: ui.button.primary
|
||||
context: Main action.
|
||||
- feedback.positive
|
||||
refines: page.lottery.base
|
||||
`)
|
||||
writeTestFile(t, root, "button.gsp", `id: ui.button.primary
|
||||
type: ui
|
||||
resolution: L3
|
||||
context: Primary button.
|
||||
`)
|
||||
writeTestFile(t, root, "feedback.gsp", `id: feedback.positive
|
||||
type: feedback
|
||||
resolution: L2
|
||||
context: Positive feedback.
|
||||
`)
|
||||
writeTestFile(t, root, "base.gsp", `id: page.lottery.base
|
||||
resolution: L2
|
||||
context: Base lottery page.
|
||||
`)
|
||||
|
||||
project, err := LoadProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := project.Validate("")
|
||||
if !report.OK {
|
||||
t.Fatalf("expected valid project, got errors: %+v", report.Errors)
|
||||
}
|
||||
|
||||
flat := project.Flatten("page.lottery.main", -1, Filter{})
|
||||
got := ids(flat.Units)
|
||||
want := []string{"page.lottery.main", "page.lottery.base", "feedback.positive", "ui.button.primary"}
|
||||
if !sameStrings(got, want) {
|
||||
t.Fatalf("flatten ids = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingReference(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "page.gsp", `id: page.missing
|
||||
with:
|
||||
- ui.missing
|
||||
`)
|
||||
project, err := LoadProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := project.Validate("")
|
||||
if report.OK {
|
||||
t.Fatal("expected validation failure")
|
||||
}
|
||||
found := false
|
||||
for _, issue := range report.Errors {
|
||||
if issue.Code == "missing_with" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected missing_with error, got %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageCheck(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "page.gsp", `id: page.low
|
||||
resolution: L2
|
||||
`)
|
||||
project, err := LoadProject(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := project.StageCheck("implement")
|
||||
if report.OK {
|
||||
t.Fatal("expected implement stage check to fail")
|
||||
}
|
||||
if report.Errors[0].Code != "low_resolution" {
|
||||
t.Fatalf("expected low_resolution, got %+v", report.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, root, name, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func ids(units []*Unit) []string {
|
||||
out := make([]string, 0, len(units))
|
||||
for _, unit := range units {
|
||||
out = append(out, unit.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user