/
docNemo
/
clothes-graph
Обзор
Документация
Войти
/
docNemo
/
clothes-graph
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
tests/ClothesGraph.Model.Tests/PatternFileTests.cs
347 строк
13 KB
docNemo
Фундамент продукта: параметрическое построение выкроек, печать, установщик
08 авг 2026, 22:17
08 авг 2026, 22:17
cd47cce
Код
Авторство
О чём код?
using System; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; using ClothesGraph.Geometry; using ClothesGraph.Model; using ClothesGraph.Model.Drafting; using ClothesGraph.Model.Measurements; using ClothesGraph.Model.Pieces; using ClothesGraph.Model.Storage; using Xunit; namespace ClothesGraph.Model.Tests; public class PatternFileTests : IDisposable { private readonly string _directory = Path.Combine( Path.GetTempPath(), "clothes-graph-files", Guid.NewGuid().ToString("N")[..8]); public PatternFileTests() => Directory.CreateDirectory(_directory); public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); } private string PathFor(string name) => Path.Combine(_directory, name); /// <summary>Документ со всеми видами построений, деталью и размерным рядом.</summary> private static PatternDocument BuildDocument() { var document = new PatternDocument("46"); var chest = document.Measurements.Add("Ог", Length.FromCentimetres(92), "Обхват груди"); document.Measurements.Add("Дтс", Length.FromCentimetres(42)); document.Sizes.AddSize("48"); document.Sizes.SetValue("48", chest.Id, Length.FromCentimetres(96)); var draft = document.Draft; var a = draft.Add(new FreePoint(NodeId.New(), "А", document.ParseFormula("0"), document.ParseFormula("0"))).Id; var b = draft.Add(new OffsetPoint(NodeId.New(), "А1", a, document.ParseFormula("Ог/2 + 4"), document.ParseFormula("0"))).Id; var c = draft.Add(new OffsetPoint(NodeId.New(), "Т1", b, document.ParseFormula("Дтс"), document.ParseFormula("270"))).Id; var d = draft.Add(new OffsetPoint(NodeId.New(), "Т", a, document.ParseFormula("Дтс"), document.ParseFormula("270"))).Id; var top = draft.Add(new SegmentNode(NodeId.New(), "АА1", a, b)).Id; var right = draft.Add(new SegmentNode(NodeId.New(), "А1Т1", b, c)).Id; var bottom = draft.Add(new SegmentNode(NodeId.New(), "ТТ1", d, c)).Id; var left = draft.Add(new SegmentNode(NodeId.New(), "АТ", a, d)).Id; var middle = draft.Add(new PointOnCurve(NodeId.New(), "М", top, document.ParseFormula("Ог/4"), PointOnCurveMode.Distance)).Id; var control = draft.Add(new OffsetPoint(NodeId.New(), "К", middle, document.ParseFormula("max(3, 5)"), document.ParseFormula("270 - 0"))).Id; draft.Add(new CubicCurveNode(NodeId.New(), "Кривая", a, control, middle, b)); var piece = new PatternPiece(PieceId.New(), "Основа", document.ParseFormula("1")) { Quantity = 2, Note = "проверочная", Grainline = new Grainline(document.ParseFormula("90")) }; piece.AddSegment(top); piece.AddSegment(right); piece.AddSegment(bottom, reversed: true); piece.AddSegment(left, reversed: true); piece.SetSegmentAllowance(2, document.ParseFormula("4")); piece.AddNotch(new Notch(document.ParseFormula("20"), "контрольная")); piece.FoldSegmentIndex = 3; document.Pieces.Add(piece); document.Print.PageSizeName = "A3"; document.Print.PageWidth = Length.FromMillimetres(297); document.Print.PageHeight = Length.FromMillimetres(420); document.Print.IsLandscape = true; document.Print.Margin = Length.FromMillimetres(10); document.Print.Overlap = Length.FromMillimetres(20); return document; } [Fact] public void Размещение_деталей_сохраняется() { var original = BuildDocument(); var piece = original.Pieces[0]; original.Print.Placements[piece.Id] = new PiecePlacement( new Offset2(Length.FromCentimetres(12), Length.FromCentimetres(-4)), RotationDegrees: 90); var path = PathFor("раскладка.cgp"); PatternFile.Save(original, path); var restored = PatternFile.Load(path); // Раскладка — результат ручной работы, и терять её при закрытии // документа нельзя. Assert.True(restored.Print.Placements.TryGetValue(piece.Id, out var placement)); Assert.Equal(Length.FromCentimetres(12), placement!.Offset.X); Assert.Equal(Length.FromCentimetres(-4), placement.Offset.Y); Assert.Equal(90, placement.RotationDegrees, precision: 3); } [Fact] public void Настройки_печати_сохраняются() { var original = BuildDocument(); var path = PathFor("печать.cgp"); PatternFile.Save(original, path); var restored = PatternFile.Load(path); Assert.Equal("A3", restored.Print.PageSizeName); Assert.Equal(Length.FromMillimetres(297), restored.Print.PageWidth); Assert.True(restored.Print.IsLandscape); Assert.Equal(Length.FromMillimetres(10), restored.Print.Margin); Assert.Equal(Length.FromMillimetres(20), restored.Print.Overlap); Assert.True(restored.Print.JoinWithOverlap); } [Fact] public void Сохранение_и_открытие_восстанавливает_документ() { var original = BuildDocument(); var path = PathFor("проект.cgp"); PatternFile.Save(original, path); var restored = PatternFile.Load(path); Assert.Equal(original.Measurements.Count, restored.Measurements.Count); Assert.Equal(original.Draft.Count, restored.Draft.Count); Assert.Equal(original.Pieces.Count, restored.Pieces.Count); Assert.Equal(original.Sizes.Sizes, restored.Sizes.Sizes); Assert.Equal(original.Sizes.BaseSize, restored.Sizes.BaseSize); Assert.Equal(original.FormulaUnit, restored.FormulaUnit); } [Fact] public void Восстановленный_чертёж_даёт_ту_же_геометрию() { var original = BuildDocument(); var path = PathFor("геометрия.cgp"); PatternFile.Save(original, path); var restored = PatternFile.Load(path); var before = new DraftEvaluator(original.Draft) .Evaluate(original.ResolveMeasurements(), original.FormulaUnit); var after = new DraftEvaluator(restored.Draft) .Evaluate(restored.ResolveMeasurements(), restored.FormulaUnit); Assert.True(before.IsComplete); Assert.True(after.IsComplete); // Сравнение по именам: идентификаторы совпадают, но проверка по именам // заодно подтверждает, что не перепутались сами построения. foreach (var node in original.Draft.Nodes.OfType<PointNode>()) { var same = restored.Draft.FindByName(node.Name); Assert.NotNull(same); Assert.Equal(before.Points[node.Id], after.Points[same!.Id]); } } [Fact] public void Формулы_сохраняют_ссылки_на_мерки() { var original = BuildDocument(); var path = PathFor("формулы.cgp"); PatternFile.Save(original, path); var restored = PatternFile.Load(path); var chest = restored.Measurements.FindByName("Ог"); Assert.NotNull(chest); // Правка мерки в восстановленном документе обязана двигать построения: // значит ссылка сохранилась, а не превратилась в число. var node = (OffsetPoint)restored.Draft.FindByName("А1")!; Assert.Contains(chest!.Id, node.Distance.Measurements); Assert.Equal("Ог / 2 + 4", node.Distance.ToText(restored)); } [Fact] public void Значения_размерной_сетки_сохраняются() { var original = BuildDocument(); var path = PathFor("размеры.cgp"); PatternFile.Save(original, path); var restored = PatternFile.Load(path); var chest = restored.Measurements.FindByName("Ог")!; Assert.True(restored.Sizes.TryGetValue("48", chest.Id, out var value)); Assert.Equal(Length.FromCentimetres(96), value); } [Fact] public void Свойства_детали_сохраняются() { var original = BuildDocument(); var path = PathFor("деталь.cgp"); PatternFile.Save(original, path); var restored = PatternFile.Load(path); var piece = Assert.Single(restored.Pieces); Assert.Equal("Основа", piece.Name); Assert.Equal(2, piece.Quantity); Assert.Equal("проверочная", piece.Note); Assert.Equal(3, piece.FoldSegmentIndex); Assert.Equal(4, piece.Contour.Count); Assert.True(piece.Contour[2].Reversed); Assert.Single(piece.Notches); Assert.NotNull(piece.Grainline); Assert.Single(piece.SegmentAllowances); } [Fact] public void Файл_более_новой_версии_не_открывается() { var path = PathFor("будущее.cgp"); WriteRawDocument(path, $$""" { "formatVersion": {{FileFormat.CurrentVersion + 1}}, "sizes": { "baseSize": "46" } } """); var error = Assert.Throws<UnsupportedFileVersionException>(() => PatternFile.Load(path)); Assert.Equal(FileFormat.CurrentVersion + 1, error.FileVersion); Assert.Contains("Обновите программу", error.Message); } [Fact] public void Повреждённый_файл_сообщает_об_этом() { var path = PathFor("мусор.cgp"); File.WriteAllText(path, "это не архив"); Assert.Throws<InvalidPatternFileException>(() => PatternFile.Load(path)); } [Fact] public void Архив_без_описания_документа_сообщает_об_этом() { var path = PathFor("пустой.cgp"); using (var stream = File.Create(path)) using (var archive = new ZipArchive(stream, ZipArchiveMode.Create)) archive.CreateEntry("readme.txt"); var error = Assert.Throws<InvalidPatternFileException>(() => PatternFile.Load(path)); Assert.Contains("описания документа", error.Message); } [Fact] public void Прерванная_запись_не_разрушает_прежний_файл() { var path = PathFor("важный.cgp"); PatternFile.Save(BuildDocument(), path); var originalBytes = File.ReadAllBytes(path); // Документ с построением, которое формат не поддерживает: запись // сорвётся на середине. var broken = new PatternDocument("46"); broken.Draft.Add(new UnsupportedNode(NodeId.New(), "плохое")); Assert.ThrowsAny<Exception>(() => PatternFile.Save(broken, path)); // Прежний файл обязан остаться на месте и открываться. Assert.Equal(originalBytes, File.ReadAllBytes(path)); Assert.NotNull(PatternFile.Load(path)); // Временные файлы не должны оставаться в папке. Assert.Empty(Directory.GetFiles(_directory, "*.tmp-*")); } [Fact] public void Повторное_сохранение_даёт_тот_же_результат() { var document = BuildDocument(); var first = PathFor("раз.cgp"); var second = PathFor("два.cgp"); PatternFile.Save(document, first); PatternFile.Save(PatternFile.Load(first), second); // Прогон через файл и обратно не должен ничего терять и ничего // добавлять: иначе документ «дрейфует» при каждом открытии. Assert.Equal(ReadDocumentJson(first), ReadDocumentJson(second)); } private static void WriteRawDocument(string path, string json) { using var stream = File.Create(path); using var archive = new ZipArchive(stream, ZipArchiveMode.Create); var entry = archive.CreateEntry(FileFormat.DocumentEntry); using var entryStream = entry.Open(); entryStream.Write(Encoding.UTF8.GetBytes(json)); } private static string ReadDocumentJson(string path) { using var stream = File.OpenRead(path); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); using var entryStream = archive.GetEntry(FileFormat.DocumentEntry)!.Open(); using var reader = new StreamReader(entryStream); return reader.ReadToEnd(); } /// <summary>Построение, не поддержанное форматом файла.</summary> private sealed class UnsupportedNode(NodeId id, string name) : PointNode(id, name) { public override System.Collections.Generic.IEnumerable<NodeId> Dependencies => []; public override System.Collections.Generic.IEnumerable<MeasurementId> MeasurementDependencies => []; public override Point2 Evaluate(INodeEvaluationContext context) => Point2.Origin; public override DraftNode Clone() => new UnsupportedNode(Id, Name); } }