diff --git a/TagsCloudContainer/CircularCloudLayouter.cs b/TagsCloudContainer/CircularCloudLayouter.cs new file mode 100644 index 000000000..56cea4401 --- /dev/null +++ b/TagsCloudContainer/CircularCloudLayouter.cs @@ -0,0 +1,31 @@ +using System.Drawing; +using TagsCloudContainer.Interfaces; + +namespace TagsCloudContainer; + +public class CircularCloudLayouter(IPointGenerator pointGenerator): ITagCloudLayouter +{ + private readonly List _rectangles = new(); + private readonly Grid _grid = new(); + public Result PutNextRectangle(Size rectangleSize) + { + if (rectangleSize.Width <= 0 || rectangleSize.Height <= 0) + return Result.Fail("Размеры прямоугольника должны быть положительными."); + + Rectangle newRectangle; + do + { + var point = pointGenerator.GetNextPoint(); + var location = new Point( + point.X - rectangleSize.Width / 2, + point.Y - rectangleSize.Height / 2); + newRectangle = new Rectangle(location, rectangleSize); + } while (_grid.IsIntersecting(newRectangle)); + + _grid.AddRectangle(newRectangle); + _rectangles.Add(newRectangle); + return newRectangle; + } + + public IEnumerable GetRectangles() => _rectangles; +} \ No newline at end of file diff --git a/TagsCloudContainer/CloudImageRenderer.cs b/TagsCloudContainer/CloudImageRenderer.cs new file mode 100644 index 000000000..f4e6b67f4 --- /dev/null +++ b/TagsCloudContainer/CloudImageRenderer.cs @@ -0,0 +1,70 @@ +using System.Drawing; +using System.Drawing.Imaging; +using TagsCloudContainer.Interfaces; +using TagsCloudContainer.Options; + +namespace TagsCloudContainer; + +public class CloudImageRenderer : ITagCloudRenderer +{ + private readonly Random random = new(); + private const float MinFontSize = 12f; + private const float MaxFontSize = 72f; + private const float FrequencyMultiplier = 2f; + + public Result Render(IEnumerable tags, string outputFilePath, RenderingOptions options) + { + var checkResult = CheckFitSize(tags, options); + if (!checkResult.IsSuccess) + { + return checkResult; + } + + return Result.OfAction(() => + { + using var bitmap = new Bitmap(options.ImageSize.Width, options.ImageSize.Height); + using var graphics = Graphics.FromImage(bitmap); + + graphics.Clear(options.BackgroundColor); + graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; + + foreach (var tag in tags) + { + var fontSize = Math.Max(MinFontSize, Math.Min(MaxFontSize, tag.Frequency * FrequencyMultiplier)); + using var font = new Font(options.Font, fontSize, FontStyle.Bold); + + var color = options.WordColors[random.Next(options.WordColors.Length)]; + using var brush = new SolidBrush(color); + + graphics.DrawString( + tag.Word, + font, + brush, + tag.Rectangle.Location + ); + } + + bitmap.Save(outputFilePath, ImageFormat.Png); + }, $"Ошибка при сохранении результата в файл: {outputFilePath}"); + } + + private static Result CheckFitSize(IEnumerable tags, RenderingOptions options) + { + if (!tags.Any()) + return Result.Ok(); + + var minX = tags.Min(t => t.Rectangle.Left); + var minY = tags.Min(t => t.Rectangle.Top); + var maxX = tags.Max(t => t.Rectangle.Right); + var maxY = tags.Max(t => t.Rectangle.Bottom); + + if (minX < 0 || minY < 0 || + maxX > options.ImageSize.Width || + maxY > options.ImageSize.Height) + { + return Result.Fail("Облако тегов вышло за пределы изображения."); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/DependencyInjectionConfig.cs b/TagsCloudContainer/DependencyInjectionConfig.cs new file mode 100644 index 000000000..1e6cb4b3a --- /dev/null +++ b/TagsCloudContainer/DependencyInjectionConfig.cs @@ -0,0 +1,76 @@ +using System.Drawing; +using Autofac; +using TagsCloudContainer.DocumentReaders; +using TagsCloudContainer.Interfaces; +using TagsCloudContainer.Options; +using TagsCloudContainer.PointGenerators; + +namespace TagsCloudContainer; + +public static class DependencyInjectionConfig +{ + public static IContainer BuildContainer(Options.Options? options = null) + { + var builder = new ContainerBuilder(); + + builder.Register(c => new RenderingOptions()) + .AsSelf() + .SingleInstance(); + + var boringWords = ""; + if (options is not null) + { + boringWords = options.BoringWordsFilePath; + } + + builder.RegisterType() + .As() + .SingleInstance() + .WithParameter("filepath", + boringWords); + + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + + builder.RegisterType().AsSelf().SingleInstance(); + + builder.RegisterType().As().SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance() + .WithParameter("center", new Point(500, 500)); + + builder.RegisterType() + .Named("linear"); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.Register(c => + new TagCloudGenerator( + c.Resolve(), + c.Resolve(), + c.Resolve(), + c.Resolve(), + c.Resolve(), + c.Resolve() + )) + .SingleInstance(); + + return builder.Build(); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/Dockerfile b/TagsCloudContainer/Dockerfile new file mode 100644 index 000000000..0d99d8c28 --- /dev/null +++ b/TagsCloudContainer/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["TagsCloudContainer/TagsCloudContainer.csproj", "TagsCloudContainer/"] +RUN dotnet restore "TagsCloudContainer/TagsCloudContainer.csproj" +COPY . . +WORKDIR "/src/TagsCloudContainer" +RUN dotnet build "TagsCloudContainer.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "TagsCloudContainer.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "TagsCloudContainer.dll"] diff --git a/TagsCloudContainer/DocumentReaders/DocumentReader.cs b/TagsCloudContainer/DocumentReaders/DocumentReader.cs new file mode 100644 index 000000000..bf235d1bb --- /dev/null +++ b/TagsCloudContainer/DocumentReaders/DocumentReader.cs @@ -0,0 +1,28 @@ +using TagsCloudContainer.Interfaces; + +namespace TagsCloudContainer.DocumentReaders; + +public class DocumentReader: IDocumentReader +{ + private readonly Dictionary _readers; + public string[] SupportedDocumentExtensions => _readers.Keys.ToArray(); + public DocumentReader(IEnumerable readers) + { + _readers = readers + .SelectMany(reader => reader.SupportedDocumentExtensions.Select(ext => new { ext, reader })) + .ToDictionary(x => x.ext, x => x.reader); + } + public Result ReadDocument(string filePath) + { + if (!File.Exists(filePath)) + return Result.Fail($"Файл не найден или недоступен: {filePath}"); + + var extension = Path.GetExtension(filePath).ToLower(); + if (!_readers.TryGetValue(extension, out var reader)) + return Result.Fail($"Формат файла {extension} не поддерживается."); + + return reader + .ReadDocument(filePath) + .RefineError($"Не удалось прочитать документ: {filePath}"); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/DocumentReaders/TxtReader.cs b/TagsCloudContainer/DocumentReaders/TxtReader.cs new file mode 100644 index 000000000..e0f7bead1 --- /dev/null +++ b/TagsCloudContainer/DocumentReaders/TxtReader.cs @@ -0,0 +1,13 @@ +using TagsCloudContainer.Interfaces; + +namespace TagsCloudContainer.DocumentReaders; + +public class TxtReader : IDocumentReader +{ + public string[] SupportedDocumentExtensions => [".txt"]; + + public Result ReadDocument(string filePath) + { + return File.ReadAllLines(filePath); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/DocumentReaders/WordDocumentReader.cs b/TagsCloudContainer/DocumentReaders/WordDocumentReader.cs new file mode 100644 index 000000000..33bbf3c54 --- /dev/null +++ b/TagsCloudContainer/DocumentReaders/WordDocumentReader.cs @@ -0,0 +1,19 @@ +using DocumentFormat.OpenXml.Packaging; +using TagsCloudContainer.Interfaces; + +namespace TagsCloudContainer.DocumentReaders; + +public class WordDocumentReader : IDocumentReader +{ + public string[] SupportedDocumentExtensions => [".doc", ".docx"]; + + public Result ReadDocument(string filePath) + { + using var doc = WordprocessingDocument.Open(filePath, false); + var body = doc.MainDocumentPart?.Document.Body; + if (body == null) return Array.Empty(); + + var text = body.InnerText; + return text.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/Extensions/PointExtensions.cs b/TagsCloudContainer/Extensions/PointExtensions.cs new file mode 100644 index 000000000..78acda497 --- /dev/null +++ b/TagsCloudContainer/Extensions/PointExtensions.cs @@ -0,0 +1,13 @@ +using System.Drawing; + +namespace TagsCloudContainer.Extensions; + +public static class PointExtensions +{ + public static double DistanceFromCenter(this Point point, Point center) + { + var dx = point.X - center.X; + var dy = point.Y - center.Y; + return Math.Sqrt(dx * dx + dy * dy); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/Grid.cs b/TagsCloudContainer/Grid.cs new file mode 100644 index 000000000..4adf61ab1 --- /dev/null +++ b/TagsCloudContainer/Grid.cs @@ -0,0 +1,64 @@ +using System.Drawing; + +namespace TagsCloudContainer; + +public class Grid +{ + private readonly int cellSize; + private readonly Dictionary> cells = new(); + + public Grid(int cellSize = 50) + { + this.cellSize = cellSize; + } + public void AddRectangle(Rectangle rectangle) + { + var cellsToUpdate = GetCellsCoveredByRectangle(rectangle); + + foreach (var cell in cellsToUpdate) + { + if (!cells.ContainsKey(cell)) + { + cells[cell] = new List(); + } + + cells[cell].Add(rectangle); + } + } + + public bool IsIntersecting(Rectangle rectangle) + { + var cellsToCheck = GetCellsCoveredByRectangle(rectangle); + + foreach (var cell in cellsToCheck) + { + if (!cells.TryGetValue(cell, out var rectanglesInCell)) + { + continue; + } + + if (rectanglesInCell.Any(r => r.IntersectsWith(rectangle))) + { + return true; + } + } + + return false; + } + + private IEnumerable GetCellsCoveredByRectangle(Rectangle rectangle) + { + var startX = rectangle.Left / cellSize; + var endX = rectangle.Right / cellSize; + var startY = rectangle.Top / cellSize; + var endY = rectangle.Bottom / cellSize; + + for (var x = startX; x <= endX; x++) + { + for (var y = startY; y <= endY; y++) + { + yield return new Point(x, y); + } + } + } +} \ No newline at end of file diff --git a/TagsCloudContainer/Interfaces/IDocumentReader.cs b/TagsCloudContainer/Interfaces/IDocumentReader.cs new file mode 100644 index 000000000..990958671 --- /dev/null +++ b/TagsCloudContainer/Interfaces/IDocumentReader.cs @@ -0,0 +1,7 @@ +namespace TagsCloudContainer.Interfaces; + +public interface IDocumentReader +{ + string[] SupportedDocumentExtensions { get; } + Result ReadDocument(string filePath); +} \ No newline at end of file diff --git a/TagsCloudContainer/Interfaces/IPointGenerator.cs b/TagsCloudContainer/Interfaces/IPointGenerator.cs new file mode 100644 index 000000000..b26edeac2 --- /dev/null +++ b/TagsCloudContainer/Interfaces/IPointGenerator.cs @@ -0,0 +1,8 @@ +using System.Drawing; + +namespace TagsCloudContainer.Interfaces; + +public interface IPointGenerator +{ + Point GetNextPoint(); +} \ No newline at end of file diff --git a/TagsCloudContainer/Interfaces/ITagCloudGenerator.cs b/TagsCloudContainer/Interfaces/ITagCloudGenerator.cs new file mode 100644 index 000000000..d1a543dca --- /dev/null +++ b/TagsCloudContainer/Interfaces/ITagCloudGenerator.cs @@ -0,0 +1,8 @@ +using TagsCloudContainer.Options; + +namespace TagsCloudContainer.Interfaces; + +public interface ITagCloudGenerator +{ + Result GenerateCloud(string inputFilePath, string outputFilePath, RenderingOptions options); +} \ No newline at end of file diff --git a/TagsCloudContainer/Interfaces/ITagCloudLayouter.cs b/TagsCloudContainer/Interfaces/ITagCloudLayouter.cs new file mode 100644 index 000000000..3f776b464 --- /dev/null +++ b/TagsCloudContainer/Interfaces/ITagCloudLayouter.cs @@ -0,0 +1,9 @@ +using System.Drawing; + +namespace TagsCloudContainer.Interfaces; + +public interface ITagCloudLayouter +{ + Result PutNextRectangle(Size rectangleSize); + IEnumerable GetRectangles(); +} \ No newline at end of file diff --git a/TagsCloudContainer/Interfaces/ITagCloudRenderer.cs b/TagsCloudContainer/Interfaces/ITagCloudRenderer.cs new file mode 100644 index 000000000..adeeb4330 --- /dev/null +++ b/TagsCloudContainer/Interfaces/ITagCloudRenderer.cs @@ -0,0 +1,8 @@ +using TagsCloudContainer.Options; + +namespace TagsCloudContainer.Interfaces; + +public interface ITagCloudRenderer +{ + Result Render(IEnumerable tags, string outputFilePath, RenderingOptions options); +} \ No newline at end of file diff --git a/TagsCloudContainer/Interfaces/ITextSizeCalculator.cs b/TagsCloudContainer/Interfaces/ITextSizeCalculator.cs new file mode 100644 index 000000000..4aff39e68 --- /dev/null +++ b/TagsCloudContainer/Interfaces/ITextSizeCalculator.cs @@ -0,0 +1,9 @@ +using System.Drawing; +using TagsCloudContainer.Options; + +namespace TagsCloudContainer.Interfaces; + +public interface ITextSizeCalculator +{ + Size GetWordSize(string word, int frequency, RenderingOptions options); +} \ No newline at end of file diff --git a/TagsCloudContainer/Interfaces/IWordFrequencyAnalyzer.cs b/TagsCloudContainer/Interfaces/IWordFrequencyAnalyzer.cs new file mode 100644 index 000000000..490d502a2 --- /dev/null +++ b/TagsCloudContainer/Interfaces/IWordFrequencyAnalyzer.cs @@ -0,0 +1,6 @@ +namespace TagsCloudContainer.Interfaces; + +public interface IWordFrequencyAnalyzer +{ + List Analyze(IEnumerable words); +} \ No newline at end of file diff --git a/TagsCloudContainer/Interfaces/IWordProcessor.cs b/TagsCloudContainer/Interfaces/IWordProcessor.cs new file mode 100644 index 000000000..cb06f9b67 --- /dev/null +++ b/TagsCloudContainer/Interfaces/IWordProcessor.cs @@ -0,0 +1,6 @@ +namespace TagsCloudContainer.Interfaces; + +public interface IWordProcessor +{ + Result> ProcessWords(IEnumerable? words); +} \ No newline at end of file diff --git a/TagsCloudContainer/LogarithmicScaling.cs b/TagsCloudContainer/LogarithmicScaling.cs new file mode 100644 index 000000000..0877927d0 --- /dev/null +++ b/TagsCloudContainer/LogarithmicScaling.cs @@ -0,0 +1,38 @@ +using System.Drawing; +using TagsCloudContainer.Interfaces; +using TagsCloudContainer.Options; + +namespace TagsCloudContainer; + +public class LogarithmicScaling : ITextSizeCalculator +{ + public Size GetWordSize(string word, int frequency, RenderingOptions options) + { + using var bitmap = new Bitmap(1, 1); + using var graphics = Graphics.FromImage(bitmap); + + const float minFontSize = 12f; + const float maxFontSize = 72f; + const float maxFrequency = 100f; + + const float power = 0.7f; + + var normalizedFreq = (float)Math.Pow(frequency, power); + var fontSize = minFontSize + (maxFontSize - minFontSize) * normalizedFreq / + (float)Math.Pow(maxFrequency, power); + + fontSize = Math.Max(minFontSize, Math.Min(maxFontSize, fontSize)); + + using var font = new Font(options.Font, fontSize, FontStyle.Bold); + + var size = graphics.MeasureString(word, font); + + const float paddingMultiplier = 0.2f; + var padding = fontSize * paddingMultiplier; + + return new Size( + (int)Math.Ceiling(size.Width + padding), + (int)Math.Ceiling(size.Height + padding) + ); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/Options/Options.cs b/TagsCloudContainer/Options/Options.cs new file mode 100644 index 000000000..92c83a2aa --- /dev/null +++ b/TagsCloudContainer/Options/Options.cs @@ -0,0 +1,31 @@ +using CommandLine; + +namespace TagsCloudContainer.Options; + +public class Options +{ + [Option('i', "input", Required = true, HelpText = "Path to the input text file.")] + public string InputFilePath { get; set; } + + [Option('o', "output", Required = true, HelpText = "Path to save the output image.")] + public string OutputFilePath { get; set; } + + [Option("bgcolor", Default = "White", HelpText = "Background color (name or hex).")] + public string BackgroundColor { get; set; } + + [Option("wordcolors", Default = "Black,Blue,Green,Red,Purple", + HelpText = "Comma-separated list of word colors (names or hex values).")] + public string WordColors { get; set; } + + [Option("font", Default = "Times New Roman", HelpText = "Font name for text rendering.")] + public string Font { get; set; } + + [Option("width", Default = 1000, HelpText = "Width of the output image.")] + public int Width { get; set; } + + [Option("height", Default = 1000, HelpText = "Height of the output image.")] + public int Height { get; set; } + + [Option("boringwords", Default = "", HelpText = "Path to the file containing boring words.")] + public string BoringWordsFilePath { get; set; } +} \ No newline at end of file diff --git a/TagsCloudContainer/Options/RenderingOptions.cs b/TagsCloudContainer/Options/RenderingOptions.cs new file mode 100644 index 000000000..e50de59d8 --- /dev/null +++ b/TagsCloudContainer/Options/RenderingOptions.cs @@ -0,0 +1,20 @@ +using System.Drawing; + +namespace TagsCloudContainer.Options; + +public class RenderingOptions +{ + public Color BackgroundColor { get; set; } = Color.White; + + public Color[] WordColors { get; set; } = + { + Color.Black, + Color.FromArgb(255, 65, 105, 225), + Color.FromArgb(255, 34, 139, 34), + Color.FromArgb(255, 178, 34, 34), + Color.FromArgb(255, 148, 0, 211) + }; + + public string Font { get; set; } = "Times New Roman"; + public Size ImageSize { get; set; } = new(1000, 1000); +} \ No newline at end of file diff --git a/TagsCloudContainer/PointGenerators/LinearSpiral.cs b/TagsCloudContainer/PointGenerators/LinearSpiral.cs new file mode 100644 index 000000000..7ad87cc74 --- /dev/null +++ b/TagsCloudContainer/PointGenerators/LinearSpiral.cs @@ -0,0 +1,43 @@ +using System.Drawing; +using TagsCloudContainer.Interfaces; + +namespace TagsCloudContainer.PointGenerators; + +public class LinearSpiral : IPointGenerator +{ + private readonly Point center; + private double angle; + private double radius; + private readonly double radiusStep; + private Point? lastGeneratedPoint; + + public LinearSpiral(Point center, double radiusStep = 10) + { + this.center = center; + this.radiusStep = radiusStep; + angle = 0; + radius = radiusStep; + } + + public Point GetNextPoint() + { + Point newPoint; + do + { + var x = (int)(center.X + radius * Math.Cos(angle)); + var y = (int)(center.Y + radius * Math.Sin(angle)); + + angle += Math.PI / 6; + if (angle >= 2 * Math.PI) + { + angle = 0; + radius += radiusStep; + } + + newPoint = new Point(x, y); + } while (newPoint == lastGeneratedPoint); + + lastGeneratedPoint = newPoint; + return newPoint; + } +} \ No newline at end of file diff --git a/TagsCloudContainer/PointGenerators/SpiralGenerator.cs b/TagsCloudContainer/PointGenerators/SpiralGenerator.cs new file mode 100644 index 000000000..9004addef --- /dev/null +++ b/TagsCloudContainer/PointGenerators/SpiralGenerator.cs @@ -0,0 +1,42 @@ +using System.Drawing; +using TagsCloudContainer.Interfaces; + +namespace TagsCloudContainer.PointGenerators; + +public class SpiralGenerator: IPointGenerator +{ + private readonly Point center; + private double phi; + private readonly double spiralStep; + private readonly double deltaPhi; + private Point? lastGeneratedPoint; + + public SpiralGenerator(Point center, double spiralStep = 1, double deltaPhiDegrees = (Math.PI / 180)) + { + if (spiralStep <= 0) + throw new ArgumentException("Шаг спирали должен быть больше 0"); + + this.center = center; + phi = 0; + this.spiralStep = spiralStep; + deltaPhi = deltaPhiDegrees; + } + + public Point GetNextPoint() + { + Point newPoint; + + do + { + var radius = spiralStep * phi; + var x = (int)Math.Round(radius * Math.Cos(phi)) + center.X; + var y = (int)Math.Round(radius * Math.Sin(phi)) + center.Y; + phi += deltaPhi; + + newPoint = new Point(x, y); + } while (newPoint == lastGeneratedPoint); + + lastGeneratedPoint = newPoint; + return newPoint; + } +} \ No newline at end of file diff --git a/TagsCloudContainer/Program.cs b/TagsCloudContainer/Program.cs new file mode 100644 index 000000000..ed9dc4e08 --- /dev/null +++ b/TagsCloudContainer/Program.cs @@ -0,0 +1,125 @@ +using System.Drawing; +using System.Drawing.Text; +using Autofac; +using CommandLine; +using TagsCloudContainer; +using TagsCloudContainer.Interfaces; +using TagsCloudContainer.Options; + +public static class Program +{ + public static void Main(string[] args) + { + var optionsResult = Parser.Default.ParseArguments(args) + .MapResult( + (Options opts) => Result.Ok(opts), + errs => Result.Fail(string.Join(Environment.NewLine, errs)) + ); + if (!optionsResult.IsSuccess) + { + Console.WriteLine($"Ошибка в аргументах командной строки: {optionsResult.Error}"); + return; + } + + var options = optionsResult.Value; + var containerResult = Result.Of(() => DependencyInjectionConfig.BuildContainer(options), + "Ошибка при настройке контейнера зависимостей."); + if (containerResult.IsSuccess) + { + Console.WriteLine(containerResult.Error); + return; + } + + var container = containerResult.Value; + using var scope = container.BeginLifetimeScope(); + var generatorResult = Result.Of(() => + scope.Resolve(), + "Ошибка при разрешении зависимости ITagCloudGenerator."); + if (generatorResult.IsSuccess) + { + Console.WriteLine(generatorResult.Error); + return; + } + + var renderingOptionsResult = ParseRenderingOptions(options); + if (!renderingOptionsResult.IsSuccess) + { + Console.WriteLine($"Ошибка в настройках: {renderingOptionsResult.Error}"); + return; + } + + var tagCloudGenerator = generatorResult.Value; + var generateResult = tagCloudGenerator.GenerateCloud( + options.InputFilePath, + options.OutputFilePath, + renderingOptionsResult.Value); + + Console.WriteLine(!generateResult.IsSuccess + ? $"Ошибка при генерации облака тегов: {generateResult.Error}" + : "Облако тегов успешно сгенерировано!"); + } + + private static Result ParseRenderingOptions(Options options) + { + var backgroundColorResult = ParseColor(options.BackgroundColor) + .RefineError($"Некорректный цвет для фона ({options.BackgroundColor})"); + + return backgroundColorResult.Then(backgroundColor => + { + var wordColorsArrayResult = options.WordColors + .Split(',') + .Select(ParseColor) + .ToArray(); + + var firstFail = wordColorsArrayResult.FirstOrDefault(r => !r.IsSuccess); + if (!firstFail.IsSuccess) + return Result.Fail(firstFail.Error); + + var wordColors = wordColorsArrayResult.Select(r => r.Value).ToArray(); + + var fontCheckResult = ValidateFontName(options.Font); + if (!fontCheckResult.IsSuccess) + return Result.Fail(fontCheckResult.Error); + + var renderingOptions = new RenderingOptions + { + BackgroundColor = backgroundColor, + WordColors = wordColors, + Font = options.Font, + ImageSize = new Size(options.Width, options.Height) + }; + return renderingOptions; + }); + } + + private static Result ParseColor(string colorString) + { + return Result.Of(() => + { + if (Enum.TryParse(typeof(KnownColor), colorString, true, out var knownColorObj)) + { + var knownColor = (KnownColor)knownColorObj!; + return Color.FromKnownColor(knownColor); + } + + var withHash = colorString.StartsWith("#") ? colorString : $"#{colorString}"; + return ColorTranslator.FromHtml(withHash); + }, $"Invalid color format: {colorString}. Use a valid name (e.g., White) or HEX (e.g., #FFFFFF).\");"); + } + + public static Result ValidateFontName(string fontName) + { + return Result.Of(() => + { + using var fontsCollection = new InstalledFontCollection(); + var installedFamilies = fontsCollection.Families + .Select(family => family.Name.ToLowerInvariant()) + .ToHashSet(); + + if (!installedFamilies.Contains(fontName.ToLowerInvariant())) + throw new ArgumentException($"Шрифт '{fontName}' не найден в системе."); + + return fontName; + }, $"Не удалось проверить шрифт '{fontName}'."); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/Result.cs b/TagsCloudContainer/Result.cs new file mode 100644 index 000000000..8b8b28f49 --- /dev/null +++ b/TagsCloudContainer/Result.cs @@ -0,0 +1,66 @@ +namespace TagsCloudContainer; + +public class None +{ + private None() + { + } +} + +public readonly struct Result(string error, TValue value = default!) +{ + public TValue Value { get; } = value; + public string Error { get; } = error; + public bool IsSuccess => Error == null; + + public static implicit operator Result(TValue value) => + Result.Ok(value); +} + +public static class Result +{ + public static Result AsResult(this T value) => + Ok(value); + + public static Result Ok(T value) => + new(null!, value); + + public static Result Ok() => + Ok(null!); + + public static Result Fail(string error) => + new(error); + + public static Result Of(Func func, string? error = null) + { + try + { + return func(); + } + catch (Exception e) + { + return Fail(error ?? e.Message); + } + } + + public static Result OfAction(Action action, string? error = null) => + Of( + () => + { + action(); + return null!; + }, + error); + + public static Result Then(this Result result, Action action, string? error = null) => + result.Then(input => OfAction(() => action(input), error)); + + public static Result Then(this Result result, Func func, string? error = null) => + result.Then(input => Of(() => func(input), error)); + + public static Result Then(this Result result, Func> func) => + result.IsSuccess ? func(result.Value) : Fail(result.Error); + + public static Result RefineError(this Result result, string error) + => result.IsSuccess ? result : Fail($"{error}. {result.Error}"); +} \ No newline at end of file diff --git a/TagsCloudContainer/Tag.cs b/TagsCloudContainer/Tag.cs new file mode 100644 index 000000000..89962e3bd --- /dev/null +++ b/TagsCloudContainer/Tag.cs @@ -0,0 +1,10 @@ +using System.Drawing; + +namespace TagsCloudContainer; + +public class Tag(string word, int frequency) +{ + public string Word { get; } = word; + public int Frequency { get; } = frequency; + public Rectangle Rectangle { get; set; } +} \ No newline at end of file diff --git a/TagsCloudContainer/TagCloudGenerator.cs b/TagsCloudContainer/TagCloudGenerator.cs new file mode 100644 index 000000000..18ef00ce7 --- /dev/null +++ b/TagsCloudContainer/TagCloudGenerator.cs @@ -0,0 +1,44 @@ +using TagsCloudContainer.Interfaces; +using TagsCloudContainer.Options; + +namespace TagsCloudContainer; + +public class TagCloudGenerator( + IWordProcessor wordProcessor, + ITagCloudLayouter layouter, + ITagCloudRenderer renderer, + IDocumentReader textReader, + IWordFrequencyAnalyzer analyzer, + ITextSizeCalculator sizeCalculator +) + : ITagCloudGenerator +{ + public Result GenerateCloud(string inputFilePath, string outputFilePath, RenderingOptions options) + { + var wordsResult = textReader.ReadDocument(inputFilePath); + if (!wordsResult.IsSuccess) + return Result.Fail(wordsResult.Error); + + var processedWordsResult = wordProcessor.ProcessWords(wordsResult.Value); + if (!processedWordsResult.IsSuccess) + { + return Result.Fail(processedWordsResult.Error); + } + + var tags = analyzer.Analyze(processedWordsResult.Value); + foreach (var tag in tags) + { + var size = sizeCalculator.GetWordSize(tag.Word, tag.Frequency, options); + var rectResult = layouter.PutNextRectangle(size); + if (!rectResult.IsSuccess) + return Result.Fail(rectResult.Error); + + tag.Rectangle = rectResult.Value; + } + + var renderResult = renderer.Render(tags, outputFilePath, options); + return !renderResult.IsSuccess + ? Result.Fail(renderResult.Error) + : Result.Ok(); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/TagsCloudContainer.csproj b/TagsCloudContainer/TagsCloudContainer.csproj new file mode 100644 index 000000000..a609635f6 --- /dev/null +++ b/TagsCloudContainer/TagsCloudContainer.csproj @@ -0,0 +1,32 @@ + + + + Exe + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + + + + + + + + diff --git a/TagsCloudContainer/WordFrequencyAnalyzer.cs b/TagsCloudContainer/WordFrequencyAnalyzer.cs new file mode 100644 index 000000000..a7008a174 --- /dev/null +++ b/TagsCloudContainer/WordFrequencyAnalyzer.cs @@ -0,0 +1,18 @@ +using TagsCloudContainer.Interfaces; + +namespace TagsCloudContainer; + +public class WordFrequencyAnalyzer: IWordFrequencyAnalyzer +{ + public List Analyze(IEnumerable words) + { + var wordFrequencies = words + .GroupBy(w => w) + .ToDictionary(g => g.Key, g => g.Count()); + + return wordFrequencies + .OrderByDescending(pair => pair.Value) + .Select(pair => new Tag(pair.Key, pair.Value)) + .ToList(); + } +} \ No newline at end of file diff --git a/TagsCloudContainer/WordProcessor.cs b/TagsCloudContainer/WordProcessor.cs new file mode 100644 index 000000000..8e7307b3e --- /dev/null +++ b/TagsCloudContainer/WordProcessor.cs @@ -0,0 +1,32 @@ +using TagsCloudContainer.Interfaces; + +namespace TagsCloudContainer; + +public class WordProcessor: IWordProcessor +{ + private readonly HashSet _boringWords; + + public WordProcessor(string filepath) + { + if (string.IsNullOrWhiteSpace(filepath)) + { + _boringWords = []; + return; + } + + var words = File.ReadAllLines(filepath); + _boringWords = [..words]; + } + public Result> ProcessWords(IEnumerable? words) + { + if (words is null) + { + return Result.Fail>(new ArgumentNullException(nameof(words)).Message); + } + + return Result.Ok(words + .Select(word => word.ToLower().Trim()) + .Where(word => !string.IsNullOrWhiteSpace(word)) + .Where(word => !_boringWords.Contains(word))); + } +} diff --git a/TagsCloudContainer/input.txt b/TagsCloudContainer/input.txt new file mode 100644 index 000000000..e7f2be1eb --- /dev/null +++ b/TagsCloudContainer/input.txt @@ -0,0 +1,151 @@ +github +github +github +code +code +code +programming +programming +programming +programming +developer +developer +developer +software +software +software +python +python +javascript +javascript +java +java +repository +repository +cloud +cloud +cloud +algorithm +algorithm +design +design +pattern +pattern +testing +testing +testing +database +database +framework +framework +api +api +api +security +security +docker +docker +kubernetes +git +git +git +devops +devops +async +async +frontend +backend +backend +optimization +debug +debug +architecture +architecture +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +microservices +agile +agile +code +review +review +deployment +deployment +scalability +performance +performance +testing +integration +integration +development +development +development \ No newline at end of file diff --git a/TagsCloudContainerTest/CircularCloudLayouterTest.cs b/TagsCloudContainerTest/CircularCloudLayouterTest.cs new file mode 100644 index 000000000..e591d8f04 --- /dev/null +++ b/TagsCloudContainerTest/CircularCloudLayouterTest.cs @@ -0,0 +1,122 @@ +using System.Drawing; +using Autofac; +using FluentAssertions; +using NUnit.Framework; +using TagsCloudContainer; +using TagsCloudContainer.Interfaces; +using TagsCloudContainer.Options; + +public class TagCloudGeneratorIntegrationTests +{ + private IContainer container; + private string tempDirectory; + private string inputPath; + private string outputPath; + + [SetUp] + public void GlobalSetup() + { + if (Directory.Exists(tempDirectory)) + Directory.Delete(tempDirectory, true); + container = DependencyInjectionConfig.BuildContainer(null); + tempDirectory = Path.Combine(Path.GetTempPath(), "TagCloudTests"); + Directory.CreateDirectory(tempDirectory); + inputPath = Path.Combine(tempDirectory, "input.txt"); + outputPath = Path.Combine(tempDirectory, "output.png"); + } + + [TearDown] + public void GlobalCleanup() + { + if (Directory.Exists(tempDirectory)) + Directory.Delete(tempDirectory, true); + } + + [Test] + public void GenerateCloud_WithSimpleInput_CreatesValidOutput() + { + const string testText = @" + This is a test text + with multiple words + some words appear + multiple times in the text + test test test + "; + File.WriteAllText(inputPath, testText); + + using var scope = container.BeginLifetimeScope(); + var generator = scope.Resolve(); + var options = new RenderingOptions + { + BackgroundColor = Color.White, + WordColors = [Color.Black], + Font = "Arial", + ImageSize = new Size(800, 600) + }; + + var result = generator.GenerateCloud(inputPath, outputPath, options); + + result.Error.Should().BeNullOrEmpty(); + File.Exists(outputPath).Should().BeTrue(); + using var image = Image.FromFile(outputPath); + image.Width.Should().Be(options.ImageSize.Width); + image.Height.Should().Be(options.ImageSize.Height); + } + + [Test] + public void GenerateCloud_WithDifferentLayouters() + { + const string testText = "test word frequency cloud generator"; + File.WriteAllText(inputPath, testText); + + using var scope = container.BeginLifetimeScope(); + var generator = scope.Resolve(); + var options = new RenderingOptions + { + BackgroundColor = Color.Navy, + WordColors = [Color.White, Color.Yellow], + Font = "Arial", + ImageSize = new Size(1000, 1000) + }; + + foreach (var layouterName in new[] { "linear", "circular" }) + { + var outputFile = Path.Combine(tempDirectory, $"output_{layouterName}.png"); + var result = generator.GenerateCloud(inputPath, outputFile, options); + + result.Error.Should().BeNullOrEmpty(); + File.Exists(outputFile).Should().BeTrue(); + using var image = Image.FromFile(outputFile); + image.Width.Should().Be(options.ImageSize.Width); + image.Height.Should().Be(options.ImageSize.Height); + } + } + + [Test] + public void GenerateCloud_WithLargeInputAndSmallImageSize_ReportsOutOfRangeErrorAndDoesNotCreateOutputFile() + { + const string outRangeError = "Облако тегов вышло за пределы изображения."; + var random = new Random(42); + var words = new[] { "test", "cloud", "generator", "word", "frequency" }; + var testText = string.Join(" ", + Enumerable.Range(0, 1000) + .Select(_ => words[random.Next(words.Length)])); + + File.WriteAllText(inputPath, testText); + + using var scope = container.BeginLifetimeScope(); + var generator = scope.Resolve(); + var options = new RenderingOptions + { + BackgroundColor = Color.White, + WordColors = [Color.Black], + Font = "Arial", + ImageSize = new Size(300, 300) + }; + + var result = generator.GenerateCloud(inputPath, outputPath, options); + + result.Error.Should().Be(outRangeError); + File.Exists(outputPath).Should().BeFalse(); + } +} diff --git a/TagsCloudContainerTest/LinearSpiralTests.cs b/TagsCloudContainerTest/LinearSpiralTests.cs new file mode 100644 index 000000000..d70b6eb6c --- /dev/null +++ b/TagsCloudContainerTest/LinearSpiralTests.cs @@ -0,0 +1,63 @@ +using System.Drawing; +using FluentAssertions; +using NUnit.Framework; +using TagsCloudContainer.Extensions; +using TagsCloudContainer.PointGenerators; + +namespace TagsCloudContainerTest; + +public class LinearSpiralTests +{ + private Point center; + private LinearSpiral generator; + + [SetUp] + public void Setup() + { + center = new Point(0, 0); + generator = new LinearSpiral(center); + } + + [Test] + public void GetNextPoint_FirstPointShouldBeOnInitialRadius() + { + var firstPoint = generator.GetNextPoint(); + var distance = firstPoint.DistanceFromCenter(center); + + distance.Should().Be(10.0); + } + + [Test] + public void GetNextPoint_ShouldGeneratePointsWithIncreasingRadius() + { + var firstPoint = generator.GetNextPoint(); + var firstDistance = firstPoint.DistanceFromCenter(center); + + for (var i = 0; i < 13; i++) + { + generator.GetNextPoint(); + } + + var nextPoint = generator.GetNextPoint(); + var nextDistance = nextPoint.DistanceFromCenter(center); + + nextDistance.Should().BeGreaterThan(firstDistance); + } + + [Test] + public void GetNextPoint_ShouldNotGenerateConsecutiveDuplicatePoints() + { + const int numberOfPoints = 100; + Point? previousPoint = null; + + for (var i = 0; i < numberOfPoints; i++) + { + var currentPoint = generator.GetNextPoint(); + if (previousPoint != null) + { + currentPoint.Should().NotBe(previousPoint); + } + previousPoint = currentPoint; + } + } +} \ No newline at end of file diff --git a/TagsCloudContainerTest/LogarithmicScalingTests.cs b/TagsCloudContainerTest/LogarithmicScalingTests.cs new file mode 100644 index 000000000..ec5b83dfd --- /dev/null +++ b/TagsCloudContainerTest/LogarithmicScalingTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using NUnit.Framework; +using TagsCloudContainer; +using TagsCloudContainer.Options; +using Size = System.Drawing.Size; + +namespace TagsCloudContainerTest; + +public class LogarithmicScalingTests +{ + private LogarithmicScaling calculator; + private RenderingOptions options; + + [SetUp] + public void Setup() + { + calculator = new LogarithmicScaling(); + options = new RenderingOptions + { + Font = "Arial", + ImageSize = new Size(1000, 1000) + }; + } + + [Test] + public void GetWordSize_ReturnsSizeGreaterThanZero() + { + var size = calculator.GetWordSize("test", 1, options); + + size.Width.Should().BeGreaterThan(0); + size.Height.Should().BeGreaterThan(0); + } + + [Test] + public void GetWordSize_HigherFrequencyGivesLargerSize() + { + var smallSize = calculator.GetWordSize("test", 1, options); + var largeSize = calculator.GetWordSize("test", 100, options); + + largeSize.Width.Should().BeGreaterThan(smallSize.Width); + largeSize.Height.Should().BeGreaterThan(smallSize.Height); + } + + [Test] + public void GetWordSize_LongerWordGivesWiderSize() + { + var shortWordSize = calculator.GetWordSize("test", 10, options); + var longWordSize = calculator.GetWordSize("testtesttest", 10, options); + + longWordSize.Width.Should().BeGreaterThan(shortWordSize.Width); + } + + [Test] + public void GetWordSize_SameInputGivesSameOutput() + { + var size1 = calculator.GetWordSize("test", 10, options); + var size2 = calculator.GetWordSize("test", 10, options); + + size2.Should().Be(size1); + } +} \ No newline at end of file diff --git a/TagsCloudContainerTest/SpiralGeneratorTests.cs b/TagsCloudContainerTest/SpiralGeneratorTests.cs new file mode 100644 index 000000000..ba97f59de --- /dev/null +++ b/TagsCloudContainerTest/SpiralGeneratorTests.cs @@ -0,0 +1,84 @@ +using NUnit.Framework; +using FluentAssertions; +using System.Drawing; +using TagsCloudContainer.Extensions; +using TagsCloudContainer.PointGenerators; + +namespace TagsCloudVisualizationTest; + +public class SpiralGeneratorTests +{ + private Point center; + private SpiralGenerator generator; + + [SetUp] + public void Setup() + { + center = new Point(0, 0); + generator = new SpiralGenerator(center); + } + + [Test] + public void Constructor_WithNegativeSpiralStep_ThrowsArgumentException() + { + Action act = () => new SpiralGenerator(center, -1); + + act.Should().Throw() + .WithMessage("Шаг спирали должен быть больше 0"); + } + + [Test] + public void GetNextPoint_FirstPoint_ShouldBeAtCenter() + { + var firstPoint = generator.GetNextPoint(); + + firstPoint.Should().Be(center); + } + + [Test] + public void GetNextPoint_ShouldGeneratePointsWithIncreasingDistanceFromCenter() + { + const int numberOfPoints = 50; + var initialPoint = generator.GetNextPoint(); + var initialDistance = initialPoint.DistanceFromCenter(center); + const int halfwayPoint = numberOfPoints / 2; + + for (var i = 0; i < halfwayPoint; i++) + { + generator.GetNextPoint(); + } + + var middlePoint = generator.GetNextPoint(); + var middleDistance = middlePoint.DistanceFromCenter(center); + + + for (var i = halfwayPoint; i < numberOfPoints; i++) + { + generator.GetNextPoint(); + } + + var finalPoint = generator.GetNextPoint(); + var finalDistance = finalPoint.DistanceFromCenter(center); + + finalDistance.Should().BeGreaterThan(middleDistance) + .And.BeGreaterThan(initialDistance); + } + + [Test] + public void GetNextPoint_ShouldNotGenerateConsecutiveDuplicatePoints() + { + const int numberOfPoints = 100; + Point? previousPoint = null; + + for (var i = 0; i < numberOfPoints; i++) + { + var currentPoint = generator.GetNextPoint(); + if (previousPoint != null) + { + currentPoint.Should().NotBe(previousPoint, + "последовательные точки не должны повторяться"); + } + previousPoint = currentPoint; + } + } +} \ No newline at end of file diff --git a/TagsCloudContainerTest/TagCloudGeneratorIntegrationTests.cs b/TagsCloudContainerTest/TagCloudGeneratorIntegrationTests.cs new file mode 100644 index 000000000..4941cf360 --- /dev/null +++ b/TagsCloudContainerTest/TagCloudGeneratorIntegrationTests.cs @@ -0,0 +1,92 @@ +using System.Drawing; +using Autofac; +using FluentAssertions; +using NUnit.Framework; +using TagsCloudContainer; +using TagsCloudContainer.Interfaces; +using TagsCloudContainer.Options; + +namespace TagsCloudContainerTest; + +public class TagCloudGeneratorIntegrationTests +{ + private IContainer container; + private string tempDirectory; + private string inputPath; + private string outputPath; + + [OneTimeSetUp] + public void GlobalSetup() + { + container = DependencyInjectionConfig.BuildContainer(); + tempDirectory = Path.Combine(Path.GetTempPath(), "TagCloudTests"); + Directory.CreateDirectory(tempDirectory); + inputPath = Path.Combine(tempDirectory, "input.txt"); + outputPath = Path.Combine(tempDirectory, "output.png"); + } + + [OneTimeTearDown] + public void GlobalCleanup() + { + if (Directory.Exists(tempDirectory)) + Directory.Delete(tempDirectory, true); + } + + [Test] + public void GenerateCloud_WithSimpleInput_CreatesValidOutput() + { + const string testText = @" + This is a test text + with multiple words + some words appear + multiple times in the text + test test test + "; + File.WriteAllText(inputPath, testText); + + using var scope = container.BeginLifetimeScope(); + var generator = scope.Resolve(); + var options = new RenderingOptions + { + BackgroundColor = Color.White, + WordColors = [Color.Black], + Font = "Arial", + ImageSize = new Size(800, 600) + }; + + generator.GenerateCloud(inputPath, outputPath, options); + + File.Exists(outputPath).Should().BeTrue(); + using var image = Image.FromFile(outputPath); + image.Width.Should().Be(options.ImageSize.Width); + image.Height.Should().Be(options.ImageSize.Height); + } + + [Test] + public void GenerateCloud_WithLargeImageSize_ReportsOutOfRangeErrorAndDoesNotCreateOutputFile() + { + const string outRangeError = "Облако тегов вышло за пределы изображения."; + var random = new Random(42); + var words = new[] { "test", "cloud", "generator", "word", "frequency" }; + var testText = string.Join(" ", + Enumerable.Range(0, 1000) + .Select(_ => words[random.Next(words.Length)])); + + File.WriteAllText(inputPath, testText); + + using var scope = container.BeginLifetimeScope(); + var generator = scope.Resolve(); + var options = new RenderingOptions + { + BackgroundColor = Color.White, + WordColors = [Color.Black], + Font = "Arial", + ImageSize = new Size(10000, 10000) + }; + + var result = generator.GenerateCloud(inputPath, outputPath, options); + + result.Error.Should().Be(outRangeError); + File.Exists(outputPath).Should().BeFalse(); + } +} \ No newline at end of file diff --git a/TagsCloudContainerTest/TagsCloudContainerTest.csproj b/TagsCloudContainerTest/TagsCloudContainerTest.csproj new file mode 100644 index 000000000..2f7d24d77 --- /dev/null +++ b/TagsCloudContainerTest/TagsCloudContainerTest.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + diff --git a/TagsCloudContainerTest/WordFrequencyAnalyzerTests.cs b/TagsCloudContainerTest/WordFrequencyAnalyzerTests.cs new file mode 100644 index 000000000..7b6f78a50 --- /dev/null +++ b/TagsCloudContainerTest/WordFrequencyAnalyzerTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using NUnit.Framework; +using TagsCloudContainer; + +public class WordFrequencyAnalyzerTests +{ + private WordFrequencyAnalyzer analyzer; + + [SetUp] + public void Setup() + { + analyzer = new WordFrequencyAnalyzer(); + } + + [Test] + public void Analyze_WithEmptyInput_ReturnsEmptyList() + { + var result = analyzer.Analyze(Array.Empty()); + + result.Should().BeEmpty(); + } + + [Test] + public void Analyze_CountsWordFrequencies() + { + var words = new[] { "hello", "world", "hello", "test" }; + + var result = analyzer.Analyze(words); + + result.Should().HaveCount(3); + result.First().Word.Should().Be("hello"); + result.First().Frequency.Should().Be(2); + } + + [Test] + public void Analyze_OrdersByFrequencyDescending() + { + var words = new[] { "rare", "common", "common", "common", "medium", "medium" }; + + var result = analyzer.Analyze(words); + + result.Select(t => t.Frequency).Should().BeInDescendingOrder(); + result[0].Word.Should().Be("common"); + result[1].Word.Should().Be("medium"); + result[2].Word.Should().Be("rare"); + } +} \ No newline at end of file diff --git a/TagsCloudContainerTest/WordProcessorTests.cs b/TagsCloudContainerTest/WordProcessorTests.cs new file mode 100644 index 000000000..88b2366d8 --- /dev/null +++ b/TagsCloudContainerTest/WordProcessorTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using NUnit.Framework; +using TagsCloudContainer; + +namespace TagsCloudContainerTest; + +[TestFixture] +public class WordProcessorTests +{ + private WordProcessor processor; + private string boringWordsFilePath; + + [SetUp] + public void Setup() + { + boringWordsFilePath = Path.GetTempFileName(); + File.WriteAllLines(boringWordsFilePath, new[] { "the", "a", "an" }); + + processor = new WordProcessor(boringWordsFilePath); + } + + [TearDown] + public void Cleanup() + { + if (File.Exists(boringWordsFilePath)) + { + File.Delete(boringWordsFilePath); + } + } + + [Test] + public void ProcessWords_WithNullInput_ReturnsFailResult() + { + var result = processor.ProcessWords(null); + + result.IsSuccess.Should().BeFalse("при null надо возвращать ошибку"); + result.Error.Should().Contain("Value cannot be null", "должна присутствовать ошибка о null"); + } + + [Test] + public void ProcessWords_WithEmptyInput_ReturnsEmptyCollection() + { + var result = processor.ProcessWords(Array.Empty()); + + result.IsSuccess.Should().BeTrue("не должно быть ошибок для пустого списка"); + result.Error.Should().BeNullOrEmpty("при успешной обработке ошибки отсутствуют"); + result.Value.Should().BeEmpty("результат должен быть пустым для пустого ввода"); + } + + [Test] + public void ProcessWords_RemovesBoringWords() + { + var words = new[] { "Hello", "the", "World", "a" }; + var expected = new List { "hello", "world" }; + + var result = processor.ProcessWords(words); + + result.IsSuccess.Should().BeTrue("должен успешно обработать массив"); + result.Error.Should().BeNullOrEmpty("при успешной обработке ошибки отсутствуют"); + result.Value.Should().BeEquivalentTo(expected, "должны оставаться только осмысленные слова"); + } + + [Test] + public void ProcessWords_ConvertsToLowerCase() + { + var words = new[] { "Hello", "WORLD" }; + var expected = new List { "hello", "world" }; + + var result = processor.ProcessWords(words); + + result.IsSuccess.Should().BeTrue("должен успешно обработать массив"); + result.Error.Should().BeNullOrEmpty("при успешной обработке ошибки отсутствуют"); + result.Value.Should().BeEquivalentTo(expected, "должен конвертировать слова в нижний регистр"); + } + + [Test] + public void ProcessWords_RemovesWhitespace() + { + var words = new[] { " hello ", "world " }; + var expected = new List { "hello", "world" }; + + var result = processor.ProcessWords(words); + + result.IsSuccess.Should().BeTrue("должен успешно обработать массив"); + result.Error.Should().BeNullOrEmpty("при успешной обработке ошибки отсутствуют"); + result.Value.Should().BeEquivalentTo(expected, "пробелы должны быть удалены"); + } +} diff --git a/fp.sln b/fp.sln index d104ab530..fe6951a90 100644 --- a/fp.sln +++ b/fp.sln @@ -10,6 +10,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Summator", "Samples\Summato EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConwaysGameOfLife", "Samples\ConwaysGameOfLife\ConwaysGameOfLife.csproj", "{4B77EC28-5FB5-4095-B3D7-127F5C488D6E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudContainer", "TagsCloudContainer\TagsCloudContainer.csproj", "{31BC919F-3BF6-421E-82DE-7A21A37AE2F2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TagsCloudContainerTest", "TagsCloudContainerTest\TagsCloudContainerTest.csproj", "{11125C01-271C-48C9-9412-FB5FE022C549}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,6 +36,14 @@ Global {4B77EC28-5FB5-4095-B3D7-127F5C488D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU {4B77EC28-5FB5-4095-B3D7-127F5C488D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU {4B77EC28-5FB5-4095-B3D7-127F5C488D6E}.Release|Any CPU.Build.0 = Release|Any CPU + {31BC919F-3BF6-421E-82DE-7A21A37AE2F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31BC919F-3BF6-421E-82DE-7A21A37AE2F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31BC919F-3BF6-421E-82DE-7A21A37AE2F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31BC919F-3BF6-421E-82DE-7A21A37AE2F2}.Release|Any CPU.Build.0 = Release|Any CPU + {11125C01-271C-48C9-9412-FB5FE022C549}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11125C01-271C-48C9-9412-FB5FE022C549}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11125C01-271C-48C9-9412-FB5FE022C549}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11125C01-271C-48C9-9412-FB5FE022C549}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {C33F3A5E-A1ED-4657-9B35-968A4CB23AA1} = {754C1CC8-A8B6-46C6-B35C-8A43B80111A0}