feat: zoo commands (catperson/dogperson/foxperson, shibe/birb/neko/waifu/catboy) + ApiRequestQueue
- AnimalApi: 8 public APIs with fallback chain + Caffeine cache (TTL 30s) - ZooPersonCommand (deterministic personality + image), AnimalImageCommand - util/ApiRequestQueue: fixed pool 4, isolated responses, graceful shutdown - wired into AnimalApi + NekosLifeApi, MemeCommand moved to OkHttp timeouts - 15 tests green
This commit is contained in:
parent
92d1d158b3
commit
7cf4b9debb
19 changed files with 1016 additions and 33 deletions
|
|
@ -18,6 +18,7 @@ public final class CommandCategories {
|
|||
public static final String GIF_INTERACTIVE = "GIF-интерактив";
|
||||
public static final String NSFW_IMAGES = "NSFW";
|
||||
public static final String MEASUREMENTS = "Измерения";
|
||||
public static final String ANIMALS = "Животные";
|
||||
|
||||
private CommandCategories() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import creator.loki.core.registry.PrefixCommandRegistrar;
|
|||
import creator.loki.core.security.CooldownManager;
|
||||
import creator.loki.core.security.RateLimiter;
|
||||
import creator.loki.database.DatabaseModule;
|
||||
import creator.loki.feature.animals.AnimalModule;
|
||||
import creator.loki.feature.config.ConfigModule;
|
||||
import creator.loki.feature.counter.CounterModule;
|
||||
import creator.loki.feature.economy.EconomyModule;
|
||||
|
|
@ -34,6 +35,7 @@ import creator.loki.feature.tiktok.TikTokModule;
|
|||
import creator.loki.feature.utility.UtilityModule;
|
||||
import creator.loki.feature.voice.VoiceModule;
|
||||
import creator.loki.feature.welcome.WelcomeModule;
|
||||
import creator.loki.util.ApiRequestQueue;
|
||||
|
||||
public class BotModule extends AbstractModule {
|
||||
@Override
|
||||
|
|
@ -64,6 +66,7 @@ public class BotModule extends AbstractModule {
|
|||
install(new ImageModule());
|
||||
install(new TikTokModule());
|
||||
install(new EntertainmentModule());
|
||||
install(new AnimalModule());
|
||||
install(new TicketModule());
|
||||
install(new UtilityModule());
|
||||
install(new ReactionRolesModule());
|
||||
|
|
@ -76,6 +79,12 @@ public class BotModule extends AbstractModule {
|
|||
return ConfigLoader.load();
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
ApiRequestQueue provideApiRequestQueue() {
|
||||
return new ApiRequestQueue();
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
EventDispatcher provideEventDispatcher(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import creator.loki.feature.counter.service.listener.MemberCounterListener;
|
|||
import creator.loki.feature.level.service.tracker.VoiceTracker;
|
||||
import creator.loki.feature.metrics.HealthHttpServer;
|
||||
import creator.loki.feature.moderation.MuteScheduler;
|
||||
import creator.loki.util.ApiRequestQueue;
|
||||
|
||||
public class LifecycleModule extends AbstractModule {
|
||||
@Override
|
||||
|
|
@ -24,5 +25,6 @@ public class LifecycleModule extends AbstractModule {
|
|||
binder.addBinding().to(MuteScheduler.class);
|
||||
binder.addBinding().to(HealthHttpServer.class);
|
||||
binder.addBinding().to(Database.class);
|
||||
binder.addBinding().to(ApiRequestQueue.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ public final class HelpCategories {
|
|||
Map.entry(CommandCategories.ROLES, "🎭"),
|
||||
Map.entry(CommandCategories.GIF_INTERACTIVE, "💞"),
|
||||
Map.entry(CommandCategories.NSFW_IMAGES, "🔞"),
|
||||
Map.entry(CommandCategories.MEASUREMENTS, "📏"));
|
||||
Map.entry(CommandCategories.MEASUREMENTS, "📏"),
|
||||
Map.entry(CommandCategories.ANIMALS, "🐾"));
|
||||
|
||||
COLORS =
|
||||
Map.ofEntries(
|
||||
|
|
@ -45,7 +46,8 @@ public final class HelpCategories {
|
|||
Map.entry(CommandCategories.ROLES, new Color(155, 89, 182)),
|
||||
Map.entry(CommandCategories.GIF_INTERACTIVE, new Color(233, 30, 99)),
|
||||
Map.entry(CommandCategories.NSFW_IMAGES, new Color(180, 0, 0)),
|
||||
Map.entry(CommandCategories.MEASUREMENTS, new Color(255, 159, 67)));
|
||||
Map.entry(CommandCategories.MEASUREMENTS, new Color(255, 159, 67)),
|
||||
Map.entry(CommandCategories.ANIMALS, new Color(46, 204, 113)));
|
||||
|
||||
DESCRIPTIONS =
|
||||
Map.ofEntries(
|
||||
|
|
@ -64,7 +66,8 @@ public final class HelpCategories {
|
|||
Map.entry(
|
||||
CommandCategories.GIF_INTERACTIVE, "Действия с участником в GIF (nekos.life)"),
|
||||
Map.entry(CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)"),
|
||||
Map.entry(CommandCategories.MEASUREMENTS, "Забавные измерительные команды"));
|
||||
Map.entry(CommandCategories.MEASUREMENTS, "Забавные измерительные команды"),
|
||||
Map.entry(CommandCategories.ANIMALS, "Зоо-викторины и картинки животных"));
|
||||
}
|
||||
|
||||
private HelpCategories() {}
|
||||
|
|
|
|||
164
src/main/java/creator/loki/feature/animals/AnimalApi.java
Normal file
164
src/main/java/creator/loki/feature/animals/AnimalApi.java
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package creator.loki.feature.animals;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import creator.loki.util.ApiRequestQueue;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Клиент к публичным API животных и аниме-картинок. Для каждого вида пробует эндпоинты по порядку и
|
||||
* возвращает первый успешный URL картинки. Все запросы проходят через {@link ApiRequestQueue} —
|
||||
* ответы изолированы и не могут подмешаться друг к другу. Успешные URL кэшируются на короткое
|
||||
* время, чтобы повторные команды отвечали мгновенно и не перегружали внешние API.
|
||||
*/
|
||||
@Singleton
|
||||
public class AnimalApi {
|
||||
private static final Logger logger = LoggerFactory.getLogger(AnimalApi.class);
|
||||
private static final String NEKOS_BASE = "https://nekos.life/api/v2/img/";
|
||||
private static final long CACHE_TTL_SECONDS = 30;
|
||||
|
||||
private final ApiRequestQueue queue;
|
||||
private final OkHttpClient client;
|
||||
private final Cache<String, String> cache;
|
||||
|
||||
@Inject
|
||||
public AnimalApi(final ApiRequestQueue queue) {
|
||||
this(queue, buildClient());
|
||||
}
|
||||
|
||||
AnimalApi(final ApiRequestQueue queue, final OkHttpClient client) {
|
||||
this.queue = queue;
|
||||
this.client = client;
|
||||
this.cache =
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(64)
|
||||
.expireAfterWrite(CACHE_TTL_SECONDS, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Возвращает URL картинки для вида или null, если все эндпоинты недоступны. */
|
||||
public String fetchImage(final AnimalKind kind) {
|
||||
String cached = cache.getIfPresent(kind.name());
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
String url = queue.submit(() -> fetchImageBlocking(kind));
|
||||
if (url != null) {
|
||||
cache.put(kind.name(), url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private String fetchImageBlocking(final AnimalKind kind) {
|
||||
for (AnimalKind.Endpoint endpoint : kind.getEndpoints()) {
|
||||
String url = tryFetch(endpoint);
|
||||
if (url != null) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String tryFetch(final AnimalKind.Endpoint endpoint) {
|
||||
Request request = new Request.Builder().url(endpoint.url()).get().build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
logger.warn("Animal API error: {} for {}", response.code(), endpoint.url());
|
||||
return null;
|
||||
}
|
||||
JsonElement json = JsonParser.parseString(response.body().string());
|
||||
return endpoint.extractor().extract(json);
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
logger.warn("Failed to fetch {}", endpoint.url(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static OkHttpClient buildClient() {
|
||||
return new OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
static UrlExtractor objectKey(final String key) {
|
||||
return json -> json.getAsJsonObject().get(key).getAsString();
|
||||
}
|
||||
|
||||
static UrlExtractor firstArrayString() {
|
||||
return json -> json.getAsJsonArray().get(0).getAsString();
|
||||
}
|
||||
|
||||
static UrlExtractor firstArrayObjectKey(final String key) {
|
||||
return json -> json.getAsJsonArray().get(0).getAsJsonObject().get(key).getAsString();
|
||||
}
|
||||
|
||||
/** Вид животного/аниме-категории с цепочкой эндпоинтов (primary + fallback). */
|
||||
public enum AnimalKind {
|
||||
CAT(
|
||||
"🐱",
|
||||
List.of(
|
||||
new Endpoint(
|
||||
"https://api.thecatapi.com/v1/images/search", firstArrayObjectKey("url")))),
|
||||
DOG(
|
||||
"🐶",
|
||||
List.of(new Endpoint("https://dog.ceo/api/breeds/image/random", objectKey("message")))),
|
||||
FOX("🦊", List.of(new Endpoint("https://randomfox.ca/floof/", objectKey("image")))),
|
||||
SHIBE(
|
||||
"🐕",
|
||||
List.of(
|
||||
new Endpoint(
|
||||
"https://shibe.online/api/shibes?count=1&urls=true&httpsUrls=true",
|
||||
firstArrayString()),
|
||||
new Endpoint("https://dog.ceo/api/breed/shiba/images/random", objectKey("message")))),
|
||||
BIRB("🐦", List.of(new Endpoint("https://some-random-api.com/img/birb", objectKey("link")))),
|
||||
NEKO("😺", List.of(new Endpoint(NEKOS_BASE + "neko", objectKey("url")))),
|
||||
WAIFU(
|
||||
"🌸",
|
||||
List.of(
|
||||
new Endpoint("https://api.waifu.pics/sfw/waifu", objectKey("url")),
|
||||
new Endpoint(NEKOS_BASE + "waifu", objectKey("url")))),
|
||||
CATBOY(
|
||||
"😼",
|
||||
List.of(
|
||||
new Endpoint("https://api.catboys.com/img", objectKey("url")),
|
||||
new Endpoint(NEKOS_BASE + "neko", objectKey("url"))));
|
||||
|
||||
private final String emoji;
|
||||
private final List<Endpoint> endpoints;
|
||||
|
||||
AnimalKind(final String emoji, final List<Endpoint> endpoints) {
|
||||
this.emoji = emoji;
|
||||
this.endpoints = endpoints;
|
||||
}
|
||||
|
||||
public String getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
public List<Endpoint> getEndpoints() {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/** Эндпоинт с URL запроса и логикой извлечения картинки из JSON. */
|
||||
public record Endpoint(String url, UrlExtractor extractor) {}
|
||||
}
|
||||
|
||||
/** Извлекает URL картинки из JSON-ответа API. */
|
||||
@FunctionalInterface
|
||||
interface UrlExtractor {
|
||||
String extract(JsonElement json);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package creator.loki.feature.animals;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import creator.loki.core.registry.CommandRegistrar;
|
||||
import creator.loki.core.registry.CommandRegistry;
|
||||
import creator.loki.feature.animals.command.AnimalImageCommand;
|
||||
import creator.loki.feature.animals.command.ZooPersonCommand;
|
||||
import creator.loki.feature.entertainment.service.RatingService;
|
||||
|
||||
public class AnimalCommandRegistrar implements CommandRegistrar {
|
||||
private final RatingService ratingService;
|
||||
private final AnimalApi animalApi;
|
||||
|
||||
@Inject
|
||||
public AnimalCommandRegistrar(final RatingService ratingService, final AnimalApi animalApi) {
|
||||
this.ratingService = ratingService;
|
||||
this.animalApi = animalApi;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(final CommandRegistry registry) {
|
||||
for (ZooPersonCommand.Flavor flavor : ZooPersonCommand.Flavor.values()) {
|
||||
registry.register(new ZooPersonCommand(flavor, ratingService, animalApi));
|
||||
}
|
||||
for (AnimalImageCommand.Flavor flavor : AnimalImageCommand.Flavor.values()) {
|
||||
registry.register(new AnimalImageCommand(flavor, animalApi));
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main/java/creator/loki/feature/animals/AnimalModule.java
Normal file
23
src/main/java/creator/loki/feature/animals/AnimalModule.java
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package creator.loki.feature.animals;
|
||||
|
||||
import com.google.inject.AbstractModule;
|
||||
import com.google.inject.Provides;
|
||||
import com.google.inject.Singleton;
|
||||
import com.google.inject.multibindings.Multibinder;
|
||||
import creator.loki.core.registry.CommandRegistrar;
|
||||
import creator.loki.util.ApiRequestQueue;
|
||||
|
||||
public class AnimalModule extends AbstractModule {
|
||||
@Override
|
||||
protected void configure() {
|
||||
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
|
||||
.addBinding()
|
||||
.to(AnimalCommandRegistrar.class);
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
AnimalApi provideAnimalApi(final ApiRequestQueue queue) {
|
||||
return new AnimalApi(queue);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package creator.loki.feature.animals.command;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
import creator.loki.command.Command;
|
||||
import creator.loki.command.CommandCategories;
|
||||
import creator.loki.command.CommandContext;
|
||||
import creator.loki.embed.EmbedFactory;
|
||||
import creator.loki.embed.EmbedTemplates;
|
||||
import creator.loki.feature.animals.AnimalApi;
|
||||
import creator.loki.feature.animals.AnimalApi.AnimalKind;
|
||||
|
||||
/** Команда-картинка: случайное животное или аниме-категория. */
|
||||
public class AnimalImageCommand implements Command {
|
||||
|
||||
public enum Flavor {
|
||||
SHIBE("shibe", "Случайная шиба-ину", AnimalKind.SHIBE),
|
||||
BIRB("birb", "Случайная птичка", AnimalKind.BIRB),
|
||||
NEKO("neko", "Случайная неко", AnimalKind.NEKO),
|
||||
WAIFU("waifu", "Случайная вайфу", AnimalKind.WAIFU),
|
||||
CATBOY("catboy", "Случайный кошко-мальчик", AnimalKind.CATBOY);
|
||||
|
||||
private final String name;
|
||||
private final String title;
|
||||
private final AnimalKind kind;
|
||||
|
||||
Flavor(final String name, final String title, final AnimalKind kind) {
|
||||
this.name = name;
|
||||
this.title = title;
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public AnimalKind getKind() {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Color EMBED_COLOR = new Color(88, 101, 242);
|
||||
|
||||
private final Flavor flavor;
|
||||
private final AnimalApi animalApi;
|
||||
|
||||
public AnimalImageCommand(final Flavor flavor, final AnimalApi animalApi) {
|
||||
this.flavor = flavor;
|
||||
this.animalApi = animalApi;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return flavor.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return flavor.title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCategory() {
|
||||
return CommandCategories.ANIMALS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(final CommandContext context) {
|
||||
String imageUrl = animalApi.fetchImage(flavor.getKind());
|
||||
if (imageUrl == null) {
|
||||
context
|
||||
.getChannel()
|
||||
.sendMessageEmbeds(
|
||||
EmbedTemplates.error("Ошибка", "Не удалось загрузить картинку, попробуй позже."))
|
||||
.queue();
|
||||
return;
|
||||
}
|
||||
|
||||
context
|
||||
.getChannel()
|
||||
.sendMessageEmbeds(
|
||||
EmbedFactory.create()
|
||||
.withTitle(flavor.getKind().getEmoji() + " " + flavor.title)
|
||||
.withImage(imageUrl)
|
||||
.withColor(EMBED_COLOR)
|
||||
.withFooter("Источник: " + flavor.getKind().name())
|
||||
.build())
|
||||
.queue();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package creator.loki.feature.animals.command;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import creator.loki.command.Command;
|
||||
import creator.loki.command.CommandCategories;
|
||||
import creator.loki.command.CommandContext;
|
||||
import creator.loki.embed.EmbedFactory;
|
||||
import creator.loki.feature.animals.AnimalApi;
|
||||
import creator.loki.feature.animals.AnimalApi.AnimalKind;
|
||||
import creator.loki.feature.animals.config.AnimalPersonality;
|
||||
import creator.loki.feature.animals.config.AnimalPersonalityCatalog;
|
||||
import creator.loki.feature.entertainment.service.RatingService;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
|
||||
/** Зоо-викторина «Какой ты кот/пёс/лис»: процент + роль + картинка. */
|
||||
public class ZooPersonCommand implements Command {
|
||||
|
||||
public enum Flavor {
|
||||
CAT("catperson", "Какой ты кот", 1001, AnimalKind.CAT, AnimalPersonalityCatalog.catLevels()),
|
||||
DOG("dogperson", "Какой ты пёс", 1002, AnimalKind.DOG, AnimalPersonalityCatalog.dogLevels()),
|
||||
FOX("foxperson", "Какой ты лис", 1003, AnimalKind.FOX, AnimalPersonalityCatalog.foxLevels());
|
||||
|
||||
private final String name;
|
||||
private final String question;
|
||||
private final int salt;
|
||||
private final AnimalKind kind;
|
||||
private final List<AnimalPersonality> levels;
|
||||
|
||||
Flavor(
|
||||
final String name,
|
||||
final String question,
|
||||
final int salt,
|
||||
final AnimalKind kind,
|
||||
final List<AnimalPersonality> levels) {
|
||||
this.name = name;
|
||||
this.question = question;
|
||||
this.salt = salt;
|
||||
this.kind = kind;
|
||||
this.levels = levels;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getQuestion() {
|
||||
return question;
|
||||
}
|
||||
|
||||
public AnimalKind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public List<AnimalPersonality> getLevels() {
|
||||
return levels;
|
||||
}
|
||||
|
||||
int getSalt() {
|
||||
return salt;
|
||||
}
|
||||
}
|
||||
|
||||
private final Flavor flavor;
|
||||
private final RatingService ratingService;
|
||||
private final AnimalApi animalApi;
|
||||
|
||||
public ZooPersonCommand(
|
||||
final Flavor flavor, final RatingService ratingService, final AnimalApi animalApi) {
|
||||
this.flavor = flavor;
|
||||
this.ratingService = ratingService;
|
||||
this.animalApi = animalApi;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return flavor.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return flavor.getQuestion();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsage() {
|
||||
return getName() + " [@user]";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCategory() {
|
||||
return CommandCategories.ANIMALS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(final CommandContext context) {
|
||||
if (!ratingService.checkAndApplyCooldown(context, getName())) {
|
||||
return;
|
||||
}
|
||||
|
||||
Member target = ratingService.resolveTarget(context);
|
||||
int percent = ratingService.getDeterministicValue(target.getId(), flavor.getSalt(), 0, 100);
|
||||
AnimalPersonality personality = AnimalPersonality.find(percent, flavor.getLevels());
|
||||
String imageUrl = animalApi.fetchImage(flavor.getKind());
|
||||
|
||||
EmbedFactory embed =
|
||||
EmbedFactory.create()
|
||||
.withTitle(flavor.getKind().getEmoji() + " " + flavor.getQuestion())
|
||||
.withDescription(
|
||||
"**"
|
||||
+ personality.getTitle()
|
||||
+ "** — "
|
||||
+ percent
|
||||
+ "%\n\n"
|
||||
+ personality.getDescription())
|
||||
.withThumbnail(target.getUser().getAvatarUrl())
|
||||
.withColor(personality.getColor())
|
||||
.withFooter("💡 Детерминировано вашим Discord ID");
|
||||
if (imageUrl != null) {
|
||||
embed.withImage(imageUrl);
|
||||
}
|
||||
|
||||
context.getChannel().sendMessageEmbeds(embed.build()).queue();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package creator.loki.feature.animals.config;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.util.List;
|
||||
|
||||
/** Роль животного-персонажа с порогом, описанием и цветом. */
|
||||
public class AnimalPersonality {
|
||||
private final int threshold;
|
||||
private final String title;
|
||||
private final String description;
|
||||
private final Color color;
|
||||
|
||||
public AnimalPersonality(
|
||||
final int threshold, final String title, final String description, final Color color) {
|
||||
this.threshold = threshold;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public int getThreshold() {
|
||||
return threshold;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public Color getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит роль для значения (список должен быть отсортирован по убыванию threshold).
|
||||
*
|
||||
* @param value значение процента 0-100
|
||||
* @param levels список ролей
|
||||
* @return подходящая роль
|
||||
*/
|
||||
public static AnimalPersonality find(final int value, final List<AnimalPersonality> levels) {
|
||||
return levels.stream()
|
||||
.filter(level -> value >= level.threshold)
|
||||
.findFirst()
|
||||
.orElse(levels.get(levels.size() - 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package creator.loki.feature.animals.config;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.util.List;
|
||||
|
||||
/** Каталог ролей животных для зоо-викторин. */
|
||||
public final class AnimalPersonalityCatalog {
|
||||
private AnimalPersonalityCatalog() {}
|
||||
|
||||
private static final List<AnimalPersonality> CAT =
|
||||
List.of(
|
||||
new AnimalPersonality(
|
||||
90,
|
||||
"Король котов",
|
||||
"Грациозный хищник, весь дом — твой личный трон.",
|
||||
new Color(255, 215, 0)),
|
||||
new AnimalPersonality(
|
||||
70,
|
||||
"Британский вислоухий",
|
||||
"Спокоен, пока не надо кормить. Уважаешь порядок и диван.",
|
||||
new Color(139, 69, 19)),
|
||||
new AnimalPersonality(
|
||||
50,
|
||||
"Дворовый кот",
|
||||
"Любишь свободу и вкусняшки. Мурчишь только тем, кто заслужил.",
|
||||
new Color(255, 165, 0)),
|
||||
new AnimalPersonality(
|
||||
30,
|
||||
"Котёнок",
|
||||
"Милый и игривый, но наглый, когда чего-то хочешь.",
|
||||
new Color(100, 200, 255)),
|
||||
new AnimalPersonality(
|
||||
0,
|
||||
"Дикий кот",
|
||||
"Не даёшь себя гладить, но внимательно следишь за всеми.",
|
||||
new Color(128, 128, 128)));
|
||||
|
||||
private static final List<AnimalPersonality> DOG =
|
||||
List.of(
|
||||
new AnimalPersonality(
|
||||
90,
|
||||
"Альфа-волк",
|
||||
"Вожак стаи. Защищаешь своих и метишь территорию.",
|
||||
new Color(255, 215, 0)),
|
||||
new AnimalPersonality(
|
||||
70,
|
||||
"Немецкая овчарка",
|
||||
"Преданный и умный, всегда на страже порядка.",
|
||||
new Color(139, 69, 19)),
|
||||
new AnimalPersonality(
|
||||
50,
|
||||
"Золотистый ретривер",
|
||||
"Друг для всех. Хвост виляет сам, даже когда спишь.",
|
||||
new Color(255, 165, 0)),
|
||||
new AnimalPersonality(
|
||||
30,
|
||||
"Мопс",
|
||||
"Ленивый, но невероятно обаятельный. Храпишь с достоинством.",
|
||||
new Color(160, 82, 45)),
|
||||
new AnimalPersonality(
|
||||
0,
|
||||
"Бродяга",
|
||||
"Свободная душа. Ищешь свой дом, но уходишь, если скучно.",
|
||||
new Color(100, 100, 100)));
|
||||
|
||||
private static final List<AnimalPersonality> FOX =
|
||||
List.of(
|
||||
new AnimalPersonality(
|
||||
90,
|
||||
"Мифический девятихвостый",
|
||||
"Хитрость и мудрость на высшем уровне. Все к тебе приходят за советом.",
|
||||
new Color(255, 215, 0)),
|
||||
new AnimalPersonality(
|
||||
70,
|
||||
"Огненный лис",
|
||||
"Яркий и заметный. На тебя оборачиваются, а ты лишь ухмыляешься.",
|
||||
new Color(255, 69, 0)),
|
||||
new AnimalPersonality(
|
||||
50,
|
||||
"Обычный лис",
|
||||
"Хитрожопый, но честный, когда это выгодно.",
|
||||
new Color(255, 140, 0)),
|
||||
new AnimalPersonality(
|
||||
30,
|
||||
"Лисёнок",
|
||||
"Любопытный и милый, всё нюхаешь и лезешь куда не просят.",
|
||||
new Color(255, 200, 150)),
|
||||
new AnimalPersonality(
|
||||
0,
|
||||
"Полярный лис",
|
||||
"Холодный и загадочный. Друзей мало, но зато настоящие.",
|
||||
new Color(200, 230, 255)));
|
||||
|
||||
public static List<AnimalPersonality> catLevels() {
|
||||
return CAT;
|
||||
}
|
||||
|
||||
public static List<AnimalPersonality> dogLevels() {
|
||||
return DOG;
|
||||
}
|
||||
|
||||
public static List<AnimalPersonality> foxLevels() {
|
||||
return FOX;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
package creator.loki.feature.entertainment.commands;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
|
@ -13,8 +10,22 @@ import creator.loki.command.Command;
|
|||
import creator.loki.command.CommandCategories;
|
||||
import creator.loki.command.CommandContext;
|
||||
import creator.loki.embed.EmbedFactory;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
public class MemeCommand implements Command {
|
||||
private static final String MEME_API = "https://meme-api.com/gimme";
|
||||
|
||||
private final OkHttpClient client;
|
||||
|
||||
public MemeCommand() {
|
||||
this.client =
|
||||
new OkHttpClient.Builder()
|
||||
.connectTimeout(6, TimeUnit.SECONDS)
|
||||
.readTimeout(8, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
|
|
@ -44,22 +55,7 @@ public class MemeCommand implements Command {
|
|||
.queue(
|
||||
loadingMsg -> {
|
||||
try {
|
||||
var url = new URI("https://meme-api.com/gimme").toURL();
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
|
||||
|
||||
BufferedReader in =
|
||||
new BufferedReader(
|
||||
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
|
||||
StringBuilder response = new StringBuilder();
|
||||
String line;
|
||||
while ((line = in.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
in.close();
|
||||
|
||||
JsonObject json = JsonParser.parseString(response.toString()).getAsJsonObject();
|
||||
JsonObject json = fetchMeme();
|
||||
String memeUrl = json.get("url").getAsString();
|
||||
String title = json.get("title").getAsString();
|
||||
String subreddit = json.get("subreddit").getAsString();
|
||||
|
|
@ -74,9 +70,19 @@ public class MemeCommand implements Command {
|
|||
.build())
|
||||
.queue();
|
||||
|
||||
} catch (Exception e) {
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
loadingMsg.editMessage("❌ Не удалось загрузить мем: " + e.getMessage()).queue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private JsonObject fetchMeme() throws IOException {
|
||||
Request request = new Request.Builder().url(MEME_API).get().build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("meme-api вернул " + response.code());
|
||||
}
|
||||
return JsonParser.parseString(response.body().string()).getAsJsonObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.google.gson.JsonObject;
|
|||
import com.google.gson.JsonParser;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import creator.loki.util.ApiRequestQueue;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
|
@ -20,24 +21,29 @@ public class NekosLifeApi {
|
|||
|
||||
private static NekosLifeApi instance;
|
||||
|
||||
private final ApiRequestQueue queue;
|
||||
private final OkHttpClient client;
|
||||
|
||||
@Inject
|
||||
public NekosLifeApi() {
|
||||
this.client =
|
||||
new OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build();
|
||||
NekosLifeApi.instance = this;
|
||||
public NekosLifeApi(final ApiRequestQueue queue) {
|
||||
this(queue, buildClient());
|
||||
}
|
||||
|
||||
NekosLifeApi(OkHttpClient client) {
|
||||
public NekosLifeApi() {
|
||||
this(new ApiRequestQueue(), buildClient());
|
||||
}
|
||||
|
||||
NekosLifeApi(final ApiRequestQueue queue, final OkHttpClient client) {
|
||||
this.queue = queue;
|
||||
this.client = client;
|
||||
NekosLifeApi.instance = this;
|
||||
}
|
||||
|
||||
public String getImage(final String category) {
|
||||
return queue.submit(() -> getImageBlocking(category));
|
||||
}
|
||||
|
||||
private String getImageBlocking(final String category) {
|
||||
int maxRetries = 3;
|
||||
long retryDelay = 1000;
|
||||
|
||||
|
|
@ -93,6 +99,13 @@ public class NekosLifeApi {
|
|||
}
|
||||
}
|
||||
|
||||
private static OkHttpClient buildClient() {
|
||||
return new OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static String fetchImage(final String category) {
|
||||
if (instance == null) {
|
||||
instance = new NekosLifeApi();
|
||||
|
|
|
|||
59
src/main/java/creator/loki/util/ApiRequestQueue.java
Normal file
59
src/main/java/creator/loki/util/ApiRequestQueue.java
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package creator.loki.util;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import creator.loki.core.Lifecycle;
|
||||
|
||||
/**
|
||||
* Очередь исходящих API-запросов. Каждый запрос выполняется изолированно в своём потоке (пул
|
||||
* фиксированного размера), поэтому ответ всегда привязан к своему запросу и не может «подмениться»
|
||||
* под соседний, а одновременные запросы не перегружают внешние API.
|
||||
*/
|
||||
public class ApiRequestQueue implements Lifecycle {
|
||||
private static final int DEFAULT_CONCURRENCY = 4;
|
||||
|
||||
private final ExecutorService executor;
|
||||
|
||||
public ApiRequestQueue() {
|
||||
this(DEFAULT_CONCURRENCY);
|
||||
}
|
||||
|
||||
public ApiRequestQueue(final int maxConcurrent) {
|
||||
this.executor = Executors.newFixedThreadPool(maxConcurrent);
|
||||
}
|
||||
|
||||
ApiRequestQueue(final ExecutorService executor) {
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
/** Выполняет задачу через очередь и блокирует до получения результата. */
|
||||
public <T> T submit(final Supplier<T> task) {
|
||||
Future<T> future = executor.submit(task::get);
|
||||
try {
|
||||
return future.get();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (ExecutionException e) {
|
||||
throw new IllegalStateException("API request failed", e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
executor.shutdown();
|
||||
try {
|
||||
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
executor.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package creator.loki.feature.animals;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import creator.loki.feature.animals.AnimalApi.UrlExtractor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AnimalApiTest {
|
||||
|
||||
@Test
|
||||
void objectKeyExtractsValueByKey() {
|
||||
UrlExtractor extractor = AnimalApi.objectKey("url");
|
||||
|
||||
String url = extractor.extract(JsonParser.parseString("{\"url\":\"https://x/y.jpg\"}"));
|
||||
|
||||
assertEquals("https://x/y.jpg", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstArrayStringExtractsFirstElement() {
|
||||
UrlExtractor extractor = AnimalApi.firstArrayString();
|
||||
|
||||
String url = extractor.extract(JsonParser.parseString("[\"https://x/y.jpg\"]"));
|
||||
|
||||
assertEquals("https://x/y.jpg", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstArrayObjectKeyExtractsKeyFromFirstObject() {
|
||||
UrlExtractor extractor = AnimalApi.firstArrayObjectKey("url");
|
||||
|
||||
String url = extractor.extract(JsonParser.parseString("[{\"url\":\"https://x/y.jpg\"}]"));
|
||||
|
||||
assertEquals("https://x/y.jpg", url);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package creator.loki.feature.animals.command;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import creator.loki.command.CommandContext;
|
||||
import creator.loki.feature.animals.AnimalApi;
|
||||
import creator.loki.feature.animals.AnimalApi.AnimalKind;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||
import net.dv8tion.jda.api.requests.restaction.MessageCreateAction;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AnimalImageCommandTest {
|
||||
|
||||
@Mock AnimalApi animalApi;
|
||||
@Mock MessageChannel channel;
|
||||
@Mock Message message;
|
||||
@Mock MessageCreateAction sendAction;
|
||||
|
||||
@Captor ArgumentCaptor<MessageEmbed> embedCaptor;
|
||||
|
||||
@Test
|
||||
void sendsImage() {
|
||||
when(animalApi.fetchImage(any(AnimalKind.class))).thenReturn("https://shibe.jpg");
|
||||
when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendAction);
|
||||
|
||||
new AnimalImageCommand(AnimalImageCommand.Flavor.SHIBE, animalApi)
|
||||
.execute(new CommandContext(message, List.of(), channel, null, null));
|
||||
|
||||
verify(channel).sendMessageEmbeds(embedCaptor.capture());
|
||||
MessageEmbed embed = embedCaptor.getValue();
|
||||
assertTrue(embed.getTitle().contains("шиба"));
|
||||
assertNotNull(embed.getImage());
|
||||
assertEquals("https://shibe.jpg", embed.getImage().getUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void imageNullShowsError() {
|
||||
when(animalApi.fetchImage(any(AnimalKind.class))).thenReturn(null);
|
||||
when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendAction);
|
||||
|
||||
new AnimalImageCommand(AnimalImageCommand.Flavor.SHIBE, animalApi)
|
||||
.execute(new CommandContext(message, List.of(), channel, null, null));
|
||||
|
||||
verify(channel).sendMessageEmbeds(embedCaptor.capture());
|
||||
assertTrue(embedCaptor.getValue().getTitle().startsWith("❌"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package creator.loki.feature.animals.command;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import creator.loki.command.CommandContext;
|
||||
import creator.loki.feature.animals.AnimalApi;
|
||||
import creator.loki.feature.animals.AnimalApi.AnimalKind;
|
||||
import creator.loki.feature.entertainment.service.RatingService;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||
import net.dv8tion.jda.api.entities.User;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||
import net.dv8tion.jda.api.requests.restaction.MessageCreateAction;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ZooPersonCommandTest {
|
||||
|
||||
@Mock RatingService ratingService;
|
||||
@Mock AnimalApi animalApi;
|
||||
@Mock MessageChannel channel;
|
||||
@Mock Message message;
|
||||
@Mock Member member;
|
||||
@Mock User user;
|
||||
@Mock MessageCreateAction sendAction;
|
||||
|
||||
@Captor ArgumentCaptor<MessageEmbed> embedCaptor;
|
||||
|
||||
@Test
|
||||
void cooldownBlocksMessageSend() {
|
||||
when(ratingService.checkAndApplyCooldown(any(), anyString())).thenReturn(false);
|
||||
|
||||
new ZooPersonCommand(ZooPersonCommand.Flavor.CAT, ratingService, animalApi)
|
||||
.execute(new CommandContext(message, List.of(), channel, member, null));
|
||||
|
||||
verify(channel, never()).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsQuizEmbed() {
|
||||
when(ratingService.checkAndApplyCooldown(any(), anyString())).thenReturn(true);
|
||||
when(ratingService.resolveTarget(any())).thenReturn(member);
|
||||
when(member.getId()).thenReturn("123");
|
||||
when(member.getUser()).thenReturn(user);
|
||||
when(user.getAvatarUrl()).thenReturn("https://avatar.png");
|
||||
when(ratingService.getDeterministicValue(anyString(), anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(73);
|
||||
when(animalApi.fetchImage(any(AnimalKind.class))).thenReturn("https://cat.jpg");
|
||||
when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendAction);
|
||||
|
||||
new ZooPersonCommand(ZooPersonCommand.Flavor.CAT, ratingService, animalApi)
|
||||
.execute(new CommandContext(message, List.of(), channel, member, null));
|
||||
|
||||
verify(channel).sendMessageEmbeds(embedCaptor.capture());
|
||||
MessageEmbed embed = embedCaptor.getValue();
|
||||
assertTrue(embed.getTitle().contains("Какой ты кот"));
|
||||
assertTrue(embed.getDescription().contains("73%"));
|
||||
assertNotNull(embed.getImage());
|
||||
assertEquals("https://cat.jpg", embed.getImage().getUrl());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package creator.loki.feature.animals.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AnimalPersonalityCatalogTest {
|
||||
|
||||
@Test
|
||||
void catLevelsAreSortedDescendingAndBounded() {
|
||||
assertLevels(AnimalPersonalityCatalog.catLevels());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dogLevelsAreSortedDescendingAndBounded() {
|
||||
assertLevels(AnimalPersonalityCatalog.dogLevels());
|
||||
}
|
||||
|
||||
@Test
|
||||
void foxLevelsAreSortedDescendingAndBounded() {
|
||||
assertLevels(AnimalPersonalityCatalog.foxLevels());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findReturnsLastLevelForZero() {
|
||||
List<AnimalPersonality> levels = AnimalPersonalityCatalog.catLevels();
|
||||
|
||||
assertSame(last(levels), AnimalPersonality.find(0, levels));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findReturnsFirstLevelForHundred() {
|
||||
List<AnimalPersonality> levels = AnimalPersonalityCatalog.catLevels();
|
||||
|
||||
assertSame(first(levels), AnimalPersonality.find(100, levels));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findReturnsLevelWithExactThreshold() {
|
||||
List<AnimalPersonality> levels = AnimalPersonalityCatalog.catLevels();
|
||||
AnimalPersonality second = levels.get(1);
|
||||
|
||||
assertSame(second, AnimalPersonality.find(second.getThreshold(), levels));
|
||||
}
|
||||
|
||||
private static void assertLevels(final List<AnimalPersonality> levels) {
|
||||
assertFalse(levels.isEmpty());
|
||||
assertEquals(0, last(levels).getThreshold());
|
||||
assertTrue(first(levels).getThreshold() > 0);
|
||||
for (int i = 0; i < levels.size() - 1; i++) {
|
||||
assertTrue(levels.get(i).getThreshold() > levels.get(i + 1).getThreshold());
|
||||
}
|
||||
}
|
||||
|
||||
private static AnimalPersonality first(final List<AnimalPersonality> levels) {
|
||||
return levels.get(0);
|
||||
}
|
||||
|
||||
private static AnimalPersonality last(final List<AnimalPersonality> levels) {
|
||||
return levels.get(levels.size() - 1);
|
||||
}
|
||||
}
|
||||
61
src/test/java/creator/loki/util/ApiRequestQueueTest.java
Normal file
61
src/test/java/creator/loki/util/ApiRequestQueueTest.java
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package creator.loki.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ApiRequestQueueTest {
|
||||
|
||||
@Test
|
||||
void submitReturnsTaskResult() {
|
||||
var queue = new ApiRequestQueue(1);
|
||||
try {
|
||||
assertEquals("ok", queue.submit(() -> "ok"));
|
||||
} finally {
|
||||
queue.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentSubmissionsGetOwnResults() throws Exception {
|
||||
var queue = new ApiRequestQueue(4);
|
||||
try {
|
||||
try (var pool = Executors.newFixedThreadPool(8)) {
|
||||
List<Callable<String>> tasks =
|
||||
IntStream.range(0, 40)
|
||||
.mapToObj(
|
||||
i ->
|
||||
(Callable<String>)
|
||||
() ->
|
||||
queue.submit(
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(5);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return "result-" + i;
|
||||
}))
|
||||
.collect(Collectors.toList());
|
||||
List<Future<String>> futures = pool.invokeAll(tasks);
|
||||
List<String> results = new ArrayList<>();
|
||||
for (Future<String> future : futures) {
|
||||
results.add(future.get());
|
||||
}
|
||||
for (int i = 0; i < 40; i++) {
|
||||
assertEquals("result-" + i, results.get(i), "response must match its request");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
queue.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue