Ответ надписи — габарит глифов; запись на диск создаёт каталог

ksConvertTextToCurve ВОЗВРАЩАЕТ ссылку на кривые, и только по ней ksGetObjGabaritRect
даёт габарит букв: по текстовой ссылке это ячейка строки (Arial h=10 «HH» — 23.75 против
19.97 у глифов, второе совпадает с выдавленным телом). Агент вписывает надпись в поле
по габариту, поэтому в ответе теперь оба числа.

OutputPath.Prepare создаёт каталог назначения и делает путь абсолютным (document_save,
export_step, снимок): КОМПАС в несуществующую папку молча не пишет, а диагностика при
этом винила занятое имя файла — чинили не ту причину. Причины разведены.
This commit is contained in:
2026-07-31 16:36:01 +03:00
parent 461e731857
commit 31b933e1cc
12 changed files with 344 additions and 57 deletions
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Threading;
@@ -88,8 +88,8 @@ public sealed class ConversionService
private void ExportCore(string path, StepFormat format)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Путь экспорта не задан.", nameof(path));
// Каталог создаём сами: конвертер в несуществующую папку возвращает FALSE без причины.
var full = Documents.OutputPath.Prepare(path);
var app = _session.Application;
object? active = null;
@@ -110,8 +110,8 @@ public sealed class ConversionService
?? throw new InvalidOperationException("ConverterParameters вернул не IAdditionConvertParameters.");
prm.Format = (ksKOMPASConverterEnum)formatCode;
if (!doc3d1.ConvertToAdditionFormat(path, (AdditionConvertParameters)prm))
throw new InvalidOperationException("ConvertToAdditionFormat вернул FALSE (экспорт STEP не удался).");
if (!doc3d1.ConvertToAdditionFormat(full, (AdditionConvertParameters)prm))
throw new InvalidOperationException($"ConvertToAdditionFormat вернул FALSE (экспорт STEP по пути «{full}» не удался).");
}
finally
{
@@ -64,22 +64,33 @@ public sealed class DocumentService
}), ct);
/// <summary>Сохранить активный документ по указанному пути (безопасно для новых документов).</summary>
public Task SaveAsAsync(string path, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => WithActive(d =>
public Task<string> SaveAsAsync(string path, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
d.SaveAs(path);
// SaveAs ничего не возвращает, а КОМПАС молча не сохраняет, если файл с этим именем уже
// открыт другим документом. Без проверки по факту агент получает ложный «Сохранено».
var actual = d.PathName ?? string.Empty;
if (!string.Equals(actual, path, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
throw new InvalidOperationException(
$"КОМПАС не сохранил документ по пути «{path}»" +
(actual.Length == 0
? " (документ так и остался несохранённым)"
: $" (документ остался по пути «{actual}»)") +
". Обычная причина — файл с этим именем уже открыт в КОМПАС другим документом.");
return true;
}), ct);
// Каталог создаём ДО SaveAs: в отсутствующую папку КОМПАС молча не пишет.
var full = OutputPath.Prepare(path);
return WithActive(d =>
{
d.SaveAs(full);
// SaveAs ничего не возвращает, а КОМПАС молча не сохраняет, если файл с этим именем уже
// открыт другим документом. Без проверки по факту агент получает ложный «Сохранено».
var actual = d.PathName ?? string.Empty;
var samePath = string.Equals(actual, full, StringComparison.OrdinalIgnoreCase);
if (!samePath || !File.Exists(full))
throw new InvalidOperationException(
$"КОМПАС не сохранил документ по пути «{full}»" +
(actual.Length == 0
? " (документ так и остался несохранённым)"
: samePath
? " (путь документу присвоен, но файла на диске нет — проверьте права на запись)"
: $" (документ остался по пути «{actual}»)") +
(samePath
? "."
: ". Обычная причина — файл с этим именем уже открыт в КОМПАС другим документом; " +
"освободите имя через document_close(all=true)."));
return full;
});
}, ct);
/// <summary>
/// Задать наименование и/или обозначение детали активного 3D-документа. Наименование — это имя
@@ -0,0 +1,52 @@
namespace Kompas.Mcp.Core.Documents;
/// <summary>
/// Подготовка пути, по которому мы собираемся ЧТО-ТО ЗАПИСАТЬ (документ, STEP, снимок).
/// <para>КОМПАС каталогов не создаёт: <c>SaveAs</c> в несуществующую папку просто ничего не пишет
/// и молчит, а <c>ConvertToAdditionFormat</c> возвращает FALSE без причины. Агент в этот момент
/// думает, что имя файла занято, и уходит чинить не то. Поэтому каталог создаём сами, а путь
/// приводим к абсолютному — относительный КОМПАС трактует от своего рабочего каталога,
/// а не от каталога агента.</para>
/// </summary>
public static class OutputPath
{
/// <summary>
/// Привести путь к абсолютному и создать каталог назначения. Возвращает полный путь.
/// </summary>
/// <param name="path">Путь к файлу (абсолютный или относительный).</param>
/// <param name="parameterName">Имя параметра инструмента — попадёт в текст ошибки.</param>
public static string Prepare(string? path, string parameterName = "path")
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Путь к файлу не задан.", parameterName);
string full;
try
{
full = Path.GetFullPath(path);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new ArgumentException($"Некорректный путь «{path}»: {ex.Message}", parameterName, ex);
}
if (Path.GetFileName(full).Length == 0)
throw new ArgumentException($"Путь «{path}» указывает на каталог, а не на файл.", parameterName);
var directory = Path.GetDirectoryName(full);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
throw new InvalidOperationException(
$"Каталог «{directory}» не существует и не создаётся: {ex.Message}", ex);
}
}
return full;
}
}
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas6API5;
using Kompas6Constants;
using Kompas6Constants3D;
@@ -10,13 +10,13 @@ namespace Kompas.Mcp.Core.Modeling;
public sealed partial class PartModeler
{
/// <summary>Создать эскиз на базовой плоскости и открыть его на редактирование. Возвращает id эскиза.</summary>
public Task<int> OpenSketchAsync(BasePlane plane, CancellationToken ct = default)
public Task<int> OpenSketchAsync(BasePlane plane, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
var planeEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(plane)) as ksEntity
?? throw new InvalidOperationException("Не удалось получить базовую плоскость.");
return CreateSketchOn(part, planeEntity);
return CreateSketchOn(part, planeEntity, name);
}, ct);
/// <summary>
@@ -24,7 +24,7 @@ public sealed partial class PartModeler
/// (мировые координаты модели, мм), и открыть его на редактирование. Возвращает id эскиза.
/// Это ключ к итеративному циклу «построил → выбрал грань → новый эскиз на ней».
/// </summary>
public Task<int> OpenSketchOnFaceAsync(double x, double y, double z, CancellationToken ct = default)
public Task<int> OpenSketchOnFaceAsync(double x, double y, double z, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
@@ -38,14 +38,14 @@ public sealed partial class PartModeler
var face = faces.GetByIndex(0) as ksEntity
?? throw new InvalidOperationException("Грань из коллекции не приводится к ksEntity.");
return CreateSketchOn(part, face);
return CreateSketchOn(part, face, name);
}, ct);
/// <summary>
/// Создать эскиз на грани детали, заданной индексом из list_faces, и открыть на редактирование.
/// Индекс стабилен, пока геометрия не меняется. Возвращает id эскиза.
/// </summary>
public Task<int> OpenSketchOnFaceIndexAsync(int faceIndex, CancellationToken ct = default)
public Task<int> OpenSketchOnFaceIndexAsync(int faceIndex, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
var part = GetTopPart();
@@ -57,21 +57,20 @@ public sealed partial class PartModeler
var face = faces.GetByIndex(faceIndex) as ksEntity
?? throw new InvalidOperationException("Грань по индексу не приводится к ksEntity.");
return CreateSketchOn(part, face);
return CreateSketchOn(part, face, name);
}, ct);
/// <summary>
/// Создать смещённую плоскость (база + offset мм) и открыть на ней эскиз. Возвращает id эскиза.
/// Снимает ограничение «только базовые плоскости»: эскиз на произвольной высоте/смещении.
/// </summary>
public Task<int> OpenSketchOnOffsetPlaneAsync(BasePlane basePlane, double offset, bool direction = true, CancellationToken ct = default)
public Task<int> OpenSketchOnOffsetPlaneAsync(BasePlane basePlane, double offset, bool direction = true, string? name = null, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (!double.IsFinite(offset))
throw new ArgumentException("Смещение должно быть конечным числом.", nameof(offset));
var part = GetTopPart();
var planeEntity = part.NewEntity((short)Obj3dType.o3d_planeOffset) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_planeOffset) вернул null.");
var planeEntity = NewEntity(part, Obj3dType.o3d_planeOffset, null);
var pdef = planeEntity.GetDefinition() as ksPlaneOffsetDefinition
?? throw new InvalidOperationException("GetDefinition() плоскости вернул не ksPlaneOffsetDefinition.");
var baseEntity = part.GetDefaultEntity((short)BasePlanes.ToObj3dType(basePlane)) as ksEntity
@@ -81,14 +80,13 @@ public sealed partial class PartModeler
pdef.direction = direction;
if (!planeEntity.Create())
throw new InvalidOperationException("Create() смещённой плоскости вернул FALSE.");
return CreateSketchOn(part, planeEntity);
return CreateSketchOn(part, planeEntity, name);
}, ct);
/// <summary>Общее ядро: создать эскиз на заданной плоскости/грани и открыть на редактирование.</summary>
private int CreateSketchOn(ksPart part, ksEntity planeEntity)
private int CreateSketchOn(ksPart part, ksEntity planeEntity, string? name)
{
var entity = part.NewEntity((short)Obj3dType.o3d_sketch) as ksEntity
?? throw new InvalidOperationException("NewEntity(o3d_sketch) вернул null.");
var entity = NewEntity(part, Obj3dType.o3d_sketch, name);
var def = entity.GetDefinition() as ksSketchDefinition
?? throw new InvalidOperationException("GetDefinition() эскиза вернул не ksSketchDefinition.");
@@ -176,8 +174,7 @@ public sealed partial class PartModeler
PointCore(editor, Point(e, 0));
break;
case SketchEntityKind.Text:
var length = TextCore(editor, Point(e, 0), e);
return $"надпись «{e.Text}» — длина {length:F2} мм от точки вставки";
return TextCore(editor, Point(e, 0), e).Describe(e.Text);
default:
throw new ArgumentOutOfRangeException(nameof(e), $"Неизвестный тип примитива: {e.Kind}.");
}
@@ -387,13 +384,31 @@ public sealed partial class PartModeler
private const int FontItalicOn = 0x40; // ITALIC_ON
private const int FontBoldOn = 0x100; // BOLD_ON
/// <summary>Габарит объекта эскиза по его reference или <c>null</c>, если КОМПАС его не отдал.
/// Отсутствие габарита не повод рушить построение — примитив уже создан.</summary>
private (double MinX, double MinY, double MaxX, double MaxY)? GabaritOf(ksDocument2D editor, int reference)
{
ksRectParam? rect = null;
try
{
rect = NewParam<ksRectParam>(StructType2DEnum.ko_RectParam);
if (editor.ksGetObjGabaritRect(reference, rect) == 0) return null;
if (rect.GetpBot() is not ksMathPointParam bot) return null;
if (rect.GetpTop() is not ksMathPointParam top) return null;
try { return (bot.x, bot.y, top.x, top.y); }
finally { ReleaseCom(bot); ReleaseCom(top); }
}
catch (System.Runtime.InteropServices.COMException) { return null; }
finally { ReleaseCom(rect); }
}
/// <summary>
/// Надпись в эскизе. Текст выбранным шрифтом сразу переводится в кривые
/// (<c>ksConvertTextToCurve</c>): пока он остаётся текстом, это оформление, и формообразующая
/// операция его не видит. Возвращает длину строки (мм) — по ней агент выравнивает надпись,
/// иначе ширина известна только после выдавливания.
/// операция его не видит. Возвращает метрики надписи — по ним агент выравнивает её, иначе
/// фактическое место, занятое буквами, известно только после выдавливания.
/// </summary>
private double TextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
private TextMetrics TextCore(ksDocument2D editor, (double x, double y) at, SketchEntity e)
{
var text = e.Text ?? "";
if (text.Length == 0)
@@ -452,10 +467,13 @@ public sealed partial class PartModeler
throw new InvalidOperationException(
$"ksTextEx вернул 0 (надпись не создана — шрифт «{e.FontName}» установлен в системе?).");
var length = editor.ksGetTextLengthFromReference(reference);
if (editor.ksConvertTextToCurve(reference) == 0)
var advance = editor.ksGetTextLengthFromReference(reference);
var curves = editor.ksConvertTextToCurve(reference);
if (curves == 0)
throw new InvalidOperationException("ksConvertTextToCurve вернул 0 (надпись не переведена в кривые).");
return length;
// Габарит берём по ссылке на КРИВЫЕ, которую вернула конвертация: у исходного текстового
// объекта рамка идёт по ячейке строки, а она шире глифов (на скриптовых шрифтах — до 8 %).
return new TextMetrics(advance, GabaritOf(editor, curves));
}
finally
{
@@ -0,0 +1,38 @@
using System.Globalization;
namespace Kompas.Mcp.Core.Modeling;
/// <summary>
/// Метрики построенной надписи. Агенту нужны ДВЕ разные величины, и путать их дорого:
/// <list type="bullet">
/// <item><description><b>Габарит глифов</b> — прямоугольник, реально занятый буквами. По нему
/// выравнивают надпись и вписывают её в поле; только он совпадает с тем, что получится после
/// выдавливания.</description></item>
/// <item><description><b>Ширина ячейки</b> (<c>ksGetTextLengthFromReference</c>) — шаг строки
/// с боковыми выносами. Больше габарита на 1–2 % у наборных шрифтов и на 7–9 % у скриптовых.</description></item>
/// </list>
/// Пока в ответе была только ячейка, подгонка надписи под размер шла вслепую: вставить →
/// выдавить → замерить → пересобрать документ.
/// </summary>
/// <param name="Advance">Ширина ячейки строки, мм.</param>
/// <param name="Box">Габарит глифов в координатах эскиза (мм) или <c>null</c>, если КОМПАС его не отдал.</param>
public readonly record struct TextMetrics(
double Advance,
(double MinX, double MinY, double MaxX, double MaxY)? Box)
{
/// <summary>Заметка для ответа инструмента: где легли буквы и сколько заняли.</summary>
public string Describe(string? text)
{
var what = string.IsNullOrEmpty(text) ? "надпись" : $"надпись «{text}»";
var cell = F(Advance);
if (Box is not { } b)
return $"{what} — ширина ячейки {cell} мм (габарит глифов КОМПАС не отдал)";
var width = F(b.MaxX - b.MinX);
var height = F(b.MaxY - b.MinY);
return $"{what} — глифы X {F(b.MinX)}…{F(b.MaxX)}, Y {F(b.MinY)}…{F(b.MaxY)} " +
$"({width} × {height} мм), ширина ячейки {cell} мм";
}
private static string F(double value) => value.ToString("F2", CultureInfo.InvariantCulture);
}
@@ -1,4 +1,4 @@
using System.Runtime.Versioning;
using System.Runtime.Versioning;
using Kompas.Mcp.Core.Threading;
using Kompas6API5;
@@ -63,8 +63,9 @@ public sealed class SnapshotService
// вывод работает надёжно.
param.returnResultAsArrayBytes = false;
var outFile = saveToFile ?? Path.Combine(
Path.GetTempPath(), $"kompas-snap-{Guid.NewGuid():N}.{format.FileExtension()}");
var outFile = saveToFile is null
? Path.Combine(Path.GetTempPath(), $"kompas-snap-{Guid.NewGuid():N}.{format.FileExtension()}")
: Documents.OutputPath.Prepare(saveToFile, nameof(saveToFile));
var deleteAfter = saveToFile is null;
try
+2 -2
View File
@@ -47,8 +47,8 @@ public sealed class DocumentTools(KompasSession session, DocumentService docs, P
return "Документ сохранён.";
}
await docs.SaveAsAsync(path);
return $"Сохранено: {path}";
var saved = await docs.SaveAsAsync(path);
return $"Сохранено: {saved}";
}
[McpServerTool(Name = "document_close")]
+10 -8
View File
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Modeling;
using ModelContextProtocol.Server;
@@ -17,7 +17,8 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
"на грани в мировых координатах модели (запасной вариант, когда индекс не подходит). " +
"entities — список примитивов, autoClose=true закрывает эскиз сразу, чтобы можно было звать " +
"extrude/revolve. Без entities эскиз остаётся открытым — наполняйте его через sketch_add. " +
"Возвращает id эскиза.")]
"name — имя эскиза в дереве построения («Контур плашки»): давайте его ВСЕГДА, иначе дерево " +
"состоит из безликих «Эскиз:1…N» и по нему нельзя понять, что где. Возвращает id эскиза.")]
public async Task<string> Create(
[Description("Базовая плоскость: XOY | XOZ | YOZ")] string? plane = null,
[Description("Индекс грани из list_faces (надёжнее выбора по точке)")] int? faceIndex = null,
@@ -27,7 +28,8 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
[Description("Смещение от базовой плоскости, мм (только вместе с plane)")] double offset = 0,
[Description("Прямое направление смещения от базовой плоскости")] bool offsetDirection = true,
[Description("Примитивы эскиза: список объектов {type, points, centerX, ...}")] SketchEntityInput[]? entities = null,
[Description("Закрыть эскиз после построения entities (нужно перед операцией)")] bool autoClose = true)
[Description("Закрыть эскиз после построения entities (нужно перед операцией)")] bool autoClose = true,
[Description("Имя эскиза в дереве построения, например «Контур плашки»")] string? name = null)
{
await session.ConnectAsync();
var parsed = SketchEntityInputs.Parse(entities);
@@ -36,12 +38,12 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
string on;
if (faceIndex is { } fi)
{
id = await modeler.OpenSketchOnFaceIndexAsync(fi);
id = await modeler.OpenSketchOnFaceIndexAsync(fi, name);
on = $"грани [{fi}]";
}
else if (x is { } px && y is { } py && z is { } pz)
{
id = await modeler.OpenSketchOnFaceAsync(px, py, pz);
id = await modeler.OpenSketchOnFaceAsync(px, py, pz, name);
on = $"грани в точке ({px}, {py}, {pz})";
}
else if (!string.IsNullOrWhiteSpace(plane))
@@ -49,12 +51,12 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
var basePlane = BasePlanes.Parse(plane);
if (offset == 0)
{
id = await modeler.OpenSketchAsync(basePlane);
id = await modeler.OpenSketchAsync(basePlane, name);
on = $"плоскости {plane}";
}
else
{
id = await modeler.OpenSketchOnOffsetPlaneAsync(basePlane, offset, offsetDirection);
id = await modeler.OpenSketchOnOffsetPlaneAsync(basePlane, offset, offsetDirection, name);
on = $"плоскости {plane}, смещение {offset} мм";
}
}
@@ -95,7 +97,7 @@ public sealed class SketchTools(KompasSession session, PartModeler modeler)
return $"В эскиз id={sketchId} добавлено примитивов: {parsed.Count}.{state}{Notes(notes)}";
}
/// <summary>Заметки по построенным примитивам (сейчас — фактическая длина надписей).</summary>
/// <summary>Заметки по построенным примитивам (сейчас — метрики надписей: габарит глифов и ячейка).</summary>
private static string Notes(IReadOnlyList<string> notes)
{
var meaningful = notes.Where(n => !string.IsNullOrEmpty(n)).ToList();
@@ -52,6 +52,30 @@ public sealed class DocumentTests : IntegrationTestBase
}
}
[Fact]
public async Task SaveAs_creates_the_missing_directory()
{
// КОМПАС каталогов не создаёт: SaveAs в несуществующую папку молча ничего не пишет,
// а прежняя диагностика винила «занятое имя файла» — агент чинил не ту причину.
var path = Path.Combine(Path.GetDirectoryName(TestPaths.NewFile(".m3d"))!,
"подпапка кейса", "деталь.m3d");
Assert.False(Directory.Exists(Path.GetDirectoryName(path)));
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var saved = await _docs.SaveAsAsync(path);
Assert.Equal(path, saved, ignoreCase: true);
Assert.True(File.Exists(path), "файл не появился на диске");
}
finally
{
await _docs.CloseAsync(save: false);
try { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); } catch (IOException) { }
}
}
[Fact]
public async Task Part_name_and_marking_survive_the_write()
{
@@ -44,8 +44,9 @@ public sealed class SketchTextTests : IntegrationTestBase
await _modeler.ExtrudeAsync(s, depth: 2);
await _modeler.RebuildAsync();
// Длина строки нужна агенту для выравнивания: до выдавливания её узнать неоткуда.
Assert.Contains("длина", notes[0], StringComparison.Ordinal);
// Метрики нужны агенту для выравнивания: до выдавливания их узнать неоткуда.
Assert.Contains("глифы", notes[0], StringComparison.Ordinal);
Assert.Contains("ширина ячейки", notes[0], StringComparison.Ordinal);
var info = await _query.GetPartInfoAsync();
Assert.True(info.Volume > 0, "надпись не дала объёма — текст не переведён в кривые");
@@ -58,6 +59,36 @@ public sealed class SketchTextTests : IntegrationTestBase
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Reported_glyph_box_matches_the_extruded_geometry()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
// Ширина ячейки (её и возвращали раньше) больше габарита глифов, поэтому агент,
// вписывающий надпись в поле, промахивался. Сверяем заявленный габарит с фактическим.
var s = await _modeler.OpenSketchAsync(BasePlane.XOY);
var notes = await _modeler.AddEntitiesAsync(s, [Text("HH", 0, 0, 10, "Arial")], autoClose: true);
await _modeler.ExtrudeAsync(s, depth: 2);
await _modeler.RebuildAsync();
var numbers = System.Text.RegularExpressions.Regex
.Matches(notes[0], @"-?\d+\.\d{2}")
.Select(m => double.Parse(m.Value, System.Globalization.CultureInfo.InvariantCulture))
.ToList();
Assert.True(numbers.Count >= 7, $"в заметке нет чисел габарита: {notes[0]}");
var (minX, maxX, minY, maxY) = (numbers[0], numbers[1], numbers[2], numbers[3]);
var box = await _query.GetBoundingBoxAsync();
Assert.InRange(maxX - minX, box.SizeX - 0.05, box.SizeX + 0.05);
Assert.InRange(maxY - minY, box.SizeY - 0.05, box.SizeY + 0.05);
// Единицы — миллиметры эскиза, а не сантиметры листа: высота прописной ≈ height.
Assert.InRange(maxY - minY, 9.5, 10.5);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Unknown_font_is_reported_by_name()
{
+65
View File
@@ -0,0 +1,65 @@
using Kompas.Mcp.Core.Documents;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Подготовка пути записи. КОМПАС в несуществующую папку молча не пишет, а инструмент рапортовал
/// «Сохранено» либо винил занятое имя файла — агент уходил чинить не ту причину.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OutputPathTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "kompas-mcp-tests", Guid.NewGuid().ToString("N")[..8]);
[Fact]
public void Missing_directory_is_created()
{
var path = Path.Combine(_root, "вложенная папка", "деталь.m3d");
var full = OutputPath.Prepare(path);
Assert.Equal(path, full);
Assert.True(Directory.Exists(Path.GetDirectoryName(full)), "каталог назначения не создан");
Assert.False(File.Exists(full), "сам файл создавать не нужно — его пишет КОМПАС");
}
[Fact]
public void Relative_path_becomes_absolute()
{
var full = OutputPath.Prepare("деталь.m3d");
Assert.True(Path.IsPathRooted(full), "относительный путь КОМПАС трактует от своего каталога");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_path_is_rejected(string? path)
=> Assert.Throws<ArgumentException>(() => OutputPath.Prepare(path));
[Fact]
public void Directory_without_a_file_name_is_rejected()
{
var ex = Assert.Throws<ArgumentException>(() => OutputPath.Prepare(_root + Path.DirectorySeparatorChar));
Assert.Contains("каталог", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Existing_directory_is_left_alone()
{
Directory.CreateDirectory(_root);
var marker = Path.Combine(_root, "уже был.txt");
File.WriteAllText(marker, "x");
OutputPath.Prepare(Path.Combine(_root, "деталь.m3d"));
Assert.True(File.Exists(marker), "подготовка пути не должна трогать содержимое каталога");
}
public void Dispose()
{
try { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); }
catch (IOException) { /* временный каталог подчистит система */ }
}
}
@@ -0,0 +1,45 @@
using Kompas.Mcp.Core.Modeling;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Ответ по надписи. Агент вписывает текст в заданное поле, поэтому ему нужен габарит ГЛИФОВ;
/// ширина ячейки (ksGetTextLengthFromReference) больше него на 1–2 % у наборных шрифтов и на
/// 7–9 % у скриптовых — подстановка одного вместо другого промахивается мимо поля.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TextMetricsTests
{
[Fact]
public void Box_and_cell_are_both_reported()
{
var m = new TextMetrics(94.57, (1.0, 12.14, 87.93, 28.94));
var text = m.Describe("OldMan");
Assert.Contains("OldMan", text, StringComparison.Ordinal);
Assert.Contains("1.00…87.93", text, StringComparison.Ordinal); // габарит по X
Assert.Contains("12.14…28.94", text, StringComparison.Ordinal); // габарит по Y
Assert.Contains("86.93 × 16.80", text, StringComparison.Ordinal); // ширина × высота глифов
Assert.Contains("94.57", text, StringComparison.Ordinal); // ячейка — отдельно
}
[Fact]
public void Missing_box_is_stated_instead_of_being_faked()
{
var text = new TextMetrics(50.0, null).Describe("EDITION");
Assert.Contains("50.00", text, StringComparison.Ordinal);
Assert.Contains("не отдал", text, StringComparison.Ordinal);
}
[Fact]
public void Decimal_separator_does_not_depend_on_the_machine_locale()
{
var text = new TextMetrics(1.5, (0, 0, 1, 2)).Describe("x");
// Запятая в строке есть — она разделяет оси; проверяем именно дробную часть числа.
Assert.Contains("1.50", text, StringComparison.Ordinal);
Assert.DoesNotContain("1,50", text, StringComparison.Ordinal);
}
}