Update BuildAndPackage

This commit is contained in:
2026-04-09 17:23:00 +02:00
parent 4f8bfc133a
commit 68aea4ba00
5 changed files with 250 additions and 6 deletions
+1 -1
View File
@@ -397,7 +397,7 @@ func runBuildTopPackage(ctx context) error {
return err return err
} }
result, err := topdata.BuildPackage(p, func(message string) { result, err := topdata.BuildAndPackage(p, func(message string) {
fmt.Fprintf(ctx.stdout, "[build-top-package] %s\n", message) fmt.Fprintf(ctx.stdout, "[build-top-package] %s\n", message)
}) })
if err != nil { if err != nil {
+10 -2
View File
@@ -94,8 +94,16 @@ func buildModule(p *project.Project, progress ProgressFunc) (BuildResult, error)
var topPackage topdata.PackageResult var topPackage topdata.PackageResult
var err error var err error
if p.HasTopData() { if p.HasTopData() {
progressf(progress, "Building native top package...") progressf(progress, "Building native topdata...")
topPackage, err = topdata.BuildPackage(p, func(message string) { nativeTopData, buildErr := topdata.BuildNative(p, func(message string) {
progressf(progress, "Top package: "+message)
})
if buildErr != nil {
return BuildResult{}, buildErr
}
progressf(progress, "Packaging native top package...")
topPackage, err = topdata.BuildPackageFromBuild(p, nativeTopData, func(message string) {
progressf(progress, "Top package: "+message) progressf(progress, "Top package: "+message)
}) })
if err != nil { if err != nil {
+68
View File
@@ -546,6 +546,74 @@ func TestBuildModuleBuildsTopPackageAndInjectsHAKList(t *testing.T) {
} }
} }
func TestBuildModuleCompilesTopDataOnlyOnce(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"))
mustMkdir(t, filepath.Join(root, "topdata", "data", "repadjust"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod",
"hak_order": ["sow_top"]
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
},
"topdata": {
"source": "topdata",
"build": "build/topdata"
}
}
`)
mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{
"file_type": "IFO ",
"file_version": "V3.2",
"root": {
"struct_type": 0,
"fields": [
{"label": "Mod_Name", "type": "CExoString", "value": "Test Module"},
{"label": "Mod_CustomTlk", "type": "CExoString", "value": "sow_tlk"}
]
}
}
`)
mustWriteFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
mustWriteFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
"output": "repadjust.2da",
"columns": ["Label"],
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
}
`)
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)
}
var progress []string
if _, err := BuildModuleWithProgress(p, func(message string) {
progress = append(progress, message)
}); err != nil {
t.Fatalf("build module: %v", err)
}
joined := strings.Join(progress, "\n")
if got := strings.Count(joined, "Building native topdata datasets..."); got != 1 {
t.Fatalf("expected exactly one native topdata compile pass, got %d\nprogress:\n%s", got, joined)
}
}
func TestCompareFailsWhenTopPackageIsStale(t *testing.T) { func TestCompareFailsWhenTopPackageIsStale(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src", "module")) mustMkdir(t, filepath.Join(root, "src", "module"))
+121
View File
@@ -1,12 +1,15 @@
package topdata package topdata
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"time"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
@@ -19,6 +22,17 @@ const (
) )
func BuildPackage(p *project.Project, progress func(string)) (PackageResult, error) { func BuildPackage(p *project.Project, progress func(string)) (PackageResult, error) {
if progress == nil {
progress = func(string) {}
}
nativeResult, err := packagedBuildResult(p)
if err != nil {
return PackageResult{}, err
}
return packageBuiltTopData(p, nativeResult, progress)
}
func BuildAndPackage(p *project.Project, progress func(string)) (PackageResult, error) {
if progress == nil { if progress == nil {
progress = func(string) {} progress = func(string) {}
} }
@@ -29,6 +43,13 @@ func BuildPackage(p *project.Project, progress func(string)) (PackageResult, err
return packageBuiltTopData(p, nativeResult, progress) return packageBuiltTopData(p, nativeResult, progress)
} }
func BuildPackageFromBuild(p *project.Project, nativeResult BuildResult, progress func(string)) (PackageResult, error) {
if progress == nil {
progress = func(string) {}
}
return packageBuiltTopData(p, nativeResult, progress)
}
func packageBuiltTopData(p *project.Project, nativeResult BuildResult, progress func(string)) (PackageResult, error) { func packageBuiltTopData(p *project.Project, nativeResult BuildResult, progress func(string)) (PackageResult, error) {
if progress == nil { if progress == nil {
progress = func(string) {} progress = func(string) {}
@@ -207,3 +228,103 @@ func copyFile(dst, src string) error {
} }
return nil return nil
} }
func packagedBuildResult(p *project.Project) (BuildResult, error) {
if !p.HasTopData() {
return BuildResult{}, errors.New("topdata is not configured for this project")
}
buildDir := p.TopDataBuildDir()
output2DA := filepath.Join(buildDir, "2da")
outputTLK := filepath.Join(buildDir, "tlk")
if _, err := os.Stat(output2DA); err != nil {
return BuildResult{}, fmt.Errorf("topdata build output missing: run build-topdata first")
}
if _, err := os.Stat(outputTLK); err != nil {
return BuildResult{}, fmt.Errorf("topdata tlk output missing: run build-topdata first")
}
files2DA, oldest2DA, err := countCompiledFiles(output2DA, ".2da")
if err != nil {
return BuildResult{}, err
}
filesTLK, oldestTLK, err := countCompiledFiles(outputTLK, ".tlk")
if err != nil {
return BuildResult{}, err
}
if files2DA == 0 {
return BuildResult{}, fmt.Errorf("topdata build output missing: run build-topdata first")
}
if filesTLK != 1 {
return BuildResult{}, fmt.Errorf("expected exactly 1 compiled tlk output, found %d", filesTLK)
}
newestSource, newestPath, err := newestTopDataSource(p.TopDataSourceDir())
if err != nil {
return BuildResult{}, err
}
oldestBuild := oldest2DA
if oldestBuild.IsZero() || (!oldestTLK.IsZero() && oldestTLK.Before(oldestBuild)) {
oldestBuild = oldestTLK
}
if !newestSource.IsZero() && !oldestBuild.IsZero() && newestSource.After(oldestBuild) {
return BuildResult{}, fmt.Errorf("topdata build output is older than source file %s; run build-topdata first", newestPath)
}
return BuildResult{
Mode: "native",
Output2DADir: output2DA,
OutputTLKDir: outputTLK,
Files2DA: files2DA,
FilesTLK: filesTLK,
}, nil
}
func countCompiledFiles(dir, extension string) (int, time.Time, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, time.Time{}, fmt.Errorf("read compiled output dir %s: %w", dir, err)
}
count := 0
oldest := time.Time{}
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), extension) {
continue
}
info, err := entry.Info()
if err != nil {
return 0, time.Time{}, fmt.Errorf("stat compiled output %s: %w", filepath.Join(dir, entry.Name()), err)
}
count++
if oldest.IsZero() || info.ModTime().Before(oldest) {
oldest = info.ModTime()
}
}
return count, oldest, nil
}
func newestTopDataSource(sourceDir string) (time.Time, string, error) {
newest := time.Time{}
newestPath := ""
err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if newest.IsZero() || info.ModTime().After(newest) {
newest = info.ModTime()
newestPath = path
}
return nil
})
if err != nil {
return time.Time{}, "", fmt.Errorf("scan topdata sources: %w", err)
}
return newest, newestPath, nil
}
+50 -3
View File
@@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
@@ -8062,7 +8063,7 @@ func TestBuildNativeWithSiblingAssetsRepo(t *testing.T) {
} }
} }
func TestBuildPackageIncludesCompiled2DAAndTopAssets(t *testing.T) { func TestBuildAndPackageIncludesCompiled2DAAndTopAssets(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui")) mkdirAll(t, filepath.Join(root, "topdata", "assets", "gui"))
@@ -8077,9 +8078,9 @@ func TestBuildPackageIncludesCompiled2DAAndTopAssets(t *testing.T) {
proj := testProject(root) proj := testProject(root)
proj.Config.TopData.ReferenceBuilder = "" proj.Config.TopData.ReferenceBuilder = ""
result, err := BuildPackage(proj, nil) result, err := BuildAndPackage(proj, nil)
if err != nil { if err != nil {
t.Fatalf("BuildPackage failed: %v", err) t.Fatalf("BuildAndPackage failed: %v", err)
} }
if result.OutputHAKPath != filepath.Join(root, "build", PackageHAKFileName) { if result.OutputHAKPath != filepath.Join(root, "build", PackageHAKFileName) {
t.Fatalf("unexpected hak path: %s", result.OutputHAKPath) t.Fatalf("unexpected hak path: %s", result.OutputHAKPath)
@@ -8120,6 +8121,52 @@ func TestBuildPackageIncludesCompiled2DAAndTopAssets(t *testing.T) {
} }
} }
func TestBuildPackageRequiresExistingCompiledOutput(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
"output": "repadjust.2da",
"columns": ["Label"],
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
proj := testProject(root)
proj.Config.TopData.ReferenceBuilder = ""
if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "run build-topdata first") {
t.Fatalf("expected missing compiled output error, got %v", err)
}
}
func TestBuildPackageFailsWhenCompiledOutputIsStale(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
basePath := filepath.Join(root, "topdata", "data", "repadjust", "base.json")
writeFile(t, basePath, `{
"output": "repadjust.2da",
"columns": ["Label"],
"rows": [{"id": 0, "Label": "TEST_LABEL"}]
}`+"\n")
proj := testProject(root)
proj.Config.TopData.ReferenceBuilder = ""
if _, err := BuildNative(proj, nil); err != nil {
t.Fatalf("BuildNative failed: %v", err)
}
newTime := time.Now().Add(2 * time.Second)
if err := os.Chtimes(basePath, newTime, newTime); err != nil {
t.Fatalf("touch topdata source: %v", err)
}
if _, err := BuildPackage(proj, nil); err == nil || !strings.Contains(err.Error(), "older than source file") {
t.Fatalf("expected stale compiled output error, got %v", err)
}
}
func TestValidateProjectErrorsOnTopPackageAssetCollision(t *testing.T) { func TestValidateProjectErrorsOnTopPackageAssetCollision(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))