Phase 5: scaffold Crucible (sow-tools) — dispatcher + builder shims, container, PR-first CI, fail-closed (D11, D19).
This commit is contained in:
@@ -1,546 +0,0 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/music"
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
)
|
||||
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityError Severity = "error"
|
||||
SeverityWarning Severity = "warning"
|
||||
)
|
||||
|
||||
type Diagnostic struct {
|
||||
Path string
|
||||
Message string
|
||||
Severity Severity
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
Diagnostics []Diagnostic
|
||||
SourceCount int
|
||||
DecompiledModels []string
|
||||
}
|
||||
|
||||
type loadedDocument struct {
|
||||
Path string
|
||||
ResRef string
|
||||
Extension string
|
||||
Document gff.Document
|
||||
}
|
||||
|
||||
type assetOccurrence struct {
|
||||
Path string
|
||||
Group string
|
||||
}
|
||||
|
||||
type assetGroupResolver struct {
|
||||
configs []project.HAKConfig
|
||||
defaultGroup string
|
||||
}
|
||||
|
||||
func ValidateProject(p *project.Project) Report {
|
||||
report := Report{}
|
||||
resourceIndex := map[string]string{}
|
||||
scriptIndex := map[string]string{}
|
||||
assetIndex := map[string]string{}
|
||||
assetOccurrences := map[string][]assetOccurrence{}
|
||||
documents := make([]loadedDocument, 0, len(p.Inventory.SourceFiles))
|
||||
resolver := newAssetGroupResolver(p)
|
||||
effective := p.EffectiveConfig()
|
||||
|
||||
for _, rel := range p.Inventory.SourceFiles {
|
||||
if hasUppercaseResourceName(rel) {
|
||||
report.add(rel, "resource filenames should be lowercase", SeverityWarning)
|
||||
}
|
||||
abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
|
||||
document, resref, extension, err := loadDocument(abs)
|
||||
if err != nil {
|
||||
report.add(rel, err.Error(), SeverityError)
|
||||
continue
|
||||
}
|
||||
report.SourceCount++
|
||||
documents = append(documents, loadedDocument{
|
||||
Path: rel,
|
||||
ResRef: resref,
|
||||
Extension: extension,
|
||||
Document: document,
|
||||
})
|
||||
|
||||
key := strings.ToLower(resref) + "." + extension
|
||||
if previous, exists := resourceIndex[key]; exists {
|
||||
report.add(rel, fmt.Sprintf("duplicate resource %s also defined by %s", key, previous), SeverityError)
|
||||
} else {
|
||||
resourceIndex[key] = rel
|
||||
}
|
||||
|
||||
validateDocumentStructure(&report, rel, extension, document, effective.Validation.RequiredFields)
|
||||
}
|
||||
|
||||
for _, rel := range p.Inventory.ScriptFiles {
|
||||
if hasUppercaseResourceName(rel) {
|
||||
report.add(rel, "resource filenames should be lowercase", SeverityWarning)
|
||||
}
|
||||
base := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
|
||||
if previous, exists := scriptIndex[base]; exists {
|
||||
report.add(rel, fmt.Sprintf("duplicate script resource %s also defined by %s", base, previous), SeverityError)
|
||||
} else {
|
||||
scriptIndex[base] = rel
|
||||
}
|
||||
}
|
||||
|
||||
for _, rel := range p.Inventory.AssetFiles {
|
||||
musicSource := isMusicSourceAsset(p, rel)
|
||||
if hasUppercaseResourceName(rel) && !musicSource {
|
||||
report.add(rel, "resource filenames should be lowercase", SeverityWarning)
|
||||
}
|
||||
base := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
|
||||
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(rel)), ".")
|
||||
if extension == "mdl" {
|
||||
if isTextMDL(filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) {
|
||||
report.DecompiledModels = append(report.DecompiledModels, rel)
|
||||
}
|
||||
}
|
||||
key := strings.ToLower(base) + "." + extension
|
||||
if _, exists := assetIndex[key]; !exists {
|
||||
assetIndex[key] = rel
|
||||
}
|
||||
if musicSource {
|
||||
continue
|
||||
}
|
||||
|
||||
group, err := resolver.groupFor(rel)
|
||||
if err != nil {
|
||||
report.add(rel, err.Error(), SeverityError)
|
||||
continue
|
||||
}
|
||||
|
||||
assetOccurrences[key] = append(assetOccurrences[key], assetOccurrence{
|
||||
Path: rel,
|
||||
Group: group,
|
||||
})
|
||||
}
|
||||
|
||||
classifyAssetDuplicates(&report, assetOccurrences)
|
||||
|
||||
for _, document := range documents {
|
||||
validateReferences(&report, document, resourceIndex, scriptIndex, assetIndex, effective.Validation.BuiltinScriptPrefixes)
|
||||
}
|
||||
|
||||
slices.SortFunc(report.Diagnostics, func(a, b Diagnostic) int {
|
||||
if cmp := strings.Compare(a.Path, b.Path); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
if cmp := strings.Compare(string(a.Severity), string(b.Severity)); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
return strings.Compare(a.Message, b.Message)
|
||||
})
|
||||
slices.Sort(report.DecompiledModels)
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
func (r Report) SummaryLines(maxPaths int) []string {
|
||||
return summarizeDiagnostics(r.Diagnostics, maxPaths)
|
||||
}
|
||||
|
||||
func (r Report) DecompiledSummaryLine(maxPaths int) string {
|
||||
if len(r.DecompiledModels) == 0 {
|
||||
return ""
|
||||
}
|
||||
return formatSummaryLine("info", "ASCII/decompiled .mdl files detected", r.DecompiledModels, maxPaths)
|
||||
}
|
||||
|
||||
func (r *Report) add(path, message string, severity Severity) {
|
||||
r.Diagnostics = append(r.Diagnostics, Diagnostic{Path: path, Message: message, Severity: severity})
|
||||
}
|
||||
|
||||
func (r Report) HasErrors() bool {
|
||||
return r.ErrorCount() > 0
|
||||
}
|
||||
|
||||
func (r Report) ErrorCount() int {
|
||||
count := 0
|
||||
for _, diagnostic := range r.Diagnostics {
|
||||
if diagnostic.Severity == SeverityError {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (r Report) WarningCount() int {
|
||||
count := 0
|
||||
for _, diagnostic := range r.Diagnostics {
|
||||
if diagnostic.Severity == SeverityWarning {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func summarizeDiagnostics(diagnostics []Diagnostic, maxPaths int) []string {
|
||||
type key struct {
|
||||
severity Severity
|
||||
message string
|
||||
}
|
||||
groups := map[key][]string{}
|
||||
order := make([]key, 0)
|
||||
for _, diagnostic := range diagnostics {
|
||||
k := key{severity: diagnostic.Severity, message: diagnostic.Message}
|
||||
if _, ok := groups[k]; !ok {
|
||||
order = append(order, k)
|
||||
}
|
||||
groups[k] = append(groups[k], diagnostic.Path)
|
||||
}
|
||||
slices.SortFunc(order, func(a, b key) int {
|
||||
if a.severity != b.severity {
|
||||
if a.severity == SeverityError {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.message, b.message)
|
||||
})
|
||||
lines := make([]string, 0, len(order))
|
||||
for _, k := range order {
|
||||
paths := append([]string(nil), groups[k]...)
|
||||
slices.Sort(paths)
|
||||
paths = slices.Compact(paths)
|
||||
lines = append(lines, formatSummaryLine(string(k.severity), k.message, paths, maxPaths))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func formatSummaryLine(prefix, message string, paths []string, maxPaths int) string {
|
||||
if len(paths) == 0 {
|
||||
return fmt.Sprintf("%s: %s", prefix, message)
|
||||
}
|
||||
if maxPaths <= 0 {
|
||||
maxPaths = 3
|
||||
}
|
||||
display := paths
|
||||
if len(display) > maxPaths {
|
||||
display = display[:maxPaths]
|
||||
}
|
||||
location := strings.Join(display, ", ")
|
||||
if len(paths) > len(display) {
|
||||
location += fmt.Sprintf(", and %d more", len(paths)-len(display))
|
||||
}
|
||||
return fmt.Sprintf("%s: %s [%s]", prefix, message, location)
|
||||
}
|
||||
|
||||
func isTextMDL(path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
return false
|
||||
}
|
||||
if len(data) > 4096 {
|
||||
data = data[:4096]
|
||||
}
|
||||
if strings.IndexByte(string(data), 0) >= 0 {
|
||||
return false
|
||||
}
|
||||
printable := 0
|
||||
for _, b := range data {
|
||||
if b == '\n' || b == '\r' || b == '\t' || (b >= 32 && b <= 126) {
|
||||
printable++
|
||||
}
|
||||
}
|
||||
ratio := float64(printable) / float64(len(data))
|
||||
lower := strings.ToLower(string(data))
|
||||
return strings.Contains(lower, "newmodel ") || strings.Contains(lower, "donemodel") || ratio >= 0.9
|
||||
}
|
||||
|
||||
func loadDocument(path string) (gff.Document, string, string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return gff.Document{}, "", "", fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
stem := strings.TrimSuffix(filepath.Base(path), ".json")
|
||||
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(stem)), ".")
|
||||
if extension == "" {
|
||||
return gff.Document{}, "", "", fmt.Errorf("missing resource extension before .json")
|
||||
}
|
||||
resref := strings.ToLower(strings.TrimSuffix(stem, "."+extension))
|
||||
|
||||
var document gff.Document
|
||||
if err := json.Unmarshal(raw, &document); err != nil {
|
||||
return gff.Document{}, "", "", fmt.Errorf("parse json: %w", err)
|
||||
}
|
||||
return document, resref, extension, nil
|
||||
}
|
||||
|
||||
func validateDocumentStructure(report *Report, path, extension string, document gff.Document, requiredFields map[string][]string) {
|
||||
expectedType := strings.ToUpper(extension)
|
||||
if strings.TrimSpace(document.FileType) != "" && strings.TrimSpace(document.FileType) != expectedType {
|
||||
report.add(path, fmt.Sprintf("file_type %q does not match expected %q", document.FileType, expectedType), SeverityError)
|
||||
}
|
||||
|
||||
required := requiredFields[extension]
|
||||
if len(required) > 0 {
|
||||
fields := map[string]struct{}{}
|
||||
for _, field := range document.Root.Fields {
|
||||
fields[field.Label] = struct{}{}
|
||||
}
|
||||
for _, label := range required {
|
||||
if _, ok := fields[label]; !ok {
|
||||
report.add(path, fmt.Sprintf("missing required field %q", label), SeverityError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateUniqueLabels(report, path, "root", document.Root)
|
||||
}
|
||||
|
||||
func validateUniqueLabels(report *Report, path, scope string, s gff.Struct) {
|
||||
seen := map[string]struct{}{}
|
||||
for _, field := range s.Fields {
|
||||
if _, exists := seen[field.Label]; exists {
|
||||
report.add(path, fmt.Sprintf("duplicate field label %q in %s", field.Label, scope), SeverityError)
|
||||
} else {
|
||||
seen[field.Label] = struct{}{}
|
||||
}
|
||||
|
||||
switch value := field.Value.(type) {
|
||||
case gff.Struct:
|
||||
validateUniqueLabels(report, path, scope+"."+field.Label, value)
|
||||
case gff.ListValue:
|
||||
for index, item := range value {
|
||||
validateUniqueLabels(report, path, fmt.Sprintf("%s.%s[%d]", scope, field.Label, index), item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateReferences(report *Report, document loadedDocument, resources, scripts, assets map[string]string, builtinScriptPrefixes []string) {
|
||||
walkFields(document.Document.Root, func(field gff.Field) {
|
||||
value, ok := fieldStringValue(field.Value)
|
||||
if !ok || value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case isScriptField(field.Label):
|
||||
if isBuiltinScript(value, builtinScriptPrefixes) {
|
||||
return
|
||||
}
|
||||
if _, exists := scripts[strings.ToLower(value)]; !exists {
|
||||
report.add(document.Path, fmt.Sprintf("missing script reference %q from field %q", value, field.Label), SeverityWarning)
|
||||
}
|
||||
case field.Label == "Conversation":
|
||||
key := strings.ToLower(value) + ".dlg"
|
||||
if _, exists := resources[key]; !exists {
|
||||
report.add(document.Path, fmt.Sprintf("missing dialog reference %q", key), SeverityError)
|
||||
}
|
||||
case field.Label == "Mod_Entry_Area":
|
||||
key := strings.ToLower(value) + ".are"
|
||||
if _, exists := resources[key]; !exists {
|
||||
report.add(document.Path, fmt.Sprintf("missing area reference %q", key), SeverityError)
|
||||
}
|
||||
case field.Label == "Model":
|
||||
if !hasAsset(strings.ToLower(value), []string{"mdl"}, assets) {
|
||||
report.add(document.Path, fmt.Sprintf("missing model asset for %q", value), SeverityWarning)
|
||||
}
|
||||
case field.Label == "Sound":
|
||||
if !hasAsset(strings.ToLower(value), []string{"wav"}, assets) {
|
||||
report.add(document.Path, fmt.Sprintf("missing sound asset for %q", value), SeverityWarning)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func classifyAssetDuplicates(report *Report, occurrences map[string][]assetOccurrence) {
|
||||
keys := make([]string, 0, len(occurrences))
|
||||
for key := range occurrences {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
entries := occurrences[key]
|
||||
if len(entries) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
groups := make([]string, 0, len(entries))
|
||||
groupSet := map[string]struct{}{}
|
||||
paths := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
paths = append(paths, entry.Path)
|
||||
if _, exists := groupSet[entry.Group]; exists {
|
||||
continue
|
||||
}
|
||||
groupSet[entry.Group] = struct{}{}
|
||||
groups = append(groups, entry.Group)
|
||||
}
|
||||
slices.Sort(groups)
|
||||
slices.Sort(paths)
|
||||
|
||||
if len(groups) == 1 {
|
||||
report.add(entries[0].Path, fmt.Sprintf("duplicate asset resource %s appears multiple times in hak group %q (%s); build will fail if duplicates land in the same generated hak", key, groups[0], strings.Join(paths, ", ")), SeverityError)
|
||||
continue
|
||||
}
|
||||
|
||||
report.add(entries[0].Path, fmt.Sprintf("duplicate asset resource %s is split across hak groups %s (%s); later hak order overrides earlier at runtime", key, strings.Join(groups, ", "), strings.Join(paths, ", ")), SeverityWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func newAssetGroupResolver(p *project.Project) assetGroupResolver {
|
||||
configs := make([]project.HAKConfig, len(p.Config.HAKs))
|
||||
copy(configs, p.Config.HAKs)
|
||||
slices.SortFunc(configs, func(a, b project.HAKConfig) int {
|
||||
if a.Priority != b.Priority {
|
||||
if a.Priority < b.Priority {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
return assetGroupResolver{configs: configs, defaultGroup: p.Config.Module.ResRef}
|
||||
}
|
||||
|
||||
func (r assetGroupResolver) groupFor(rel string) (string, error) {
|
||||
if len(r.configs) == 0 {
|
||||
return r.defaultGroup, nil
|
||||
}
|
||||
for _, cfg := range r.configs {
|
||||
if matchesAnyPattern(rel, cfg.Include) {
|
||||
return cfg.Name, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("asset does not match any hak include pattern")
|
||||
}
|
||||
|
||||
func matchesAnyPattern(path string, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if matchPathPattern(filepath.ToSlash(path), filepath.ToSlash(pattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchPathPattern(path, pattern string) bool {
|
||||
pathSegs := strings.Split(strings.Trim(path, "/"), "/")
|
||||
patternSegs := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
return matchSegments(pathSegs, patternSegs)
|
||||
}
|
||||
|
||||
func matchSegments(pathSegs, patternSegs []string) bool {
|
||||
if len(patternSegs) == 0 {
|
||||
return len(pathSegs) == 0
|
||||
}
|
||||
if patternSegs[0] == "**" {
|
||||
if matchSegments(pathSegs, patternSegs[1:]) {
|
||||
return true
|
||||
}
|
||||
if len(pathSegs) > 0 {
|
||||
return matchSegments(pathSegs[1:], patternSegs)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(pathSegs) == 0 {
|
||||
return false
|
||||
}
|
||||
ok, err := filepath.Match(patternSegs[0], pathSegs[0])
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
return matchSegments(pathSegs[1:], patternSegs[1:])
|
||||
}
|
||||
|
||||
func walkFields(s gff.Struct, visit func(gff.Field)) {
|
||||
for _, field := range s.Fields {
|
||||
visit(field)
|
||||
switch value := field.Value.(type) {
|
||||
case gff.Struct:
|
||||
walkFields(value, visit)
|
||||
case gff.ListValue:
|
||||
for _, item := range value {
|
||||
walkFields(item, visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasUppercaseResourceName(rel string) bool {
|
||||
base := filepath.Base(rel)
|
||||
return base != strings.ToLower(base)
|
||||
}
|
||||
|
||||
func fieldStringValue(value gff.Value) (string, bool) {
|
||||
switch typed := value.(type) {
|
||||
case gff.StringValue:
|
||||
return string(typed), true
|
||||
case gff.ResRefValue:
|
||||
return string(typed), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func isScriptField(label string) bool {
|
||||
return strings.HasPrefix(label, "On") || strings.HasSuffix(label, "Script") || strings.Contains(label, "Script")
|
||||
}
|
||||
|
||||
func hasAsset(name string, extensions []string, assets map[string]string) bool {
|
||||
for _, extension := range extensions {
|
||||
if _, exists := assets[name+"."+extension]; exists {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isMusicSourceAsset(p *project.Project, rel string) bool {
|
||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||||
ext := strings.ToLower(filepath.Ext(rel))
|
||||
effective := p.EffectiveConfig()
|
||||
for _, dataset := range effective.Music.Datasets {
|
||||
if !music.PathIsUnder(dataset.Source, rel) {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range dataset.ConvertExtensions {
|
||||
if ext == strings.ToLower(strings.TrimSpace(candidate)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if music.PathIsUnder("envi/music", rel) {
|
||||
for _, candidate := range effective.Music.ConvertExtensions {
|
||||
if ext == strings.ToLower(strings.TrimSpace(candidate)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBuiltinScript(name string, builtinScriptPrefixes []string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
for _, prefix := range builtinScriptPrefixes {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ResourceTypeForSourceExtension(extension string) (uint16, bool) {
|
||||
return erf.ResourceTypeForExtension(extension)
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
)
|
||||
|
||||
func TestValidateProjectWarnsForCrossHAKDuplicateAssets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "src"))
|
||||
mustMkdir(t, filepath.Join(root, "assets", "low"))
|
||||
mustMkdir(t, filepath.Join(root, "assets", "high"))
|
||||
mustMkdir(t, filepath.Join(root, "build"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
|
||||
"module": {
|
||||
"name": "Test Assets",
|
||||
"resref": "testassets"
|
||||
},
|
||||
"paths": {
|
||||
"source": "src",
|
||||
"assets": "assets",
|
||||
"build": "build"
|
||||
},
|
||||
"haks": [
|
||||
{ "name": "low", "priority": 1, "max_bytes": 0, "split": false, "include": ["low/**"] },
|
||||
{ "name": "high", "priority": 2, "max_bytes": 0, "split": false, "include": ["high/**"] }
|
||||
]
|
||||
}
|
||||
`)
|
||||
mustWriteFile(t, filepath.Join(root, "assets", "low", "shared.mdl"), "low")
|
||||
mustWriteFile(t, filepath.Join(root, "assets", "high", "shared.mdl"), "high")
|
||||
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
if err := p.ValidateLayout(); err != nil {
|
||||
t.Fatalf("validate layout: %v", err)
|
||||
}
|
||||
if err := p.Scan(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
report := ValidateProject(p)
|
||||
if report.HasErrors() {
|
||||
t.Fatalf("expected warnings only, got errors: %#v", report.Diagnostics)
|
||||
}
|
||||
if report.WarningCount() != 1 {
|
||||
t.Fatalf("expected 1 warning, got %d (%#v)", report.WarningCount(), report.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectErrorsForSameHAKDuplicateAssets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "src"))
|
||||
mustMkdir(t, filepath.Join(root, "assets", "core", "a"))
|
||||
mustMkdir(t, filepath.Join(root, "assets", "core", "b"))
|
||||
mustMkdir(t, filepath.Join(root, "build"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
|
||||
"module": {
|
||||
"name": "Test Assets",
|
||||
"resref": "testassets"
|
||||
},
|
||||
"paths": {
|
||||
"source": "src",
|
||||
"assets": "assets",
|
||||
"build": "build"
|
||||
},
|
||||
"haks": [
|
||||
{ "name": "core", "priority": 1, "max_bytes": 0, "split": false, "include": ["core/**"] }
|
||||
]
|
||||
}
|
||||
`)
|
||||
mustWriteFile(t, filepath.Join(root, "assets", "core", "a", "shared.mdl"), "one")
|
||||
mustWriteFile(t, filepath.Join(root, "assets", "core", "b", "shared.mdl"), "two")
|
||||
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
if err := p.ValidateLayout(); err != nil {
|
||||
t.Fatalf("validate layout: %v", err)
|
||||
}
|
||||
if err := p.Scan(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
report := ValidateProject(p)
|
||||
if !report.HasErrors() {
|
||||
t.Fatalf("expected same-hak duplicate to be an error, got %#v", report.Diagnostics)
|
||||
}
|
||||
if report.ErrorCount() != 1 {
|
||||
t.Fatalf("expected 1 error, got %d (%#v)", report.ErrorCount(), report.Diagnostics)
|
||||
}
|
||||
if report.WarningCount() != 0 {
|
||||
t.Fatalf("expected no warnings, got %d (%#v)", report.WarningCount(), report.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectWarnsForUppercaseResourceNames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "src", "blueprints", "items"))
|
||||
mustMkdir(t, filepath.Join(root, "assets", "vfx"))
|
||||
mustMkdir(t, filepath.Join(root, "build"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
|
||||
"module": {
|
||||
"name": "Test Module",
|
||||
"resref": "testmod"
|
||||
},
|
||||
"paths": {
|
||||
"source": "src",
|
||||
"assets": "assets",
|
||||
"build": "build"
|
||||
}
|
||||
}
|
||||
`)
|
||||
mustWriteFile(t, filepath.Join(root, "src", "blueprints", "items", "I_ELVENCHAIN.uti.json"), `{
|
||||
"file_type": "UTI ",
|
||||
"file_version": "V3.2",
|
||||
"root": {"struct_type": 0, "fields": []}
|
||||
}
|
||||
`)
|
||||
mustWriteFile(t, filepath.Join(root, "assets", "vfx", "SPELL_FIRE.tga"), "fire")
|
||||
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
if err := p.ValidateLayout(); err != nil {
|
||||
t.Fatalf("validate layout: %v", err)
|
||||
}
|
||||
if err := p.Scan(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
report := ValidateProject(p)
|
||||
if report.WarningCount() < 2 {
|
||||
t.Fatalf("expected lowercase warnings, got %#v", report.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectWarnsForMissingScriptReferences(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "src", "dialogs"))
|
||||
mustMkdir(t, filepath.Join(root, "assets"))
|
||||
mustMkdir(t, filepath.Join(root, "build"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
|
||||
"module": {
|
||||
"name": "Test Module",
|
||||
"resref": "testmod"
|
||||
},
|
||||
"paths": {
|
||||
"source": "src",
|
||||
"assets": "assets",
|
||||
"build": "build"
|
||||
}
|
||||
}
|
||||
`)
|
||||
mustWriteFile(t, filepath.Join(root, "src", "dialogs", "merchant.dlg.json"), `{
|
||||
"file_type": "DLG ",
|
||||
"file_version": "V3.2",
|
||||
"root": {
|
||||
"struct_type": 0,
|
||||
"fields": [
|
||||
{
|
||||
"label": "Script",
|
||||
"type": "ResRef",
|
||||
"value": "open_store"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
if err := p.ValidateLayout(); err != nil {
|
||||
t.Fatalf("validate layout: %v", err)
|
||||
}
|
||||
if err := p.Scan(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
report := ValidateProject(p)
|
||||
if report.HasErrors() {
|
||||
t.Fatalf("expected warnings only, got errors: %#v", report.Diagnostics)
|
||||
}
|
||||
if report.WarningCount() == 0 {
|
||||
t.Fatalf("expected missing-script warning, got %#v", report.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectUsesConfiguredBuiltinScriptPrefixes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "src", "dialogs"))
|
||||
mustMkdir(t, filepath.Join(root, "assets"))
|
||||
mustMkdir(t, filepath.Join(root, "build"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
paths:
|
||||
source: src
|
||||
assets: assets
|
||||
build: build
|
||||
validation:
|
||||
builtin_script_prefixes:
|
||||
- custom_
|
||||
`)
|
||||
mustWriteFile(t, filepath.Join(root, "src", "dialogs", "merchant.dlg.json"), `{
|
||||
"file_type": "DLG ",
|
||||
"file_version": "V3.2",
|
||||
"root": {
|
||||
"struct_type": 0,
|
||||
"fields": [
|
||||
{
|
||||
"label": "Script",
|
||||
"type": "ResRef",
|
||||
"value": "custom_open_store"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
if err := p.ValidateLayout(); err != nil {
|
||||
t.Fatalf("validate layout: %v", err)
|
||||
}
|
||||
if err := p.Scan(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
report := ValidateProject(p)
|
||||
if report.HasErrors() || report.WarningCount() != 0 {
|
||||
t.Fatalf("expected configured script prefix to suppress warning, got %#v", report.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectUsesConfiguredRequiredFields(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "src", "module"))
|
||||
mustMkdir(t, filepath.Join(root, "assets"))
|
||||
mustMkdir(t, filepath.Join(root, "build"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
paths:
|
||||
source: src
|
||||
assets: assets
|
||||
build: build
|
||||
validation:
|
||||
required_fields:
|
||||
ifo: []
|
||||
`)
|
||||
mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
|
||||
"file_type": "IFO ",
|
||||
"file_version": "V3.2",
|
||||
"root": {
|
||||
"struct_type": 0,
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
if err := p.ValidateLayout(); err != nil {
|
||||
t.Fatalf("validate layout: %v", err)
|
||||
}
|
||||
if err := p.Scan(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
report := ValidateProject(p)
|
||||
if report.HasErrors() {
|
||||
t.Fatalf("expected configured required fields to suppress IFO error, got %#v", report.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectReportsDecompiledModelsWithoutFailingValidation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "src"))
|
||||
mustMkdir(t, filepath.Join(root, "assets", "models"))
|
||||
mustMkdir(t, filepath.Join(root, "build"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
|
||||
"module": {
|
||||
"name": "Test Module",
|
||||
"resref": "testmod"
|
||||
},
|
||||
"paths": {
|
||||
"source": "src",
|
||||
"assets": "assets",
|
||||
"build": "build"
|
||||
}
|
||||
}
|
||||
`)
|
||||
mustWriteFile(t, filepath.Join(root, "assets", "models", "plain_text.mdl"), "newmodel plain_text\nsetsupermodel plain_text NULL\nbeginmodelgeom plain_text\nendmodelgeom plain_text\ndonemodel plain_text\n")
|
||||
if err := os.WriteFile(filepath.Join(root, "assets", "models", "binary_ok.mdl"), []byte{0x00, 0x01, 0x02, 0x03}, 0o644); err != nil {
|
||||
t.Fatalf("write binary mdl: %v", err)
|
||||
}
|
||||
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
if err := p.ValidateLayout(); err != nil {
|
||||
t.Fatalf("validate layout: %v", err)
|
||||
}
|
||||
if err := p.Scan(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
report := ValidateProject(p)
|
||||
if report.HasErrors() {
|
||||
t.Fatalf("expected no validation errors, got %#v", report.Diagnostics)
|
||||
}
|
||||
if len(report.DecompiledModels) != 1 {
|
||||
t.Fatalf("expected 1 decompiled model, got %#v", report.DecompiledModels)
|
||||
}
|
||||
if report.DecompiledModels[0] != "models/plain_text.mdl" {
|
||||
t.Fatalf("unexpected decompiled model path: %#v", report.DecompiledModels)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdir(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user