1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
|
package admin
// Room-rename mutation-plan regressions: the rename, the reference rewrites
// (including self-loops), the runtime migration callback, undo, redo, and
// injected partial-write failures must all agree.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func renameRoom(t *testing.T, s *AdminServer, id, newID int) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(map[string]int{"id": id, "new_id": newID})
req := httptest.NewRequest(http.MethodPost, "/api/room/rename", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
s.handleRoomRename(rec, req)
return rec
}
func roomBody(t *testing.T, s *AdminServer, id int) string {
t.Helper()
path, ok := s.world.GetRoomPath(id)
if !ok {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
// TestRoomRenameFullPlanCoversReferencesAndSelfLoop: renaming a room that a
// neighbor references and that references itself rewrites the neighbor, the
// renamed room's own id field AND self-loop, moves the file, and records the
// whole plan for undo.
func TestRoomRenameFullPlan(t *testing.T) {
s := newTestAdminServer(t, map[int]string{
5: "id: 5\nname: Lab\nexits:\n north:\n room: 6\n east:\n room: 5\n",
6: "id: 6\nname: Hall\nexits:\n south:\n room: 5\n",
})
var migrated []map[int]int
// The wired callback mirrors production (the game's MigrateRoomIDs):
// record the map and rebuild the world room index after migration.
migrate := func(idMap map[int]int) {
recorded := map[int]int{}
for k, v := range idMap {
recorded[k] = v
}
migrated = append(migrated, recorded)
s.world.RebuildRoomIndex(s.dataDir)
}
s.rewriteRoomIDs = migrate
s.undoStack.SetMigrateRoomIDs(migrate)
rec := renameRoom(t, s, 5, 7)
if rec.Code != http.StatusOK {
t.Fatalf("rename failed: %d %s", rec.Code, rec.Body.String())
}
// Renamed room file: moved, id field rewritten, self-loop corrected.
selfBody := roomBody(t, s, 7)
if !strings.Contains(selfBody, "room: 7") {
t.Fatalf("renamed room self-loop not rewritten: %q", selfBody)
}
if _, err := os.Stat(filepath.Join(s.dataDir, "rooms", "5.yaml")); !os.IsNotExist(err) {
t.Fatalf("old room file still exists: %v", err)
}
// The referencing room's exit moved.
hallBody := roomBody(t, s, 6)
if !strings.Contains(hallBody, "room: 7") {
t.Fatalf("reference rewrite missing: %q", hallBody)
}
// The runtime migration callback ran with the forward map.
if len(migrated) != 1 {
t.Fatalf("runtime migration callback not invoked with one plan: %v", migrated)
}
// Undo: everything returns to before-images, inverse migration runs.
if s.undoStack.Undo() == nil {
t.Fatal("undo failed")
}
if roomBody(t, s, 5) == "" {
t.Fatal("undo did not restore the renamed room at its old path")
}
if _, err := os.Stat(filepath.Join(s.dataDir, "rooms", "7.yaml")); !os.IsNotExist(err) {
t.Fatalf("undo left the new room file behind: %v", err)
}
if hall := roomBody(t, s, 6); strings.Contains(hall, "room: 7") {
t.Fatalf("undo did not restore the referencing room: %q", hall)
}
// The last migration callback must be the inverse map.
if last := migrated[len(migrated)-1]; !(len(last) == 1 && last[7] == 5) {
t.Fatalf("undo migration callback = %v, want inverse {7:5}", last)
}
// Redo: forward again, whole plan re-applied.
if s.undoStack.Redo() == nil {
t.Fatal("redo failed")
}
if !strings.Contains(roomBody(t, s, 6), "room: 7") {
t.Fatal("redo did not re-apply the reference rewrite")
}
if roomBody(t, s, 5) != "" {
t.Fatal("redo resurrected the old room file")
}
if last := migrated[len(migrated)-1]; !(len(last) == 1 && last[5] == 7) {
t.Fatalf("redo migration callback = %v, want forward {5:7}", last)
}
}
// TestRoomRenameAbortsOnWriteFailure: when the write side fails mid-plan,
// the journal transaction rolls back — the old room file and its references
// stay untouched, and no half-renamed state survives.
func TestRoomRenameAbortsOnWriteFailure(t *testing.T) {
s := newTestAdminServer(t, map[int]string{
5: "id: 5\nname: Lab\nexits:\n north:\n room: 6\n",
6: "id: 6\nname: Hall\nexits:\n south:\n room: 5\n",
})
// A read-only rooms directory makes every room-file write fail.
roomsDir := filepath.Join(s.dataDir, "rooms")
if err := os.Chmod(roomsDir, 0500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(roomsDir, 0700) })
rec := renameRoom(t, s, 5, 7)
if rec.Code == http.StatusOK {
t.Fatalf("rename succeeded despite the write failure: %s", rec.Body.String())
}
os.Chmod(roomsDir, 0700)
// Nothing was renamed: room 5 still exists at its old path with its
// original content, and room 7 does not exist.
if body := roomBody(t, s, 5); !strings.Contains(body, "id: 5") {
t.Fatalf("room 5 damaged by failed rename: %q", body)
}
if roomExists(s, 7) {
t.Fatal("room 7 exists after the failed rename")
}
}
|