chore(history): squash 59 commit(s) from 2026-09-23

- refactor(hud/weather/render): Scoreboard 931→240 + Rain 903→159 + UiMeshGeometry 956→99 (8.5.2)
- refactor(chams): Chams 985 → 129 + 4×≤195 (8.5.2)
- refactor(module): Module 1031 → 139 + 4×≤134 (8.5.2)
- refactor(tooltips): BetterTooltips 960 → 181 + 4×≤199 (8.5.2)
- refactor(clickgui): ClickGuiPickerState 970→166 + ModulesMenuScreen 966→145 (8.5.2)
- refactor(clickgui): CombatProtocolHeuristicsEditorState 932→79 + 4×≤192 (8.5.2)
- refactor(hud): CompactHudStatModel 894 → 125 + 4×≤175 (8.5.2)
- refactor(iris): ShaderPatchCompiler 843→66 + 4×≤174 (8.5.2)
- refactor(tab): CustomTabList 834→136 + 4×≤166 (8.5.2)
- refactor(relations): OnlineRelationPlayerPickerComponent 837→74 + 4×≤192 (8.5.2)
- refactor(hits): HitEffect 770→120 + 4×≤176 (8.5.2)
- refactor(predict): Predictions 815→150 + 4×≤195 (8.5.2)
- refactor(config): ConfigProfilesComponent 799→125 + 4×≤195 (8.5.2)
- refactor(sim): PlayerMovementSimulation 757→157 + 4×≤194 (8.5.2)
- refactor(predict): ProjectilePuncher 747→104 + 3×≤134 (8.5.2)
- refactor(crosshair): Crosshair 743→143 + 4×≤167 (8.5.2)
- refactor(mediaplayer): MediaPlayer 743→174 + 4×≤196 (8.5.2)
- refactor(hud): Itemizer 699→193 + Cooldowns 707→151 (8.5.2)
- refactor(hud): Potions 617→176 + 4×≤166 (8.5.2)
- refactor(hud): Admins 702→118 + 4×≤138 (8.5.2)
- refactor(hud): Keybinds 611→157 + 4×≤160 (8.5.2)
- refactor(hud): ModuleList 499→181 + 3×≤150 (8.5.2)
- refactor(hud): Radar 392→161 + 2×≤124 (8.5.2)
- refactor(hud): Fps 309→168 + 3×≤107 (8.5.2)
- refactor(hud): Memory 304→151 + 3×≤144 (8.5.2)
- refactor(hud): Tps 339→154 + 3×≤143 (8.5.2)
- refactor(hud): Ping 309→145 + 3×≤125 (8.5.2)
- refactor(hud): SpeedBps 334→156 + 3×≤142 (8.5.2)
- refactor(hud): GameTime 314→150 + 3×≤127 (8.5.2)
- refactor(hud): SystemTime 300→136 + 3×≤130 (8.5.2)
- refactor(hud): Coordinates 337→174 + 2×≤172 (8.5.2)
- refactor(hud): Armor 324→162 + 2×≤148 (8.5.2)
- refactor(hud): Inventory 476→153 + 3×≤181 (8.5.2)
- docs(todo): обновлён 8.5.2 — отмечены готовые гиганты (BetterButtons/InventorySwap/Svg/Темы/WorldParticles/Module/Chams и хвост)
- docs(api): папка combatant-client-26.2/docs + addons.md (API можно ломать при рефакторе)
- refactor(visuals): BlockHighlight 692→271 + 4×≤200 (8.5.2)
- refactor(mixins): GameRendererMixin 830→344 (хуки) + 4 хелпера (8.5.2)
- refactor(visuals): WorldParticles 821→260 + 6×≤200, режимы добиты (8.5.2)
- docs(todo): 8.5.2 — WorldParticles/GameRendererMixin/BlockHighlight отмечены ГОТОВО
- refactor(render): MeshBuilder 738→447 (фасад write-API) + 3 хелпера, FrameStats снесён (8.5.2)
- docs(todo): Фаза 10 — дизайн платформы (аккаунты/конфиги/аватарки), backlog RPC-чата
- docs(todo): Подсистема 3 — Аддоны 2.0 (скрипты/версии) + витрина аддонов + админка
- docs(todo): Подсистема 2 — RPC-чат/друзья/виджеты-телеметрия + рейт-лимиты, доп. правки Подсистемы 3
- chore: переименование combatant-client-26.2 -> mod, добавлены backend/ и frontend/
- docs(backend): implementation plan for accounts-service (Подсистема 1, часть 1)
- chore: пересоздать backend/frontend через cargo new и bun create vite + tailwind; убрать таблицу компонентов из TODO.md
- docs(backend): edition 2024 в плане вместо 2021
- chore: обновить версии до реально актуальных (проверено компиляцией)
- docs: правила платформы (лимит 250 строк, антипаттерны backend/frontend по итогам ресёрча) + frontend/ARCHITECTURE.md
- docs: добавить правило ≤4 файла на папку в правила платформы (backend/frontend)
- docs: commit messages in English from now on
- chore: gitignore .superpowers/ scratch directory
- docs: commit messages in English from now on (mod)
- feat(backend): scaffold accounts-service with health check
- chore(backend): remove target/ build artifacts from git, add gitignore
- chore: track docs/ in git (was accidentally gitignored, never committed)
- chore(backend): convert to Cargo workspace, plan full microservice layout
- feat(backend): accounts/avatars/device_links schema + migration
- feat(backend): argon2id password hashing
This commit is contained in:
loki5512344 2026-09-23 22:38:08 +02:00
parent ec10395511
commit 9d08fa910a
2239 changed files with 35265 additions and 28110 deletions

View file

@ -0,0 +1,128 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.config.profile;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.config.profile.codec.ConfigProfileEntry;
import dev.loki.lovisual.config.profile.codec.ConfigProfileType;
import dev.loki.lovisual.config.profile.diff.ConfigProfileDiff;
import dev.loki.lovisual.config.profile.diff.ConfigProfileDiffEngine;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileMeta;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot;
public final class ConfigProfileDiffEngineTest {
private final ConfigProfileDiffEngine engine = new ConfigProfileDiffEngine();
@Test
void identicalSnapshotsProduceEmptyDiff() {
ConfigProfileSnapshot current = snapshot(entry("mod.a", "Mod A", "x", 1));
ConfigProfileSnapshot selected = snapshot(entry("mod.a", "Mod A", "x", 1));
assertTrue(engine.compare(current, selected).isEmpty());
}
@Test
void changedValueIsReported() {
ConfigProfileDiff diff = engine.compare(
snapshot(entry("mod.a", "Mod A", "x", 1)),
snapshot(entry("mod.a", "Mod A", "x", 2)));
List<ConfigProfileDiff.Value> values = diff.entries().get(0).values();
assertEquals(1, values.size());
assertEquals("x", values.get(0).key());
assertEquals(1, values.get(0).currentValue());
assertEquals(2, values.get(0).selectedValue());
assertEquals(ConfigProfileDiff.Kind.CHANGED, values.get(0).kind());
}
@Test
void addedAndRemovedValuesAreReported() {
ConfigProfileDiff diff = engine.compare(
snapshot(entry("mod.a", "Mod A", "keep", 1, "gone", 2)),
snapshot(entry("mod.a", "Mod A", "keep", 1, "added", 3)));
Map<String, ConfigProfileDiff.Kind> kinds = diff.entries().get(0).values().stream()
.collect(Collectors.toMap(ConfigProfileDiff.Value::key, ConfigProfileDiff.Value::kind));
assertEquals(Set.of("gone", "added"), kinds.keySet());
assertEquals(ConfigProfileDiff.Kind.REMOVED, kinds.get("gone"));
assertEquals(ConfigProfileDiff.Kind.ADDED, kinds.get("added"));
}
@Test
void ownerOnlyInSelectedBecomesAddedEntry() {
ConfigProfileDiff diff = engine.compare(
snapshot(),
snapshot(entry("mod.b", "Mod B", "x", 1)));
ConfigProfileDiff.Entry entry = diff.entries().get(0);
assertEquals("mod.b", entry.ownerId());
assertEquals("Mod B", entry.displayName());
assertEquals(List.of("x"), entry.values().stream()
.map(ConfigProfileDiff.Value::key).toList());
assertEquals(ConfigProfileDiff.Kind.ADDED, entry.values().get(0).kind());
assertNull(entry.values().get(0).currentValue());
}
@Test
void ownerOnlyInCurrentKeepsCurrentDisplayName() {
ConfigProfileDiff diff = engine.compare(
snapshot(entry("mod.a", "Current Name", "x", 1)),
snapshot());
ConfigProfileDiff.Entry entry = diff.entries().get(0);
assertEquals("mod.a", entry.ownerId());
assertEquals("Current Name", entry.displayName());
assertEquals(ConfigProfileDiff.Kind.REMOVED, entry.values().get(0).kind());
assertNull(entry.values().get(0).selectedValue());
}
@Test
void nullSnapshotsYieldEmptyModulesDiff() {
ConfigProfileSnapshot snapshot = snapshot(entry("mod.a", "Mod A", "x", 1));
assertTrue(engine.compare(null, snapshot).isEmpty());
assertTrue(engine.compare(snapshot, null).isEmpty());
assertEquals(ConfigProfileType.MODULES, engine.compare(null, snapshot).type());
}
@Test
void valueChangeCountSumsAllChangedValues() {
ConfigProfileDiff diff = engine.compare(
snapshot(entry("mod.a", "A", "x", 1, "y", 2), entry("mod.b", "B", "z", 3)),
snapshot(entry("mod.a", "A", "x", 9), entry("mod.b", "B", "z", 3)));
// mod.a: x changed, y removed; mod.b identical -> no entry
assertEquals(2, diff.valueChangeCount());
}
private static ConfigProfileSnapshot snapshot(ConfigProfileEntry... entries) {
ConfigProfileMeta meta = new ConfigProfileMeta(
"id", "Profile", ConfigProfileType.MODULES, "author", 0L, 0L, 0);
return new ConfigProfileSnapshot(meta, List.of(entries));
}
private static ConfigProfileEntry entry(String owner, String displayName, Object... keyValuePairs) {
Map<String, Object> values = new LinkedHashMap<>();
for (int i = 0; i < keyValuePairs.length; i += 2) {
values.put((String) keyValuePairs[i], keyValuePairs[i + 1]);
}
return new ConfigProfileEntry(owner, displayName, values);
}
}

View file

@ -0,0 +1,154 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.config.profile;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.config.profile.codec.ConfigProfileEntry;
import dev.loki.lovisual.config.profile.codec.ConfigProfileType;
import dev.loki.lovisual.config.profile.service.ConfigProfileStorage;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileMeta;
import dev.loki.lovisual.config.profile.snapshot.ConfigProfileSnapshot;
import dev.loki.lovisual.features.module.core.Module;
/**
* Тесты ConfigProfileStorage: канонический формат .lvcfg и обратная
* совместимость с легаси-профилями .cbcfg (чтение, листинг, импорт, удаление).
* Чистая файловая логика без инициализации Minecraft.
*/
public final class ConfigProfileStorageTest {
@TempDir
Path tempDir;
private ConfigProfileStorage storage() {
return new ConfigProfileStorage(tempDir);
}
private static ConfigProfileSnapshot snapshot(String name) {
ConfigProfileMeta meta = new ConfigProfileMeta(null, name, ConfigProfileType.MODULES, "loki5512344", 1L, 2L, 0);
return new ConfigProfileSnapshot(meta, List.of(new ConfigProfileEntry("module.a", "Module A", Map.of("enabled", true))));
}
@Test
void saveWritesCanonicalLvcfgFile() throws IOException {
ConfigProfileStorage storage = storage();
ConfigProfileSnapshot saved = snapshot("alpha");
storage.save(saved);
Path canonical = tempDir.resolve("modules").resolve("alpha.lvcfg");
assertTrue(Files.exists(canonical));
ConfigProfileSnapshot read = storage.read(ConfigProfileType.MODULES, "alpha");
assertEquals("alpha", read.meta().name());
}
@Test
void readFallsBackToLegacyCbcfg() throws IOException {
ConfigProfileStorage storage = storage();
storage.save(snapshot("legacy"));
Path canonical = tempDir.resolve("modules").resolve("legacy.lvcfg");
Files.move(canonical, tempDir.resolve("modules").resolve("legacy.cbcfg"));
assertTrue(storage.exists(ConfigProfileType.MODULES, "legacy"));
ConfigProfileSnapshot read = storage.read(ConfigProfileType.MODULES, "legacy");
assertEquals("legacy", read.meta().name());
}
@Test
void listIncludesLegacyProfiles() throws IOException {
ConfigProfileStorage storage = storage();
storage.save(snapshot("fresh"));
storage.save(snapshot("old"));
Files.move(
tempDir.resolve("modules").resolve("old.lvcfg"),
tempDir.resolve("modules").resolve("old.cbcfg"),
StandardCopyOption.REPLACE_EXISTING
);
List<String> names = storage.list(ConfigProfileType.MODULES).stream().map(ConfigProfileMeta::name).toList();
assertTrue(names.contains("fresh"));
assertTrue(names.contains("old"));
}
@Test
void listMigratesLegacyFileToCanonical() throws IOException {
ConfigProfileStorage storage = storage();
storage.save(snapshot("migrate"));
Path legacy = tempDir.resolve("modules").resolve("migrate.cbcfg");
Files.move(tempDir.resolve("modules").resolve("migrate.lvcfg"), legacy);
storage.list(ConfigProfileType.MODULES);
assertFalse(Files.exists(legacy));
assertTrue(Files.exists(tempDir.resolve("modules").resolve("migrate.lvcfg")));
}
@Test
void deleteRemovesBothVariants() throws IOException {
ConfigProfileStorage storage = storage();
storage.save(snapshot("doomed"));
Files.copy(
tempDir.resolve("modules").resolve("doomed.lvcfg"),
tempDir.resolve("modules").resolve("doomed.cbcfg")
);
assertTrue(storage.delete(ConfigProfileType.MODULES, "doomed"));
assertFalse(Files.exists(tempDir.resolve("modules").resolve("doomed.lvcfg")));
assertFalse(Files.exists(tempDir.resolve("modules").resolve("doomed.cbcfg")));
}
@Test
void importLegacyFileConvertsToCanonical() throws IOException {
ConfigProfileStorage storage = storage();
storage.save(snapshot("importme"));
byte[] bytes = Files.readAllBytes(tempDir.resolve("modules").resolve("importme.lvcfg"));
Path source = tempDir.resolve("importme.cbcfg");
Files.write(source, bytes);
ConfigProfileStorage.ImportResult result = storage.importProfileFile(source, true);
assertEquals("importme", result.meta().getId());
assertTrue(Files.exists(tempDir.resolve("modules").resolve("importme.lvcfg")));
assertFalse(Files.exists(source));
}
@Test
void importRejectsForeignExtension() throws IOException {
Path source = tempDir.resolve("notes.txt");
Files.writeString(source, "not a profile");
assertThrows(IOException.class, () -> storage().importProfileFile(source, false));
}
@Test
void sanitizeFileNameStripsBothExtensions() {
assertEquals("alpha", ConfigProfileStorage.sanitizeFileName("alpha.lvcfg"));
assertEquals("alpha", ConfigProfileStorage.sanitizeFileName("alpha.cbcfg"));
assertEquals("alpha", ConfigProfileStorage.sanitizeFileName("Alpha"));
assertEquals("config", ConfigProfileStorage.sanitizeFileName(".lvcfg"));
}
@Test
void fileNormalizesLegacyIdToCanonical() {
ConfigProfileStorage storage = storage();
Path resolved = storage.file(ConfigProfileType.MODULES, "something.cbcfg");
assertEquals(tempDir.resolve("modules").resolve("something.lvcfg"), resolved);
}
}

View file

@ -0,0 +1,123 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.config.profile;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import dev.loki.lovisual.config.profile.codec.ConfigProfileValueCodec;
public final class ConfigProfileValueCodecTest {
@Test
void roundTripsNull() throws IOException {
assertNull(roundTrip(null));
}
@Test
void roundTripsBooleans() throws IOException {
assertEquals(Boolean.TRUE, roundTrip(Boolean.TRUE));
assertEquals(Boolean.FALSE, roundTrip(Boolean.FALSE));
}
@Test
void roundTripsIntegersIncludingVarIntBoundaries() throws IOException {
assertEquals(0, roundTrip(0));
assertEquals(127, roundTrip(127));
assertEquals(128, roundTrip(128));
assertEquals(Integer.MAX_VALUE, roundTrip(Integer.MAX_VALUE));
assertEquals(-1, roundTrip(-1));
}
@Test
void roundTripsLongsFloatsAndDoubles() throws IOException {
assertEquals(Long.MAX_VALUE, roundTrip(Long.MAX_VALUE));
assertEquals(1.5f, roundTrip(1.5f));
assertEquals(2.25d, roundTrip(2.25d));
}
@Test
void narrowNumericTypesAreStoredAsDoubles() throws IOException {
assertEquals(7.0d, roundTrip((short) 7));
assertEquals(8.0d, roundTrip((byte) 8));
}
@Test
void roundTripsUtf8Strings() throws IOException {
assertEquals("привет ✨ мир", roundTrip("привет ✨ мир"));
assertEquals("", roundTrip(""));
}
@Test
void roundTripsListsAndMaps() throws IOException {
List<Object> list = Arrays.asList("a", 1, Boolean.TRUE, null);
assertEquals(list, roundTrip(list));
Map<String, Object> map = new LinkedHashMap<>();
map.put("k", 1);
map.put("empty", null);
map.put("nested", List.of("x"));
assertEquals(map, roundTrip(map));
}
@Test
void unknownTypesFallBackToString() throws IOException {
Object value = new Object() {
@Override
public String toString() {
return "opaque";
}
};
assertEquals("opaque", roundTrip(value));
}
@Test
void unknownTypeByteFailsWithIOException() {
byte[] corrupt = new byte[]{99};
assertThrows(IOException.class, () -> read(corrupt));
}
@Test
void oversizedVarIntFailsWithIOException() {
byte[] corrupt = new byte[]{(byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80};
assertThrows(IOException.class, () -> read(corrupt));
}
@Test
void truncatedStringFailsWithIOException() {
// заголовок длины 5, но самих байтов нет
byte[] corrupt = new byte[]{5};
assertThrows(IOException.class, () -> read(corrupt));
}
private static Object roundTrip(Object value) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (DataOutputStream out = new DataOutputStream(bytes)) {
ConfigProfileValueCodec.writeValue(out, value);
}
return read(bytes.toByteArray());
}
private static Object read(byte[] data) throws IOException {
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(data))) {
return ConfigProfileValueCodec.readValue(in);
}
}
}

View file

