diff --git a/pom.xml b/pom.xml index f508fbd..6509584 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,12 @@ provided + + commons-codec + commons-codec + 1.16.0 + + org.apache.logging.log4j diff --git a/src/main/java/edu/hw8/task1/QuotesClient.java b/src/main/java/edu/hw8/task1/QuotesClient.java new file mode 100644 index 0000000..25852a7 --- /dev/null +++ b/src/main/java/edu/hw8/task1/QuotesClient.java @@ -0,0 +1,51 @@ +package edu.hw8.task1; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import lombok.SneakyThrows; + +public class QuotesClient { + private final String address; + private final int port; + private final ByteBuffer buffer = ByteBuffer.allocate(1024); + private SocketChannel clientChannel; + + public QuotesClient(String address, int port) { + this.address = address; + this.port = port; + } + + @SneakyThrows + public void start() { + clientChannel = SocketChannel.open(new InetSocketAddress(address, port)); + clientChannel.configureBlocking(false); + } + + @SneakyThrows + public String requestQuote(String message) { + clientChannel.write(ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8))); + return readFromServer(); + } + + @SneakyThrows + private String readFromServer() { + buffer.clear(); + StringBuilder answer = new StringBuilder(); + while (clientChannel.read(buffer) > 0 || answer.isEmpty()) { + buffer.flip(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + answer.append(new String(bytes, StandardCharsets.UTF_8)); + buffer.clear(); + } + return answer.toString(); + } + + @SneakyThrows + public void close() { + clientChannel.close(); + } + +} diff --git a/src/main/java/edu/hw8/task1/QuotesServer.java b/src/main/java/edu/hw8/task1/QuotesServer.java new file mode 100644 index 0000000..e3c754e --- /dev/null +++ b/src/main/java/edu/hw8/task1/QuotesServer.java @@ -0,0 +1,80 @@ +package edu.hw8.task1; + +import java.net.InetSocketAddress; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.util.Iterator; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.function.Consumer; +import lombok.SneakyThrows; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class QuotesServer { + private static final Logger LOGGER = LogManager.getLogger(QuotesServer.class); + private final int port; + private final QuotesStorage quotesStorage; + private final ExecutorService executorService; + private final Semaphore parallelConnectionSemaphore; + private Consumer messageConsumer; + private ServerSocketChannel serverSocketChannel; + + public QuotesServer(int port, QuotesStorage quotesStorage, int parallelConnections) { + this.port = port; + this.quotesStorage = quotesStorage; + this.parallelConnectionSemaphore = new Semaphore(parallelConnections); + this.executorService = Executors.newFixedThreadPool(parallelConnections); + this.messageConsumer = message -> LOGGER.info("Ваня: {}", message); + } + + @SneakyThrows + public void start() { + try (ServerSocketChannel channel = ServerSocketChannel.open()) { + serverSocketChannel = channel; + Selector selector = Selector.open(); + channel.configureBlocking(false); + channel.register(selector, SelectionKey.OP_ACCEPT); + channel.bind(new InetSocketAddress(port)); + processConnections(channel, selector); + } + } + + @SneakyThrows + private void processConnections(ServerSocketChannel channel, Selector selector) { + while (channel.isOpen()) { + if (selector.selectNow() > 0 || !selector.selectedKeys().isEmpty()) { + Iterator iterator = selector.selectedKeys().iterator(); + while (iterator.hasNext()) { + SelectionKey key = iterator.next(); + if (key.isAcceptable() && parallelConnectionSemaphore.tryAcquire()) { + accept(channel); + iterator.remove(); + } + } + } + } + } + + @SneakyThrows + private void accept(ServerSocketChannel channel) { + SocketChannel clientChannel = channel.accept(); + executorService.execute(new QuotesServerWorker(clientChannel, quotesStorage) + .afterClosing(parallelConnectionSemaphore::release) + .onMessage(messageConsumer) + ); + } + + @SneakyThrows + public void stop() { + serverSocketChannel.close(); + executorService.shutdownNow(); + } + + public void setMessageConsumer(Consumer messageConsumer) { + this.messageConsumer = messageConsumer; + } +} diff --git a/src/main/java/edu/hw8/task1/QuotesServerWorker.java b/src/main/java/edu/hw8/task1/QuotesServerWorker.java new file mode 100644 index 0000000..2fdfdf9 --- /dev/null +++ b/src/main/java/edu/hw8/task1/QuotesServerWorker.java @@ -0,0 +1,89 @@ +package edu.hw8.task1; + +import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import java.util.function.Consumer; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; + +@RequiredArgsConstructor +public class QuotesServerWorker implements Runnable { + + private final SocketChannel clientChannel; + private final QuotesStorage quotesStorage; + private boolean isConnected = true; + private final ByteBuffer byteBuffer = ByteBuffer.allocate(1024); + private Runnable after; + private Consumer messageConsumer; + + @SneakyThrows @Override + public void run() { + Selector selector = Selector.open(); + clientChannel.configureBlocking(false); + clientChannel.register(selector, SelectionKey.OP_READ); + while (isConnected) { + if (selector.selectNow() > 0) { + Iterator iterator = selector.selectedKeys().iterator(); + while (iterator.hasNext()) { + SelectionKey key = iterator.next(); + if (key.isReadable()) { + String message = readMessageFromClient(); + if (message == null) { + isConnected = false; + break; + } + if (messageConsumer != null) { + messageConsumer.accept(message); + } + writeMessageToClient(quotesStorage.getQuote(message)); + } + iterator.remove(); + } + } + } + if (after != null) { + after.run(); + } + } + + @SneakyThrows + private String readMessageFromClient() { + try { + StringBuilder message = new StringBuilder(); + int read = clientChannel.read(byteBuffer); + if (read <= 0) { + return null; + } + while (read > 0) { + byteBuffer.flip(); + byte[] bytesArray = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytesArray); + message.append(new String(bytesArray, StandardCharsets.UTF_8)); + byteBuffer.clear(); + read = clientChannel.read(byteBuffer); + } + return message.toString(); + } catch (Exception e) { + return null; + } + } + + @SneakyThrows + private void writeMessageToClient(String message) { + clientChannel.write(ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8))); + } + + public QuotesServerWorker afterClosing(Runnable after) { + this.after = after; + return this; + } + + public QuotesServerWorker onMessage(Consumer messageSupplier) { + this.messageConsumer = messageSupplier; + return this; + } +} diff --git a/src/main/java/edu/hw8/task1/QuotesStorage.java b/src/main/java/edu/hw8/task1/QuotesStorage.java new file mode 100644 index 0000000..be27098 --- /dev/null +++ b/src/main/java/edu/hw8/task1/QuotesStorage.java @@ -0,0 +1,34 @@ +package edu.hw8.task1; + +import java.util.HashMap; +import java.util.Map; + +public class QuotesStorage { + private final Map quotesMap = new HashMap<>(); + + public static QuotesStorage createDefault() { + QuotesStorage quotesStorage = new QuotesStorage(); + quotesStorage.appendQuote("личности", "Не переходи на личности там, где их нет"); + quotesStorage.appendQuote( + "оскорбления", + "Если твои противники перешли на личные оскорбления, будь уверена — твоя победа не за горами" + ); + quotesStorage.appendQuote( + "глупый", + "А я тебе говорил, что ты глупый? Так вот, я забираю свои слова обратно... Ты просто бог идиотизма" + ); + quotesStorage.appendQuote( + "интеллект", + "Чем ниже интеллект, тем громче оскорбления" + ); + return quotesStorage; + } + + public String getQuote(String id) { + return quotesMap.getOrDefault(id, "Неизвестная цитата"); + } + + public void appendQuote(String id, String quote) { + quotesMap.put(id, quote); + } +} diff --git a/src/main/java/edu/hw8/task2/FibonacciComputer.java b/src/main/java/edu/hw8/task2/FibonacciComputer.java new file mode 100644 index 0000000..4ab7b7e --- /dev/null +++ b/src/main/java/edu/hw8/task2/FibonacciComputer.java @@ -0,0 +1,33 @@ +package edu.hw8.task2; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class FibonacciComputer { + + private FibonacciComputer() { + } + + public static int compute(int n) { + if (n == 0) { + return 0; + } + if (n == 1) { + return 1; + } + return compute(n - 1) + compute(n - 2); + } + + public static List computeList(List list, int threadsCount) { + ThreadPool threadPool = FixedThreadPool.create(threadsCount); + threadPool.start(); + List result = new CopyOnWriteArrayList<>(); + for (int i = 0; i < list.size(); i++) { + int current = i; + threadPool.execute(() -> result.add(FibonacciComputer.compute(list.get(current)))); + } + threadPool.close(); + threadPool.awaitTermination(); + return result; + } +} diff --git a/src/main/java/edu/hw8/task2/FixedThreadPool.java b/src/main/java/edu/hw8/task2/FixedThreadPool.java new file mode 100644 index 0000000..6ceff9f --- /dev/null +++ b/src/main/java/edu/hw8/task2/FixedThreadPool.java @@ -0,0 +1,85 @@ +package edu.hw8.task2; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.SneakyThrows; + +public final class FixedThreadPool implements ThreadPool { + + private final int poolSize; + private final FixedThreadPoolWorker[] workers; + private final BlockingQueue runnableBlockingQueue; + private final AtomicInteger currentWorkersCount = new AtomicInteger(0); + private final AtomicBoolean canAcceptTasks = new AtomicBoolean(false); + + private FixedThreadPool(int poolSize) { + if (poolSize <= 0) { + throw new IllegalArgumentException("Pool size must be greater than 0"); + } + this.poolSize = poolSize; + this.workers = new FixedThreadPoolWorker[poolSize]; + runnableBlockingQueue = new LinkedBlockingQueue<>(); + } + + public static FixedThreadPool create(int poolSize) { + return new FixedThreadPool(poolSize); + } + + @SneakyThrows + public void execute(Runnable task) { + if (canAcceptTasks.get()) { + if (currentWorkersCount.get() < poolSize) { + workers[currentWorkersCount.get()] = new FixedThreadPoolWorker(); // Lazy initialization + currentWorkersCount.incrementAndGet(); + } + runnableBlockingQueue.put(task); + } + } + + @Override + public void start() { + canAcceptTasks.set(true); + } + + @Override + public void awaitTermination() { + for (FixedThreadPoolWorker worker : workers) { + worker.join(); + } + } + + @Override + public void close() { + canAcceptTasks.set(false); + } + + public class FixedThreadPoolWorker implements Runnable { + + private Runnable task; + private final Thread thread; + + public FixedThreadPoolWorker() { + thread = new Thread(this); + thread.start(); + } + + public void run() { + while (canAcceptTasks.get() || !runnableBlockingQueue.isEmpty() || task != null) { + if (task == null) { + task = runnableBlockingQueue.poll(); + continue; + } + task.run(); + task = runnableBlockingQueue.poll(); + } + } + + @SneakyThrows + public void join() { + thread.join(); + } + } + +} diff --git a/src/main/java/edu/hw8/task2/ThreadPool.java b/src/main/java/edu/hw8/task2/ThreadPool.java new file mode 100644 index 0000000..d9a5795 --- /dev/null +++ b/src/main/java/edu/hw8/task2/ThreadPool.java @@ -0,0 +1,13 @@ +package edu.hw8.task2; + +public interface ThreadPool extends AutoCloseable { + + void execute(Runnable runnable); + + void start(); + + void awaitTermination(); + + @Override + void close(); +} diff --git a/src/main/java/edu/hw8/task3/AbstractPasswordsDecoder.java b/src/main/java/edu/hw8/task3/AbstractPasswordsDecoder.java new file mode 100644 index 0000000..85d904c --- /dev/null +++ b/src/main/java/edu/hw8/task3/AbstractPasswordsDecoder.java @@ -0,0 +1,132 @@ +package edu.hw8.task3; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; +import lombok.SneakyThrows; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; + +public abstract class AbstractPasswordsDecoder implements PasswordsDecoder { + + private static final int MINIMAL_PASSWORD_LENGTH = 4; + private static final int MAX_PASSWORD_LENGTH = 6; + + // Actually, we not need to synchronize userMap and decoded, + // because probability of hash collision is very small with MD5 algorithm + protected Map usersMap; + protected final List decoded = new CopyOnWriteArrayList<>(); + protected static final byte[] ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789".getBytes(StandardCharsets.UTF_8); + + public AbstractPasswordsDecoder(Map usersMap) { + this.usersMap = usersMap + .entrySet() + .stream().collect(Collectors.toMap( + entry -> { + try { + // To provide more performance + return new ByteArray(Hex.decodeHex(entry.getValue().toCharArray())); + } catch (DecoderException e) { + throw new RuntimeException(e); + } + }, + Map.Entry::getKey + )); + } + + @Override + public abstract List decode(); + + @SneakyThrows + protected void decodeInRange(int min, int max) { + boolean containsSmallPasswords = min != -1; + int[] indexes = + createIndexes(MAX_PASSWORD_LENGTH, containsSmallPasswords ? MINIMAL_PASSWORD_LENGTH : MAX_PASSWORD_LENGTH); + indexes[indexes.length - 1] = min; + // Create there to provide each thread new MessageDigest instance + MessageDigest messageDigest = MessageDigest.getInstance("MD5"); + while (indexes[indexes.length - 1] < max) { + if (decoded.size() == usersMap.size()) { + break; + } + var bytes = nextPassword(indexes); + ByteArray encodedPassword = encodeMd5(bytes, messageDigest); + if (usersMap.containsKey(encodedPassword)) { + decoded.add(new User(usersMap.get(encodedPassword), new String(bytes))); + } + } + } + + private int[] createIndexes(int passwordLength, int minimumLength) { + int[] indexes = new int[passwordLength]; + for (int i = 0; i < indexes.length; i++) { + if (i >= minimumLength) { + indexes[i] = -1; + } else { + indexes[i] = 0; + } + } + return indexes; + } + + private byte[] nextPassword(int[] indexes) { + byte[] bytes = makeByteString(indexes); + addOneToIndexes(indexes, ALPHABET.length); + return bytes; + } + + private byte[] makeByteString(int[] indexes) { + byte[] bytes = new byte[indexes.length]; + int realCount = indexes.length; + for (int i = 0; i < indexes.length; i++) { + if (indexes[i] < 0) { + realCount = i; + break; + } + bytes[i] = ALPHABET[indexes[i]]; + } + bytes = Arrays.copyOf(bytes, realCount); + return bytes; + } + + private void addOneToIndexes(int[] indexes, int max) { + indexes[0]++; + for (int i = 0; i < indexes.length - 1; i++) { + if (indexes[i] >= max) { + indexes[i] = 0; + indexes[i + 1]++; + } else { + break; + } + } + } + + @SneakyThrows + private ByteArray encodeMd5(byte[] password, MessageDigest messageDigest) { + messageDigest.update(password); + return new ByteArray(messageDigest.digest()); + } + + protected record ByteArray(byte[] array) { + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (object == null || getClass() != object.getClass()) { + return false; + } + ByteArray byteArray = (ByteArray) object; + return Arrays.equals(array, byteArray.array); + } + + @Override + public int hashCode() { + return Arrays.hashCode(array); + } + } +} diff --git a/src/main/java/edu/hw8/task3/ParallelPasswordsDecoder.java b/src/main/java/edu/hw8/task3/ParallelPasswordsDecoder.java new file mode 100644 index 0000000..4e28f00 --- /dev/null +++ b/src/main/java/edu/hw8/task3/ParallelPasswordsDecoder.java @@ -0,0 +1,38 @@ +package edu.hw8.task3; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import lombok.SneakyThrows; + +public class ParallelPasswordsDecoder extends AbstractPasswordsDecoder { + private final ExecutorService executorService; + private final int threadCount; + + public ParallelPasswordsDecoder(Map usersMap, int threadCount) { + super(new ConcurrentHashMap<>(usersMap)); + this.executorService = Executors.newFixedThreadPool(threadCount); + this.threadCount = threadCount; + } + + @SneakyThrows + @Override + public List decode() { + int offset = -1; + int passwordsPerThread = ALPHABET.length / threadCount; + for (int thread = 0; thread < threadCount - 1; thread++) { + final int start = offset; + executorService.execute(() -> decodeInRange(start, start + passwordsPerThread)); + offset += passwordsPerThread; + } + final int start = offset; + executorService.execute(() -> decodeInRange(start, ALPHABET.length)); + executorService.shutdown(); + executorService.awaitTermination(Integer.MAX_VALUE, TimeUnit.SECONDS); + return decoded; + } + +} diff --git a/src/main/java/edu/hw8/task3/PasswordsDecoder.java b/src/main/java/edu/hw8/task3/PasswordsDecoder.java new file mode 100644 index 0000000..d7359a7 --- /dev/null +++ b/src/main/java/edu/hw8/task3/PasswordsDecoder.java @@ -0,0 +1,7 @@ +package edu.hw8.task3; + +import java.util.List; + +public interface PasswordsDecoder { + List decode(); +} diff --git a/src/main/java/edu/hw8/task3/SingleThreadPasswordDecoder.java b/src/main/java/edu/hw8/task3/SingleThreadPasswordDecoder.java new file mode 100644 index 0000000..fe23550 --- /dev/null +++ b/src/main/java/edu/hw8/task3/SingleThreadPasswordDecoder.java @@ -0,0 +1,16 @@ +package edu.hw8.task3; + +import java.util.List; +import java.util.Map; + +public class SingleThreadPasswordDecoder extends AbstractPasswordsDecoder { + public SingleThreadPasswordDecoder(Map usersMap) { + super(usersMap); + } + + @Override + public List decode() { + decodeInRange(-1, ALPHABET.length); + return decoded; + } +} diff --git a/src/main/java/edu/hw8/task3/User.java b/src/main/java/edu/hw8/task3/User.java new file mode 100644 index 0000000..d40a72f --- /dev/null +++ b/src/main/java/edu/hw8/task3/User.java @@ -0,0 +1,4 @@ +package edu.hw8.task3; + +public record User(String name, String password) { +} diff --git a/src/test/java/edu/hw8/task1/QuotesTest.java b/src/test/java/edu/hw8/task1/QuotesTest.java new file mode 100644 index 0000000..a38f52f --- /dev/null +++ b/src/test/java/edu/hw8/task1/QuotesTest.java @@ -0,0 +1,48 @@ +package edu.hw8.task1; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import lombok.SneakyThrows; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class QuotesTest { + + @SneakyThrows + @Test + @DisplayName("Тестирование работы QuotesServer и QuotesClient") + public void quotes_shouldCorrectlyWork() { + List userRequests = new ArrayList<>(); + List serverResponses = new ArrayList<>(); + QuotesStorage quotesStorage = QuotesStorage.createDefault(); + QuotesServer quotesServer = new QuotesServer(12345, quotesStorage, 2); + QuotesClient quotesClient = new QuotesClient("localhost", 12345); + + quotesServer.setMessageConsumer(userRequests::add); + Executors.newSingleThreadExecutor().execute(quotesServer::start); + Thread.sleep(1000); + + // Imitate multiconnnection + quotesClient.start(); + quotesClient.close(); + quotesClient.start(); + quotesClient.close(); + quotesClient.start(); + + serverResponses.add(quotesClient.requestQuote("личности")); + serverResponses.add(quotesClient.requestQuote("оскорбления")); + + Thread.sleep(1000); + quotesServer.stop(); + quotesClient.close(); + + Assertions.assertThat(userRequests).containsExactly("личности", "оскорбления"); + Assertions.assertThat(serverResponses).containsExactly( + "Не переходи на личности там, где их нет", + "Если твои противники перешли на личные оскорбления, будь уверена — твоя победа не за горами" + ); + } + +} diff --git a/src/test/java/edu/hw8/task2/FibonacciComputerTest.java b/src/test/java/edu/hw8/task2/FibonacciComputerTest.java new file mode 100644 index 0000000..4af45cb --- /dev/null +++ b/src/test/java/edu/hw8/task2/FibonacciComputerTest.java @@ -0,0 +1,20 @@ +package edu.hw8.task2; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.stream.IntStream; + +public class FibonacciComputerTest { + + @Test + @DisplayName("Тестирование FibonacciComputer#computeList") + public void computeList_shouldReturnCorrectResult() { + List list = IntStream.range(0, 13).boxed().toList(); + List result = FibonacciComputer.computeList(list, 4); + Assertions.assertThat(result).containsExactlyInAnyOrder(0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144); + } + +} diff --git a/src/test/java/edu/hw8/task3/PasswordDecoderTest.java b/src/test/java/edu/hw8/task3/PasswordDecoderTest.java new file mode 100644 index 0000000..d48f457 --- /dev/null +++ b/src/test/java/edu/hw8/task3/PasswordDecoderTest.java @@ -0,0 +1,47 @@ +package edu.hw8.task3; + +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class PasswordDecoderTest { + + @Test + @DisplayName("Тестирование ParallelPasswordsDecoder#decode") + public void decode_shouldReturnCorrectResult_whenMultiThread() { + PasswordsDecoder decoder = new ParallelPasswordsDecoder( + Map.of( + "k.p.maslov", "18e84fa2f549f87a4a3ee3a46e63c3e2", + "k.p.mdfaslov", "9a32f4a86b3bcd053642f4c708be27f8", + "k.p.в", "0765c0f7c8e3df239f846c4f78ed1da6", + "kh.p.в", "2354b5f68a4ce8a030eee955639fdd16" + ), Runtime.getRuntime().availableProcessors() + ); + Assertions.assertThat(decoder.decode()).containsExactly( + new User("k.p.maslov", "abbba"), + new User("k.p.mdfaslov", "czzzc"), + new User("k.p.в", "gvvvg"), + new User("kh.p.в", "zbbbz") + ); + } + + @Test + @DisplayName("Тестирование SingleThreadPasswordDecoder#decode") + public void decode_shouldReturnCorrectResult_whenSingleThread() { + PasswordsDecoder decoder = new SingleThreadPasswordDecoder( + Map.of( + "k.p.maslov", "18e84fa2f549f87a4a3ee3a46e63c3e2", + "k.p.mdfaslov", "9a32f4a86b3bcd053642f4c708be27f8", + "k.p.в", "0765c0f7c8e3df239f846c4f78ed1da6", + "kh.p.в", "2354b5f68a4ce8a030eee955639fdd16" + ) + ); + Assertions.assertThat(decoder.decode()).containsExactly( + new User("k.p.maslov", "abbba"), + new User("k.p.mdfaslov", "czzzc"), + new User("k.p.в", "gvvvg"), + new User("kh.p.в", "zbbbz") + ); + } +}