Строчные ссылки на файлы модов и своё ожидание готовности
CI / test (push) Successful in 24s
Release / release (push) Successful in 28s

Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком,
вместе с именем папки мода. На Linux такие файлы не находятся, и сервер
сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды»
раскладывает рядом строчные симлинки: файлы модов не меняются, повторный
запуск после докачки безопасен, а на файловых системах без различия
регистра кнопка честно говорит, что делать нечего.

Ожидание строки о готовности стало настройкой профиля со значением по
умолчанию 30 минут вместо общих 15. Сборке на три сотни модов пятнадцати
не хватает, и панель объявляла бы работающим сервер, который ещё никого
не пустит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:30:40 +03:00
co-authored by Claude Opus 5
parent e0c33706b3
commit 5d9bedaad2
11 changed files with 511 additions and 38 deletions
+126
View File
@@ -0,0 +1,126 @@
package casefix
import (
"context"
"os"
"path/filepath"
"testing"
)
// content собирает дерево мастерской с файлами в разном регистре.
func content(t *testing.T) (root, dir string) {
t.Helper()
root = t.TempDir()
dir = filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
"3722134990", "mods", "GunsOfMarz", "42.16", "media", "AnimSets")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"LoadShotgun_HB.xml", "aim_down.xml"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("<anim/>"), 0o644); err != nil {
t.Fatal(err)
}
}
return root, dir
}
// Проверять создание ссылок есть смысл только там, где регистр вообще
// различается: на Windows и macOS чинить нечего.
func skipCaseInsensitive(t *testing.T, result Result) {
t.Helper()
if result.CaseInsensitive {
t.Skip("файловая система не различает регистр")
}
}
func TestApplyLinksUppercaseNames(t *testing.T) {
root, dir := content(t)
result, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
skipCaseInsensitive(t, result)
if result.Created == 0 {
t.Fatalf("не создано ни одной ссылки: %+v", result)
}
// Ссылка нужна и на файл, и на каждую папку пути: игра опускает в нижний
// регистр путь целиком, включая имя мода.
link := filepath.Join(dir, "loadshotgun_hb.xml")
if data, err := os.ReadFile(link); err != nil || string(data) != "<anim/>" {
t.Fatalf("ссылка на файл не читается: %v", err)
}
modDir := filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
"3722134990", "mods", "gunsofmarz")
if _, err := os.Stat(modDir); err != nil {
t.Fatalf("нет ссылки на папку мода: %v", err)
}
if _, err := os.Stat(filepath.Join(modDir, "42.16", "media", "animsets", "loadshotgun_hb.xml")); err != nil {
t.Fatalf("путь целиком строчными не собрался: %v", err)
}
}
// Повторный запуск после докачки модов не должен ничего ломать.
func TestApplyIsIdempotent(t *testing.T) {
root, _ := content(t)
first, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
skipCaseInsensitive(t, first)
second, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
if second.Created != 0 {
t.Errorf("второй проход создал ссылки заново: %d", second.Created)
}
if second.Existing != first.Created {
t.Errorf("готовых ссылок %d, а создано было %d", second.Existing, first.Created)
}
}
// Имя, которое уже занято настоящим файлом, не трогаем.
func TestApplyKeepsExistingLowercaseFile(t *testing.T) {
root, dir := content(t)
path := filepath.Join(dir, "loadshotgun_hb.xml")
if err := os.WriteFile(path, []byte("свой файл"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Apply(context.Background(), []string{root}, nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "свой файл" {
t.Fatalf("существующий файл перезаписан: %q, %v", data, err)
}
}
func TestApplyWithoutWorkshopDir(t *testing.T) {
if _, err := Apply(context.Background(), []string{t.TempDir()}, nil); err == nil {
t.Fatal("ожидалась ошибка про ненайденную папку модов")
}
}
// На Windows и macOS кнопка должна честно сказать, что чинить нечего, а не
// пересчитывать все имена как «уже готовые».
func TestApplyReportsCaseInsensitiveFilesystem(t *testing.T) {
root, dir := content(t)
if !caseInsensitive(dir) {
t.Skip("файловая система различает регистр")
}
result, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
if !result.CaseInsensitive {
t.Error("нечувствительность к регистру не распознана")
}
if result.Created != 0 || result.Existing != 0 || result.Scanned != 0 {
t.Errorf("дерево обошли впустую: %+v", result)
}
}