- 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건 통과를 확인한다.
342 lines
12 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|