Files
AX-Copilot-Codex/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs
lacvet bcb3cc4039 개발언어 워크플로 힌트와 문서 품질 출력 경로를 고도화한다
- CodeLanguageCatalog에 manifest/build/test/lint 조회 API와 workflow summary 조합기를 추가해 no-LSP fallback과 컨텍스트 생성이 같은 힌트 소스를 재사용하도록 정리한다.

- WorkspaceContextGenerator에 Language Workflow 섹션을 추가해 상위 언어의 실행 힌트를 .ax-context.md에 기록하고, HtmlSkill/ExcelSkill은 공통 ArtifactQualityOutputFormatter로 품질 요약과 repair guide를 일관되게 출력하도록 맞춘다.

- README.md, docs/DEVELOPMENT.md, docs/NEXT_ROADMAP.md를 2026-04-15 09:49 (KST) 기준으로 갱신하고, CodeLanguageCatalogTests 및 WorkspaceContextGeneratorTests를 확장해 빌드 경고 0/오류 0과 관련 테스트 35건 통과를 확인한다.
2026-04-15 09:52:36 +09:00

342 lines
12 KiB
C#

using System.IO;
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class WorkspaceContextGeneratorTests
{
// ═══════════════════════════════════════════
// LoadContext
// ═══════════════════════════════════════════
[Fact]
public void LoadContext_NullFolder_ReturnsNull()
{
WorkspaceContextGenerator.LoadContext(null).Should().BeNull();
}
[Fact]
public void LoadContext_EmptyFolder_ReturnsNull()
{
WorkspaceContextGenerator.LoadContext("").Should().BeNull();
}
[Fact]
public void LoadContext_NonExistentFile_ReturnsNull()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
// .ax-context.md가 없으면 null
WorkspaceContextGenerator.LoadContext(tempDir).Should().BeNull();
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public void LoadContext_ExistingFile_ReturnsContent()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var contextPath = Path.Combine(tempDir, ".ax-context.md");
File.WriteAllText(contextPath, "# Test Context\nHello");
var result = WorkspaceContextGenerator.LoadContext(tempDir);
result.Should().NotBeNull();
result.Should().Contain("Test Context");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public void LoadContext_LargeFile_Truncated()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var contextPath = Path.Combine(tempDir, ".ax-context.md");
// 4000자 제한 초과하는 내용 작성
File.WriteAllText(contextPath, new string('A', 5000));
var result = WorkspaceContextGenerator.LoadContext(tempDir);
result.Should().NotBeNull();
result!.Should().Contain("(truncated)");
result!.Length.Should().BeLessOrEqualTo(4100); // 4000 + truncation message
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
// ═══════════════════════════════════════════
// EnsureContextAsync — 멱등성
// ═══════════════════════════════════════════
[Fact]
public async Task EnsureContextAsync_NullFolder_ReturnsNull()
{
var result = await WorkspaceContextGenerator.EnsureContextAsync(null!);
result.Should().BeNull();
}
[Fact]
public async Task EnsureContextAsync_NonExistentFolder_ReturnsNull()
{
var result = await WorkspaceContextGenerator.EnsureContextAsync("/nonexistent/path/xyz");
result.Should().BeNull();
}
[Fact]
public async Task EnsureContextAsync_ExistingContextFile_ReturnsWithoutRegeneration()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var contextPath = Path.Combine(tempDir, ".ax-context.md");
File.WriteAllText(contextPath, "# Pre-existing context");
var result = await WorkspaceContextGenerator.EnsureContextAsync(tempDir);
result.Should().Contain("Pre-existing context");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
// ═══════════════════════════════════════════
// GenerateAsync — 실제 생성
// ═══════════════════════════════════════════
[Fact]
public async Task GenerateAsync_CreatesContextFile()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
// 테스트 파일 구조 생성
File.WriteAllText(Path.Combine(tempDir, "test.cs"), "class Test {}");
File.WriteAllText(Path.Combine(tempDir, "helper.cs"), "class Helper {}");
var subDir = Path.Combine(tempDir, "src");
Directory.CreateDirectory(subDir);
File.WriteAllText(Path.Combine(subDir, "main.cs"), "class Main {}");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().NotBeNullOrEmpty();
result.Should().Contain("# Workspace Context (auto-generated)");
result.Should().Contain("## Project");
result.Should().Contain("## Structure");
result.Should().Contain(".cs");
// 파일 생성 확인
File.Exists(Path.Combine(tempDir, ".ax-context.md")).Should().BeTrue();
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_DetectsDotNetBuildSystem()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "MyProject.sln"), "solution content");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain(".NET");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_DetectsNodeJsBuildSystem()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "package.json"), """{"name": "test"}""");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("Node.js");
result.Should().Contain("## Key Manifests");
result.Should().Contain("Node package");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_IncludesLanguageSnapshot()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "main.go"), "package main");
File.WriteAllText(Path.Combine(tempDir, "worker.go"), "package main");
File.WriteAllText(Path.Combine(tempDir, "helper.py"), "print('hi')");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("## Language Snapshot");
result.Should().Contain("Go:");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_IncludesReadmeSummary()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "README.md"),
"# My Project\n\nThis is a test project for unit testing.");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("## README Summary");
result.Should().Contain("test project");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_DetectsContextFiles()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "AGENTS.md"), "Agent rules here");
var skillsDir = Path.Combine(tempDir, ".claude", "skills", "deck");
Directory.CreateDirectory(skillsDir);
File.WriteAllText(Path.Combine(skillsDir, "SKILL.md"), "# skill");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("## Existing Context Files");
result.Should().Contain("AGENTS.md");
result.Should().Contain("## Agent Context");
result.Should().Contain(".claude/skills");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_SkipsHiddenDirs()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
// node_modules는 건너뛰어야 함
var nodeModules = Path.Combine(tempDir, "node_modules");
Directory.CreateDirectory(nodeModules);
File.WriteAllText(Path.Combine(nodeModules, "package.json"), "{}");
// src는 표시되어야 함
var src = Path.Combine(tempDir, "src");
Directory.CreateDirectory(src);
File.WriteAllText(Path.Combine(src, "main.cs"), "class Main {}");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("src/");
result.Should().NotContain("node_modules/");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
// ═══════════════════════════════════════════
// 취소 지원
// ═══════════════════════════════════════════
[Fact]
public async Task EnsureContextAsync_Cancellation_Throws()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
using var cts = new CancellationTokenSource();
cts.Cancel();
// 이미 취소된 토큰으로 호출 — 파일이 없으므로 GenerateAsync 진입
// OperationCanceledException 또는 정상 반환 (구현에 따라)
// 최소한 크래시하지 않으면 OK
try
{
await WorkspaceContextGenerator.EnsureContextAsync(tempDir, cts.Token);
}
catch (OperationCanceledException)
{
// 예상된 동작
}
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task GenerateAsync_IncludesLanguageWorkflowHints()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "Cargo.toml"), "[package]\nname='sample'");
File.WriteAllText(Path.Combine(tempDir, "main.rs"), "fn main() {}");
var result = await WorkspaceContextGenerator.GenerateAsync(tempDir);
result.Should().Contain("## Language Workflow");
result.Should().Contain("Rust:");
result.Should().Contain("Cargo.toml");
result.Should().Contain("cargo build");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
}