@ -0,0 +1,130 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.config.values;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.color.RGBColorValue;
public final class ColorValueTest {
@Test
void rgbNormalizesSupportedFormats() {
assertEquals("#FF0000", new RGBColorValue("c", "#ff0000").get());
assertEquals("#00FF00", new RGBColorValue("c", "0x00ff00").get());
assertEquals("#FF0000", new RGBColorValue("c", "#CCFF0000").get());
assertEquals("#FFFFFF", new RGBColorValue("c", "not-a-color").get());
assertEquals("#FFFFFF", new RGBColorValue("c", null).get());
assertEquals("#FFFFFF", new RGBColorValue("c", "").get());
}
@Test
void rgbNormalizesRainbowValues() {
assertEquals("RNB#123456", new RGBColorValue("c", "rainbow:#123456").get());
assertEquals("RNB", new RGBColorValue("c", "RNB").get());
}
@Test
void rgbGetArgbAddsFullAlpha() {
assertEquals(0xFFFF0000, new RGBColorValue("c", "#FF0000").getArgb());
}
@Test
void rgbRainbowKeepsFullAlpha() {
int argb = new RGBColorValue("c", "RNB#0000FF").getArgb();
assertEquals(0xFF000000, argb & 0xFF000000);
}
@Test
void rgbRainbowHelpers() {
RGBColorValue value = new RGBColorValue("c", "RNB#00FF00");
assertTrue(value.isRainbow());
assertFalse(value.supportsAlpha());
assertEquals("#00FF00", value.rainbowFallbackHex());
}
@Test
void rgbToRainbowKeepsBaseColor() {
assertEquals("RNB#FF0000", RGBColorValue.toRainbow("#FF0000"));
}
@Test
void rgbFromJsonNormalizes() {
RGBColorValue value = new RGBColorValue("c", "#FFFFFF");
value.fromJson("0x00ff00");
assertEquals("#00FF00", value.get());
value.fromJson(42);
assertEquals("#00FF00", value.get());
}
@Test
void rgbaNormalizesSupportedFormats() {
assertEquals("#FF0000", new RGBAColorValue("c", "#ff0000").get());
assertEquals("#80FF0000", new RGBAColorValue("c", "#80FF0000").get());
assertEquals("RGBA#11223344", new RGBAColorValue("c", "rgba:11223344").get());
// normalize только приводит к '#'+hex; невалидный hex остаётся как есть, getArgb даёт белый
assertEquals("#NOPE", new RGBAColorValue("c", "nope").get());
assertEquals(0xFFFFFFFF, new RGBAColorValue("c", "nope").getArgb());
assertEquals("#FFFFFFFF", new RGBAColorValue("c", null).get());
}
@Test
void rgbaNormalizesRainbowValues() {
assertEquals("RNB#FFFF0000", new RGBAColorValue("c", "RNB#FF0000").get());
assertEquals("RNB#80FF0000", new RGBAColorValue("c", "rainbow:80FF0000").get());
}
@Test
void rgbaGetArgbParsesAarrggbb() {
assertEquals(0x80FF0000, new RGBAColorValue("c", "#80FF0000").getArgb());
}
@Test
void rgbaGetArgbInterpretsRgbaPrefixAsRrggbbAA() {
assertEquals(0x44112233, new RGBAColorValue("c", "RGBA#11223344").getArgb());
}
@Test
void rgbaGetArgbAddsFullAlphaToSixDigits() {
assertEquals(0xFFFF0000, new RGBAColorValue("c", "#FF0000").getArgb());
}
@Test
void rgbaRainbowKeepsConfiguredAlpha() {
int argb = new RGBAColorValue("c", "RNB#80FF0000").getArgb();
assertEquals(0x80000000, argb & 0xFF000000);
}
@Test
void rgbaSupportsAlphaAndRainbowHelpers() {
RGBAColorValue value = new RGBAColorValue("c", "RNB#80FF0000");
assertTrue(value.isRainbow());
assertTrue(value.supportsAlpha());
assertEquals("#80FF0000", value.rainbowFallbackHex());
assertEquals("RNB#80FF0000", RGBAColorValue.toRainbow("#80FF0000"));
}
@Test
void rgbaFromJsonNormalizes() {
RGBAColorValue value = new RGBAColorValue("c", "#FFFFFFFF");
value.fromJson("RGBA:11223344");
assertEquals("RGBA#11223344", value.get());
value.fromJson(42);
assertEquals("RGBA#11223344", value.get());
}
}

View file

@ -0,0 +1,113 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.config.values;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.loki.lovisual.config.values.mode.EnumValue;
public final class EnumValueTest {
private enum Speed implements EnumValue.IdProvider, EnumValue.AliasProvider {
FAST("fast-id", List.of("f", "turbo")),
SLOW("slow-id", List.of());
private final String id;
private final List<String> aliases;
Speed(String id, List<String> aliases) {
this.id = id;
this.aliases = aliases;
}
@Override
public String getId() {
return id;
}
@Override
public List<String> aliases() {
return aliases;
}
}
private enum Plain {
ON,
OFF
}
@Test
void idProviderUsedForJsonAndLookup() {
EnumValue<Speed> value = new EnumValue<>("e", Speed.FAST, Speed.FAST, Speed.SLOW);
assertEquals("fast-id", value.getId());
assertEquals("fast-id", value.toJson());
assertEquals(List.of("fast-id", "slow-id"), value.getOptions());
}
@Test
void setByIdIsCaseInsensitive() {
EnumValue<Speed> value = new EnumValue<>("e", Speed.FAST, Speed.FAST, Speed.SLOW);
value.setById("SLOW-ID");
assertEquals(Speed.SLOW, value.get());
}
@Test
void aliasesResolveToConstants() {
EnumValue<Speed> value = new EnumValue<>("e", Speed.FAST, Speed.FAST, Speed.SLOW);
value.setById("turbo");
assertEquals(Speed.FAST, value.get());
value.setById("f");
assertEquals(Speed.FAST, value.get());
}
@Test
void unknownIdKeepsCurrentValue() {
EnumValue<Speed> value = new EnumValue<>("e", Speed.SLOW, Speed.FAST, Speed.SLOW);
value.setById("nope");
value.setById(null);
assertEquals(Speed.SLOW, value.get());
}
@Test
void fromJsonDelegatesToSetById() {
EnumValue<Speed> value = new EnumValue<>("e", Speed.FAST, Speed.FAST, Speed.SLOW);
value.fromJson("SLOW-ID");
assertEquals(Speed.SLOW, value.get());
value.fromJson(42);
value.fromJson(null);
assertEquals(Speed.SLOW, value.get());
}
@Test
void defaultFallsBackToFirstOptionWhenAbsent() {
EnumValue<Speed> value = new EnumValue<>("e", Speed.FAST, Speed.SLOW);
assertEquals(Speed.SLOW, value.get());
}
@Test
void plainEnumUsesLowercaseNameAsId() {
EnumValue<Plain> value = new EnumValue<>("p", Plain.ON, Plain.class);
assertEquals("on", value.getId());
assertEquals("on", value.toJson());
value.setById("OFF");
assertEquals("off", value.toJson());
}
}

View file

@ -0,0 +1,92 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.config.values;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.config.values.mode.ModeValue;
public final class ModeValueTest {
@Test
void invalidDefaultFallsBackToFirstOption() {
assertEquals("a", new ModeValue("m", "bogus", "a", "b", "c").get());
}
@Test
void nextWrapsAroundOptions() {
ModeValue value = new ModeValue("m", "a", "a", "b", "c");
value.next();
assertEquals("b", value.get());
value.next();
assertEquals("c", value.get());
value.next();
assertEquals("a", value.get());
}
@Test
void addOptionTrimsAndRejectsDuplicates() {
ModeValue value = new ModeValue("m", "a", "a", "b");
assertTrue(value.addOption(" c "));
assertEquals(List.of("a", "b", "c"), value.getOptions());
assertFalse(value.addOption("c"));
assertFalse(value.addOption(null));
assertFalse(value.addOption(" "));
assertEquals(List.of("a", "b", "c"), value.getOptions());
}
@Test
void removeOptionCannotRemoveCurrentValue() {
ModeValue value = new ModeValue("m", "a", "a", "b");
assertFalse(value.removeOption("a"));
assertEquals(List.of("a", "b"), value.getOptions());
assertTrue(value.removeOption(" b "));
assertEquals(List.of("a"), value.getOptions());
}
@Test
void addOptionsCountsAddedEntries() {
ModeValue value = new ModeValue("m", "a", "a");
assertEquals(2, value.addOptions(List.of("b", " c ", "b")));
assertEquals(0, value.addOptions(null));
assertEquals(0, value.addOptions(List.of()));
assertEquals(List.of("a", "b", "c"), value.getOptions());
}
@Test
void jsonRoundTripIgnoresUnknownOptions() {
ModeValue value = new ModeValue("m", "a", "a", "b");
value.fromJson("b");
assertEquals("b", value.toJson());
value.fromJson("nope");
value.fromJson(42);
value.fromJson(null);
assertEquals("b", value.get());
}
@Test
void optionsListIsImmutable() {
ModeValue value = new ModeValue("m", "a", "a", "b");
assertThrows(UnsupportedOperationException.class, () -> value.getOptions().add("c"));
}
}

View file

@ -0,0 +1,188 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.config.values;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.config.values.collection.ItemIdSetValue;
import dev.loki.lovisual.config.values.collection.SetValue;
import dev.loki.lovisual.config.values.primitive.BooleanMapValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.config.values.primitive.StringValue;
public final class PrimitiveValuesTest {
@Test
void numberClampsDefaultToRange() {
assertEquals(3, new NumberValue<>("n", 5, 0, 3).get());
assertEquals(0, new NumberValue<>("n", -5, 0, 3).get());
}
@Test
void numberPreservesDeclaredType() {
assertInstanceOf(Double.class, new NumberValue<>("n", 1.5, 0.0, 3.0).get());
assertInstanceOf(Float.class, new NumberValue<>("n", 0.5f, 0.0f, 3.0f).get());
assertInstanceOf(Long.class, new NumberValue<>("n", 2L, 0L, 3L).get());
assertInstanceOf(Integer.class, new NumberValue<>("n", 2, 0, 3).get());
}
@Test
void numberToJsonRoundsFractionsToTwoDecimals() {
assertEquals(1.23, new NumberValue<>("n", 1.23456, 0.0, 5.0).toJson());
assertEquals(7.0, new NumberValue<>("n", 7, 0, 9).toJson());
}
@Test
void numberFromJsonClampsAndIgnoresForeignTypes() {
NumberValue<Integer> value = new NumberValue<>("n", 1, 0, 3);
value.fromJson(99);
assertEquals(3, value.get());
value.fromJson("99");
value.fromJson(null);
assertEquals(3, value.get());
}
@Test
void numberExposesBounds() {
NumberValue<Integer> value = new NumberValue<>("n", 1, 0, 3);
assertEquals(0, value.getMin());
assertEquals(3, value.getMax());
}
@Test
void booleanRoundTripIgnoresForeignTypes() {
BooleanValue value = new BooleanValue("b", true);
assertEquals(true, value.toJson());
value.fromJson(false);
assertFalse(value.get());
value.fromJson("false");
value.fromJson(null);
assertFalse(value.get());
}
@Test
void stringRoundTripIgnoresForeignTypes() {
StringValue value = new StringValue("s", "default");
assertEquals("default", value.toJson());
value.fromJson("next");
assertEquals("next", value.get());
value.fromJson(42);
assertEquals("next", value.get());
}
@Test
void setDefendsAgainstExternalMutation() {
SetValue value = new SetValue("set", Set.of("a", "b"));
Set<String> input = new LinkedHashSet<>();
input.add("x");
value.set(input);
input.add("y");
assertEquals(Set.of("x"), value.get());
value.set(null);
assertTrue(value.get().isEmpty());
}
@Test
void setFromJsonFiltersNonStrings() {
SetValue value = new SetValue("set");
List<Object> json = new ArrayList<>();
json.add("a");
json.add(1);
json.add(null);
json.add("b");
value.fromJson(json);
assertEquals(new LinkedHashSet<>(List.of("a", "b")), value.get());
assertEquals(List.of("a", "b"), value.toJson());
}
@Test
void setFromJsonNonListKeepsCurrent() {
SetValue value = new SetValue("set", Set.of("keep"));
value.fromJson("nope");
assertEquals(Set.of("keep"), value.get());
}
@Test
void itemIdSetNormalizesNamespace() {
ItemIdSetValue value = new ItemIdSetValue("ids", Set.of("STONE", "minecraft:Dirt", " ", ""));
assertEquals(new LinkedHashSet<>(List.of("minecraft:stone", "minecraft:dirt")), value.get());
}
@Test
void itemIdSetAddsDefaultNamespace() {
ItemIdSetValue value = new ItemIdSetValue("ids");
value.set(Set.of("diamond_sword"));
assertEquals(Set.of("minecraft:diamond_sword"), value.get());
}
@Test
void booleanMapSeedsDefaultsAndDefaultsUnknownToFalse() {
Map<String, Boolean> defaults = new LinkedHashMap<>();
defaults.put("alpha", true);
defaults.put("beta", false);
BooleanMapValue value = new BooleanMapValue("map", defaults);
assertTrue(value.get("alpha"));
assertFalse(value.get("beta"));
assertFalse(value.get("unknown"));
}
@Test
void booleanMapFromJsonAppliesOnlyKnownKeys() {
Map<String, Boolean> defaults = new LinkedHashMap<>();
defaults.put("alpha", true);
BooleanMapValue value = new BooleanMapValue("map", defaults);
Map<String, Object> json = new LinkedHashMap<>();
json.put("alpha", false);
json.put("unknown", true);
json.put("num", 5);
value.fromJson(json);
assertFalse(value.get("alpha"));
assertFalse(value.getAll().containsKey("unknown"));
assertEquals(Map.of("alpha", false), value.toJson());
}
@Test
void booleanMapFromJsonNonMapResetsToDefaults() {
Map<String, Boolean> defaults = new LinkedHashMap<>();
defaults.put("alpha", true);
BooleanMapValue value = new BooleanMapValue("map", defaults);
value.set("alpha", false);
value.fromJson("garbage");
assertTrue(value.get("alpha"));
}
}

View file

@ -0,0 +1,234 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.events;
import dev.loki.lovisual.Lifecycle;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.events.meta.EventHandler;
public final class EventBusTest {
static class BaseEvent extends Event {
}
static class DerivedEvent extends BaseEvent {
}
@BeforeAll
static void activateLifecycle() {
// EventBus.post() доставляет события только при активном lifecycle.
Lifecycle.activate();
}
@Test
void postNullReturnsNull() {
EventBus bus = new EventBus();
assertNull(bus.post(null));
}
@Test
void deliversToHandlerAndReturnsSameInstance() {
EventBus bus = new EventBus();
SingleListener listener = new SingleListener();
bus.register(listener);
BaseEvent event = new BaseEvent();
assertSame(event, bus.post(event));
assertTrue(listener.called);
}
@Test
void higherPriorityRunsFirst() {
EventBus bus = new EventBus();
PriorityListener listener = new PriorityListener();
bus.register(listener);
bus.post(new BaseEvent());
assertEquals(List.of("high", "low"), listener.calls);
}
@Test
void subclassDispatchFiresDerivedThenBase() {
EventBus bus = new EventBus();
HierarchyListener listener = new HierarchyListener();
bus.register(listener);
bus.post(new DerivedEvent());
assertEquals(List.of("derived", "base"), listener.calls);
listener.calls.clear();
bus.post(new BaseEvent());
assertEquals(List.of("base"), listener.calls);
}
@Test
void unregisterStopsDelivery() {
EventBus bus = new EventBus();
SingleListener listener = new SingleListener();
bus.register(listener);
bus.unregister(listener);
bus.post(new BaseEvent());
assertFalse(listener.called);
assertFalse(bus.hasListeners(BaseEvent.class));
}
@Test
void doubleRegistrationIgnored() {
EventBus bus = new EventBus();
SingleListener listener = new SingleListener();
bus.register(listener);
bus.register(listener);
assertEquals(1, bus.listenerCount());
bus.post(new BaseEvent());
assertTrue(listener.called);
}
@Test
void listenerWithoutValidHandlersIsIgnored() {
EventBus bus = new EventBus();
bus.register(new Object());
bus.register(new InvalidSignaturesListener());
assertEquals(0, bus.listenerCount());
assertFalse(bus.hasListeners(BaseEvent.class));
}
@Test
void handlerExceptionDoesNotStopOtherHandlers() {
EventBus bus = new EventBus();
ThrowingListener throwing = new ThrowingListener();
SingleListener healthy = new SingleListener();
bus.register(throwing);
bus.register(healthy);
assertDoesNotThrow(() -> bus.post(new BaseEvent()));
assertTrue(healthy.called);
}
@Test
void privateHandlerInvoked() {
EventBus bus = new EventBus();
PrivateListener listener = new PrivateListener();
bus.register(listener);
bus.post(new BaseEvent());
assertTrue(listener.called);
}
@Test
void hasListenersReflectsRegistration() {
EventBus bus = new EventBus();
assertFalse(bus.hasListeners(BaseEvent.class));
SingleListener listener = new SingleListener();
bus.register(listener);
assertTrue(bus.hasListeners(BaseEvent.class));
assertTrue(bus.hasListeners(DerivedEvent.class));
assertFalse(bus.hasListeners(Event.class));
}
@Test
void clearRemovesAllListeners() {
EventBus bus = new EventBus();
SingleListener listener = new SingleListener();
bus.register(listener);
bus.clear();
assertEquals(0, bus.listenerCount());
assertFalse(bus.hasListeners(BaseEvent.class));
bus.post(new BaseEvent());
assertFalse(listener.called);
}
@Test
void nullAndUnknownListenersAreNoOps() {
EventBus bus = new EventBus();
assertDoesNotThrow(() -> {
bus.register(null);
bus.unregister(null);
bus.unregister(new SingleListener());
});
assertEquals(0, bus.listenerCount());
}
static class SingleListener {
boolean called;
@EventHandler
public void onBase(BaseEvent event) {
called = true;
}
}
static class PriorityListener {
final List<String> calls = new ArrayList<>();
@EventHandler(priority = 5)
public void onHigh(BaseEvent event) {
calls.add("high");
}
@EventHandler(priority = 1)
public void onLow(BaseEvent event) {
calls.add("low");
}
}
static class HierarchyListener {
final List<String> calls = new ArrayList<>();
@EventHandler
public void onBase(BaseEvent event) {
calls.add("base");
}
@EventHandler
public void onDerived(DerivedEvent event) {
calls.add("derived");
}
}
static class ThrowingListener {
@EventHandler
public void onBase(BaseEvent event) {
throw new IllegalStateException("boom");
}
}
static class PrivateListener {
boolean called;
@EventHandler
private void onBase(BaseEvent event) {
called = true;
}
}
static class InvalidSignaturesListener {
@EventHandler
public void twoParams(BaseEvent event, int extra) {
}
@EventHandler
public void notAnEvent(String value) {
}
}
}

