DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+773
View File
@@ -0,0 +1,773 @@
package services
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
modpkg "pvfine/internal/mod"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
type exportLogCapture struct {
mu sync.Mutex
lines strings.Builder
}
func (l *exportLogCapture) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.lines.Write(p)
}
func (l *exportLogCapture) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.lines.String()
}
func TestExportMetadataReusesSnapshotAndInvalidatesOnChanges(t *testing.T) {
c, exports := exportFixture(t)
r := modRequest("selection")
r.Scopes, r.IncludeDependencies = []string{"stackable"}, true
first, err := exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
original := exports.plan
r.Name, r.Version = "改名后的MOD", "2.3-beta"
next, err := exports.Prepare(r)
if err != nil || next.ID == first.ID {
t.Fatalf("metadata update = %#v, %v", next, err)
}
if &exports.plan.pkg.Entries[0].Data[0] != &original.pkg.Entries[0].Data[0] || exports.plan.created != original.created {
t.Fatal("metadata update decoded content or extended snapshot lifetime")
}
exports.Release(first.ID)
if exports.plan == nil {
t.Fatal("releasing old preview removed newer metadata task")
}
i, _ := c.archive.Find("stackable/demo.stk")
if err := NewEditorService(c).SetText(i, "[price]\n333"); err != nil {
t.Fatal(err)
}
latest, err := exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
if &exports.plan.pkg.Entries[0].Data[0] == &original.pkg.Entries[0].Data[0] {
t.Fatal("reused stale content after a mutation")
}
root, err := exports.executeTo(latest.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
if got := readExportFile(t, root, "pvf/stackable/demo.stk"); !strings.Contains(got, "333") {
t.Fatalf("stale script = %q", got)
}
if manifest := readExportFile(t, root, "pack.json"); !strings.Contains(manifest, `"version": "2.3-beta"`) {
t.Fatalf("custom version missing: %s", manifest)
}
}
func TestExportStringCommentsAndMalformedRows(t *testing.T) {
capture := &exportLogCapture{}
oldOutput := log.Writer()
log.SetOutput(capture)
t.Cleanup(func() { log.SetOutput(oldOutput) })
content := exportContent{
path: "string/itemshop.uv.str", dataType: pvf.TypeUnicode,
data: []byte("\ufeff\t// 注释>不应成为条目\r\n \t\r\nname>中文>名称\r\nbad row\r\nempty>\r\n>empty key\r\nname>更新名称\r\nspace> 保留空格 \r\n\t // 更多注释"),
}
diagnostics := &exportDiagnostics{}
pairs, err := parseExportPairsReported(content, modpkg.MergeStrings, diagnostics)
if err != nil || diagnostics.skipped != 2 || len(pairs) != 3 {
t.Fatalf("pairs = %#v, skipped = %d, err = %v", pairs, diagnostics.skipped, err)
}
values := pairMap(pairs)
if values["name"] != "更新名称" || values["empty"] != "" || values["space"] != " 保留空格 " {
t.Fatalf("lost string content: %#v", values)
}
if output := capture.String(); !strings.Contains(output, "[pvfine:export] skipped: 无法解析文字表 string/itemshop.uv.str 第 4 行") || !strings.Contains(output, "第 6 行") || strings.Contains(output, "第 1 行") {
t.Fatalf("missing malformed row logs or treated comment as malformed: %s", output)
}
}
func TestExportDependenciesSkipMalformedContentWithoutAborting(t *testing.T) {
c, exports := exportFixture(t)
editor := NewEditorService(c)
i, _ := c.archive.Find("string/demo.str")
if err := editor.SetText(i, "\t// 注释\nbad row\nname>可用名称\nempty>"); err != nil {
t.Fatal(err)
}
script, _ := c.archive.Find("stackable/demo.stk")
if err := editor.SetText(script, "[name]\n{8=`<1::name>`}\n[desc]\n{10=`<1::missing>`}"); err != nil {
t.Fatal(err)
}
r := modRequest("selection")
r.Scopes, r.IncludeDependencies = []string{"stackable"}, true
preview, err := exports.Prepare(r)
if err != nil || preview.DependencyCount != 3 || len(preview.Warnings) != 1 {
t.Fatalf("best effort preview = %#v, %v", preview, err)
}
root, err := exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
if got := readExportFile(t, root, "merge/string/demo.str"); got != "name>可用名称\n" {
t.Fatalf("bad rows or unrelated keys exported: %q", got)
}
// An unreadable table is skipped once and unresolved references warn.
c.mu.Lock()
err = c.archive.SetDataType(i, pvf.TypeScript)
c.batchRevision++
c.mu.Unlock()
if err != nil {
t.Fatal(err)
}
preview, err = exports.Prepare(r)
if err != nil || preview.FileCount != 2 || len(preview.Warnings) != 1 {
t.Fatalf("unreadable dependency should not abort: %#v, %v", preview, err)
}
// Parseable but format-incompatible registration IDs also skip, rather
// than aborting the whole task at the final writer validation.
list, _ := c.archive.Find("list/stackable.lst")
if err := editor.SetText(list, "-1 `stackable/demo.stk`"); err != nil {
t.Fatal(err)
}
preview, err = exports.Prepare(r)
if err != nil || preview.FileCount != 1 || len(preview.Warnings) != 1 {
t.Fatalf("unsupported dependency should not abort: %#v, %v", preview, err)
}
}
func TestExportModTablesRequireChangeBaseline(t *testing.T) {
c, exports := exportFixture(t)
for _, dependencies := range []bool{false, true} {
r := modRequest("selection")
r.Scopes, r.IncludeDependencies = []string{"stackable", "list", "string"}, dependencies
preview, err := exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
for _, entry := range exports.plan.pkg.Entries {
if entry.Operation != modpkg.ReplaceFile && !dependencies {
t.Fatalf("full table exported without baseline: %#v", entry)
}
if entry.Path == "string/demo.str" && len(entry.Pairs) != 1 {
t.Fatalf("full dependency table exported: %#v", entry)
}
}
if len(preview.Warnings) != 1 {
t.Fatalf("skipped tables not reported: %#v", preview)
}
}
// A table-only selection cannot produce an applicable mod.
r := modRequest("selection")
r.Scopes = []string{"list", "string"}
if _, err := exports.Prepare(r); err == nil || !strings.Contains(err.Error(), "无改动基线") {
t.Fatalf("baseline-less table-only export accepted: %v", err)
}
versions := NewVersionService(c)
if _, err := versions.Initialize(); err != nil {
t.Fatal(err)
}
script, _ := c.archive.Find("stackable/demo.stk")
if err := NewEditorService(c).SetText(script, "[price]\n200"); err != nil {
t.Fatal(err)
}
c.mu.RLock()
_, changes, err := exports.exportSourceLocked(modRequest("working"))
c.mu.RUnlock()
if err != nil || len(changes) != 1 || changes[0].before != nil {
t.Fatalf("ordinary script unnecessarily loaded baseline: %#v, %v", changes, err)
}
}
func exportFixture(t *testing.T) (*core, *ExportService) {
t.Helper()
a := pvf.New()
for p, text := range map[string]string{
"stackable/demo.stk": "[name]\n{8=`<1::name>`}\n[price]\n100",
"list/stackable.lst": "1 `stackable/demo.stk`",
"list/n_string.lst": "1 `string/demo.str`",
} {
if _, err := a.AddFileText(p, text, pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
if _, err := a.AddFileText("string/demo.str", "name>测试物品\nempty>\nkeep>保留", pvf.TypeUnicode); err != nil {
t.Fatal(err)
}
src := filepath.Join(t.TempDir(), "test.pvf")
if err := a.SaveAs(src); err != nil {
t.Fatal(err)
}
a, err := pvf.Open(src)
if err != nil {
t.Fatal(err)
}
c := NewCore()
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
return c, NewExportService(c)
}
func modRequest(source string) ExportRequest {
return ExportRequest{Source: source, Mode: "mod", Format: "110USextend", Name: "测试MOD"}
}
func readExportFile(t *testing.T, root, p string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(p)))
if err != nil {
t.Fatal(err)
}
return string(data)
}
func TestExportMemoryWithoutVersionControl(t *testing.T) {
c, exports := exportFixture(t)
if _, err := exports.Prepare(ExportRequest{Source: "memory", Mode: "direct"}); err == nil {
t.Fatal("unchanged memory accepted")
}
i, _ := c.archive.Find("string/demo.str")
text := "name>内存中文\nempty>\nkeep>保留"
if err := NewEditorService(c).SetText(i, text); err != nil {
t.Fatal(err)
}
func() {
c.mu.Lock()
defer c.mu.Unlock()
for n := 0; n < 501; n++ {
if _, err := c.archive.AddFileText(fmt.Sprintf("new/%d.stk", n), "[price]\n123", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
}()
for _, mode := range []string{"direct", "mod"} {
request := modRequest("memory")
request.Mode = mode
preview, err := exports.Prepare(request)
expectedCount := 502
if mode == "mod" {
expectedCount = 501
}
if err != nil || preview.FileCount != expectedCount {
t.Fatalf("%s preview = %#v, err = %v", mode, preview, err)
}
if c.versionRepo != nil {
t.Fatal("memory export initialized version control")
}
expectedTreeCount := 502
if mode == "mod" {
if len(preview.Warnings) != 1 {
t.Fatalf("missing skipped table warning: %#v", preview.Warnings)
}
}
if len(preview.Files) != expectedTreeCount {
t.Fatalf("tree truncated: %d", len(preview.Files))
}
root, err := exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
if mode == "direct" {
if got := readExportFile(t, root, "string/demo.str"); strings.TrimSpace(got) != text {
t.Fatalf("memory text = %q", got)
}
} else if _, err := os.Stat(filepath.Join(root, "merge", "string", "demo.str")); !os.IsNotExist(err) {
t.Fatalf("table without baseline exported: %v", err)
}
unchanged := "stackable/demo.stk"
if mode == "mod" {
unchanged = "pvf/" + unchanged
}
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(unchanged))); !os.IsNotExist(err) {
t.Fatalf("unchanged file exported: %v", err)
}
}
}
func TestExportSelectionExclusions(t *testing.T) {
c, exports := exportFixture(t)
c.mu.Lock()
_, err := c.archive.AddFileText("notes.txt", "保留的备注", pvf.TypeUnicode)
c.mu.Unlock()
if err != nil {
t.Fatal(err)
}
for _, mode := range []string{"direct", "mod"} {
r := modRequest("selection")
r.Mode, r.Scopes = mode, []string{"stackable", "string", "notes.txt"}
preview, err := exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
prefix := ""
if mode == "mod" {
prefix = "pvf/"
}
excluded := prefix + "stackable/demo.stk"
for _, invalid := range []string{"../outside", "unknown.stk", "pack.json"} {
if _, err := exports.executeSelectedTo(preview.ID, t.TempDir(), []string{invalid}); err == nil {
t.Fatalf("invalid exclusion accepted: %s", invalid)
}
}
all := []string{}
for _, file := range preview.Files {
if !file.Required {
all = append(all, file.Path)
}
}
emptyDir := t.TempDir()
if _, err := exports.executeSelectedTo(preview.ID, emptyDir, all); err == nil {
t.Fatal("empty export accepted")
}
if entries, err := os.ReadDir(emptyDir); err != nil || len(entries) != 0 {
t.Fatalf("empty selection wrote files: %v", err)
}
root, err := exports.executeSelectedTo(preview.ID, t.TempDir(), []string{excluded, excluded})
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(excluded))); !os.IsNotExist(err) {
t.Fatalf("excluded file exported: %v", err)
}
if mode == "mod" {
manifest := readExportFile(t, root, "pack.json")
if strings.Contains(manifest, "stackable/demo.stk") {
t.Fatalf("metadata contains excluded file: %s", manifest)
}
readExportFile(t, root, "pvf/notes.txt")
} else {
readExportFile(t, root, "string/demo.str")
}
}
}
func TestExportExcludedDependenciesAndStaleSelection(t *testing.T) {
c, exports := exportFixture(t)
r := modRequest("selection")
r.Scopes, r.IncludeDependencies = []string{"stackable"}, true
preview, err := exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
dependencyCount := 0
for _, file := range preview.Files {
dependencyCount += file.DependencyCount
}
if dependencyCount != preview.DependencyCount {
t.Fatalf("file dependency counts disagree: %d / %d", dependencyCount, preview.DependencyCount)
}
root, err := exports.executeSelectedTo(preview.ID, t.TempDir(), []string{"merge/string/demo.str"})
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "merge", "string", "demo.str")); !os.IsNotExist(err) {
t.Fatalf("excluded dependency re-added: %v", err)
}
readExportFile(t, root, "merge/list/n_string.lst")
preview, err = exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
i, _ := c.archive.Find("stackable/demo.stk")
if err := NewEditorService(c).SetText(i, "[price]\n222"); err != nil {
t.Fatal(err)
}
if _, err := exports.executeSelectedTo(preview.ID, t.TempDir(), []string{"merge/string/demo.str"}); !errors.Is(err, ErrExportStale) {
t.Fatalf("stale selection accepted: %v", err)
}
}
func TestExportSelectionDirectAndModDependencies(t *testing.T) {
c, exports := exportFixture(t)
direct := ExportRequest{Source: "selection", Mode: "direct", Scopes: []string{"stackable", "stackable/demo.stk"}}
preview, err := exports.Prepare(direct)
if err != nil || preview.FileCount != 1 {
t.Fatalf("preview = %#v, error %v", preview, err)
}
root, err := exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
i, _ := c.archive.Find("stackable/demo.stk")
want, _ := c.archive.Text(i)
if text := readExportFile(t, root, "stackable/demo.stk"); text != want {
t.Fatalf("direct text = %q", text)
}
r := modRequest("selection")
r.Scopes, r.IncludeDependencies = []string{"stackable"}, true
preview, err = exports.Prepare(r)
if err != nil || preview.FileCount != 4 || preview.DependencyCount != 3 {
t.Fatalf("dependency preview = %#v, error %v", preview, err)
}
root, err = exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
if text := readExportFile(t, root, "merge/string/demo.str"); text != "name>测试物品\n" {
t.Fatalf("dependency table = %q", text)
}
if text := readExportFile(t, root, "merge/list/stackable.lst"); text != "1\t`stackable/demo.stk`\n" {
t.Fatalf("registration = %q", text)
}
// Selected tables without a baseline are skipped; dependency completion
// still emits only the rows matching the exported script.
r.Scopes = []string{"stackable/demo.stk", "string/demo.str", "list"}
preview, err = exports.Prepare(r)
if err != nil || preview.FileCount != 4 || preview.DependencyCount != 3 || len(preview.Warnings) != 1 {
t.Fatalf("explicit preview = %#v, error %v", preview, err)
}
}
func TestExportWorkingAndCommitUseLogicalDifferences(t *testing.T) {
c, exports := exportFixture(t)
versions, editor := NewVersionService(c), NewEditorService(c)
if _, err := versions.Initialize(); err != nil {
t.Fatal(err)
}
i, _ := c.archive.Find("string/demo.str")
if err := editor.SetText(i, "name>修改后的物品\nempty>\nnew>新值"); err != nil {
t.Fatal(err)
}
r := modRequest("working")
preview, err := exports.Prepare(r)
if err != nil || preview.FileCount != 1 || preview.SkippedDeletes != 1 || len(preview.Warnings) != 1 {
t.Fatalf("working preview = %#v, error %v", preview, err)
}
root, err := exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
if text := readExportFile(t, root, "merge/string/demo.str"); text != "name>修改后的物品\nnew>新值\n" {
t.Fatalf("delta = %q", text)
}
commit, err := versions.Commit("修改文字表")
if err != nil {
t.Fatal(err)
}
if err := editor.SetText(i, "name>当前工作区不同内容"); err != nil {
t.Fatal(err)
}
r.Source, r.CommitID = "commit", commit.ID
preview, err = exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
root, err = exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
if text := readExportFile(t, root, "merge/string/demo.str"); strings.Contains(text, "当前工作区") || !strings.Contains(text, "修改后的物品") {
t.Fatalf("history leaked current content: %q", text)
}
}
func TestExportDeletedFileDirectPreservesBeforeAndModSkips(t *testing.T) {
c, exports := exportFixture(t)
versions := NewVersionService(c)
if _, err := versions.Initialize(); err != nil {
t.Fatal(err)
}
i, _ := c.archive.Find("stackable/demo.stk")
old, _ := c.archive.CanonicalText(i)
if _, err := NewArchiveService(c).DeleteFiles([]int32{i}); err != nil {
t.Fatal(err)
}
direct := ExportRequest{Source: "working", Mode: "direct"}
preview, err := exports.Prepare(direct)
if err != nil || preview.FileCount != 1 {
t.Fatalf("deleted direct preview = %#v, error %v", preview, err)
}
root, err := exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
if text := readExportFile(t, root, "stackable/demo.stk"); text != old {
t.Fatalf("deleted before = %q", text)
}
if _, err := exports.Prepare(modRequest("working")); err == nil {
t.Fatal("delete-only mod accepted")
}
commit, err := versions.Commit("删除文件")
if err != nil {
t.Fatal(err)
}
direct.Source, direct.CommitID = "commit", commit.ID
preview, err = exports.Prepare(direct)
if err != nil {
t.Fatal(err)
}
root, err = exports.executeTo(preview.ID, t.TempDir())
if err != nil || readExportFile(t, root, "stackable/demo.stk") != old {
t.Fatalf("commit deleted export: %v", err)
}
}
func TestExportWorkingIncludesMoreThan500Changes(t *testing.T) {
c, exports := exportFixture(t)
if _, err := NewVersionService(c).Initialize(); err != nil {
t.Fatal(err)
}
c.mu.Lock()
paths := make([]string, 510)
for i := range paths {
paths[i] = fmt.Sprintf("etc/new%d.etc", i)
if _, err := c.archive.AddFileText(paths[i], "[value]\n1", pvf.TypeScript); err != nil {
c.mu.Unlock()
t.Fatal(err)
}
}
after, err := pvfversion.ContentSnapshotFromArchive(c.archive, paths)
if err == nil {
err = c.recordVersionMutationLocked("bulk add", pvfversion.ContentSnapshot{}, after)
}
c.batchRevision++
c.mu.Unlock()
if err != nil {
t.Fatal(err)
}
preview, err := exports.Prepare(ExportRequest{Source: "working", Mode: "direct"})
if err != nil || preview.FileCount != 510 {
t.Fatalf("all changes preview = %#v, error %v", preview, err)
}
}
func TestExportRejectsStaleReleasedAndExpiredPlans(t *testing.T) {
c, exports := exportFixture(t)
request := ExportRequest{Source: "selection", Mode: "direct", Scopes: []string{"stackable"}}
for _, reason := range []string{"mutation", "generation", "session", "release", "expiry", "head"} {
preview, err := exports.Prepare(request)
if err != nil {
t.Fatal(err)
}
switch reason {
case "mutation":
c.batchRevision++
case "generation":
c.archiveGeneration++
case "session":
c.versionLoadID++
case "release":
exports.Release(preview.ID)
case "expiry":
exports.plan.created = time.Now().Add(-11 * time.Minute)
case "head":
c.versionHead.ID = "changed"
}
dir := t.TempDir()
if _, err := exports.executeTo(preview.ID, dir); !errors.Is(err, ErrExportStale) {
t.Fatalf("%s stale error = %v", reason, err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 0 {
t.Fatal("stale plan wrote files")
}
}
}
func TestExportHistoricalDependenciesNeverUseCurrentWorktree(t *testing.T) {
c, exports := exportFixture(t)
versions, editor := NewVersionService(c), NewEditorService(c)
if _, err := versions.Initialize(); err != nil {
t.Fatal(err)
}
script, _ := c.archive.Find("stackable/demo.stk")
if err := editor.SetText(script, "[name]\n{8=`<1::name>`}\n[price]\n200"); err != nil {
t.Fatal(err)
}
commit, err := versions.Commit("修改价格")
if err != nil {
t.Fatal(err)
}
table, _ := c.archive.Find("string/demo.str")
if err := editor.SetText(table, "name>不应导出的当前文字"); err != nil {
t.Fatal(err)
}
r := modRequest("commit")
r.CommitID, r.IncludeDependencies = commit.ID, true
preview, err := exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
root, err := exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
if text := readExportFile(t, root, "merge/string/demo.str"); text != "name>测试物品\n" {
t.Fatalf("historical dependency = %q", text)
}
// Broken references are logged and skipped, without blocking the export.
if err := editor.SetText(script, "[name]\n{8=`<99::missing>`}"); err != nil {
t.Fatal(err)
}
r = modRequest("selection")
r.Scopes, r.IncludeDependencies = []string{"stackable"}, true
preview, err = exports.Prepare(r)
if err != nil || len(preview.Warnings) != 1 {
t.Fatalf("missing reference should warn without aborting: %#v, %v", preview, err)
}
r.IncludeDependencies = false
if _, err := exports.Prepare(r); err != nil {
t.Fatalf("strict export should not resolve dependencies: %v", err)
}
}
type failingModWriter struct{ modpkg.Extend110Writer }
func (failingModWriter) Write(root string, pkg modpkg.Package) error {
if err := os.WriteFile(filepath.Join(root, "partial.txt"), []byte("partial"), 0o644); err != nil {
return err
}
return errors.New("injected write failure")
}
func TestModPublishRejectsExistingAndCleansFailure(t *testing.T) {
pkg := modpkg.Package{Metadata: modpkg.Metadata{Name: "test"}, Entries: []modpkg.Entry{
{Path: "a.etc", Operation: modpkg.ReplaceFile, Encoding: modpkg.Text, DataType: 1, Data: []byte("1")},
}}
parent := t.TempDir()
if _, err := writeModDirectory(parent, pkg, failingModWriter{}); err == nil {
t.Fatal("injected failure not reported")
}
entries, _ := os.ReadDir(parent)
if len(entries) != 0 {
t.Fatalf("failed export left entries: %#v", entries)
}
root, err := writeModDirectory(parent, pkg, modpkg.Extend110Writer{})
if err != nil {
t.Fatal(err)
}
if _, err := writeModDirectory(parent, pkg, modpkg.Extend110Writer{}); err == nil {
t.Fatal("existing package overwritten")
}
if got := readExportFile(t, root, "pvf/a.etc"); got != "1" {
t.Fatalf("existing content changed: %q", got)
}
}
type concurrentDestinationModWriter struct {
modpkg.Extend110Writer
createDestination func() error
}
func (w concurrentDestinationModWriter) Write(root string, pkg modpkg.Package) error {
if err := w.Extend110Writer.Write(root, pkg); err != nil {
return err
}
return w.createDestination()
}
func TestModPublishRejectsDestinationCreatedDuringWrite(t *testing.T) {
for _, kind := range []string{"empty directory", "nonempty directory", "file"} {
t.Run(kind, func(t *testing.T) {
parent := t.TempDir()
pkg := modpkg.Package{Metadata: modpkg.Metadata{Name: "test"}, Entries: []modpkg.Entry{
{Path: "a.etc", Operation: modpkg.ReplaceFile, Encoding: modpkg.Text, DataType: 1, Data: []byte("1")},
}}
dest := filepath.Join(parent, "test-1.0")
writer := concurrentDestinationModWriter{createDestination: func() error {
if kind == "file" {
return os.WriteFile(dest, []byte("existing"), 0o644)
}
if err := os.Mkdir(dest, 0o755); err != nil {
return err
}
if kind == "nonempty directory" {
return os.WriteFile(filepath.Join(dest, "keep.txt"), []byte("existing"), 0o644)
}
return nil
}}
if _, err := writeModDirectory(parent, pkg, writer); err == nil {
t.Fatal("destination created during export was overwritten")
}
entries, err := os.ReadDir(parent)
if err != nil || len(entries) != 1 || entries[0].Name() != "test-1.0" {
t.Fatalf("destination removed or temporary directory leaked: %#v, %v", entries, err)
}
if kind == "empty directory" {
entries, err := os.ReadDir(dest)
if err != nil || len(entries) != 0 {
t.Fatalf("empty destination changed: %#v, %v", entries, err)
}
} else {
path := dest
if kind == "nonempty directory" {
path = filepath.Join(dest, "keep.txt")
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "existing" {
t.Fatalf("destination content changed: %q, %v", data, err)
}
}
})
}
}
func TestExportListDeltaAndEmptyString(t *testing.T) {
before := exportContent{path: "list/test.lst", dataType: pvf.TypeScript, data: []byte("1 `a.stk`\n2 `b.stk`")}
after := exportContent{path: before.path, dataType: pvf.TypeScript, data: []byte("1 `changed.stk`\n3 `new.stk`")}
entry, removed, err := exportModEntry(after, &before)
if err != nil || removed != 1 || len(entry.Pairs) != 2 {
t.Fatalf("list delta = %#v, removed %d, error %v", entry, removed, err)
}
before = exportContent{path: "string/test.str", dataType: pvf.TypeUnicode, data: []byte("key>value>with separator")}
after = exportContent{path: before.path, dataType: pvf.TypeUnicode, data: []byte("key>")}
entry, removed, err = exportModEntry(after, &before)
if err != nil || removed != 0 || len(entry.Pairs) != 1 || entry.Pairs[0].Value != "" {
t.Fatalf("empty string delta = %#v, error %v", entry, err)
}
entry, _, err = exportModEntry(exportContent{path: "notes.txt", dataType: pvf.TypeUnicode, data: []byte("\ufeff中文")}, nil)
if err != nil || string(entry.Data) != "中文" {
t.Fatalf("text BOM was not removed: %#v, error %v", entry, err)
}
}
func TestExportRealArchive(t *testing.T) {
c := testArchive(t) // Opt-in only through PVF_TESTFILE.
t.Cleanup(c.closeArchive)
exports := NewExportService(c)
c.mu.RLock()
selected := ""
for i := int32(0); i < c.archive.FileCount(); i++ {
p := c.archive.Path(i)
if c.archive.File(i).DataType == pvf.TypeScript && !strings.EqualFold(filepath.Ext(p), ".lst") && modpkg.ValidatePath(p) == nil {
selected = p
break
}
}
c.mu.RUnlock()
if selected == "" {
t.Skip("archive has no compatible script")
}
r := modRequest("selection")
r.Scopes = []string{selected}
preview, err := exports.Prepare(r)
if err != nil {
t.Fatal(err)
}
root, err := exports.executeTo(preview.ID, t.TempDir())
if err != nil {
t.Fatal(err)
}
got := readExportFile(t, root, "pvf/"+selected)
c.mu.RLock()
i, _ := c.archive.Find(selected)
want, err := c.archive.Text(i)
c.mu.RUnlock()
if err != nil || got != want {
t.Fatal("mod text differs from archive rendering")
}
}