服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
506 lines
15 KiB
Go
506 lines
15 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
const autosaveScriptPath = "test/example.equ"
|
|
const autosaveKeepPath = "test/keep.equ"
|
|
|
|
// newAutosaveFixture writes a synthetic source archive and settings that point
|
|
// the backup slot at a nested temporary directory (so the directory creation
|
|
// path is covered too).
|
|
func newAutosaveFixture(t *testing.T) (sourcePath string, settings *SettingsService, cachePath string) {
|
|
t.Helper()
|
|
built := pvf.New()
|
|
if _, err := built.AddFileText(autosaveScriptPath, "[name]\n`before`", pvf.TypeScript); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := built.AddFileText(autosaveKeepPath, "[name]\n`keep`", pvf.TypeScript); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var encoded bytes.Buffer
|
|
if err := built.SaveTo(&encoded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sourcePath = filepath.Join(t.TempDir(), "Script.pvf")
|
|
if err := os.WriteFile(sourcePath, encoded.Bytes(), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
settings = newSettingsService(filepath.Join(t.TempDir(), "settings.json"))
|
|
appSettings := DefaultAppSettings()
|
|
appSettings.AutosaveEnabled = true
|
|
cachePath = filepath.Join(t.TempDir(), "backup", "autosave.pvf")
|
|
appSettings.AutosavePath = cachePath
|
|
if err := settings.SaveSettings(appSettings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return sourcePath, settings, cachePath
|
|
}
|
|
|
|
func openAutosaveCore(t *testing.T, sourcePath string) *core {
|
|
t.Helper()
|
|
core := NewCore()
|
|
t.Cleanup(core.closeArchive)
|
|
if _, err := core.openArchive(sourcePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return core
|
|
}
|
|
|
|
func fixtureIndex(t *testing.T, c *core, path string) int32 {
|
|
t.Helper()
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.archive == nil {
|
|
t.Fatal("no archive loaded")
|
|
}
|
|
index, ok := c.archive.Find(path)
|
|
if !ok {
|
|
t.Fatalf("path %q missing from the archive", path)
|
|
}
|
|
return index
|
|
}
|
|
|
|
func fixtureInfo(t *testing.T, c *core) ArchiveInfo {
|
|
t.Helper()
|
|
info, ok := c.archiveInfo()
|
|
if !ok {
|
|
t.Fatal("no archive loaded")
|
|
}
|
|
return info
|
|
}
|
|
|
|
func readFixtureBytes(t *testing.T, path string) []byte {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func TestAutosaveSnapshotKeepsWorkspaceDirty(t *testing.T) {
|
|
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
|
core := openAutosaveCore(t, sourcePath)
|
|
service := NewAutosaveService(core, settings)
|
|
editor := NewEditorService(core)
|
|
|
|
if err := editor.SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`after`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
original := readFixtureBytes(t, sourcePath)
|
|
|
|
if err := service.CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if info := fixtureInfo(t, core); info.ModifiedCount == 0 {
|
|
t.Fatal("workspace lost its unsaved state after the snapshot")
|
|
}
|
|
if !bytes.Equal(original, readFixtureBytes(t, sourcePath)) {
|
|
t.Fatal("snapshot wrote to the source file")
|
|
}
|
|
|
|
backup, err := pvf.Open(cachePath)
|
|
if err != nil {
|
|
t.Fatalf("open backup: %v", err)
|
|
}
|
|
index, ok := backup.Find(autosaveScriptPath)
|
|
if !ok {
|
|
t.Fatal("backup lost the edited file")
|
|
}
|
|
text, err := backup.Text(index)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(text, "after") {
|
|
t.Fatalf("backup text = %q", text)
|
|
}
|
|
|
|
meta, ok := readAutosaveMeta(cachePath)
|
|
if !ok {
|
|
t.Fatal("backup metadata missing")
|
|
}
|
|
if meta.SourcePath != sourcePath {
|
|
t.Fatalf("metadata source = %q, want %q", meta.SourcePath, sourcePath)
|
|
}
|
|
if len(meta.ModifiedPaths) != 1 || meta.ModifiedPaths[0] != autosaveScriptPath {
|
|
t.Fatalf("metadata modified paths = %v", meta.ModifiedPaths)
|
|
}
|
|
if len(meta.AddedPaths) != 0 {
|
|
t.Fatalf("metadata added paths = %v, want none", meta.AddedPaths)
|
|
}
|
|
if meta.SourceSize == 0 || meta.SourceModified == 0 || meta.CachedAt == 0 {
|
|
t.Fatalf("metadata source identity = %#v", meta)
|
|
}
|
|
}
|
|
|
|
func TestAutosaveSkipsCleanWorkspace(t *testing.T) {
|
|
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
|
core := openAutosaveCore(t, sourcePath)
|
|
service := NewAutosaveService(core, settings)
|
|
|
|
if err := service.CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
|
t.Fatalf("clean workspace wrote a backup: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAutosaveRestoreReplaysInterruptedSession(t *testing.T) {
|
|
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
|
|
|
// First session: edit one file, add one, delete one, then snapshot. The
|
|
// process is "killed" by dropping the core without saving.
|
|
first := openAutosaveCore(t, sourcePath)
|
|
firstService := NewAutosaveService(first, settings)
|
|
if err := NewEditorService(first).SetText(fixtureIndex(t, first, autosaveScriptPath), "[name]\n`recovered`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
archives := NewArchiveService(first)
|
|
added, err := archives.CreateFile("custom/new.stk", pvf.TypeScript)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := NewEditorService(first).SetText(added.FileIndex, "[name]\n`新增文件`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := archives.DeleteFiles([]int32{fixtureIndex(t, first, autosaveKeepPath)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := firstService.CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first.closeArchive()
|
|
|
|
// Second session: nothing is open yet, exactly like a fresh start.
|
|
second := NewCore()
|
|
t.Cleanup(second.closeArchive)
|
|
recovery := NewAutosaveService(second, settings)
|
|
pending, err := recovery.PendingRecovery()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pending == nil {
|
|
t.Fatal("no pending recovery reported")
|
|
}
|
|
if pending.SourcePath != sourcePath || !pending.SourceExists {
|
|
t.Fatalf("pending = %#v", pending)
|
|
}
|
|
if pending.SourceChanged {
|
|
t.Fatal("source reported as changed right after the snapshot")
|
|
}
|
|
if pending.PendingFiles != 2 {
|
|
t.Fatalf("pending files = %d, want 2", pending.PendingFiles)
|
|
}
|
|
|
|
info, err := recovery.Restore()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Path != sourcePath {
|
|
t.Fatalf("restored path = %q, want %q", info.Path, sourcePath)
|
|
}
|
|
if info.ModifiedCount == 0 {
|
|
t.Fatal("restored workspace is reported as saved")
|
|
}
|
|
|
|
meta, _ := NewEditorService(second).GetFile(fixtureIndex(t, second, autosaveScriptPath))
|
|
if !strings.Contains(meta.Text, "recovered") || !meta.Modified {
|
|
t.Fatalf("restored file = %#v", meta)
|
|
}
|
|
if _, ok := second.archive.Find("custom/new.stk"); !ok {
|
|
t.Fatal("restored workspace lost the added file")
|
|
}
|
|
if _, ok := second.archive.Find(autosaveKeepPath); ok {
|
|
t.Fatal("restored workspace kept a file deleted before the snapshot")
|
|
}
|
|
|
|
// The backup survives a successful restore: only a save clears it.
|
|
if _, err := os.Stat(cachePath); err != nil {
|
|
t.Fatalf("backup should be kept after restore: %v", err)
|
|
}
|
|
|
|
// Saving writes the recovered edits back to the source and drops the slot.
|
|
if _, err := NewEditorService(second).Save(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, err := pvf.Open(sourcePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
savedIndex, ok := saved.Find(autosaveScriptPath)
|
|
if !ok {
|
|
t.Fatal("saved archive lost the edited file")
|
|
}
|
|
text, err := saved.Text(savedIndex)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(text, "recovered") {
|
|
t.Fatalf("saved text = %q", text)
|
|
}
|
|
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
|
t.Fatalf("backup survived the save: %v", err)
|
|
}
|
|
if _, err := os.Stat(cachePath + ".json"); !os.IsNotExist(err) {
|
|
t.Fatalf("backup metadata survived the save: %v", err)
|
|
}
|
|
}
|
|
|
|
// The recovery has to land on an attached version session, otherwise the
|
|
// panel would keep reporting a clean workspace while the overlay holds edits.
|
|
func TestAutosaveRestoreFeedsVersionSession(t *testing.T) {
|
|
sourcePath := writeSearchFixture(t, "autosave-version.pvf")
|
|
settings := newSettingsService(filepath.Join(t.TempDir(), "settings.json"))
|
|
appSettings := DefaultAppSettings()
|
|
appSettings.AutosaveEnabled = true
|
|
cachePath := filepath.Join(t.TempDir(), "autosave.pvf")
|
|
appSettings.AutosavePath = cachePath
|
|
if err := settings.SaveSettings(appSettings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
first := openAutosaveCore(t, sourcePath)
|
|
versions := NewVersionService(first)
|
|
if _, err := versions.Initialize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
index := fixtureIndex(t, first, "equipment/character/common/amulet/1008.equ")
|
|
if err := NewEditorService(first).SetText(index, "[name]\n`缓存恢复的项链`\n[grade]\n9"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := NewAutosaveService(first, settings).CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first.closeArchive()
|
|
|
|
second := NewCore()
|
|
t.Cleanup(second.closeArchive)
|
|
recovery := NewAutosaveService(second, settings)
|
|
if _, err := recovery.Restore(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status := NewVersionService(second).Status()
|
|
if !status.Enabled || status.Loading {
|
|
t.Fatalf("version status after restore = %#v", status)
|
|
}
|
|
if status.ChangedFiles != 1 || !status.NeedsSave {
|
|
t.Fatalf("version status lost the recovered edit: %#v", status)
|
|
}
|
|
if status.PendingChangeSets != 1 {
|
|
t.Fatalf("recovery should be undoable once: %#v", status)
|
|
}
|
|
}
|
|
|
|
// A commit without saving the PVF leaves HEAD ahead of the file on disk. The
|
|
// recovery opens that file (the session materializes HEAD into it) and then has
|
|
// to keep the backed-up edit, both in the overlay and in the version state.
|
|
func TestAutosaveRestoreReplaysCommittedUnsavedEdit(t *testing.T) {
|
|
sourcePath := writeSearchFixture(t, "autosave-committed.pvf")
|
|
settings := newSettingsService(filepath.Join(t.TempDir(), "settings.json"))
|
|
appSettings := DefaultAppSettings()
|
|
appSettings.AutosaveEnabled = true
|
|
cachePath := filepath.Join(t.TempDir(), "autosave.pvf")
|
|
appSettings.AutosavePath = cachePath
|
|
if err := settings.SaveSettings(appSettings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const target = "equipment/character/common/amulet/1008.equ"
|
|
const edited = "[name]\n`缓存恢复的项链`\n[grade]\n9"
|
|
|
|
first := openAutosaveCore(t, sourcePath)
|
|
versions := NewVersionService(first)
|
|
if _, err := versions.Initialize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := NewEditorService(first).SetText(fixtureIndex(t, first, target), edited); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := versions.Commit("编辑项链"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The PVF itself is still the old revision on disk while HEAD already holds
|
|
// the edit, exactly what a crash before saving leaves behind.
|
|
if err := NewAutosaveService(first, settings).CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first.closeArchive()
|
|
|
|
second := NewCore()
|
|
t.Cleanup(second.closeArchive)
|
|
recovery := NewAutosaveService(second, settings)
|
|
if _, err := recovery.Restore(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta, err := NewEditorService(second).GetFile(fixtureIndex(t, second, target))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(meta.Text, "缓存恢复的项链") || !meta.Modified {
|
|
t.Fatalf("restored file = %#v", meta)
|
|
}
|
|
}
|
|
|
|
func TestAutosaveRestoreRefusesDirtyWorkspace(t *testing.T) {
|
|
sourcePath, settings, _ := newAutosaveFixture(t)
|
|
core := openAutosaveCore(t, sourcePath)
|
|
service := NewAutosaveService(core, settings)
|
|
if err := NewEditorService(core).SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`still editing`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := service.Restore(); err == nil {
|
|
t.Fatal("expected restore to refuse a workspace with unsaved edits")
|
|
}
|
|
if info := fixtureInfo(t, core); info.ModifiedCount == 0 {
|
|
t.Fatal("workspace lost its edits to a refused restore")
|
|
}
|
|
}
|
|
|
|
func TestAutosaveRestoreKeepsBackupWhenSourceMissing(t *testing.T) {
|
|
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
|
first := openAutosaveCore(t, sourcePath)
|
|
snapshotter := NewAutosaveService(first, settings)
|
|
if err := NewEditorService(first).SetText(fixtureIndex(t, first, autosaveScriptPath), "[name]\n`gone`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := snapshotter.CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first.closeArchive()
|
|
if err := os.Remove(sourcePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fresh := NewCore()
|
|
t.Cleanup(fresh.closeArchive)
|
|
recovery := NewAutosaveService(fresh, settings)
|
|
pending, err := recovery.PendingRecovery()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pending == nil || pending.SourceExists {
|
|
t.Fatalf("pending = %#v, want a backup with a missing source", pending)
|
|
}
|
|
if _, err := recovery.Restore(); err == nil {
|
|
t.Fatal("expected restore to fail without the source file")
|
|
}
|
|
if _, err := os.Stat(cachePath); err != nil {
|
|
t.Fatalf("backup should be kept for a later retry: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAutosaveDiscardRemovesSlot(t *testing.T) {
|
|
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
|
core := openAutosaveCore(t, sourcePath)
|
|
service := NewAutosaveService(core, settings)
|
|
if err := NewEditorService(core).SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`discarded`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
removed, err := service.Discard()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !removed {
|
|
t.Fatal("discard reported nothing removed")
|
|
}
|
|
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
|
t.Fatalf("backup survived the discard: %v", err)
|
|
}
|
|
if _, err := os.Stat(cachePath + ".json"); !os.IsNotExist(err) {
|
|
t.Fatalf("metadata survived the discard: %v", err)
|
|
}
|
|
if service.Status().Exists {
|
|
t.Fatal("status still reports a backup")
|
|
}
|
|
}
|
|
|
|
func TestAutosaveDropForSourceIgnoresOtherArchives(t *testing.T) {
|
|
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
|
core := openAutosaveCore(t, sourcePath)
|
|
service := NewAutosaveService(core, settings)
|
|
if err := NewEditorService(core).SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`kept`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
service.DropForSource(filepath.Join(t.TempDir(), "Other.pvf"))
|
|
if _, err := os.Stat(cachePath); err != nil {
|
|
t.Fatalf("backup of another archive was dropped: %v", err)
|
|
}
|
|
|
|
service.DropForSource(sourcePath)
|
|
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
|
t.Fatalf("backup survived the source drop: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAutosaveStatusReportsSlot(t *testing.T) {
|
|
sourcePath, settings, cachePath := newAutosaveFixture(t)
|
|
core := openAutosaveCore(t, sourcePath)
|
|
service := NewAutosaveService(core, settings)
|
|
|
|
status := service.Status()
|
|
if !status.Enabled {
|
|
t.Fatal("status lost the enabled flag")
|
|
}
|
|
if status.Path != cachePath {
|
|
t.Fatalf("status path = %q, want %q", status.Path, cachePath)
|
|
}
|
|
if status.Exists {
|
|
t.Fatal("status reports a backup before the first snapshot")
|
|
}
|
|
if status.DefaultPath == "" {
|
|
t.Fatal("status is missing the default backup path")
|
|
}
|
|
|
|
if err := NewEditorService(core).SetText(fixtureIndex(t, core, autosaveScriptPath), "[name]\n`status`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.CreateSnapshot(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status = service.Status()
|
|
if !status.Exists || status.CachedAt == 0 || status.SizeBytes == 0 {
|
|
t.Fatalf("status after snapshot = %#v", status)
|
|
}
|
|
if status.SourcePath != sourcePath {
|
|
t.Fatalf("status source = %q, want %q", status.SourcePath, sourcePath)
|
|
}
|
|
if status.LastError != "" {
|
|
t.Fatalf("status error = %q, want none", status.LastError)
|
|
}
|
|
}
|
|
|
|
func TestAutosaveSnapshotRequiresArchive(t *testing.T) {
|
|
_, settings, cachePath := newAutosaveFixture(t)
|
|
core := NewCore()
|
|
t.Cleanup(core.closeArchive)
|
|
service := NewAutosaveService(core, settings)
|
|
|
|
if err := service.CreateSnapshot(); err == nil {
|
|
t.Fatal("expected an error without an open archive")
|
|
}
|
|
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
|
t.Fatalf("backup written without an archive: %v", err)
|
|
}
|
|
}
|