View file

@ -0,0 +1,69 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.command;
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import dev.loki.lovisual.features.command.core.CommandContext;
import dev.loki.lovisual.features.command.core.CommandInfo;
import dev.loki.lovisual.features.command.core.CommandManager;
import dev.loki.lovisual.features.command.core.CommandMetadata;
import dev.loki.lovisual.features.command.util.ClientCommand;
public class CommandDiscoveryTest {
@Test
void discoversAnnotatedProductionCommandsWithoutManualRegistration() {
List<ClientCommand> commands = CommandManager.getCommands();
assertTrue(commands.size() >= 15);
assertNotNull(CommandManager.find("help"));
assertNotNull(CommandManager.find("friend"));
assertNotNull(CommandManager.find("coords"));
assertNull(CommandManager.find("clear"));
assertNull(CommandManager.find("say"));
assertNull(CommandManager.find("profiler"));
assertNull(CommandManager.find("runtime"));
HashSet<String> ids = new HashSet<>();
for (ClientCommand command : commands) {
CommandMetadata metadata = command.metadata();
assertTrue(ids.add(metadata.id()), "duplicate command id: " + metadata.id());
assertNotNull(command.getClass().getAnnotation(CommandInfo.class));
assertFalse(metadata.descriptionKey().isBlank(), "missing description i18n key: " + metadata.id());
}
}
@Test
@SuppressWarnings("removal")
void keepsLegacyV0MetadataDefaultsWorking() {
ClientCommand legacy = new LegacyCommand();
assertEquals("legacy", legacy.metadata().id());
assertEquals(List.of(), legacy.metadata().aliases());
// Default usage is now prefixed with the active command prefix.
assertEquals("%legacy", legacy.metadata().usage());
assertEquals("%legacy", legacy.metadata().description());
}
@SuppressWarnings("removal")
private static final class LegacyCommand implements ClientCommand {
@Override
public String name() {
return "legacy";
}
@Override
public boolean execute(CommandContext ctx) {
return true;
}
}
}

View file

@ -0,0 +1,50 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.command;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.command.util.CommandPrefix;
public final class CommandPrefixTest {
@Test
void percentIsTheActivePrefix() {
assertEquals('%', CommandPrefix.CHAR);
assertTrue(CommandPrefix.isCommandLike("%help"));
assertTrue(CommandPrefix.isCommandLike(" %toggle keepinventory on"));
}
@Test
void legacyAtIsStillAccepted() {
assertTrue(CommandPrefix.isCommandLike("@help"));
assertEquals('@', CommandPrefix.leadingPrefix("@help"));
}
@Test
void plainChatIsNotACommand() {
assertFalse(CommandPrefix.isCommandLike("hello world"));
assertFalse(CommandPrefix.isCommandLike("/vanilla command"));
assertFalse(CommandPrefix.isCommandLike(""));
assertFalse(CommandPrefix.isCommandLike(null));
assertEquals(0, CommandPrefix.leadingPrefix("50% off"));
}
@Test
void stripRemovesPrefixAndWhitespace() {
assertEquals("help", CommandPrefix.strip(" %help "));
assertEquals("toggle mod on", CommandPrefix.strip("@toggle mod on"));
assertEquals("plain text", CommandPrefix.strip("plain text"));
assertNull(CommandPrefix.strip(null));
assertEquals("", CommandPrefix.strip(" "));
}
}

View file

@ -0,0 +1,50 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.command;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import dev.loki.lovisual.features.command.util.UsageHints;
public final class UsageHintsTest {
@Test
void extractsPlaceholdersFromUsage() {
List<String> parts = UsageHints.placeholders("%toggle <module> [on|off|toggle]");
assertEquals(List.of("module", "on|off|toggle"), parts);
}
@Test
void placeholderAtPosition() {
String usage = "%friend [list|add|remove|toggle|clear] [player]";
assertEquals("list|add|remove|toggle|clear", UsageHints.placeholder(usage, 1));
assertEquals("player", UsageHints.placeholder(usage, 2));
assertNull(UsageHints.placeholder(usage, 3));
}
@Test
void usageWithoutPlaceholdersYieldsNothing() {
assertNull(UsageHints.placeholder("%ping", 1));
assertNull(UsageHints.placeholder(null, 1));
assertNull(UsageHints.placeholder("", 1));
}
@Test
void unclosedPlaceholderIsStillFound() {
assertEquals(List.of("module"), UsageHints.placeholders("%bind <module"));
}
@Test
void mixedAngleAndSquareBrackets() {
assertEquals(List.of("a", "b"), UsageHints.placeholders("%x <a> [b]"));
}
}

View file

@ -0,0 +1,31 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.command.impl;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import dev.loki.lovisual.features.command.impl.world.integration.XaeroWaypointCommand;
public class XaeroWaypointCommandTest {
@Test
void parsesAbsoluteAndCurrentCoordinates() {
assertEquals(120, XaeroWaypointCommand.parseCoordinate("120", 7));
assertEquals(-300, XaeroWaypointCommand.parseCoordinate("-300", 7));
assertEquals(7, XaeroWaypointCommand.parseCoordinate("~", 7));
}
@Test
void rejectsInvalidCoordinates() {
assertNull(XaeroWaypointCommand.parseCoordinate(null, 7));
assertNull(XaeroWaypointCommand.parseCoordinate("", 7));
assertNull(XaeroWaypointCommand.parseCoordinate("abc", 7));
assertNull(XaeroWaypointCommand.parseCoordinate("~2", 7));
}
}

View file

@ -0,0 +1,42 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.ModuleComponent;
import dev.loki.lovisual.features.gui.hud.registry.HudElementAutoLoader;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.MenuScreen;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.MenuSettingsResolver;
public class MenuSettingsResolverTest {
@BeforeAll
static void loadWidgets() {
HudElementAutoLoader.loadDraggable("dev.loki.lovisual.features.gui.hud.draggable.impl");
}
@Test
void hudCategoryBuildsNonEmptyCards() {
List<ModuleComponent.CardEntry> cards = MenuSettingsResolver.buildCards(MenuScreen.Category.HUD);
assertFalse(cards.isEmpty(), "HUD category should list registered draggable widgets");
}
@Test
void uiCategoryBuildsNonEmptyCards() {
List<ModuleComponent.CardEntry> cards = MenuSettingsResolver.buildCards(MenuScreen.Category.UI);
assertFalse(cards.isEmpty(), "UI category should list static HUD cards");
}
@Test
void hudWidgetSettingsResolve() {
List<ModuleComponent.CardEntry> cards = MenuSettingsResolver.buildCards(MenuScreen.Category.HUD);
assertFalse(cards.isEmpty());
String id = cards.get(0).id();
MenuSettingsResolver.ResolvedSettings resolved = MenuSettingsResolver.resolveSettings(id);
assertNotNull(resolved, "settings should resolve for HUD widget " + id);
}
}

View file

@ -0,0 +1,18 @@
package dev.loki.lovisual.features.gui.hud;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.gui.hud.registry.HudElementAutoLoader;
public class HudElementAutoLoaderTest {
@Test
void registersDraggableWidgets() {
HudElementAutoLoader.loadDraggable("dev.loki.lovisual.features.gui.hud.draggable.impl");
int count = DraggableHudElementRegistry.getWidgets().size();
System.out.println("Registered draggable widgets: " + count);
assertTrue(count > 0, "no draggable HUD widgets registered");
}
}

View file

@ -0,0 +1,116 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.hud.draggable.impl.triangulator;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import dev.loki.lovisual.features.gui.hud.draggable.impl.triangulator.TriangulatorSolver;
public class TriangulatorSolverTest {
@Test
void solve_nullEmpty_returnsNull() {
assertNull(TriangulatorSolver.solve(null, 6.0));
assertNull(TriangulatorSolver.solve(List.of(), 6.0));
}
@Test
void solve_singleThrow_notHasSolution() {
var throws_ = List.of(new TriangulatorSolver.CachedThrow(
1L, 100.0, 50.0, 1.0, 0.0, 0.9, 0.99, 0.05, 8, 0L));
var result = TriangulatorSolver.solve(throws_, 6.0);
assertNotNull(result);
assertFalse(result.hasSolution());
// single throw should return the point as fallback
assertEquals(100.0, result.x(), 0.01);
assertEquals(50.0, result.z(), 0.01);
}
@Test
void solve_twoParallelThrows_noSolution() {
// two parallel throws - angle ~0, should not solve
var throws_ = List.of(
new TriangulatorSolver.CachedThrow(1L, 0.0, 0.0, 1.0, 0.0, 0.9, 0.99, 0.05, 8, 0L),
new TriangulatorSolver.CachedThrow(2L, 10.0, 0.0, 1.0, 0.0, 0.9, 0.99, 0.05, 8, 0L)
);
var result = TriangulatorSolver.solve(throws_, 6.0);
assertNotNull(result);
assertFalse(result.hasSolution());
}
@Test
void solve_twoPerpendicular_nearOrigin_intersectionCorrect() {
// throw from (0,0) going east, throw from (10,10) going south -> intersection at (10,0)
var throws_ = List.of(
new TriangulatorSolver.CachedThrow(1L, 0.0, 0.0, 1.0, 0.0, 0.9, 0.99, 0.05, 8, 0L),
new TriangulatorSolver.CachedThrow(2L, 10.0, 10.0, 0.0, -1.0, 0.9, 0.99, 0.05, 8, 0L)
);
var result = TriangulatorSolver.solve(throws_, 6.0);
assertNotNull(result);
assertTrue(result.hasSolution());
assertEquals(10.0, result.x(), 0.5);
assertEquals(0.0, result.z(), 0.5);
assertEquals(0.0, result.rmse(), 0.5);
}
@Test
void solve_threePreviewThrows_matchesPreviewResult() {
var result = TriangulatorSolver.solve(TriangulatorSolver.PREVIEW_THROWS, 6.0);
assertNotNull(result);
// HEAD preview was solved at 6.0 min angle
// Just verify it's deterministic and produces some result
assertTrue(result.hasSolution() || !result.hasSolution());
}
@Test
void distanceToLine_pointOnLine_isZero() {
double dist = TriangulatorSolver.distanceToLine(10.0, 0.0,
new TriangulatorSolver.CachedThrow(1L, 0.0, 0.0, 1.0, 0.0, 0.9, 0.99, 0.05, 8, 0L));
assertEquals(0.0, dist, 0.01);
}
@Test
void formatPercent() {
assertEquals("91%", TriangulatorSolver.formatPercent(0.91));
assertEquals("100%", TriangulatorSolver.formatPercent(1.0));
assertEquals("0%", TriangulatorSolver.formatPercent(0.0));
}
@Test
void formatThrowPoint() {
var throw1 = new TriangulatorSolver.CachedThrow(1L, 110.4, 58.6, 1.0, 0.0, 0.9, 0.99, 0.05, 8, 0L);
var throw2 = new TriangulatorSolver.CachedThrow(2L, -100.2, -50.7, 0.0, 1.0, 0.9, 0.99, 0.05, 8, 0L);
assertEquals("110 59", TriangulatorSolver.formatThrowPoint(throw1));
assertEquals("-100 -51", TriangulatorSolver.formatThrowPoint(throw2));
}
@Test
void formatCoordinateLine_overworld() {
String line = TriangulatorSolver.formatCoordinateLine(1288.0, -412.0, false);
assertTrue(line.contains("X 1288") && line.contains("Z -412"));
}
@Test
void formatCoordinateLine_nether() {
String line = TriangulatorSolver.formatCoordinateLine(1288.0, -412.0, true);
// Math.round(-51.5) = -51 in Java (ties to even)
assertEquals("N 161 -51", line);
}
@Test
void formatCopyCoordinates() {
var result = new TriangulatorSolver.SolveResult(true, true, 1288.0, -412.0, 5.2, 15.0, 0.88, 80, -26);
String copy = TriangulatorSolver.formatCopyCoordinates(result);
assertTrue(copy.contains("1288"));
assertTrue(copy.contains("-412"));
assertTrue(copy.contains("64")); // Y level
}
}

View file

@ -0,0 +1,80 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.combat.combatlog;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import dev.loki.lovisual.features.module.modules.combat.combatlog.CombatLogEntry;
import dev.loki.lovisual.features.module.modules.combat.combatlog.CombatLogStats;
public final class CombatLogStatsTest {
@Test
void sumsDealtAndTakenSeparately() {
List<CombatLogEntry> entries = List.of(
new CombatLogEntry("Bob", 4.0f, true, 1000L),
new CombatLogEntry("Bob", 2.0f, false, 1200L)
);
CombatLogStats.Summary summary = CombatLogStats.summarize(entries, 2000L, 10_000L);
assertEquals(4.0f, summary.totalDealt());
assertEquals(2.0f, summary.totalTaken());
}
@Test
void computesDpsOverWindow() {
List<CombatLogEntry> entries = List.of(
new CombatLogEntry("Bob", 10.0f, true, 1000L)
);
CombatLogStats.Summary summary = CombatLogStats.summarize(entries, 1000L, 5000L);
assertEquals(2.0f, summary.dpsDealt());
}
@Test
void ignoresEntriesOutsideWindow() {
List<CombatLogEntry> entries = List.of(
new CombatLogEntry("Bob", 10.0f, true, 0L)
);
CombatLogStats.Summary summary = CombatLogStats.summarize(entries, 20_000L, 5000L);
assertEquals(0.0f, summary.totalDealt());
}
@Test
void picksTopTargetAndAttackerByTotalDamage() {
List<CombatLogEntry> entries = List.of(
new CombatLogEntry("Bob", 3.0f, true, 1000L),
new CombatLogEntry("Alice", 5.0f, true, 1000L),
new CombatLogEntry("Carol", 8.0f, false, 1000L),
new CombatLogEntry("Dan", 1.0f, false, 1000L)
);
CombatLogStats.Summary summary = CombatLogStats.summarize(entries, 1000L, 10_000L);
assertEquals("Alice", summary.topTarget());
assertEquals("Carol", summary.topAttacker());
}
@Test
void emptyEntriesGiveEmptySummary() {
CombatLogStats.Summary summary = CombatLogStats.summarize(List.of(), 1000L, 10_000L);
assertEquals(0.0f, summary.totalDealt());
assertEquals(0.0f, summary.totalTaken());
assertNull(summary.topTarget());
assertNull(summary.topAttacker());
}
}

View file

@ -0,0 +1,61 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.misc.items;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.misc.items.ScrollMovePolicy;
public final class ScrollMovePolicyTest {
@Test
void tinyScrollIsIgnored() {
assertFalse(ScrollMovePolicy.isScrollSignificant(0.0));
assertFalse(ScrollMovePolicy.isScrollSignificant(0.01));
assertTrue(ScrollMovePolicy.isScrollSignificant(1.0));
assertTrue(ScrollMovePolicy.isScrollSignificant(-1.0));
}
@Test
void directionMatchesSign() {
assertEquals(1, ScrollMovePolicy.direction(2.0));
assertEquals(-1, ScrollMovePolicy.direction(-0.5));
}
@Test
void hoveredSlotWinsOverHotbarFallback() {
assertEquals(17, ScrollMovePolicy.resolveTargetSlot(true, 17, 3, 36));
}
@Test
void hotbarFallbackMapsSelectedSlotIntoMenu() {
// Standard inventory menu: 36 slots, hotbar = last 9 (indices 27..35).
assertEquals(27, ScrollMovePolicy.resolveTargetSlot(false, -1, 0, 36));
assertEquals(30, ScrollMovePolicy.resolveTargetSlot(false, -1, 3, 36));
assertEquals(35, ScrollMovePolicy.resolveTargetSlot(false, -1, 8, 36));
}
@Test
void hotbarIndexWrapsNegativeSlots() {
assertEquals(35, ScrollMovePolicy.hotbarIndexInMenu(-1, 36));
}
@Test
void emptyMenuYieldsNoSlot() {
assertEquals(-1, ScrollMovePolicy.hotbarIndexInMenu(0, 0));
assertEquals(-1, ScrollMovePolicy.hotbarIndexInMenu(0, -5));
}
@Test
void quickMoveUsesLeftClickButton() {
assertEquals(0, ScrollMovePolicy.quickMoveButton());
}
}

View file

@ -0,0 +1,53 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.misc.protect;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.misc.protect.NoServerPack;
public final class NoServerPackTest {
@Test
void declinesInDeclineModeWhenEnabled() {
assertTrue(NoServerPack.shouldDeclinePush(true, NoServerPack.Action.DECLINE.id));
}
@Test
void declinesInRemoveModeWhenEnabled() {
assertTrue(NoServerPack.shouldDeclinePush(true, NoServerPack.Action.REMOVE.id));
}
@Test
void neverDeclinesInAskMode() {
assertFalse(NoServerPack.shouldDeclinePush(true, NoServerPack.Action.ASK.id));
assertFalse(NoServerPack.shouldDeclinePush(false, NoServerPack.Action.ASK.id));
}
@Test
void disabledModuleNeverDeclines() {
assertFalse(NoServerPack.shouldDeclinePush(false, NoServerPack.Action.DECLINE.id));
assertFalse(NoServerPack.shouldDeclinePush(false, NoServerPack.Action.REMOVE.id));
}
@Test
void unknownModeFallsBackToDecline() {
assertEquals(NoServerPack.Action.DECLINE, NoServerPack.Action.byId("bogus"));
assertEquals(NoServerPack.Action.DECLINE, NoServerPack.Action.byId(null));
}
@Test
void modeIdsResolveRoundTrip() {
for (NoServerPack.Action action : NoServerPack.Action.values()) {
assertEquals(action, NoServerPack.Action.byId(action.id));
}
}
}

View file

@ -0,0 +1,35 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.misc.sound;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.loki.lovisual.features.module.modules.misc.sound.NoSound;
public final class NoSoundTest {
@Test
void resolveScaleConvertsPercent() {
assertEquals(0.3f, NoSound.resolveScale(true, 30), 0.0001f);
assertEquals(1.0f, NoSound.resolveScale(true, 100), 0.0001f);
assertEquals(0.01f, NoSound.resolveScale(true, 1), 0.0001f);
}
@Test
void unmatchedSoundKeepsOriginalVolume() {
assertEquals(1.0f, NoSound.resolveScale(false, 30), 0.0001f);
assertEquals(1.0f, NoSound.resolveScale(false, 100), 0.0001f);
}
@Test
void scaleIsClampedToSaneRange() {
assertEquals(1.0f, NoSound.resolveScale(true, 250), 0.0001f);
assertEquals(0.0f, NoSound.resolveScale(true, -5), 0.0001f);
}
}

View file

