fix: restore DOCX workbench runtime path

This commit is contained in:
codex
2026-07-30 14:00:31 +03:00
parent dab90f0780
commit 77a62d4564
13 changed files with 381 additions and 89 deletions
@@ -9,7 +9,7 @@
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup><ProjectReference Include="..\..\src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" /><ProjectReference Include="..\..\src\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj" /></ItemGroup>
<ItemGroup><ProjectReference Include="..\..\src\DocxWorkbench.WordInterop\DocxWorkbench.WordInterop.csproj" /><ProjectReference Include="..\..\src\StandardsWorkbench.Pdf\StandardsWorkbench.Pdf.csproj" /><PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" /></ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
@@ -1,16 +1,45 @@
using System.Diagnostics;
using System.Text.Json;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocumentAutomation.Shared;
using DocxWorkbench.WordInterop;
using Xunit.Sdk;
namespace DocumentAutomation.IntegrationTests;
public sealed class WordComIntegrationTests
{
[Fact]
public void IntegrationAssemblyIsAvailable()
public void IntegrationAssemblyIsAvailable() => Assert.True(typeof(WordComIntegrationTests).Assembly.GetName().Name is not null);
[Fact]
public void WordAvailability_DoesNotRequireOfficePia()
{
Assert.True(typeof(WordComIntegrationTests).Assembly.GetName().Name is not null);
var names = typeof(WordDocumentService).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray(); Assert.DoesNotContain(names, name => string.Equals(name, "office", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "Microsoft.Office.Interop.Word", StringComparison.OrdinalIgnoreCase));
}
[Fact(Skip = "Requires an interactive Microsoft Word installation; not run automatically in the repository validation pass.")]
public void BuildsDocxAndExportsPdfThroughWordCom()
[Fact]
public async Task Build_WithoutInsertionPoint_DoesNotStartWord()
{
Assert.Fail("Run this test manually on a Windows workstation with Word installed and a synthetic template/body fixture.");
var template = CreateDocx(false); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); File.WriteAllText(body, "text"); var before = ProcessIds(); try { using var service = new WordDocumentService(); await Assert.ThrowsAsync<WordAutomationException>(() => service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx") }, null, CancellationToken.None)); Assert.Empty(ProcessIds().Except(before)); } finally { File.Delete(template); File.Delete(body); }
}
[Fact] public async Task Build_RunsOnStaThread() { await RequireWord(); var result = await BuildTxt(); var log = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(result.LogPath)); Assert.Equal("STA", log.GetProperty("staApartment").GetString()); }
[Fact] public async Task Build_WithTxtBody_InsertsText() { await RequireWord(); var result = await BuildTxt(); Assert.Contains("TXT integration body", ReadMainText(result.OutputDocxPath)); }
[Fact] public async Task Build_WithDocxBody_InsertsDocument() { await RequireWord(); var template = CreateDocx(true); var body = CreateDocx(false, "DOCX integration body"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); try { using var service = new WordDocumentService(); await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); Assert.Contains("DOCX integration body", ReadMainText(output)); } finally { File.Delete(template); File.Delete(body); File.Delete(output); } }
[Fact] public async Task Build_ExportsPdf() { await RequireWord(); var result = await BuildTxt(); using var service = new WordDocumentService(); var pdf = await service.ExportPdfAsync(result.OutputDocxPath, Path.ChangeExtension(result.OutputDocxPath, ".pdf"), CancellationToken.None); Assert.True(File.Exists(pdf.PdfPath)); File.Delete(result.OutputDocxPath); File.Delete(pdf.PdfPath); File.Delete(result.LogPath); }
[Fact] public async Task Build_DoesNotLeaveCreatedWinwordProcess() { await RequireWord(); var before = ProcessIds(); var result = await BuildTxt(); await Task.Delay(500); Assert.Empty(ProcessIds().Except(before)); File.Delete(result.OutputDocxPath); File.Delete(result.LogPath); }
[Fact] public async Task Build_Failure_ReleasesWord() { await RequireWord(); var before = ProcessIds(); var template = CreateDocx(true); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var outputDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); Directory.CreateDirectory(outputDirectory); File.WriteAllText(body, "failure body"); try { using var service = new WordDocumentService(); await Assert.ThrowsAnyAsync<Exception>(() => service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = outputDirectory }, null, CancellationToken.None)); await Task.Delay(500); Assert.Empty(ProcessIds().Except(before)); } finally { File.Delete(template); File.Delete(body); Directory.Delete(outputDirectory, true); } }
[Fact]
public async Task RuntimeGate_DesktopDocxBodyUsesTemplateCopy()
{
await RequireWord(); var template = @"C:\Users\codex\Desktop\156-титул-ПД.docx"; var body = @"C:\Users\codex\Desktop\НХС-33-УК3-ОЗХ-13011-000-НК-ОД.docx"; if (!File.Exists(template) || !File.Exists(body)) throw SkipException.ForSkip("The supplied Desktop DOCX fixtures are not available."); var copy = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); File.Copy(template, copy); try { using (var document = WordprocessingDocument.Open(copy, true)) { var main = document.MainDocumentPart ?? throw new InvalidDataException("Main part missing."); main.Document.Body!.Append(new Paragraph(new Run(new Text("{{DOC_BODY}}")))); main.Document.Save(); } using var service = new WordDocumentService(); var result = await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = copy, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); Assert.True(File.Exists(result.OutputDocxPath)); } finally { File.Delete(copy); File.Delete(output); File.Delete(Path.ChangeExtension(output, ".generation.json")); }
}
private static async Task RequireWord() { if (!OperatingSystem.IsWindows() || Type.GetTypeFromProgID("Word.Application", false) is null) throw SkipException.ForSkip("Microsoft Word COM is not installed or registered on this workstation."); await Task.CompletedTask; }
private static async Task<WordDocumentResult> BuildTxt() { var template = CreateDocx(true); var body = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); var output = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); File.WriteAllText(body, "TXT integration body"); try { using var service = new WordDocumentService(); return await service.BuildDocumentAsync(new WordDocumentBuildRequest { TemplatePath = template, BodyPath = body, OutputDocxPath = output }, null, CancellationToken.None); } finally { File.Delete(template); File.Delete(body); } }
private static HashSet<int> ProcessIds() => Process.GetProcessesByName("WINWORD").Select(x => x.Id).ToHashSet();
private static string ReadMainText(string path) { using var doc = WordprocessingDocument.Open(path, false); return doc.MainDocumentPart?.Document.InnerText ?? ""; }
private static string CreateDocx(bool marker, string text = "template body") { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body(new Paragraph(new Run(new Text(marker ? "{{DOC_BODY}}" : text))))); main.Document.Save(); return path; }
}
@@ -1,5 +1,6 @@
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocxWorkbench.Core;
using DocxWorkbench.OpenXml;
namespace DocxWorkbench.Tests;
@@ -7,14 +8,47 @@ namespace DocxWorkbench.Tests;
public sealed class DocumentInspectorTests
{
[Fact]
public void InspectsSyntheticDocxStructure()
public void Analyze_DocxTemplateAndTxtBody_Succeeds()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx");
using (var doc = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart(); main.Document = new Document(new Body(new Paragraph(new Run(new Text("{{DOC_BODY}}"))), new Table(new TableRow(new TableCell(new Paragraph(new Run(new Text("x")))))))); main.Document.Save();
}
try { var result = new DocumentInspector().Inspect(path); Assert.Equal(1, result.Tables); Assert.True(result.Paragraphs >= 2); Assert.True(result.HasPageFields is false); }
finally { File.Delete(path); }
var docx = CreateDocx(marker: true); var txt = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt"); File.WriteAllText(txt, "line one\nline two"); try { var template = new OpenXmlDocumentInspector().Inspect(docx); var body = new TextDocumentInspector().Inspect(txt); Assert.Equal("TextMarker", template.InsertionPoint.Type); Assert.Equal(2, body.Paragraphs); } finally { File.Delete(docx); File.Delete(txt); }
}
[Fact]
public void Analyze_DocxTemplateAndDocxBody_Succeeds()
{
var template = CreateDocx(marker: true); var body = CreateDocx(marker: false); try { var inspector = new OpenXmlDocumentInspector(); Assert.Equal("TextMarker", inspector.Inspect(template).InsertionPoint.Type); Assert.Equal(1, inspector.Inspect(body).Sections); } finally { File.Delete(template); File.Delete(body); }
}
[Fact]
public void Inspect_DetectsMarkerInsertionPoint()
{
var path = CreateDocx(marker: true); try { Assert.Equal("TextMarker", new OpenXmlDocumentInspector().Inspect(path).InsertionPoint.Type); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_FindsPageFieldsInFooter()
{
var path = CreateDocx(marker: true, fields: true); try { var result = new OpenXmlDocumentInspector().Inspect(path); Assert.Contains(result.Fields, field => field.Type == "PAGE" && field.Part.StartsWith("FooterPart", StringComparison.Ordinal)); Assert.Contains(result.Fields, field => field.Type == "NUMPAGES"); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_CountsHeaderFooterGraphics()
{
var path = CreateDocx(marker: true, graphics: true); try { var result = new OpenXmlDocumentInspector().Inspect(path); Assert.True(result.ImageSummary.RasterImages >= 2); } finally { File.Delete(path); }
}
[Fact]
public void Inspect_UnsupportedFormatIsDomainError()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".bin"); File.WriteAllText(path, "not a document"); try { var exception = Assert.Throws<UnsupportedDocumentFormatException>(() => new OpenXmlDocumentInspector().Inspect(path)); Assert.Contains("Open XML inspector", exception.Message); } finally { File.Delete(path); }
}
private static string CreateDocx(bool marker, bool fields = false, bool graphics = false)
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".docx"); using var document = WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var main = document.AddMainDocumentPart(); var body = new Body(new Paragraph(new Run(new Text(marker ? "{{DOC_BODY}}" : "body")))); main.Document = new Document(body);
var header = main.AddNewPart<HeaderPart>(); header.Header = new Header(new Paragraph(new Run(new Text("header")))); var footer = main.AddNewPart<FooterPart>();
if (fields) { var paragraph = new Paragraph(); paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin })); paragraph.Append(new Run(new FieldCode(" PAGE "))); paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End })); paragraph.Append(new SimpleField { Instruction = " NUMPAGES " }); footer.Footer = new Footer(paragraph); } else footer.Footer = new Footer(new Paragraph(new Run(new Text("footer"))));
if (graphics) { var image = header.AddImagePart(ImagePartType.Png); image.FeedData(new MemoryStream([137, 80, 78, 71])); var image2 = footer.AddImagePart(ImagePartType.Png); image2.FeedData(new MemoryStream([137, 80, 78, 71])); }
body.Append(new SectionProperties(new HeaderReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(header) }, new FooterReference { Type = HeaderFooterValues.Default, Id = main.GetIdOfPart(footer) })); main.Document.Save(); return path;
}
}
@@ -0,0 +1,14 @@
using DocxWorkbench.Core;
namespace DocxWorkbench.Tests;
public sealed class WorkbenchStateTests
{
[Fact]
public void VerifyButton_DisabledBeforeBuild() { var state = new WorkbenchStateController(); Assert.False(state.CanValidate); }
[Fact]
public void VerifyButton_DisabledAfterBuildFailure() { var state = Ready(); state.BeginBuild(); state.BuildFailed(); Assert.False(state.CanValidate); Assert.False(state.CanOpenDocx); }
[Fact]
public void VerifyButton_EnabledAfterBuildSuccess() { var state = Ready(); state.BeginBuild(); state.BuildSucceeded(); Assert.True(state.CanValidate); Assert.True(state.CanOpenDocx); Assert.True(state.CanOpenPdf); }
private static WorkbenchStateController Ready() { var state = new WorkbenchStateController(); state.InputsSelected(true); state.AnalysisCompleted(true, true, true); return state; }
}