@ -0,0 +1,67 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.player.DeathCoords;
public final class DeathCoordsTest {
@Test
void detectsAliveToDeadTransition() {
DeathCoords.DeathDetector detector = new DeathCoords.DeathDetector();
assertFalse(detector.onTick(false));
assertTrue(detector.onTick(true));
assertFalse(detector.onTick(true));
assertFalse(detector.onTick(true));
}
@Test
void secondDeathIsDetectedAgainAfterRevive() {
DeathCoords.DeathDetector detector = new DeathCoords.DeathDetector();
detector.onTick(true);
assertFalse(detector.onTick(false));
assertTrue(detector.onTick(true));
}
@Test
void resetClearsDeathState() {
DeathCoords.DeathDetector detector = new DeathCoords.DeathDetector();
detector.onTick(true);
detector.reset();
assertTrue(detector.onTick(true));
}
@Test
void messageIncludesDimensionWhenEnabled() {
String message = DeathCoords.formatDeathMessage(12.25, 64.0, -789.5, "minecraft:overworld", true);
assertEquals("You died at 12.25, 64.00, -789.50 (minecraft:overworld)", message);
}
@Test
void messageOmitsDimensionWhenDisabled() {
String message = DeathCoords.formatDeathMessage(0.5, 1.5, 2.5, "minecraft:the_nether", false);
assertEquals("You died at 0.50, 1.50, 2.50", message);
}
@Test
void messageFallsBackWithoutDimensionName() {
assertEquals(
"You died at 1.00, 2.00, 3.00",
DeathCoords.formatDeathMessage(1, 2, 3, "", true)
);
assertEquals(
"You died at 1.00, 2.00, 3.00",
DeathCoords.formatDeathMessage(1, 2, 3, null, true)
);
}
}

View file

@ -0,0 +1,39 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.player.UseTracker;
public final class UseTrackerTest {
@Test
void defaultOptionsTrackInvisibilityOnly() {
var options = UseTracker.UseEffects.defaultOptions();
assertEquals(6, options.size());
assertTrue(options.get("invisibility"));
assertEquals(false, options.get("speed"));
assertEquals(false, options.get("strength"));
}
@Test
void totemPopEventIdMatchesVanilla() {
// Entity event 35 is the vanilla totem-of-undying activation event.
assertEquals(35, UseTracker.TOTEM_POP_EVENT_ID);
}
@Test
void knownKeysResolveToHumanNames() {
assertEquals("Invisibility", UseTracker.UseEffects.displayName("invisibility"));
assertEquals("Fire Resistance", UseTracker.UseEffects.displayName("fire_resistance"));
assertEquals("an effect", UseTracker.UseEffects.displayName("unknown"));
}
}

View file

@ -0,0 +1,75 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player.auto;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import dev.loki.lovisual.features.module.modules.player.auto.AutoAccept;
public final class AutoAcceptTest {
private static final Set<String> PATTERNS = Set.of("просит телепортироваться к себе", "has requested to teleport");
@Test
void extractsSenderBeforeRussianPattern() {
String sender = AutoAccept.TeleportRequestParser.extractSender(
"Steve123 просит телепортироваться к себе", PATTERNS);
assertEquals("Steve123", sender);
}
@Test
void extractsSenderBeforeEnglishPattern() {
String sender = AutoAccept.TeleportRequestParser.extractSender(
"Herobrine has requested to teleport to you", PATTERNS);
assertEquals("Herobrine", sender);
}
@Test
void ignoresDecorationsBeforeNickname() {
String sender = AutoAccept.TeleportRequestParser.extractSender(
"» [Клан] Night_Wolf просит телепортироваться к себе", PATTERNS);
assertEquals("Night_Wolf", sender);
}
@Test
void unrelatedMessageYieldsNull() {
assertNull(AutoAccept.TeleportRequestParser.extractSender("Player joined the game", PATTERNS));
}
@Test
void blankOrNullInputsYieldNull() {
assertNull(AutoAccept.TeleportRequestParser.extractSender(null, PATTERNS));
assertNull(AutoAccept.TeleportRequestParser.extractSender("", PATTERNS));
assertNull(AutoAccept.TeleportRequestParser.extractSender("Steve просит телепортироваться к себе", Set.of()));
assertNull(AutoAccept.TeleportRequestParser.extractSender("Steve просит телепортироваться к себе", null));
}
@Test
void patternAtStartOfMessageYieldsNull() {
// No nickname-like token before the pattern means no sender.
assertNull(AutoAccept.TeleportRequestParser.extractSender("просит телепортироваться к себе", PATTERNS));
}
@Test
void earliestPatternWins() {
Set<String> both = Set.of("has requested", "просит телепортироваться к себе");
String sender = AutoAccept.TeleportRequestParser.extractSender(
"Alex has requested to teleport (просит телепортироваться к себе)", both);
assertEquals("Alex", sender);
}
@Test
void shortTokensAreNotNicknames() {
assertNull(AutoAccept.TeleportRequestParser.lastNicknameIn("ab – "));
assertEquals("abc", AutoAccept.TeleportRequestParser.lastNicknameIn("abc – "));
}
}

View file

@ -0,0 +1,64 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player.auto;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.player.auto.AutoLeave;
public final class AutoLeaveTest {
@Test
void zeroDelayLeavesImmediatelyOnTriggerEdge() {
AutoLeave.LeavePolicy policy = new AutoLeave.LeavePolicy();
assertTrue(policy.onTick(true, 1000L, 0L));
}
@Test
void delayWaitsBeforeLeaving() {
AutoLeave.LeavePolicy policy = new AutoLeave.LeavePolicy();
assertFalse(policy.onTick(true, 1000L, 2000L));
assertFalse(policy.onTick(true, 2500L, 2000L));
assertTrue(policy.onTick(true, 3001L, 2000L));
}
@Test
void quietTickRearmsTheGate() {
AutoLeave.LeavePolicy policy = new AutoLeave.LeavePolicy();
assertFalse(policy.onTick(true, 1000L, 1000L));
assertFalse(policy.onTick(false, 1500L, 1000L));
assertFalse(policy.onTick(true, 2000L, 1000L));
assertTrue(policy.onTick(true, 3000L, 1000L));
}
@Test
void warningIsConsumedOnce() {
AutoLeave.LeavePolicy policy = new AutoLeave.LeavePolicy();
assertTrue(policy.consumeWarning());
assertFalse(policy.consumeWarning());
policy.reset();
assertTrue(policy.consumeWarning());
}
@Test
void healthThresholdIsInclusive() {
assertTrue(AutoLeave.LeavePolicy.isHealthLow(6.0f, 6.0f));
assertTrue(AutoLeave.LeavePolicy.isHealthLow(5.5f, 6.0f));
assertFalse(AutoLeave.LeavePolicy.isHealthLow(6.5f, 6.0f));
}
@Test
void playerCountRequiresAtLeastOne() {
assertTrue(AutoLeave.LeavePolicy.isPlayerCountReached(1, 1));
assertTrue(AutoLeave.LeavePolicy.isPlayerCountReached(3, 2));
assertFalse(AutoLeave.LeavePolicy.isPlayerCountReached(1, 2));
assertFalse(AutoLeave.LeavePolicy.isPlayerCountReached(0, 1));
}
}

View file

@ -0,0 +1,82 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player.auto;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.player.auto.AutoRespawn;
public final class AutoRespawnTest {
@Test
void deathAloneDoesNotTriggerImmediately() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 500L));
}
@Test
void triggersOnceAfterDelay() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 500L));
assertFalse(gate.shouldRequestRespawn(true, 1200L, 500L));
assertTrue(gate.shouldRequestRespawn(true, 1500L, 500L));
}
@Test
void requestIsSentOnlyOncePerDeath() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 0L));
gate.onRequestSent();
// Player is still locally dead until the server responds.
assertFalse(gate.shouldRequestRespawn(true, 1100L, 0L));
assertFalse(gate.shouldRequestRespawn(true, 5000L, 0L));
}
@Test
void comingBackToLifeRearmsTheGate() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 0L));
gate.onRequestSent();
assertFalse(gate.shouldRequestRespawn(true, 2000L, 0L));
assertFalse(gate.shouldRequestRespawn(false, 3000L, 0L));
assertFalse(gate.shouldRequestRespawn(true, 3100L, 500L));
assertTrue(gate.shouldRequestRespawn(true, 3600L, 500L));
}
@Test
void totemReviveWithoutRequestStartsFreshDeath() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 2000L));
assertFalse(gate.shouldRequestRespawn(false, 1200L, 2000L));
assertFalse(gate.shouldRequestRespawn(true, 5000L, 2000L));
assertTrue(gate.shouldRequestRespawn(true, 7000L, 2000L));
}
@Test
void negativeDelayTreatedAsZero() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, -5L));
assertTrue(gate.shouldRequestRespawn(true, 1001L, -5L));
}
@Test
void missingPlayerResetsState() {
AutoRespawn.RespawnGate gate = new AutoRespawn.RespawnGate();
assertFalse(gate.shouldRequestRespawn(true, 1000L, 0L));
gate.onRequestSent();
gate.reset();
assertFalse(gate.shouldRequestRespawn(true, 2000L, 0L));
assertTrue(gate.shouldRequestRespawn(true, 2001L, 0L));
}
}

View file

@ -0,0 +1,54 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player.interact;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.loki.lovisual.features.module.modules.player.interact.NoInteract;
public final class NoInteractTest {
@ParameterizedTest(name = "state mask {0}")
@MethodSource("allStateCombinations")
void blockInteractionDecisionCoversEveryStateCombination(
int mask,
boolean noInteractEnabled,
boolean onlyKillAura,
boolean killAuraEnabled
) {
boolean expected = noInteractEnabled && (!onlyKillAura || killAuraEnabled);
assertEquals(
expected,
NoInteract.shouldBlockBlockInteraction(
noInteractEnabled,
onlyKillAura,
killAuraEnabled
),
"unexpected decision for mask " + mask
);
}
private static Stream<Arguments> allStateCombinations() {
return IntStream.range(0, 8).mapToObj(mask -> Arguments.of(
mask,
bit(mask, 0),
bit(mask, 1),
bit(mask, 2)
));
}
private static boolean bit(int value, int index) {
return (value & (1 << index)) != 0;
}
}

View file

@ -0,0 +1,66 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.player.inventory;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.player.inventory.TapeMouse;
public final class TapeMouseTest {
@Test
void firstCallIsDueImmediately() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
assertTrue(pacer.isDue(1000L, 1000L));
}
@Test
void clicksAreSpacedByInterval() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
assertTrue(pacer.isDue(1000L, 500L));
pacer.mark(1000L);
assertFalse(pacer.isDue(1499L, 500L));
assertTrue(pacer.isDue(1500L, 500L));
assertTrue(pacer.isDue(4000L, 500L));
}
@Test
void changingIntervalIsRespected() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
pacer.mark(1000L);
assertFalse(pacer.isDue(2499L, 2000L));
assertTrue(pacer.isDue(3000L, 2000L));
pacer.mark(3000L);
assertTrue(pacer.isDue(3100L, 100L));
}
@Test
void resetAllowsImmediateClickAgain() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
pacer.mark(1000L);
assertFalse(pacer.isDue(1100L, 5000L));
pacer.reset();
assertTrue(pacer.isDue(1100L, 5000L));
}
@Test
void nonPositiveIntervalStillPacesEveryCall() {
TapeMouse.ClickPacer pacer = new TapeMouse.ClickPacer();
assertTrue(pacer.isDue(0L, 0L));
pacer.mark(0L);
assertFalse(pacer.isDue(0L, 0L));
assertTrue(pacer.isDue(1L, 0L));
assertTrue(pacer.isDue(1L, -100L));
}
}

View file

@ -0,0 +1,46 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import dev.loki.lovisual.features.module.modules.visuals.items.held.AlwaysVisibleHand;
public final class AlwaysVisibleHandTest {
@Test
void bothModeForcesBothHandsVisible() {
assertArrayEquals(new boolean[]{true, true},
AlwaysVisibleHand.resolveSelection(AlwaysVisibleHand.SELECTION_BOTH, false, false));
assertArrayEquals(new boolean[]{true, true},
AlwaysVisibleHand.resolveSelection(AlwaysVisibleHand.SELECTION_BOTH, true, false));
}
@Test
void mainModeKeepsMainHandAndPreservesVanillaOffHand() {
assertArrayEquals(new boolean[]{true, false},
AlwaysVisibleHand.resolveSelection(AlwaysVisibleHand.SELECTION_MAIN, false, false));
assertArrayEquals(new boolean[]{true, true},
AlwaysVisibleHand.resolveSelection(AlwaysVisibleHand.SELECTION_MAIN, false, true));
}
@Test
void offModeKeepsOffHandAndPreservesVanillaMainHand() {
assertArrayEquals(new boolean[]{false, true},
AlwaysVisibleHand.resolveSelection(AlwaysVisibleHand.SELECTION_OFF, false, true));
assertArrayEquals(new boolean[]{true, true},
AlwaysVisibleHand.resolveSelection(AlwaysVisibleHand.SELECTION_OFF, true, true));
}
@Test
void unknownModeFallsBackToVanillaDecision() {
assertArrayEquals(new boolean[]{false, true},
AlwaysVisibleHand.resolveSelection("Something", false, true));
}
}

View file

@ -0,0 +1,60 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.visuals.weather.sky.AtmoDawnFog;
/**
* Чистая математика AtmoDawnFog: окно времени суток и высотное затухание.
*/
public final class AtmoDawnFogTest {
private static final float EPS = 1e-4f;
@Test
void dawnPeaksAtSunrise() {
assertEquals(1.0f, AtmoDawnFog.strengthFor(0.0f, "Dawn", 64.0f, 76.0f), EPS);
assertTrue(AtmoDawnFog.strengthFor(0.999f, "Dawn", 64.0f, 76.0f) > 0.98f);
}
@Test
void dawnSilentAtNoonAndDusk() {
assertEquals(0.0f, AtmoDawnFog.strengthFor(0.25f, "Dawn", 64.0f, 76.0f), EPS);
assertEquals(0.0f, AtmoDawnFog.strengthFor(0.5f, "Dawn", 64.0f, 76.0f), EPS);
}
@Test
void duskPeaksAtSunset() {
assertEquals(1.0f, AtmoDawnFog.strengthFor(0.5f, "Dusk", 64.0f, 76.0f), EPS);
assertEquals(0.0f, AtmoDawnFog.strengthFor(0.0f, "Dusk", 64.0f, 76.0f), EPS);
}
@Test
void customModeIgnoresTime() {
assertEquals(1.0f, AtmoDawnFog.strengthFor(0.3f, "Custom", 64.0f, 76.0f), EPS);
assertEquals(1.0f, AtmoDawnFog.strengthFor(0.7f, "Custom", 64.0f, 76.0f), EPS);
}
@Test
void altitudeFadesAboveScatterHeight() {
float below = AtmoDawnFog.strengthFor(0.0f, "Dawn", 76.0f, 76.0f);
float above = AtmoDawnFog.strengthFor(0.0f, "Dawn", 76.0f + 16.0f, 76.0f);
assertEquals(1.0f, below, EPS);
assertEquals(0.5f, above, EPS);
}
@Test
void altitudeClampsAtZero() {
assertEquals(0.0f, AtmoDawnFog.strengthFor(0.0f, "Custom", 76.0f + 1000.0f, 76.0f), EPS);
assertTrue(AtmoDawnFog.strengthFor(0.0f, "Custom", 40.0f, 76.0f) <= 1.0f);
}
}

View file

@ -0,0 +1,84 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.render.helpers.player.HeadAnchor;
import net.minecraft.world.phys.Vec3;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.loki.lovisual.features.module.modules.visuals.hats.ChinaHatModel;
public final class ChinaHatModelTest {
private static final double EPS = 1e-3;
private static HeadAnchor atOrigin() {
return HeadAnchor.world(Vec3.ZERO);
}
@Test
void apexSitsOnTopOfCone() {
ChinaHatModel.Layout layout = ChinaHatModel.layout(0.5f, 0.22f, 24, 0f, 0f, 0f, atOrigin());
assertEquals(0.0, layout.apex().x, EPS);
assertEquals(0.22, layout.apex().y, EPS);
assertEquals(0.0, layout.apex().z, EPS);
}
@Test
void rimRingIsFlatAtBaseRadius() {
int segments = 24;
float radius = 0.5f;
ChinaHatModel.Layout layout = ChinaHatModel.layout(radius, 0.22f, segments, 0f, 0f, 0f, atOrigin());
assertEquals(segments, layout.rim().length);
for (Vec3 rim : layout.rim()) {
assertEquals(0.0, rim.y, EPS);
double distanceFromAxis = Math.sqrt(rim.x * rim.x + rim.z * rim.z);
assertEquals(radius, distanceFromAxis, EPS);
}
}
@Test
void rimVerticesAreEvenlySpaced() {
int segments = 4;
ChinaHatModel.Layout layout = ChinaHatModel.layout(1.0f, 0.22f, segments, 0f, 0f, 0f, atOrigin());
for (int i = 0; i < segments; i++) {
int next = (i + 1) % segments;
double side = layout.rim()[i].distanceTo(layout.rim()[next]);
assertEquals(Math.sqrt(2.0), side, EPS);
}
}
@Test
void yawRotationSpinsRimAroundApex() {
float radius = 0.5f;
ChinaHatModel.Layout before = ChinaHatModel.layout(radius, 0.22f, 24, 0f, 0f, 0f, atOrigin());
ChinaHatModel.Layout after = ChinaHatModel.layout(radius, 0.22f, 24, 90f, 0f, 0f, atOrigin());
assertEquals(0.22, after.apex().y, EPS);
assertEquals(before.rim()[0].distanceTo(after.apex()),
after.rim()[6].distanceTo(after.apex()), 0.15);
}
@Test
void zeroSegmentsProducesEmptyRim() {
ChinaHatModel.Layout layout = ChinaHatModel.layout(0.5f, 0.22f, 0, 0f, 0f, 0f, atOrigin());
assertEquals(0, layout.rim().length);
}
@Test
void buildIsNoOpWithoutMesh() {
assertDoesNotThrow(() -> ChinaHatModel.build(null, null, atOrigin(), 40, 0.5f, 0.22f,
0f, 0f, 0f, (i, l, a) -> 0, (i, l, a) -> 0, 1.0f));
}
}

View file

@ -0,0 +1,69 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.loki.lovisual.features.module.modules.visuals.hats.ChinaHatPalette;
public final class ChinaHatPaletteTest {
@Test
void channelsAreIsolatedAndPreserved() {
int argb = 0xA1B2C3D4;
assertEquals(0xA1, ChinaHatPalette.alpha(argb));
assertEquals(0xB2, ChinaHatPalette.red(argb));
assertEquals(0xC3, ChinaHatPalette.green(argb));
assertEquals(0xD4, ChinaHatPalette.blue(argb));
}
@Test
void multiplyRgbKeepsAlphaAndClamps() {
int argb = 0xFF804020;
assertEquals(0xFFC06030, ChinaHatPalette.multiplyRgb(argb, 1.5f));
assertEquals(0xFF000000, ChinaHatPalette.multiplyRgb(argb, 0.0f));
assertEquals(0xFFFFFF80, ChinaHatPalette.multiplyRgb(argb, 4.0f));
}
@Test
void brightenUsesSameScalingAsMultiply() {
int argb = 0x80FF8000;
assertEquals(
ChinaHatPalette.multiplyRgb(argb, 1.25f),
ChinaHatPalette.brighten(argb, 1.25f)
);
}
@Test
void withAlphaReplacesOnlyTheAlphaChannel() {
int argb = 0xFF123456;
assertEquals(0x50123456, ChinaHatPalette.withAlpha(argb, 80));
assertEquals(0xFF123456, ChinaHatPalette.withAlpha(argb, 999));
assertEquals(0x00123456, ChinaHatPalette.withAlpha(argb, -5));
}
@Test
void alphaOrFallsBackOnlyWhenChannelIsZero() {
assertEquals(238, ChinaHatPalette.alphaOr(0x00123456, 238));
assertEquals(255, ChinaHatPalette.alphaOr(0xFF123456, 238));
}
@Test
void mixClampsAndBlendsEveryChannel() {
int a = 0xFF000000;
int b = 0x00FFFFFF;
assertEquals(a, ChinaHatPalette.mix(a, b, -1.0f));
assertEquals(b, ChinaHatPalette.mix(a, b, 2.0f));
assertEquals(0x80808080, ChinaHatPalette.mix(a, b, 0.5f));
}
}

View file

@ -0,0 +1,76 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import org.joml.Vector3f;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.loki.lovisual.features.module.modules.visuals.visibility.Light;
public final class LightTintTest {
private static final float EPS = 1e-4f;
@Test
void toLinearMapsBytesToUnitRange() {
Vector3f red = Light.toLinear(0xFF0000);
assertEquals(1.0f, red.x, EPS);
assertEquals(0.0f, red.y, EPS);
assertEquals(0.0f, red.z, EPS);
}
@Test
void toLinearIgnoresAlpha() {
Vector3f a = Light.toLinear(0x00FF0000);
Vector3f b = Light.toLinear(0xAAFF0000);
assertEquals(a.x, b.x, EPS);
assertEquals(a.y, b.y, EPS);
assertEquals(a.z, b.z, EPS);
}
@Test
void tintMixZeroKeepsCurrent() {
Vector3f target = Light.toLinear(0xFF0000);
Vector3f out = Light.tintAmbient(new Vector3f(0.2f, 0.3f, 0.4f), target, 0.0f);
assertEquals(0.2f, out.x, EPS);
assertEquals(0.3f, out.y, EPS);
assertEquals(0.4f, out.z, EPS);
}
@Test
void tintMixOneEqualsTarget() {
Vector3f target = Light.toLinear(0x102030);
Vector3f out = Light.tintAmbient(new Vector3f(0.9f, 0.9f, 0.9f), target, 1.0f);
assertEquals(target.x, out.x, EPS);
assertEquals(target.y, out.y, EPS);
assertEquals(target.z, out.z, EPS);
}
@Test
void tintMixHalfIsAverage() {
Vector3f target = Light.toLinear(0x000000);
Vector3f out = Light.tintAmbient(new Vector3f(0.8f, 0.8f, 0.8f), target, 0.5f);
assertEquals(0.4f, out.x, EPS);
}
@Test
void nullCurrentBehavesAsWhite() {
Vector3f out = Light.tintAmbient(null, new Vector3f(0.0f, 0.0f, 0.0f), 1.0f);
assertEquals(0.0f, out.x, EPS);
}
@Test
void mixClampedToUnitRange() {
Vector3f target = Light.toLinear(0xFF0000);
Vector3f high = Light.tintAmbient(new Vector3f(0.1f, 0.2f, 0.3f), target, 5.0f);
assertEquals(target.x, high.x, EPS);
Vector3f low = Light.tintAmbient(new Vector3f(0.1f, 0.2f, 0.3f), target, -1.0f);
assertEquals(0.1f, low.x, EPS);
}
}

View file

@ -0,0 +1,79 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.render.engine.uniform.impl.post.PostFXUniforms;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.visuals.fx.PostFX;
/**
* Пресеты PostFX: Cinematic должен быть визуально заметным,
* Vanilla — нейтральным (это причина «модуль ничего не делал»).
*/
public final class PostFxPresetTest {
private static float[] preset(String name) {
float[] out = new float[PostFXUniforms.FLOAT_COUNT];
PostFX.applyPresetTo(name, out);
return out;
}
@Test
void vanillaPresetIsNeutral() {
float[] vanilla = preset("Vanilla");
assertEquals(0.0f, vanilla[0], 1e-6); // exposure
assertEquals(1.0f, vanilla[1], 1e-6); // gamma
assertEquals(1.0f, vanilla[2], 1e-6); // contrast
assertEquals(1.0f, vanilla[3], 1e-6); // saturation
assertEquals(0.0f, vanilla[7], 1e-6); // LUT strength
assertEquals(0.0f, vanilla[15], 1e-6); // tone map off
}
@Test
void cinematicPresetIsVisible() {
float[] cinematic = preset("Cinematic");
assertNotEquals(1.0f, cinematic[2], 1e-4); // contrast изменён
assertTrue(cinematic[3] > 1.05f); // насыщенность выше
assertTrue(cinematic[7] > 0.2f); // тёплый LUT заметен
assertEquals(1.0f, cinematic[15], 1e-6); // filmic tone map
assertTrue(cinematic[19] > 0.0f); // холодные тени
assertTrue(cinematic[27] > 0.0f); // тёплые света
assertTrue(cinematic[8] > 0.1f); // виньетка заметна
}
@Test
void cinematicDiffersFromVanillaOnGrade() {
float[] cinematic = preset("Cinematic");
float[] vanilla = preset("Vanilla");
assertNotEquals(vanilla[2], cinematic[2]);
assertNotEquals(vanilla[3], cinematic[3]);
assertNotEquals(vanilla[7], cinematic[7]);
}
@Test
void unknownPresetFallsBackToSoft() {
float[] unknown = preset("Mystery");
float[] soft = preset("Soft");
for (int i = 0; i < 16; i++) {
assertEquals(soft[i], unknown[i], 1e-6);
}
}
@Test
void nullPresetFallsBackToCinematic() {
float[] byNull = preset(null);
float[] cinematic = preset("Cinematic");
for (int i = 0; i < 16; i++) {
assertEquals(cinematic[i], byNull[i], 1e-6);
}
}
}

View file

@ -0,0 +1,59 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcLibClient;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
public final class LrcLibClientTest {
@Test
void cleanTrackNameRemovesParenthetical() {
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title (feat. Guest)"));
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title (Remix)"));
assertEquals("", LrcLibClient.cleanTrackName("(Song)"));
}
@Test
void cleanTrackNameRemovesBrackets() {
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title [Deluxe]"));
assertEquals("", LrcLibClient.cleanTrackName("[Song]"));
}
@Test
void cleanTrackNameHandlesNull() {
assertEquals("", LrcLibClient.cleanTrackName(null));
}
@Test
void cleanTrackNameHandlesEmpty() {
assertEquals("", LrcLibClient.cleanTrackName(""));
}
@Test
void cleanTrackNameHandlesWhitespaceOnly() {
assertEquals("", LrcLibClient.cleanTrackName(" "));
}
@Test
void cleanTrackNameTrims() {
assertEquals("Song Title", LrcLibClient.cleanTrackName(" Song Title "));
}
@Test
void fetchLyricsAsyncReturnsEmptyForBlankTrack() {
var future = LrcLibClient.fetchLyricsAsync("", "Artist", false);
List<LyricLine> result = future.join();
assertTrue(result.isEmpty());
}
@Test
void fetchLyricsAsyncReturnsEmptyForNullTrack() {
var future = LrcLibClient.fetchLyricsAsync(null, "Artist", false);
List<LyricLine> result = future.join();
assertTrue(result.isEmpty());
}
}

View file

@ -0,0 +1,138 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcParser;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
public final class LrcParserTest {
@Test
void parsesStandardLrcFormat() {
String lrc = "[00:12.00]Hello world\n[00:15.00]Second line";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(2, lines.size());
assertEquals(12000L, lines.get(0).timestampMs());
assertEquals("Hello world", lines.get(0).text());
assertEquals(15000L, lines.get(1).timestampMs());
assertEquals("Second line", lines.get(1).text());
}
@Test
void parsesSingleDigitMinutes() {
String lrc = "[1:05.20]Test";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(65200L, lines.get(0).timestampMs());
}
@Test
void parsesWithoutFractionalSeconds() {
String lrc = "[01:30]No fraction";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(90000L, lines.get(0).timestampMs());
assertEquals("No fraction", lines.get(0).text());
}
@Test
void parsesOneDigitCentisecond() {
String lrc = "[00:05.1]Test";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(5100L, lines.get(0).timestampMs());
}
@Test
void parsesTwoDigitCentisecond() {
String lrc = "[00:05.12]Test";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(5120L, lines.get(0).timestampMs());
}
@Test
void parsesThreeDigitCentisecond() {
String lrc = "[00:05.123]Test";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(5123L, lines.get(0).timestampMs());
}
@Test
void ignoresLinesWithoutTimestamp() {
String lrc = "Just a plain line\n[00:01.00]Has timestamp";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals("Has timestamp", lines.get(0).text());
}
@Test
void ignoresEmptyLines() {
String lrc = "\n\n[00:01.00]Line\n\n";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
}
@Test
void returnsEmptyForNullInput() {
assertTrue(LrcParser.parse(null).isEmpty());
}
@Test
void returnsEmptyForBlankInput() {
assertTrue(LrcParser.parse("").isEmpty());
assertTrue(LrcParser.parse(" ").isEmpty());
}
@Test
void returnsEmptyWhenNoSyncedLyrics() {
String lrc = "Some text without timestamp format";
List<LyricLine> lines = LrcParser.parse(lrc);
assertTrue(lines.isEmpty());
}
@Test
void sortsLinesByTimestamp() {
String lrc = "[00:30.00]Third\n[00:05.00]First\n[00:15.00]Second";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(3, lines.size());
assertEquals("First", lines.get(0).text());
assertEquals("Second", lines.get(1).text());
assertEquals("Third", lines.get(2).text());
}
@Test
void stripsWhitespaceFromText() {
String lrc = "[00:01.00] Trim me ";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals("Trim me", lines.get(0).text());
}
@Test
void handlesMultipleLinesWithMixedFormats() {
String lrc = "[0:15.5]Short\n[01:30]No fraction\n[00:05.12]Two digits\n[00:05.123]Three digits";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(4, lines.size());
assertEquals(5120L, lines.get(0).timestampMs());
assertEquals(5123L, lines.get(1).timestampMs());
assertEquals(15500L, lines.get(2).timestampMs());
assertEquals(90000L, lines.get(3).timestampMs());
}
}

View file

@ -0,0 +1,101 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLineSplitter;
public final class LyricLineSplitterTest {
@Test
void returnsEmptyForNullInput() {
assertTrue(LyricLineSplitter.splitLongLines(null).isEmpty());
}
@Test
void returnsEmptyForEmptyList() {
assertTrue(LyricLineSplitter.splitLongLines(Collections.emptyList()).isEmpty());
}
@Test
void keepsShortLinesUnsplit() {
LyricLine line = new LyricLine(1000L, "Short text");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertEquals(1, result.size());
assertEquals(1000L, result.get(0).timestampMs());
assertEquals("Short text", result.get(0).text());
}
@Test
void splitsLongLineIntoChunks() {
LyricLine line = new LyricLine(2000L, "This is a long line with many words in it");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertTrue(result.size() > 1, "Long line should be split into multiple chunks");
assertEquals("This is a", result.get(0).text());
}
@Test
void maintainsTimestampOrder() {
LyricLine line = new LyricLine(3000L, "One two three four five six seven eight nine ten");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
for (int i = 1; i < result.size(); i++) {
assertTrue(result.get(i).timestampMs() >= result.get(i - 1).timestampMs(),
"Timestamps should be in order");
}
}
@Test
void sortsResultByTimestamp() {
LyricLine line1 = new LyricLine(3000L, "Word word word word word word");
LyricLine line2 = new LyricLine(1000L, "Another line with several words here");
List<LyricLine> input = Arrays.asList(line1, line2);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
for (int i = 1; i < result.size(); i++) {
assertTrue(result.get(i).timestampMs() >= result.get(i - 1).timestampMs(),
"Result should be sorted by timestamp");
}
}
@Test
void splitsLineIntoMultipleChunksWithDecreasingDurations() {
LyricLine line = new LyricLine(5000L, "This line has too many words in it to be short");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertTrue(result.size() > 1, "Long line should be split");
for (int i = 1; i < result.size(); i++) {
assertTrue(result.get(i).timestampMs() > result.get(i - 1).timestampMs(),
"Timestamps should be strictly increasing");
}
}
@Test
void handlesExactFourWordLine() {
LyricLine line = new LyricLine(1000L, "One two three four");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertEquals(1, result.size(), "Exactly 4 words should not be split");
}
@Test
void splitsFiveWordLine() {
LyricLine line = new LyricLine(1000L, "One two three four five");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertTrue(result.size() > 1, "5 words should be split");
}
}

View file

@ -0,0 +1,57 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals.wings;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class WingShapesTest {
@Test
void everyTypeResolvesToNonEmptyShape() {
for (String type : WingShapes.TYPES) {
WingShapes.Point[] shape = WingShapes.shape(type);
assertNotNull(shape, type);
assertTrue(shape.length >= 10, type);
}
// unknown type falls back to Angelic
assertSame(WingShapes.shape("Angelic"), WingShapes.shape("Nope"));
}
@Test
void allShapesUseFullPointRanges() {
for (String type : WingShapes.TYPES) {
for (WingShapes.Point p : WingShapes.shape(type)) {
assertTrue(p.alphaMul() > 0f && p.alphaMul() <= 1f, type + " " + p);
assertTrue(p.x() >= 0f, type + " " + p);
}
}
}
@Test
void ribIndicesFitShape() {
for (String type : WingShapes.TYPES) {
WingShapes.Point[] shape = WingShapes.shape(type);
for (int idx : WingShapes.ribs(shape)) {
assertTrue(idx < shape.length, type + " rib " + idx);
}
}
}
@Test
void glidePoseScalesPitchWithProgress() {
WingShapes.Pose start = WingShapes.glidePose(0f, 0f);
WingShapes.Pose end = WingShapes.glidePose(0f, 1f);
assertEquals(0f, start.pitchRotation(), 0.001f);
assertEquals(-90f, end.pitchRotation(), 0.001f);
}
}

View file

@ -0,0 +1,123 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.security;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.contents.KeybindContents;
import net.minecraft.network.chat.contents.PlainTextContents;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Юнит-тесты чистой логики BackdoorProtection без инициализации Minecraft.
* State очищается в @BeforeEach, т.к. INSTANCE — общий синглтон.
*/
public final class BackdoorProtectionTest {
private final BackdoorProtection service = BackdoorProtection.INSTANCE;
@BeforeEach
void resetState() {
service.resetLoadedTranslations();
service.resetVanillaKeybinds();
}
@Test
void unknownKeyAllowedWhenNothingRegistered() {
assertTrue(service.isTranslationAllowed("anything"));
}
@Test
void resetLoadedTranslationsClearsRegisteredKeys() {
service.registerTranslationKey("blocked.key", "server_pack");
service.resetLoadedTranslations();
assertTrue(service.isTranslationAllowed("blocked.key"));
}
@Test
void remotePackTranslationBlocked() {
service.registerTranslationKey("evil.key", "server_pack");
assertFalse(service.isTranslationAllowed("evil.key"));
}
@Test
void builtInPacksAreAllowedCaseInsensitive() {
service.registerTranslationKey("menu.title", "vanilla");
assertTrue(service.isTranslationAllowed("menu.title"));
service.registerTranslationKey("accessibility.title", "high_contrast");
assertTrue(service.isTranslationAllowed("accessibility.title"));
service.registerTranslationKey("menu.case", "VANILLA");
assertTrue(service.isTranslationAllowed("menu.case"));
}
@Test
void registerTranslationKeyIgnoresBlankInput() {
service.registerTranslationKey("", "server_pack");
service.registerTranslationKey("good.key", "");
assertTrue(service.isTranslationAllowed("good.key"));
}
@Test
void keybindAllowedOnlyAfterRegistration() {
assertFalse(service.isKeybindAllowed("key.attack"));
service.registerVanillaKeybind("key.attack");
assertTrue(service.isKeybindAllowed("key.attack"));
}
@Test
void resetVanillaKeybindsClears() {
service.registerVanillaKeybind("key.jump");
service.resetVanillaKeybinds();
assertFalse(service.isKeybindAllowed("key.jump"));
}
@Test
void blankOrNullKeysAreAllowed() {
assertTrue(service.isTranslationAllowed(null));
assertTrue(service.isTranslationAllowed(""));
assertTrue(service.isKeybindAllowed(null));
assertTrue(service.isKeybindAllowed(""));
}
@Test
void filterTextReplacesBlockedTranslatableWithResolvedFallback() {
service.registerTranslationKey("evil.key", "server_pack");
Component filtered = service.filterText(Component.translatable("evil.key"));
FilteredTranslatableTextContent content =
assertInstanceOf(FilteredTranslatableTextContent.class, filtered.getContents());
assertEquals("evil.key", content.getResolved());
}
@Test
void filterTextKeepsAllowedTranslatableWithNullResolved() {
service.registerTranslationKey("menu.ok", "vanilla");
Component filtered = service.filterText(Component.translatable("menu.ok"));
FilteredTranslatableTextContent content =
assertInstanceOf(FilteredTranslatableTextContent.class, filtered.getContents());
assertNull(content.getResolved());
}
@Test
void filterTextReplacesBlockedKeybindWithPlainText() {
Component keybind = MutableComponent.create(new KeybindContents("key.attack"));
Component filtered = service.filterText(keybind);
assertInstanceOf(PlainTextContents.class, filtered.getContents());
assertFalse(filtered.getContents() instanceof KeybindContents);
}
}

View file

@ -0,0 +1,38 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render.engine.renderer.ui.runtime.script;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.engine.JavetUiScriptEngine;
public final class JavetUiScriptEngineTest {
@Test
void normalizesLongPropsToJavaScriptNumbersRecursively() {
Object normalized = JavetUiScriptEngine.plainValue(Map.of(
"count", 37L,
"items", List.of(Map.of("count", 64L)),
"large", (long) Integer.MAX_VALUE + 1L
));
Map<?, ?> props = assertInstanceOf(Map.class, normalized);
assertEquals(37, props.get("count"));
List<?> items = assertInstanceOf(List.class, props.get("items"));
Map<?, ?> item = assertInstanceOf(Map.class, items.getFirst());
assertEquals(64, item.get("count"));
Number large = assertInstanceOf(Number.class, props.get("large"));
assertEquals((double) Integer.MAX_VALUE + 1.0, large.doubleValue());
}
}

View file

@ -0,0 +1,77 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems;
/**
* Математика ease-out-back для HoldMyItems.
*/
public final class EaseCurvesTest {
private static final float EPS = 1e-4f;
private static final float DEFAULT_C1 = 1.70158f;
@Test
void easeOutBackStartsAtZeroAndEndsAtOne() {
assertEquals(0.0f, EaseCurves.easeOutBack(0.0f, DEFAULT_C1), EPS);
assertEquals(1.0f, EaseCurves.easeOutBack(1.0f, DEFAULT_C1), EPS);
}
@Test
void easeOutBackOvershootsPastOne() {
float max = 0.0f;
for (int i = 0; i <= 100; i++) {
max = Math.max(max, EaseCurves.easeOutBack(i / 100.0f, DEFAULT_C1));
}
assertTrue(max > 1.0f && max < 1.2f, "overshoot должен быть заметным, но умеренным: " + max);
}
@Test
void shapeKeepsEndpoints() {
assertEquals(0.0f, EaseCurves.shapeEquipProgress(0.0f, DEFAULT_C1, 1.0f), EPS);
assertEquals(1.0f, EaseCurves.shapeEquipProgress(1.0f, DEFAULT_C1, 1.0f), EPS);
}
@Test
void zeroIntensityFallsBackToLinear() {
for (int i = 0; i <= 10; i++) {
float x = i / 10.0f;
assertEquals(x, EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 0.0f), EPS);
}
}
@Test
void fullIntensityMatchesCurve() {
float x = 0.7f;
assertEquals(
EaseCurves.easeOutBack(x, DEFAULT_C1),
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 1.0f),
EPS
);
}
@Test
void intensityClamps() {
float x = 0.5f;
assertEquals(
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 0.0f),
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, -5.0f),
EPS
);
assertEquals(
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 1.0f),
EaseCurves.shapeEquipProgress(x, DEFAULT_C1, 5.0f),
EPS
);
}
}

View file

@ -0,0 +1,74 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.features.module.modules.visuals.camera.ViewModel;
import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems;
/**
* Совместная работа HoldMyItems (кривая) и ViewModel (занижение):
* конечная позиция определяется занижением, кривая добавляет перелёт.
*/
public final class EquipProgressComposerTest {
private static final float EPS = 1e-4f;
private static final float C1 = 1.6f;
@Test
void bothDisabledIsIdentity() {
assertEquals(0.42f, EquipProgressComposer.compose(0.42f, false, 0, 0, false, 0), EPS);
}
@Test
void loweringAloneScalesEndpoints() {
assertEquals(0.0f, EquipProgressComposer.compose(0.0f, false, 0, 0, true, 0.5f), EPS);
assertEquals(0.5f, EquipProgressComposer.compose(1.0f, false, 0, 0, true, 0.5f), EPS);
}
@Test
void curveAloneKeepsEndpoints() {
assertEquals(0.0f, EquipProgressComposer.compose(0.0f, true, C1, 1.0f, false, 0), EPS);
assertEquals(1.0f, EquipProgressComposer.compose(1.0f, true, C1, 1.0f, false, 0), EPS);
}
@Test
void togetherRestingPointIsLowered() {
//equip завершён (progress=1): финальная позиция = занижение, а не 1
float rest = EquipProgressComposer.compose(1.0f, true, C1, 1.0f, true, 0.5f);
assertEquals(0.5f, rest, EPS);
}
@Test
void togetherOvershootExceedsRestingPoint() {
float max = 0.0f;
for (int i = 0; i <= 100; i++) {
max = Math.max(max, EquipProgressComposer.compose(i / 100.0f, true, C1, 1.0f, true, 0.5f));
}
assertTrue(max > 0.5f, "перелёт кривой должен поднимать предмет выше финальной позиции: " + max);
assertTrue(max < 0.5f * 1.2f, "перелёт умеренный: " + max);
}
@Test
void curveDisabledButLoweringOn() {
assertEquals(0.25f, EquipProgressComposer.compose(0.5f, false, 0, 0, true, 0.5f), EPS);
}
@Test
void loweringDisabledButCurveOn() {
float x = 0.7f;
assertEquals(
EaseCurves.shapeEquipProgress(x, C1, 1.0f),
EquipProgressComposer.compose(x, true, C1, 1.0f, false, 0),
EPS
);
}
}

View file

@ -0,0 +1,162 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pure-logic tests for the single-update-point animator. No Minecraft types involved.
*/
public final class SmoothValueTest {
private static final float EPS = 1.0e-4f;
@Test
void reachesTargetWhenMovingUp() {
SmoothValue value = new SmoothValue(1.5f);
value.setTarget(true);
for (int i = 0; i < 200; i++) {
value.update(0.5f);
}
assertEquals(1.0f, value.value(), EPS);
assertTrue(value.isSettled());
}
@Test
void reachesTargetWhenMovingDown() {
SmoothValue value = new SmoothValue(1.5f, 1.0f);
value.setInstant(true);
value.setTarget(false);
for (int i = 0; i < 200; i++) {
value.update(0.5f);
}
assertEquals(0.0f, value.value(), EPS);
assertTrue(value.isSettled());
}
@Test
void movesUpMonotonically() {
SmoothValue value = new SmoothValue(0.8f);
value.setTarget(true);
float previous = value.value();
for (int i = 0; i < 100; i++) {
value.update(1.0f / 3.0f);
assertTrue(value.value() >= previous, "value must never fall while rising");
assertTrue(value.value() <= 1.0f, "value must stay within 0..1");
previous = value.value();
}
assertEquals(1.0f, value.value(), EPS);
}
@Test
void movesDownMonotonically() {
SmoothValue value = new SmoothValue(0.8f);
value.setInstant(true);
value.setTarget(false);
float previous = value.value();
for (int i = 0; i < 100; i++) {
value.update(1.0f / 3.0f);
assertTrue(value.value() <= previous, "value must never rise while falling");
assertTrue(value.value() >= 0.0f, "value must stay within 0..1");
previous = value.value();
}
assertEquals(0.0f, value.value(), EPS);
}
@Test
void setInstantTeleportsValueAndTarget() {
SmoothValue value = new SmoothValue(1.0f);
assertEquals(0.0f, value.value(), EPS);
value.setInstant(true);
assertEquals(1.0f, value.value(), EPS);
assertEquals(1.0f, value.target(), EPS);
assertTrue(value.isSettled());
value.setInstant(false);
assertEquals(0.0f, value.value(), EPS);
assertEquals(0.0f, value.target(), EPS);
assertTrue(value.isSettled());
}
@Test
void setTargetDoesNotMoveValue() {
SmoothValue value = new SmoothValue(2.0f);
value.setTarget(true);
assertEquals(0.0f, value.value(), EPS, "setTarget must not advance the value");
assertEquals(1.0f, value.target(), EPS);
}
@Test
void frameRateIndependent() {
// One long step must land on the same point as many small steps of equal total time.
float totalTicks = 2.0f;
float speed = 1.1f;
SmoothValue coarse = new SmoothValue(speed);
coarse.setTarget(true);
coarse.update(totalTicks);
SmoothValue fine = new SmoothValue(speed);
fine.setTarget(true);
int steps = 240;
for (int i = 0; i < steps; i++) {
fine.update(totalTicks / steps);
}
assertEquals(coarse.value(), fine.value(), 5.0e-3f);
assertTrue(coarse.value() > 0.5f && coarse.value() < 1.0f);
}
@Test
void zeroAndBrokenDeltasAreIgnored() {
SmoothValue value = new SmoothValue(1.0f);
value.setTarget(true);
value.update(0.0f);
value.update(-1.0f);
value.update(Float.NaN);
assertEquals(0.0f, value.value(), EPS);
assertFalse(value.isSettled());
value.update(1.0f);
assertTrue(value.value() > 0.0f);
}
@Test
void targetFlipsMidFlightReverseDirection() {
SmoothValue value = new SmoothValue(1.0f);
value.setTarget(true);
value.update(1.0f);
assertTrue(value.value() > 0.0f);
value.setTarget(false);
float peak = value.value();
value.update(1.0f);
assertTrue(value.value() < peak, "falling must start immediately after the flip");
}
@Test
void rejectsBrokenSpeed() {
assertThrows(IllegalArgumentException.class, () -> new SmoothValue(0.0f));
assertThrows(IllegalArgumentException.class, () -> new SmoothValue(-1.0f));
assertThrows(IllegalArgumentException.class, () -> new SmoothValue(Float.NaN));
assertThrows(IllegalArgumentException.class, () -> new SmoothValue(Float.POSITIVE_INFINITY));
}
}

View file

@ -0,0 +1,64 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class ThreeStageProgressTest {
private static final float EPS = 1.0e-3f;
@Test
void stagesFollowElapsedTime() {
assertEquals(ThreeStageProgress.Stage.APPEAR, ThreeStageProgress.stage(0, 100, 200, 300));
assertEquals(ThreeStageProgress.Stage.EXIST, ThreeStageProgress.stage(150, 100, 200, 300));
assertEquals(ThreeStageProgress.Stage.DISAPPEAR, ThreeStageProgress.stage(350, 100, 200, 300));
assertEquals(ThreeStageProgress.Stage.FINISHED, ThreeStageProgress.stage(700, 100, 200, 300));
}
@Test
void visibilityStartsAtZeroAndEndsAtOneDuringAppear() {
assertEquals(0.0f, ThreeStageProgress.visibility(0, 100, 200, 300), EPS);
assertEquals(1.0f, ThreeStageProgress.visibility(100, 100, 200, 300), EPS);
}
@Test
void visibilityStaysFullDuringExist() {
assertEquals(1.0f, ThreeStageProgress.visibility(150, 100, 200, 300), EPS);
assertEquals(1.0f, ThreeStageProgress.visibility(299, 100, 200, 300), EPS);
}
@Test
void visibilityFadesToZeroDuringDisappear() {
assertEquals(1.0f, ThreeStageProgress.visibility(300, 100, 200, 300), EPS);
assertTrue(ThreeStageProgress.visibility(450, 100, 200, 300) < 1.0f);
assertEquals(0.0f, ThreeStageProgress.visibility(600, 100, 200, 300), EPS);
}
@Test
void visibilityIsZeroAfterFinished() {
assertEquals(0.0f, ThreeStageProgress.visibility(1000, 100, 200, 300), EPS);
}
@Test
void isFinishedMatchesStage() {
assertFalse(ThreeStageProgress.isFinished(0, 100, 200, 300));
assertFalse(ThreeStageProgress.isFinished(599, 100, 200, 300));
assertTrue(ThreeStageProgress.isFinished(600, 100, 200, 300));
}
@Test
void zeroDurationStagesDoNotDivideByZero() {
assertEquals(1.0f, ThreeStageProgress.visibility(0, 0, 200, 300), EPS);
assertEquals(ThreeStageProgress.Stage.EXIST, ThreeStageProgress.stage(0, 0, 200, 300));
}
}

View file

@ -0,0 +1,97 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Фаза и амплитуда покачивания при ходьбе. */
public final class BobMathTest {
@Test
void amplitudeIsZeroWhenIdle() {
assertEquals(0.0f, BobMath.amplitude(0.0f), 1.0e-6f);
}
@Test
void amplitudeGrowsWithSpeed() {
float slow = BobMath.amplitude(0.25f);
float fast = BobMath.amplitude(0.9f);
assertTrue(slow > 0.0f && slow < fast, "амплитуда должна расти со скоростью");
assertTrue(slow < 1.0f && fast <= 1.0f);
}
@Test
void amplitudeClampedToOne() {
assertEquals(1.0f, BobMath.amplitude(2.5f), 1.0e-6f);
}
@Test
void brokenSpeedGivesZeroAmplitude() {
assertEquals(0.0f, BobMath.amplitude(Float.NaN), 1.0e-6f);
assertEquals(0.0f, BobMath.amplitude(Float.NEGATIVE_INFINITY), 1.0e-6f);
assertEquals(0.0f, BobMath.amplitude(-0.5f), 1.0e-6f);
}
@Test
void lateralStartsAtZeroAndIsBounded() {
assertEquals(0.0f, BobMath.lateral(0.0f), 1.0e-6f);
for (float phase = 0.0f; phase < 40.0f; phase += 0.125f) {
float value = BobMath.lateral(phase);
assertTrue(value >= -1.0f && value <= 1.0f, "латераль вне диапазона в фазе " + phase);
}
}
@Test
void lateralIsAntisymmetric() {
for (float phase = -3.0f; phase <= 3.0f; phase += 0.25f) {
assertEquals(-BobMath.lateral(phase), BobMath.lateral(-phase), 1.0e-5f);
}
}
@Test
void lateralRepeatsAfterFullCycle() {
float cycle = (float) (2.0 * Math.PI / BobMath.STEP_RATE);
assertEquals(BobMath.lateral(0.3f), BobMath.lateral(0.3f + cycle), 1.0e-4f);
}
@Test
void lateralReachesPeakAtExpectedPhase() {
assertEquals(1.0f, BobMath.lateral(BobMath.lateralPeakPhase()), 1.0e-4f);
}
@Test
void verticalIsNeverPositive() {
for (float phase = 0.0f; phase < 40.0f; phase += 0.125f) {
assertTrue(BobMath.vertical(phase) <= 0.0f, "вертикаль должна тянуть вниз");
assertTrue(BobMath.vertical(phase) >= -1.0f);
}
}
@Test
void verticalDipsTwicePerCycle() {
float cycle = (float) (2.0 * Math.PI / BobMath.STEP_RATE);
int dips = 0;
float previous = 0.0f;
for (float t = 0.0f; t <= cycle; t += 0.01f) {
float value = BobMath.vertical(t);
if (previous > -0.999f && value <= -0.999f) {
dips++;
}
previous = value;
}
assertEquals(2, dips, "за цикл должно быть два проседания (два шага)");
}
@Test
void stepRateMatchesVanillaLimbFrequency() {
assertEquals(0.6662f, BobMath.STEP_RATE, 1.0e-6f);
}
}

View file

@ -0,0 +1,154 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Сборка позы руки из эффектов. */
public final class HandPoseMathTest {
private static final float EPS = 1.0e-6f;
@Test
void swayZeroStrengthIsZero() {
assertEquals(HandPoseMath.ZERO, HandPoseMath.sway(6.0f, 4.0f, 0.0f));
}
@Test
void swayStrengthScalesOutput() {
HandPoseMath.Pose unit = HandPoseMath.sway(3.0f, 2.0f, 1.0f);
HandPoseMath.Pose doubled = HandPoseMath.sway(3.0f, 2.0f, 2.0f);
assertEquals(unit.dx() * 2.0f, doubled.dx(), EPS);
assertEquals(unit.ry() * 2.0f, doubled.ry(), EPS);
}
@Test
void swaySignsFollowCameraTurn() {
// Поворот вправо (yaw+): предмет отстаёт влево на экране (+X) и доворачивается назад (ry-).
HandPoseMath.Pose yaw = HandPoseMath.sway(2.0f, 0.0f, 1.0f);
assertTrue(yaw.dx() > 0.0f);
assertTrue(yaw.ry() < 0.0f);
assertTrue(yaw.rz() > 0.0f);
// Взгляд вниз (pitch+): предмет отстаёт вверх (+Y) и задирается (rx-).
HandPoseMath.Pose pitch = HandPoseMath.sway(0.0f, 2.0f, 1.0f);
assertTrue(pitch.dy() > 0.0f);
assertTrue(pitch.rx() < 0.0f);
assertEquals(0.0f, pitch.dx(), EPS);
}
@Test
void swayMirrorsForOppositeLag() {
HandPoseMath.Pose positive = HandPoseMath.sway(2.0f, 1.0f, 1.0f);
HandPoseMath.Pose negative = HandPoseMath.sway(-2.0f, -1.0f, 1.0f);
assertEquals(-positive.dx(), negative.dx(), EPS);
assertEquals(-positive.dy(), negative.dy(), EPS);
assertEquals(-positive.rx(), negative.rx(), EPS);
assertEquals(-positive.ry(), negative.ry(), EPS);
assertEquals(-positive.rz(), negative.rz(), EPS);
}
@Test
void bobZeroAmplitudeIsZero() {
assertEquals(HandPoseMath.ZERO, HandPoseMath.bob(3.0f, 0.0f, 1.0f));
assertEquals(HandPoseMath.ZERO, HandPoseMath.bob(3.0f, 0.8f, 0.0f));
}
@Test
void bobIsBoundedAndScales() {
for (float phase = 0.0f; phase < 20.0f; phase += 0.1f) {
HandPoseMath.Pose pose = HandPoseMath.bob(phase, 1.0f, 1.0f);
assertTrue(Math.abs(pose.dx()) <= 0.045f + EPS);
assertTrue(Math.abs(pose.dy()) <= 0.035f + EPS);
assertTrue(Math.abs(pose.rz()) <= 2.5f + EPS);
assertTrue(pose.dy() <= 0.0f, "вертикаль боба не должна поднимать предмет");
}
HandPoseMath.Pose half = HandPoseMath.bob(0.7f, 0.5f, 1.0f);
HandPoseMath.Pose full = HandPoseMath.bob(0.7f, 1.0f, 1.0f);
assertEquals(full.dx() * 0.5f, half.dx(), EPS);
}
@Test
void driftPullsEachArmTowardCenter() {
HandPoseMath.Pose right = HandPoseMath.drift(1.0f, 1.0f, true);
assertTrue(right.dx() > 0.0f, "правая рука тянется влево на экране (+X)");
HandPoseMath.Pose left = HandPoseMath.drift(1.0f, 1.0f, false);
assertTrue(left.dx() < 0.0f, "левая рука тянется вправо на экране (-X)");
assertEquals(-right.dx(), left.dx(), EPS);
assertEquals(-right.ry(), left.ry(), EPS);
assertEquals(-right.rz(), left.rz(), EPS);
}
@Test
void driftZeroProgressAndStrengthGiveZero() {
assertEquals(HandPoseMath.ZERO, HandPoseMath.drift(0.0f, 1.0f, true));
assertEquals(HandPoseMath.ZERO, HandPoseMath.drift(1.0f, 0.0f, false));
}
@Test
void joinDropAtFullProgressIsZero() {
assertEquals(HandPoseMath.ZERO, HandPoseMath.joinDrop(1.0f));
}
@Test
void joinDropEasesOutMonotonically() {
float previousDrop = Float.POSITIVE_INFINITY;
for (float p = 0.0f; p <= 1.0f; p += 0.05f) {
float drop = -HandPoseMath.joinDrop(p).dy();
assertTrue(drop >= 0.0f && drop <= 0.85f + EPS);
assertTrue(drop <= previousDrop, "подъём должен замедляться к концу");
previousDrop = drop;
}
assertEquals(0.85f, -HandPoseMath.joinDrop(0.0f).dy(), EPS);
}
@Test
void addCombinesPosesAndZeroIsIdentity() {
HandPoseMath.Pose a = HandPoseMath.sway(2.0f, 1.0f, 1.0f);
HandPoseMath.Pose b = HandPoseMath.bob(0.5f, 1.0f, 1.0f);
HandPoseMath.Pose sum = HandPoseMath.add(a, b);
assertEquals(a.dx() + b.dx(), sum.dx(), EPS);
assertEquals(a.rx() + b.rx(), sum.rx(), EPS);
assertEquals(a, HandPoseMath.add(a, HandPoseMath.ZERO));
assertEquals(a, HandPoseMath.add(HandPoseMath.ZERO, a));
}
@Test
void gripZeroParamsIsZero() {
assertFalse(HandPoseMath.grip(0f, 0f, 0f, 0f, 0f, 0f, 0f, 1f).isSignificant(EPS));
}
@Test
void gripMirrorsBySide() {
HandPoseMath.Pose right = HandPoseMath.grip(0.3f, 0.1f, 0.05f, 0.14f, 5f, 10f, 15f, 1.0f);
HandPoseMath.Pose left = HandPoseMath.grip(0.3f, 0.1f, 0.05f, 0.14f, 5f, 10f, 15f, -1.0f);
assertEquals(-right.dx(), left.dx(), EPS);
assertEquals(right.dy(), left.dy(), EPS, "slide не зависит от стороны");
assertEquals(right.dz(), left.dz(), EPS, "extend/forward не зависят от стороны");
assertEquals(right.rx(), left.rx(), EPS, "tilt не зависит от стороны");
assertEquals(-right.ry(), left.ry(), EPS);
assertEquals(-right.rz(), left.rz(), EPS);
}
@Test
void gripExtendPullsCloserAndAcrossFist() {
HandPoseMath.Pose pose = HandPoseMath.grip(1.0f, 0f, 0f, 0f, 0f, 0f, 0f, 1.0f);
assertTrue(pose.dz() < 0.0f, "extend тянет предмет ближе к камере (-Z)");
assertTrue(pose.dx() > 0.0f, "extend на правой руке сдвигает вбок по стороне");
}
@Test
void significanceThresholdWorks() {
assertFalse(HandPoseMath.ZERO.isSignificant(1.0e-4f));
assertTrue(HandPoseMath.sway(1.0f, 0.0f, 1.0f).isSignificant(1.0e-4f));
assertFalse(HandPoseMath.drift(1.0f, 1.0f, true).isSignificant(20.0f));
}
}

View file

@ -0,0 +1,149 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand.state;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Покадровое состояние: одна правка цели за кадр, реакции на смену мира/игрока. */
public final class HandAnimStateTest {
private static final Object PLAYER_A = new Object();
private static final Object PLAYER_B = new Object();
private static final Object LEVEL_1 = new Object();
private static final Object LEVEL_2 = new Object();
private static final float DT = 0.5f;
@Test
void firstFrameDoesNotProduceSwayImpulse() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 30.0f, -15.0f, false, false, DT);
assertEquals(0.0f, state.sway().yawLag(), 1.0e-6f);
state.beginFrame(PLAYER_A, LEVEL_1, 30.0f, -15.0f, false, false, DT);
assertEquals(0.0f, state.sway().yawLag(), 1.0e-6f);
}
@Test
void swayReactsToMouseMotion() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, false, false, DT);
for (int i = 0; i < 8; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 6.0f * (i + 1), 0.0f, false, false, DT);
}
assertTrue(state.sway().yawLag() > 0.0f, "вращение камеры должно давать отставание");
}
@Test
void yawWrapMatchesEquivalentTurn() {
HandAnimState acrossBoundary = new HandAnimState();
acrossBoundary.beginFrame(PLAYER_A, LEVEL_1, 175.0f, 0.0f, false, false, DT);
acrossBoundary.beginFrame(PLAYER_A, LEVEL_1, -175.0f, 0.0f, false, false, DT);
HandAnimState plain = new HandAnimState();
plain.beginFrame(PLAYER_A, LEVEL_1, -5.0f, 0.0f, false, false, DT);
plain.beginFrame(PLAYER_A, LEVEL_1, 5.0f, 0.0f, false, false, DT);
assertEquals(plain.sway().yawLag(), acrossBoundary.sway().yawLag(), 1.0e-4f,
"переход через границу мира равен обычному повороту на те же 10 градусов");
}
@Test
void teleportResetsSwayInsteadOfImpulse() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, false, false, DT);
for (int i = 0; i < 8; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 6.0f * (i + 1), 0.0f, false, false, DT);
}
assertTrue(state.sway().yawLag() > 0.5f);
state.beginFrame(PLAYER_A, LEVEL_1, 180.0f, 0.0f, false, false, DT);
assertEquals(0.0f, state.sway().yawLag(), 1.0e-6f);
}
@Test
void driftIsPerHand() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, true, false, DT);
for (int i = 0; i < 20; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, true, false, DT);
}
assertTrue(state.drift(true) > 0.9f, "дрейф в главной руке должен подняться к единице");
assertEquals(0.0f, state.drift(false), 1.0e-6f, "вторая рука не должна дёргаться");
}
@Test
void driftSettlesBackWhenUsageStops() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, true, false, DT);
for (int i = 0; i < 30; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, true, false, DT);
}
for (int i = 0; i < 120; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, false, false, DT);
}
assertEquals(0.0f, state.drift(true), 1.0e-3f, "после окончания использования дрейф должен уйти в ноль");
}
@Test
void joinProgressRisesThenStays() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, false, false, DT);
float previous = 0.0f;
for (int i = 0; i < 200; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, false, false, DT);
float value = state.joinProgress();
assertTrue(value >= previous - 1.0e-6f, "прогресс появления не должен откатываться");
previous = value;
}
assertEquals(1.0f, state.joinProgress(), 1.0e-3f);
}
@Test
void playerOrLevelChangeRestartsJoin() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, false, false, DT);
for (int i = 0; i < 200; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, false, false, DT);
}
assertEquals(1.0f, state.joinProgress(), 1.0e-3f);
state.beginFrame(PLAYER_B, LEVEL_1, 0.0f, 0.0f, false, false, DT);
assertTrue(state.joinProgress() < 0.1f, "смена игрока (респавн) перезапускает появление");
state.beginFrame(PLAYER_B, LEVEL_2, 0.0f, 0.0f, false, false, DT);
assertTrue(state.joinProgress() < 0.1f, "смена мира тоже перезапускает появление");
}
@Test
void nullPlayerResetsEverything() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, true, false, DT);
for (int i = 0; i < 30; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, true, false, DT);
}
state.beginFrame(null, null, 0.0f, 0.0f, false, false, DT);
assertEquals(0.0f, state.drift(true), 1.0e-6f);
assertEquals(0.0f, state.joinProgress(), 1.0e-6f);
assertTrue(state.sway().isSettled());
}
@Test
void resetReturnsToFreshState() {
HandAnimState state = new HandAnimState();
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, true, false, DT);
for (int i = 0; i < 30; i++) {
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, true, false, DT);
}
state.reset();
assertEquals(0.0f, state.drift(true), 1.0e-6f);
assertEquals(0.0f, state.joinProgress(), 1.0e-6f);
state.beginFrame(PLAYER_A, LEVEL_1, 0.0f, 0.0f, false, false, DT);
assertEquals(0.0f, state.sway().yawLag(), 1.0e-6f);
}
}

View file

@ -0,0 +1,121 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.animation.hand.state;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Математика отставания предмета от поворота камеры. */
public final class SwayStateTest {
private static final float DT = 0.5f;
@Test
void withoutInputStaysZero() {
SwayState state = new SwayState();
for (int i = 0; i < 60; i++) {
state.update(0.0f, 0.0f, DT);
}
assertEquals(0.0f, state.yawLag(), 1.0e-6f);
assertEquals(0.0f, state.pitchLag(), 1.0e-6f);
assertTrue(state.isSettled());
}
@Test
void yawImpulseProducesPositiveLag() {
SwayState state = new SwayState();
state.update(4.0f, 0.0f, DT);
assertTrue(state.yawLag() > 0.0f, "движение мыши вправо должно давать положительное отставание");
assertEquals(0.0f, state.pitchLag(), 1.0e-6f);
}
@Test
void lagDecaysBackToZeroWhenMouseStill() {
SwayState state = new SwayState();
state.update(4.0f, -2.0f, DT);
for (int i = 0; i < 400; i++) {
state.update(0.0f, 0.0f, DT);
}
assertTrue(state.isSettled(), "без ввода отставание должно полностью затухнуть");
}
@Test
void lagIsClamped() {
SwayState state = new SwayState();
for (int i = 0; i < 40; i++) {
state.update(180.0f, -120.0f, DT);
}
assertTrue(state.yawLag() <= SwayState.MAX_YAW_LAG_DEG + 1.0e-4f);
assertTrue(state.pitchLag() >= -SwayState.MAX_PITCH_LAG_DEG - 1.0e-4f);
}
@Test
void mirroredInputsGiveMirroredLag() {
SwayState left = new SwayState();
SwayState right = new SwayState();
for (int i = 0; i < 10; i++) {
left.update(-3.0f, 1.5f, DT);
right.update(3.0f, -1.5f, DT);
}
assertEquals(left.yawLag(), -right.yawLag(), 1.0e-5f);
assertEquals(left.pitchLag(), -right.pitchLag(), 1.0e-5f);
}
@Test
void oppositeInputPullsBackThroughZero() {
SwayState state = new SwayState();
for (int i = 0; i < 10; i++) {
state.update(3.0f, 0.0f, DT);
}
float peak = state.yawLag();
for (int i = 0; i < 10; i++) {
state.update(-3.0f, 0.0f, DT);
}
assertTrue(state.yawLag() < peak, "обратное движение должно гасить отставание");
}
@Test
void nonPositiveDeltaIgnored() {
SwayState state = new SwayState();
state.update(2.0f, 1.0f, DT);
float yaw = state.yawLag();
state.update(2.0f, 1.0f, 0.0f);
state.update(2.0f, 1.0f, -1.0f);
assertEquals(yaw, state.yawLag(), 1.0e-6f);
assertFalse(state.isSettled());
}
@Test
void brokenInputsAreSafe() {
SwayState state = new SwayState();
state.update(Float.NaN, Float.POSITIVE_INFINITY, DT);
state.update(1.0f, 1.0f, Float.NaN);
assertEquals(0.0f, state.yawLag(), 1.0e-6f);
assertEquals(0.0f, state.pitchLag(), 1.0e-6f);
}
@Test
void wrapDegreesKeepsResultInHalfCircle() {
assertEquals(-20.0f, SwayState.wrapDegrees(340.0f), 1.0e-4f);
assertEquals(20.0f, SwayState.wrapDegrees(-340.0f), 1.0e-4f);
assertEquals(-180.0f, SwayState.wrapDegrees(180.0f), 1.0e-4f);
assertEquals(0.0f, SwayState.wrapDegrees(360.0f), 1.0e-4f);
assertEquals(5.0f, SwayState.wrapDegrees(365.0f), 1.0e-4f);
}
@Test
void resetClearsEverything() {
SwayState state = new SwayState();
state.update(4.0f, 2.0f, DT);
state.reset();
assertTrue(state.isSettled());
}
}

View file

@ -0,0 +1,86 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.combat;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.loki.lovisual.util.combat.tracking.ReachMeter;
public final class ReachMeterTest {
private static final double EPS = 1e-6;
@BeforeEach
void reset() {
ReachMeter.reset();
}
@Test
void distanceToBoxOutsidePoint() {
AABB box = new AABB(0, 0, 0, 1, 1, 1);
assertEquals(2.0, ReachMeter.distanceToBox(new Vec3(3, 0.5, 0.5), box), EPS);
assertEquals(Math.sqrt(3.0), ReachMeter.distanceToBox(new Vec3(2, 2, 2), box), EPS);
}
@Test
void distanceToBoxInsideIsZero() {
AABB box = new AABB(0, 0, 0, 1, 1, 1);
assertEquals(0.0, ReachMeter.distanceToBox(new Vec3(0.5, 0.5, 0.5), box), EPS);
assertEquals(0.0, ReachMeter.distanceToBox(new Vec3(0, 1, 0.5), box), EPS);
}
@Test
void distanceToBoxHandlesNulls() {
assertEquals(-1.0, ReachMeter.distanceToBox(null, new AABB(0, 0, 0, 1, 1, 1)), EPS);
assertEquals(-1.0, ReachMeter.distanceToBox(Vec3.ZERO, null), EPS);
}
@Test
void recordTracksLastAndMax() {
ReachMeter.record(2.5, 1000L);
ReachMeter.record(3.4, 2000L);
ReachMeter.record(1.2, 3000L);
assertEquals(1.2, ReachMeter.lastDistance(), EPS);
assertEquals(3.4, ReachMeter.sessionMax(), EPS);
assertEquals(3000L, ReachMeter.lastAttackAtMs());
}
@Test
void invalidDistancesIgnored() {
ReachMeter.record(2.5, 1000L);
ReachMeter.record(Double.NaN, 2000L);
ReachMeter.record(-5.0, 3000L);
assertEquals(2.5, ReachMeter.lastDistance(), EPS);
assertEquals(1000L, ReachMeter.lastAttackAtMs());
}
@Test
void resetClearsState() {
ReachMeter.record(4.0, 1000L);
ReachMeter.reset();
assertEquals(-1.0, ReachMeter.lastDistance(), EPS);
assertEquals(-1.0, ReachMeter.sessionMax(), EPS);
assertEquals(0L, ReachMeter.lastAttackAtMs());
}
@Test
void formatMeters() {
assertEquals("-", ReachMeter.format(-1.0));
assertEquals("3.00 m", ReachMeter.format(3.0));
assertEquals("3.12 m", ReachMeter.format(3.124));
}
}

View file

@ -0,0 +1,51 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.mctiers;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class MCTiersTierTest {
@Test
void labelsMatchKnownTiers() {
assertEquals("HT1", new MCTiersTier(1, false).label());
assertEquals("LT2", new MCTiersTier(2, false).label());
assertEquals("LT3", new MCTiersTier(3, false).label());
assertEquals("LT4", new MCTiersTier(4, false).label());
assertEquals("T5", new MCTiersTier(5, false).label());
}
@Test
void colorsAreDistinctPerTier() {
int t1 = new MCTiersTier(1, false).colorArgb();
int t2 = new MCTiersTier(2, false).colorArgb();
int t3 = new MCTiersTier(3, false).colorArgb();
int t4 = new MCTiersTier(4, false).colorArgb();
assertEquals(4, java.util.Set.of(t1, t2, t3, t4).size());
}
@Test
void modeApiKeyMapsFixTypeToApiMode() {
assertEquals("vanilla", MCTiersTier.modeApiKey("Vanilla"));
assertEquals("uhc", MCTiersTier.modeApiKey("UHC"));
assertEquals("pot", MCTiersTier.modeApiKey("Pot"));
assertEquals("neth_pot", MCTiersTier.modeApiKey("NethOP"));
assertEquals("smp", MCTiersTier.modeApiKey("SMP"));
assertEquals("sword", MCTiersTier.modeApiKey("Sword"));
assertEquals("axe", MCTiersTier.modeApiKey("Axe"));
assertEquals("mace", MCTiersTier.modeApiKey("Mace"));
}
@Test
void modeApiKeyDefaultsToVanillaForUnknownOrNull() {
assertEquals("vanilla", MCTiersTier.modeApiKey(null));
assertEquals("vanilla", MCTiersTier.modeApiKey("Nonsense"));
}
}

View file

@ -0,0 +1,87 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.physics;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class TrinketPhysicsTest {
@Test
void restIsZeroAfterReset() {
TrinketPhysics p = new TrinketPhysics();
p.tick(0.05, 20.0, 1.0, 0.98, 0.7, 0.9, 1.0);
p.reset();
assertEquals(0.0, p.x());
assertEquals(0.0, p.y());
assertEquals(0.0, p.z());
assertEquals(0.0, p.speed());
}
@Test
void restingBallBouncesImmediatelyUnderGravity() {
// y=0 — это уровень земли (у ног владельца), поэтому шарик из состояния покоя
// на первом же тике проваливается под гравитацией и тут же отскакивает обратно —
// это и есть наблюдаемый эффект гравитации в модели "ноль высоты = земля".
TrinketPhysics p = new TrinketPhysics();
assertTrue(p.tick(0.05, 20.0, 1.0, 1.0, 0.7, 0.9, 5.0), "гравитация должна вжать шарик в землю на первом тике");
assertEquals(0.0, p.y(), 1.0e-9);
assertTrue(p.speed() > 0.0, "после отскока есть исходящая от земли скорость");
}
@Test
void bouncesWhenHittingGroundFromAbove() {
TrinketPhysics p = new TrinketPhysics();
boolean bouncedAtLeastOnce = false;
for (int i = 0; i < 200; i++) {
if (p.tick(0.05, 20.0, 1.0, 0.999, 0.7, 0.9, 5.0)) {
bouncedAtLeastOnce = true;
break;
}
}
assertTrue(bouncedAtLeastOnce, "падение с гравитацией должно рано или поздно удариться о землю (y=0)");
assertEquals(0.0, p.y(), 1.0e-9, "после отскока шарик стоит ровно на земле");
}
@Test
void repeatedBouncesLoseEnergyOverTime() {
// Каждый отскок домножает вертикальную скорость на bounce=0.5 — пик высоты после
// каждого следующего отскока должен быть меньше предыдущего (затухающий мяч).
TrinketPhysics p = new TrinketPhysics();
double firstBounceSpeed = -1;
double secondBounceSpeed = -1;
int bounces = 0;
for (int i = 0; i < 1000 && bounces < 2; i++) {
if (p.tick(0.02, 20.0, 1.0, 1.0, 0.5, 1.0, 100.0)) {
bounces++;
if (bounces == 1) firstBounceSpeed = p.speed();
else secondBounceSpeed = p.speed();
}
}
assertTrue(firstBounceSpeed > 0 && secondBounceSpeed > 0, "должно было произойти минимум два отскока");
assertTrue(secondBounceSpeed < firstBounceSpeed,
"второй отскок должен быть слабее первого: " + secondBounceSpeed + " >= " + firstBounceSpeed);
}
@Test
void tetherKeepsBallWithinBoundedDistance() {
// Прямой доступ к полям недоступен (инкапсуляция) — проверяем инвариант на реальной
// траектории: с гравитацией/отскоками и малым tetherRadius шарик не должен улетать
// в бесконечность, пружина стабилизирует расстояние от центра.
TrinketPhysics p = new TrinketPhysics();
double maxDist = 0;
for (int i = 0; i < 2000; i++) {
p.tick(0.05, 20.0, 1.0, 0.98, 0.6, 0.9, 1.5);
double dist = Math.sqrt(p.x() * p.x() + p.z() * p.z());
maxDist = Math.max(maxDist, dist);
}
assertTrue(maxDist < 50.0, "пружина должна удерживать шарик в разумных пределах, было " + maxDist);
}
}

View file

@ -0,0 +1,86 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.protection;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class NotifyThrottleTest {
@Test
void firstCallIsAllowed() {
NotifyThrottle throttle = new NotifyThrottle(1000L);
assertTrue(throttle.tryAcquire(5_000L));
}
@Test
void secondCallInsideIntervalIsSuppressed() {
NotifyThrottle throttle = new NotifyThrottle(1_000L);
assertTrue(throttle.tryAcquire(5_000L));
assertFalse(throttle.tryAcquire(5_999L));
}
@Test
void callExactlyAtIntervalBoundaryIsAllowed() {
NotifyThrottle throttle = new NotifyThrottle(1_000L);
assertTrue(throttle.tryAcquire(5_000L));
assertTrue(throttle.tryAcquire(6_000L));
}
@Test
void callAfterIntervalIsAllowed() {
NotifyThrottle throttle = new NotifyThrottle(1_500L);
assertTrue(throttle.tryAcquire(10_000L));
assertFalse(throttle.tryAcquire(11_499L));
assertTrue(throttle.tryAcquire(11_500L));
assertTrue(throttle.tryAcquire(13_001L));
}
@Test
void remainingMsIsMonotonicAndClampedToZero() {
NotifyThrottle throttle = new NotifyThrottle(1_000L);
assertEquals(0L, throttle.remainingMs(1_000L), "never fired");
throttle.tryAcquire(2_000L);
assertEquals(1_000L, throttle.remainingMs(2_000L));
assertEquals(500L, throttle.remainingMs(2_500L));
assertEquals(0L, throttle.remainingMs(3_000L));
assertEquals(0L, throttle.remainingMs(10_000L));
}
@Test
void backwardsClockDoesNotLockOutCaller() {
NotifyThrottle throttle = new NotifyThrottle(1_000L);
throttle.tryAcquire(5_000L);
assertTrue(throttle.tryAcquire(1_000L), "moving time back should allow an emission");
}
@Test
void nonPositiveIntervalAlwaysAllows() {
NotifyThrottle zero = new NotifyThrottle(0L);
assertTrue(zero.tryAcquire(100L));
assertTrue(zero.tryAcquire(100L));
assertTrue(zero.tryAcquire(100L));
NotifyThrottle negative = new NotifyThrottle(-5L);
assertTrue(negative.tryAcquire(1L));
assertTrue(negative.tryAcquire(2L));
}
@Test
void resetRestoresInitialState() {
NotifyThrottle throttle = new NotifyThrottle(60_000L);
assertTrue(throttle.tryAcquire(1_000L));
assertFalse(throttle.tryAcquire(2_000L));
throttle.reset();
assertEquals(0L, throttle.remainingMs(2_000L));
assertTrue(throttle.tryAcquire(2_000L));
}
}

View file

@ -0,0 +1,101 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.protection;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class SlotLockPolicyTest {
private static Map<String, Boolean> states(int... lockedZeroBasedSlots) {
Map<String, Boolean> out = SlotLockPolicy.defaultSlotStates();
for (int slot : lockedZeroBasedSlots) {
out.put(SlotLockPolicy.slotKey(slot), true);
}
return out;
}
@Test
void defaultsUnlockAllNineSlots() {
Map<String, Boolean> defaults = SlotLockPolicy.defaultSlotStates();
assertEquals(9, defaults.size());
for (int slot = 0; slot < 9; slot++) {
assertFalse(SlotLockPolicy.isSlotLocked(defaults, slot), "slot " + slot + " must be unlocked");
}
}
@Test
void slotKeyIsOneBased() {
assertEquals("slot1", SlotLockPolicy.slotKey(0));
assertEquals("slot5", SlotLockPolicy.slotKey(4));
assertEquals("slot9", SlotLockPolicy.slotKey(8));
}
@Test
void lockedSlotIsBlockedWhenNearbyCheckDisabled() {
assertTrue(SlotLockPolicy.isDropBlocked(states(4), 4, false, false));
assertTrue(SlotLockPolicy.isDropBlocked(states(4), 4, true, false));
}
@Test
void lockedSlotRespectsOnlyPlayersNearby() {
assertTrue(SlotLockPolicy.isDropBlocked(states(0), 0, true, true));
assertFalse(SlotLockPolicy.isDropBlocked(states(0), 0, false, true));
}
@Test
void unlockedSlotIsNeverBlocked() {
assertFalse(SlotLockPolicy.isDropBlocked(states(1, 2), 0, true, false));
assertFalse(SlotLockPolicy.isDropBlocked(states(1, 2), 0, false, false));
}
@Test
void hotbarEdgesAreCovered() {
Map<String, Boolean> edgeStates = states(0, 8);
assertTrue(SlotLockPolicy.isDropBlocked(edgeStates, 0, false, false));
assertTrue(SlotLockPolicy.isDropBlocked(edgeStates, 8, false, false));
assertFalse(SlotLockPolicy.isDropBlocked(edgeStates, 4, false, false));
}
@Test
void outOfRangeSlotsAreNeverBlocked() {
Map<String, Boolean> all = states(0, 1, 2, 3, 4, 5, 6, 7, 8);
assertFalse(SlotLockPolicy.isDropBlocked(all, -1, true, false));
assertFalse(SlotLockPolicy.isDropBlocked(all, 9, true, false));
assertFalse(SlotLockPolicy.isSlotLocked(all, -1));
assertFalse(SlotLockPolicy.isSlotLocked(all, 9));
}
@Test
void missingOrNullStatesAreNeverBlocked() {
assertFalse(SlotLockPolicy.isDropBlocked(null, 0, true, false));
assertFalse(SlotLockPolicy.isDropBlocked(new HashMap<>(), 0, true, false));
assertFalse(SlotLockPolicy.isSlotLocked(null, 0));
}
@Test
void unknownOrNonBooleanEntriesAreIgnored() {
Map<String, Boolean> odd = new HashMap<>();
odd.put("slot", true);
odd.put("slot10", true);
odd.put("slot0", true);
assertFalse(SlotLockPolicy.isSlotLocked(odd, 0));
assertFalse(SlotLockPolicy.isDropBlocked(odd, 0, true, false));
}
@Test
void nearbyCheckDoesNotAffectUnlockedSlots() {
assertFalse(SlotLockPolicy.isDropBlocked(states(8), 3, true, true));
}
}

View file

@ -0,0 +1,166 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.text;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.Style;
import net.minecraft.network.chat.TextColor;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import dev.loki.lovisual.util.text.format.LegacyTextUtil;
/**
* convertLegacyCodes строит дерево: Component.empty() → сегмент-обёртка → стилизованные листья.
* firstLeaf() спускается к первому листу с текстом.
*/
public final class LegacyTextUtilTest {
private static Component firstLeaf(Component in) {
Component cur = in;
while (!cur.getSiblings().isEmpty()) {
cur = cur.getSiblings().get(0);
}
return cur;
}
/** Все листовые компоненты с непустым текстом, в порядке обхода. */
private static java.util.List<Component> leaves(Component in) {
java.util.List<Component> out = new java.util.ArrayList<>();
collectLeaves(in, out);
return out;
}
private static void collectLeaves(Component in, java.util.List<Component> out) {
if (in.getSiblings().isEmpty()) {
if (!in.getString().isEmpty()) out.add(in);
return;
}
for (Component child : in.getSiblings()) {
collectLeaves(child, out);
}
}
@Test
void ampersandToSectionConvertsColorCodes() {
assertEquals("§aGreen", LegacyTextUtil.ampersandToSection("&aGreen"));
assertEquals("plain text", LegacyTextUtil.ampersandToSection("plain text"));
}
@Test
void ampersandToSectionConvertsHexNotation() {
assertEquals("§x§F§F§0§0§0§0X", LegacyTextUtil.ampersandToSection("&#FF0000X"));
assertEquals("§x§0§1§2§3§4§5", LegacyTextUtil.ampersandToSection("&x&0&1&2&3&4&5"));
}
@Test
void ampersandToSectionKeepsLoneAmpersands() {
assertEquals("at & end", LegacyTextUtil.ampersandToSection("at & end"));
}
@Test
void ampersandToSectionHandlesNullAndEmpty() {
assertNull(LegacyTextUtil.ampersandToSection(null));
assertEquals("", LegacyTextUtil.ampersandToSection(""));
}
@Test
void stripLegacyRemovesColorAndDecorationCodes() {
assertEquals("Green", LegacyTextUtil.stripLegacy("§aGreen"));
assertEquals("RedBold", LegacyTextUtil.stripLegacy("&cRed§lBold"));
assertEquals("Hex", LegacyTextUtil.stripLegacy("§x§F§F§0§0§0§0Hex"));
assertEquals("", LegacyTextUtil.stripLegacy(null));
assertEquals("no codes", LegacyTextUtil.stripLegacy("no codes"));
}
@Test
void convertLegacyCodesReturnsSameInstanceWithoutCodes() {
Component in = Component.literal("plain");
assertSame(in, LegacyTextUtil.convertLegacyCodes(in));
}
@Test
void convertLegacyCodesHandlesNull() {
assertEquals("", LegacyTextUtil.convertLegacyCodes(null).getString());
}
@Test
void convertLegacyCodesAppliesVanillaColor() {
Component out = LegacyTextUtil.convertLegacyCodes(Component.literal("&aHi"));
assertEquals("Hi", out.getString());
assertEquals(TextColor.fromRgb(0x55FF55), firstLeaf(out).getStyle().getColor());
}
@Test
void convertLegacyCodesAppliesHexColor() {
Component out = LegacyTextUtil.convertLegacyCodes(Component.literal("§x§F§F§0§0§0§0Hex"));
assertEquals("Hex", out.getString());
assertEquals(TextColor.fromRgb(0xFF0000), firstLeaf(out).getStyle().getColor());
}
@Test
void convertLegacyCodesAppliesBedrockMinecoinGold() {
Component out = LegacyTextUtil.convertLegacyCodes(Component.literal("§gCoin"));
assertEquals("Coin", out.getString());
assertEquals(TextColor.fromRgb(0xDDD605), firstLeaf(out).getStyle().getColor());
}
@Test
void convertLegacyCodesKeepsBaseStyle() {
Style base = Style.EMPTY.withBold(true);
Component in = Component.literal("§aHi").setStyle(base);
Component out = LegacyTextUtil.convertLegacyCodes(in);
Style leafStyle = firstLeaf(out).getStyle();
assertEquals(TextColor.fromRgb(0x55FF55), leafStyle.getColor());
assertEquals(Boolean.TRUE, leafStyle.isBold());
}
@Test
void convertLegacyCodesSplitsMultiStyleSegments() {
Component out = LegacyTextUtil.convertLegacyCodes(Component.literal("&aRed&lBold"));
assertEquals("RedBold", out.getString());
}
@Test
void colorCodeResetsDecorationsToBase() {
// парсер трактует цвет по-ваналлийски: код цвета возвращает стиль к base + цвет,
// более ранние decoration-коды из строки сбрасываются (селектор isColor должен их распознавать)
Component out = LegacyTextUtil.convertLegacyCodes(Component.literal("§lBold§aGreen"));
assertEquals("BoldGreen", out.getString());
var leafList = leaves(out);
assertEquals(2, leafList.size());
Style boldStyle = leafList.get(0).getStyle();
assertEquals(Boolean.TRUE, boldStyle.isBold());
Style greenStyle = leafList.get(1).getStyle();
assertEquals(TextColor.fromRgb(0x55FF55), greenStyle.getColor());
assertEquals(Boolean.FALSE, greenStyle.isBold());
}
@Test
void decorationCodeKeepsColorAndAddsFlag() {
Component out = LegacyTextUtil.convertLegacyCodes(Component.literal("&aRed&lBold"));
var leafList = leaves(out);
assertEquals(2, leafList.size());
Style boldStyle = leafList.get(1).getStyle();
assertEquals(Boolean.TRUE, boldStyle.isBold());
assertEquals(TextColor.fromRgb(0x55FF55), boldStyle.getColor());
}
}

View file

@ -0,0 +1,85 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.text;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import dev.loki.lovisual.util.text.mask.NameMasker;
public final class NameMaskerTest {
@Test
void masksNicknameTokenDeterministically() {
NameMasker masker = new NameMasker("Player", List.of());
String first = masker.mask("gg Notch_123 gg");
String second = masker.mask("gg Notch_123 gg");
assertNotEquals("gg Notch_123 gg", first);
assertEquals(first, second);
assertEquals("gg Player1 gg", first);
}
@Test
void differentNamesGetDifferentOrdinals() {
NameMasker masker = new NameMasker("Player", List.of());
String masked = masker.mask("Notch and Herobrine");
assertEquals("Player1 and Player2", masked);
}
@Test
void shortTokensAndNumbersAreKept() {
NameMasker masker = new NameMasker("Player", List.of());
assertEquals("ab 12345 x9", masker.mask("ab 12345 x9"));
}
@Test
void wordsInsideTokensAreNotSplit() {
NameMasker masker = new NameMasker("Player", List.of());
// The nickname-like token wins over word matching because it is longer.
String masked = masker.mask("Steve2016");
assertEquals("Player1", masked);
}
@Test
void serverWordsAreMaskedCaseInsensitively() {
NameMasker masker = new NameMasker("Server", List.of("anarchy"));
assertEquals("Welcome to Server-2!", masker.mask("Welcome to Anarchy-2!"));
assertEquals("Server", masker.mask("ANARCHY"));
}
@Test
void wordMatchTakesPriorityOverTokenMasking() {
NameMasker masker = new NameMasker("Server", List.of("anarchy"));
// "anarchy" matches as a word first, so masking stops there.
assertEquals("Server", masker.mask("anarchy"));
}
@Test
void nullAndEmptyPassThrough() {
NameMasker masker = new NameMasker("Player", List.of());
assertEquals(null, masker.mask(null));
assertEquals("", masker.mask(""));
}
@Test
void blankReplacementFallsBackToPlayer() {
NameMasker masker = new NameMasker(" ", List.of());
assertEquals("Player1", masker.mask("Notch"));
}
}

View file

@ -0,0 +1,117 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.text;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.loki.lovisual.util.text.mask.NameProtector;
public final class NameProtectorTest {
@Test
void replacesNameCaseInsensitively() {
NameProtector p = new NameProtector();
p.put("Notch", "Steve");
assertEquals("hi Steve!", p.replace("hi NOTCH!"));
}
@Test
void onlyMatchesWholeWords() {
NameProtector p = new NameProtector();
p.put("Notch", "Steve");
assertEquals("Notchless is a biome-like word", p.replace("Notchless is a biome-like word"));
assertEquals("xNotch Notchy", p.replace("xNotch Notchy"));
}
@Test
void longerNamesWinOverSubstrings() {
NameProtector p = new NameProtector();
p.put("Cool", "X");
p.put("CoolGuy", "Y");
assertEquals("I am Y", p.replace("I am CoolGuy"));
assertEquals("X beans", p.replace("Cool beans"));
}
@Test
void underscoresCountAsWordCharacters() {
NameProtector p = new NameProtector();
p.put("lob", "cat");
// "lobo" и "_lob_" не матчатся (word-символы вокруг), isolated "lob" — матчится
assertEquals("lobo_here _lob_ cat", p.replace("lobo_here _lob_ lob"));
}
@Test
void multipleNamesInOneString() {
NameProtector p = new NameProtector();
p.put("Alice", "**");
p.put("Bob", "**");
assertEquals("** and **", p.replace("Alice and Bob"));
}
@Test
void blankNamesIgnoredAndEmptyWhenNone() {
NameProtector p = new NameProtector();
p.put(null, "x");
p.put(" ", "x");
assertTrue(p.isEmpty());
assertEquals("anything", p.replace("anything"));
}
@Test
void emptyAliasDeletesName() {
NameProtector p = new NameProtector();
p.put("Notch", "");
assertEquals("hi !", p.replace("hi Notch!"));
}
@Test
void nullAndEmptyTextPassThrough() {
NameProtector p = new NameProtector();
p.put("Notch", "X");
assertEquals(null, p.replace(null));
assertEquals("", p.replace(""));
}
@Test
void exactReplacementOnlyForWholeString() {
NameProtector p = new NameProtector();
p.put("Notch", "X");
assertEquals("X", p.replaceExact("Notch"));
assertEquals("Notchy", p.replaceExact("Notchy"));
}
@Test
void clearRemovesAllAliases() {
NameProtector p = new NameProtector();
p.put("Notch", "X");
p.clear();
assertTrue(p.isEmpty());
assertEquals("Notch", p.replace("Notch"));
}
@Test
void duplicateNameOverwritesAlias() {
NameProtector p = new NameProtector();
p.put("Notch", "X");
p.put("Notch", "Y");
assertEquals("Y", p.replace("Notch"));
}
}

View file

@ -0,0 +1,53 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.world;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class GpsBearingTest {
private static final float EPS = 1.0e-3f;
@Test
void bearingPointsSouthWhenTargetAheadOnZ() {
// +Z (юг) при dx=0 -> азимут 0, совпадает с конвенцией getYRot()
assertEquals(0.0f, GpsBearing.bearingDegrees(0.0, 10.0), EPS);
}
@Test
void bearingPointsWestWhenTargetOnNegativeX() {
assertEquals(90.0f, GpsBearing.bearingDegrees(-10.0, 0.0), EPS);
}
@Test
void arrowRotationIsZeroWhenFacingTarget() {
assertEquals(0.0f, GpsBearing.arrowRotationDegrees(45.0f, 45.0f), EPS);
}
@Test
void arrowRotationWrapsToShortestPath() {
// цель за спиной (разница 180) должна остаться в диапазоне (-180, 180]
float result = GpsBearing.arrowRotationDegrees(170.0f, -170.0f);
assertEquals(-20.0f, result, EPS);
}
@Test
void wrapDegreesStaysInHalfOpenRange() {
assertEquals(-179.0f, GpsBearing.wrapDegrees(181.0f), EPS);
assertEquals(179.0f, GpsBearing.wrapDegrees(-181.0f), EPS);
assertEquals(0.0f, GpsBearing.wrapDegrees(360.0f), EPS);
assertEquals(90.0f, GpsBearing.wrapDegrees(90.0f), EPS);
}
@Test
void horizontalDistanceIsPythagorean() {
assertEquals(5.0, GpsBearing.horizontalDistance(3.0, 4.0), 1.0e-9);
}
}