Initial commit to new repository
This commit is contained in:
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
25
src/AxCopilot.Installer/AxCopilot.Installer.csproj
Normal file
25
src/AxCopilot.Installer/AxCopilot.Installer.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>annotations</Nullable>
|
||||
<AssemblyName>AxCopilot_Setup</AssemblyName>
|
||||
<ApplicationIcon>..\AxCopilot\Assets\icon.ico</ApplicationIcon>
|
||||
<Version>1.7.2</Version>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 본체 ZIP 내장 (build.bat가 payload.zip 생성) -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="payload.zip" Condition="Exists('payload.zip')">
|
||||
<LogicalName>payload.zip</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
297
src/AxCopilot.Installer/CustomMessageBox.cs
Normal file
297
src/AxCopilot.Installer/CustomMessageBox.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace AxCopilot.Installer.Offline
|
||||
{
|
||||
/// <summary>
|
||||
/// WinForms 기본 MessageBox 대체 커스텀 다이얼로그.
|
||||
/// 인스톨러 테마와 일관된 모던 디자인을 제공합니다.
|
||||
/// </summary>
|
||||
internal sealed class CustomMessageBox : Form
|
||||
{
|
||||
private DialogResult _result = DialogResult.Cancel;
|
||||
|
||||
private static readonly Color BgColor = Color.FromArgb(26, 27, 46);
|
||||
private static readonly Color CardBg = Color.FromArgb(42, 43, 64);
|
||||
private static readonly Color AccentCol = Color.FromArgb(75, 94, 252);
|
||||
private static readonly Color TextCol = Color.FromArgb(224, 228, 240);
|
||||
private static readonly Color SecondaryCol = Color.FromArgb(153, 153, 187);
|
||||
private static readonly Color BorderCol = Color.FromArgb(60, 62, 90);
|
||||
|
||||
private CustomMessageBox(string message, string title, MessageBoxButtons buttons, MessageBoxIcon icon)
|
||||
{
|
||||
Text = title;
|
||||
FormBorderStyle = FormBorderStyle.None;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
BackColor = BgColor;
|
||||
DoubleBuffered = true;
|
||||
ShowInTaskbar = false;
|
||||
TopMost = true;
|
||||
|
||||
// ── 레이아웃을 TableLayoutPanel으로 구성 (겹침 방지) ──
|
||||
var table = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 1,
|
||||
RowCount = 3,
|
||||
Padding = new Padding(28, 24, 28, 20),
|
||||
BackColor = Color.Transparent,
|
||||
AutoSize = true,
|
||||
};
|
||||
table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // 제목
|
||||
table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // 메시지
|
||||
table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // 버튼
|
||||
|
||||
// ── Row 0: 아이콘 + 제목 ──
|
||||
var titlePanel = new FlowLayoutPanel
|
||||
{
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
AutoSize = true,
|
||||
WrapContents = false,
|
||||
Margin = new Padding(0, 0, 0, 12),
|
||||
BackColor = Color.Transparent,
|
||||
};
|
||||
|
||||
var iconText = GetIconPrefix(icon);
|
||||
if (!string.IsNullOrEmpty(iconText))
|
||||
{
|
||||
titlePanel.Controls.Add(new Label
|
||||
{
|
||||
Text = iconText,
|
||||
Font = new Font("Segoe UI", 14f),
|
||||
ForeColor = GetIconColor(icon),
|
||||
AutoSize = true,
|
||||
Margin = new Padding(0, 0, 8, 0),
|
||||
});
|
||||
}
|
||||
|
||||
titlePanel.Controls.Add(new Label
|
||||
{
|
||||
Text = title,
|
||||
Font = new Font("Segoe UI", 12f, FontStyle.Bold),
|
||||
ForeColor = TextCol,
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(340, 0),
|
||||
});
|
||||
table.Controls.Add(titlePanel, 0, 0);
|
||||
|
||||
// ── Row 1: 메시지 본문 ──
|
||||
var msgLabel = new Label
|
||||
{
|
||||
Text = message,
|
||||
Font = new Font("Segoe UI", 10f),
|
||||
ForeColor = TextCol,
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(360, 300),
|
||||
Margin = new Padding(0, 0, 0, 20),
|
||||
};
|
||||
table.Controls.Add(msgLabel, 0, 1);
|
||||
|
||||
// ── Row 2: 버튼 영역 ──
|
||||
var btnPanel = new FlowLayoutPanel
|
||||
{
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
AutoSize = true,
|
||||
WrapContents = false,
|
||||
Anchor = AnchorStyles.Right,
|
||||
Margin = new Padding(0),
|
||||
BackColor = Color.Transparent,
|
||||
};
|
||||
|
||||
switch (buttons)
|
||||
{
|
||||
case MessageBoxButtons.OK:
|
||||
btnPanel.Controls.Add(CreateButton("확인", true, DialogResult.OK));
|
||||
break;
|
||||
case MessageBoxButtons.OKCancel:
|
||||
btnPanel.Controls.Add(CreateButton("확인", true, DialogResult.OK));
|
||||
btnPanel.Controls.Add(CreateButton("취소", false, DialogResult.Cancel));
|
||||
break;
|
||||
case MessageBoxButtons.YesNo:
|
||||
btnPanel.Controls.Add(CreateButton("예", true, DialogResult.Yes));
|
||||
btnPanel.Controls.Add(CreateButton("아니오", false, DialogResult.No));
|
||||
break;
|
||||
case MessageBoxButtons.YesNoCancel:
|
||||
btnPanel.Controls.Add(CreateButton("예", true, DialogResult.Yes));
|
||||
btnPanel.Controls.Add(CreateButton("아니오", false, DialogResult.No));
|
||||
btnPanel.Controls.Add(CreateButton("취소", false, DialogResult.Cancel));
|
||||
break;
|
||||
}
|
||||
table.Controls.Add(btnPanel, 0, 2);
|
||||
|
||||
Controls.Add(table);
|
||||
|
||||
// ── 크기 계산 (AutoSize 후) ──
|
||||
table.PerformLayout();
|
||||
var preferred = table.PreferredSize;
|
||||
ClientSize = new Size(
|
||||
Math.Max(380, Math.Min(preferred.Width + 10, 520)),
|
||||
Math.Min(preferred.Height + 10, 500)
|
||||
);
|
||||
|
||||
// 드래그 이동
|
||||
table.MouseDown += DragForm;
|
||||
titlePanel.MouseDown += DragForm;
|
||||
msgLabel.MouseDown += DragForm;
|
||||
|
||||
// ESC / Enter
|
||||
KeyPreview = true;
|
||||
KeyDown += (s, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Escape)
|
||||
{
|
||||
_result = buttons == MessageBoxButtons.YesNo ? DialogResult.No : DialogResult.Cancel;
|
||||
Close();
|
||||
}
|
||||
else if (e.KeyCode == Keys.Enter)
|
||||
{
|
||||
_result = (buttons == MessageBoxButtons.YesNo || buttons == MessageBoxButtons.YesNoCancel)
|
||||
? DialogResult.Yes : DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
|
||||
// Region 클리핑 (약간 더 큰 반경으로 잘림 줄임)
|
||||
using var clipPath = RoundRect(new Rectangle(0, 0, Width, Height), 18);
|
||||
Region = new Region(clipPath);
|
||||
|
||||
var g = e.Graphics;
|
||||
g.SmoothingMode = SmoothingMode.HighQuality;
|
||||
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
|
||||
// 배경 채우기 (Region 가장자리 보정)
|
||||
using var bgBrush = new SolidBrush(BgColor);
|
||||
using var bgPath = RoundRect(new Rectangle(0, 0, Width, Height), 18);
|
||||
g.FillPath(bgBrush, bgPath);
|
||||
|
||||
// 테두리 (안쪽 1.5px, 부드러운 선)
|
||||
using var borderPen = new Pen(Color.FromArgb(80, 90, 140), 1.5f);
|
||||
using var borderPath = RoundRect(new Rectangle(1, 1, Width - 3, Height - 3), 16);
|
||||
g.DrawPath(borderPen, borderPath);
|
||||
}
|
||||
|
||||
private Button CreateButton(string text, bool isPrimary, DialogResult result)
|
||||
{
|
||||
var btn = new Button
|
||||
{
|
||||
Text = text,
|
||||
Font = new Font("Segoe UI", 10f, isPrimary ? FontStyle.Bold : FontStyle.Regular),
|
||||
ForeColor = TextCol,
|
||||
BackColor = isPrimary ? AccentCol : CardBg,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
Size = new Size(90, 36),
|
||||
Margin = new Padding(4, 0, 4, 0),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
btn.FlatAppearance.BorderSize = 0;
|
||||
btn.Click += (s, e) => { _result = result; Close(); };
|
||||
|
||||
btn.Paint += (s, pe) =>
|
||||
{
|
||||
using var clipPath = RoundRect(new Rectangle(0, 0, btn.Width, btn.Height), 10);
|
||||
btn.Region = new Region(clipPath);
|
||||
|
||||
var g = pe.Graphics;
|
||||
g.SmoothingMode = SmoothingMode.HighQuality;
|
||||
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;
|
||||
|
||||
// 배경
|
||||
using var fillBrush = new SolidBrush(btn.BackColor);
|
||||
using var fillPath = RoundRect(new Rectangle(0, 0, btn.Width, btn.Height), 10);
|
||||
g.FillPath(fillBrush, fillPath);
|
||||
|
||||
// 테두리
|
||||
using var bPen = new Pen(Color.FromArgb(70, 255, 255, 255), 0.8f);
|
||||
using var bPath = RoundRect(new Rectangle(0, 0, btn.Width - 1, btn.Height - 1), 10);
|
||||
g.DrawPath(bPen, bPath);
|
||||
|
||||
// 텍스트 직접 그리기
|
||||
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
|
||||
using var textBrush = new SolidBrush(btn.ForeColor);
|
||||
g.DrawString(btn.Text, btn.Font, textBrush, new RectangleF(0, 0, btn.Width, btn.Height), sf);
|
||||
};
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
private static string GetIconPrefix(MessageBoxIcon icon)
|
||||
{
|
||||
return icon switch
|
||||
{
|
||||
MessageBoxIcon.Error => "✕",
|
||||
MessageBoxIcon.Warning => "⚠",
|
||||
MessageBoxIcon.Information => "ℹ",
|
||||
MessageBoxIcon.Question => "?",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
private static Color GetIconColor(MessageBoxIcon icon)
|
||||
{
|
||||
return icon switch
|
||||
{
|
||||
MessageBoxIcon.Error => Color.FromArgb(229, 62, 62),
|
||||
MessageBoxIcon.Warning => Color.FromArgb(221, 107, 32),
|
||||
MessageBoxIcon.Information => Color.FromArgb(75, 94, 252),
|
||||
MessageBoxIcon.Question => Color.FromArgb(75, 94, 252),
|
||||
_ => TextCol,
|
||||
};
|
||||
}
|
||||
|
||||
private void DragForm(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button == MouseButtons.Left)
|
||||
{
|
||||
NativeMethods.ReleaseCapture();
|
||||
NativeMethods.SendMessage(Handle, 0xA1, (IntPtr)0x2, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private static GraphicsPath RoundRect(Rectangle rect, int radius)
|
||||
{
|
||||
var path = new GraphicsPath();
|
||||
var d = radius * 2;
|
||||
path.AddArc(rect.X, rect.Y, d, d, 180, 90);
|
||||
path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90);
|
||||
path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90);
|
||||
path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
public static extern bool ReleaseCapture();
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
|
||||
// ─── 정적 호출 메서드 (기존 MessageBox.Show 호환) ───────────────
|
||||
|
||||
public static DialogResult Show(string message)
|
||||
=> Show(message, "AX Copilot", MessageBoxButtons.OK, MessageBoxIcon.None);
|
||||
|
||||
public static DialogResult Show(string message, string title)
|
||||
=> Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.None);
|
||||
|
||||
public static DialogResult Show(string message, string title, MessageBoxButtons buttons)
|
||||
=> Show(message, title, buttons, MessageBoxIcon.None);
|
||||
|
||||
public static DialogResult Show(string message, string title, MessageBoxButtons buttons, MessageBoxIcon icon)
|
||||
{
|
||||
var dlg = new CustomMessageBox(message, title, buttons, icon);
|
||||
dlg.ShowDialog();
|
||||
return dlg._result;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/AxCopilot.Installer/Program.cs
Normal file
16
src/AxCopilot.Installer/Program.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace AxCopilot.Installer.Offline
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new SetupForm());
|
||||
}
|
||||
}
|
||||
}
|
||||
324
src/AxCopilot.Installer/SetupForm.cs
Normal file
324
src/AxCopilot.Installer/SetupForm.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AxCopilot.Installer.Offline
|
||||
{
|
||||
public class SetupForm : Form
|
||||
{
|
||||
private const string AppName = "AX Copilot";
|
||||
private const string AppVer = "1.7.2";
|
||||
private const string Org = "AX\uC5F0\uAD6C\uC18C AI\uD300";
|
||||
private const string RegUn = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\AxCopilot";
|
||||
private const string RegRun = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
|
||||
private TextBox _pathBox;
|
||||
private Label _status, _existLbl;
|
||||
private Panel _existPnl, _progPnl;
|
||||
private ProgressBar _prog;
|
||||
private CheckBox _chkDesk, _chkAuto, _chkReg;
|
||||
private Button _btnInst, _btnCanc, _btnBrowse, _btnDel;
|
||||
private string _exPath, _exVer;
|
||||
|
||||
private static readonly string DefPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "AX Copilot");
|
||||
|
||||
[DllImport("gdi32.dll")] static extern IntPtr CreateRoundRectRgn(int a,int b,int c,int d,int e,int f);
|
||||
[DllImport("user32.dll")] static extern int SetWindowRgn(IntPtr h,IntPtr r,bool re);
|
||||
|
||||
public SetupForm()
|
||||
{
|
||||
Text = AppName + " Setup"; Size = new Size(520, 470);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
try { var ico = Icon.ExtractAssociatedIcon(Assembly.GetExecutingAssembly().Location); if (ico != null) Icon = ico; } catch { }
|
||||
FormBorderStyle = FormBorderStyle.None; DoubleBuffered = true;
|
||||
BackColor = Color.FromArgb(248, 249, 255);
|
||||
Build(); Detect();
|
||||
}
|
||||
|
||||
protected override void OnShown(EventArgs e)
|
||||
{ base.OnShown(e); SetWindowRgn(Handle, CreateRoundRectRgn(0,0,Width,Height,20,20), true); }
|
||||
|
||||
// drag
|
||||
private Point _ds; private bool _dg;
|
||||
protected override void OnMouseDown(MouseEventArgs e) { if(e.Button==MouseButtons.Left&&e.Y<82){_dg=true;_ds=e.Location;} }
|
||||
protected override void OnMouseMove(MouseEventArgs e) { if(_dg){var p=PointToScreen(e.Location);Location=new Point(p.X-_ds.X,p.Y-_ds.Y);} }
|
||||
protected override void OnMouseUp(MouseEventArgs e) { _dg=false; }
|
||||
protected override void OnMouseClick(MouseEventArgs e) { if(e.X>Width-40&&e.Y<30)Close(); }
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
var hr = new Rectangle(0,0,Width,82);
|
||||
using(var hb = new LinearGradientBrush(hr, Color.FromArgb(46,58,140), Color.FromArgb(75,94,252), 0f))
|
||||
g.FillRectangle(hb, hr);
|
||||
|
||||
// Diamond icon in header (둥근 모서리)
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
g.TranslateTransform(40, 30);
|
||||
g.RotateTransform(45);
|
||||
FillRoundRect(g, new SolidBrush(Color.FromArgb(68,136,255)), 0, 0, 9, 9, 2.5f); // 상: Blue
|
||||
FillRoundRect(g, new SolidBrush(Color.FromArgb(68,221,102)), 11, 0, 9, 9, 2.5f); // 우: Green
|
||||
FillRoundRect(g, new SolidBrush(Color.FromArgb(68,221,102)), 0, 11, 9, 9, 2.5f); // 좌: Green
|
||||
FillRoundRect(g, new SolidBrush(Color.FromArgb(255,68,102)), 11, 11, 9, 9, 2.5f); // 하: Red
|
||||
g.ResetTransform();
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.Default;
|
||||
|
||||
using(var f1 = new Font("Segoe UI",18f,FontStyle.Bold)) g.DrawString(AppName,f1,Brushes.White,60,16);
|
||||
using(var f2 = new Font("Segoe UI",9f))
|
||||
g.DrawString(Org + " \u00b7 v" + AppVer,
|
||||
f2, new SolidBrush(Color.FromArgb(170,187,255)), 60, 50);
|
||||
using(var fc = new Font("Segoe UI",14f)) g.DrawString("\u00d7",fc,new SolidBrush(Color.FromArgb(140,170,204,255)),Width-30,4);
|
||||
using(var pen = new Pen(Color.FromArgb(220,220,240))) g.DrawLine(pen,0,Height-56,Width,Height-56);
|
||||
}
|
||||
|
||||
private void Build()
|
||||
{
|
||||
int y = 94;
|
||||
_existPnl = new Panel{Left=20,Top=y,Width=Width-40,Height=30,BackColor=Color.FromArgb(255,248,225),Visible=false};
|
||||
_existLbl = new Label{Dock=DockStyle.Fill,ForeColor=Color.FromArgb(146,64,14),Font=new Font("Segoe UI",9f),
|
||||
TextAlign=ContentAlignment.MiddleLeft,Padding=new Padding(8,0,0,0)};
|
||||
_existPnl.Controls.Add(_existLbl); Controls.Add(_existPnl); y+=38;
|
||||
|
||||
Controls.Add(new Label{Text="\uC124\uCE58 \uACBD\uB85C",Left=24,Top=y,AutoSize=true,
|
||||
Font=new Font("Segoe UI",9.5f,FontStyle.Bold),ForeColor=Color.FromArgb(51,51,102)}); y+=22;
|
||||
_pathBox = new TextBox{Left=24,Top=y,Width=Width-110,Height=26,Font=new Font("Consolas",10f),
|
||||
Text=DefPath,BorderStyle=BorderStyle.FixedSingle};
|
||||
Controls.Add(_pathBox);
|
||||
_btnBrowse = Btn("\uBCC0\uACBD",Color.FromArgb(238,240,255),Color.FromArgb(75,94,252),Width-80,y-1,56,28);
|
||||
_btnBrowse.Click += (s,ev)=>{using(var d=new FolderBrowserDialog{SelectedPath=_pathBox.Text})
|
||||
if(d.ShowDialog()==DialogResult.OK)_pathBox.Text=d.SelectedPath;};
|
||||
Controls.Add(_btnBrowse); y+=34;
|
||||
|
||||
Controls.Add(new Label{Text="\uBCF8\uCCB4 + .NET Runtime \uBAA8\uB450 \uB0B4\uC7A5 (\uC778\uD130\uB137 \uBD88\uD544\uC694)",
|
||||
Left=24,Top=y,AutoSize=true,
|
||||
Font=new Font("Segoe UI",8f),ForeColor=Color.FromArgb(153,153,187)}); y+=24;
|
||||
|
||||
_chkDesk = Chk("\uBC14\uD0D5\uD654\uBA74 \uBC14\uB85C\uAC00\uAE30 \uC0DD\uC131",true,24,y); y+=24;
|
||||
_chkAuto = Chk("Windows \uC2DC\uC791 \uC2DC \uC790\uB3D9 \uC2E4\uD589",true,24,y); y+=24;
|
||||
_chkReg = Chk("\uD504\uB85C\uADF8\uB7A8 \uCD94\uAC00/\uC81C\uAC70\uC5D0 \uB4F1\uB85D",true,24,y); y+=36;
|
||||
|
||||
_progPnl = new Panel{Left=20,Top=y,Width=Width-40,Height=42,Visible=false};
|
||||
_status = new Label{Dock=DockStyle.Top,Height=20,Font=new Font("Segoe UI",9f),ForeColor=Color.FromArgb(75,94,252)};
|
||||
_prog = new ProgressBar{Dock=DockStyle.Bottom,Height=10,Style=ProgressBarStyle.Continuous};
|
||||
_progPnl.Controls.Add(_prog); _progPnl.Controls.Add(_status); Controls.Add(_progPnl);
|
||||
|
||||
int fy = Height-44;
|
||||
Controls.Add(new Label{Text="v"+AppVer,Left=24,Top=fy+4,AutoSize=true,
|
||||
Font=new Font("Segoe UI",8f),ForeColor=Color.FromArgb(187,187,204)});
|
||||
_btnDel = Btn("\uC81C\uAC70",Color.FromArgb(254,226,226),Color.FromArgb(220,38,38),Width-270,fy,60,32);
|
||||
_btnDel.Visible=false; _btnDel.Click+=async(s,ev)=>await Uninstall(); Controls.Add(_btnDel);
|
||||
_btnCanc = Btn("\uCDE8\uC18C",Color.FromArgb(240,240,248),Color.FromArgb(102,102,136),Width-200,fy,68,32);
|
||||
_btnCanc.Click+=(s,ev)=>Close(); Controls.Add(_btnCanc);
|
||||
_btnInst = Btn("\uC124\uCE58",Color.FromArgb(75,94,252),Color.White,Width-122,fy,100,32);
|
||||
_btnInst.Click+=async(s,ev)=>await Install(); Controls.Add(_btnInst);
|
||||
}
|
||||
|
||||
private CheckBox Chk(string t,bool c,int x,int y){var cb=new CheckBox{Text=t,Checked=c,Left=x,Top=y,AutoSize=true,
|
||||
Font=new Font("Segoe UI",9.5f),ForeColor=Color.FromArgb(85,85,119)};Controls.Add(cb);return cb;}
|
||||
private Button Btn(string t,Color bg,Color fg,int x,int y,int w,int h){var b=new Button{Text=t,Left=x,Top=y,Width=w,Height=h,
|
||||
FlatStyle=FlatStyle.Flat,BackColor=bg,ForeColor=fg,Font=new Font("Segoe UI",9.5f,FontStyle.Bold),Cursor=Cursors.Hand};
|
||||
b.FlatAppearance.BorderSize=0;return b;}
|
||||
|
||||
private void Detect()
|
||||
{
|
||||
try{using(var k=Registry.CurrentUser.OpenSubKey(RegUn)){
|
||||
_exPath=k!=null?k.GetValue("InstallLocation") as string:null;
|
||||
_exVer=k!=null?k.GetValue("DisplayVersion") as string:null;}}catch{}
|
||||
if(!string.IsNullOrEmpty(_exPath)&&Directory.Exists(_exPath)){
|
||||
_pathBox.Text=_exPath;_existPnl.Visible=true;
|
||||
_existLbl.Text=" \u26A0 \uAE30\uC874: v"+(_exVer??"")+" \u2014 "+_exPath;
|
||||
_btnInst.Text="\uC5C5\uADF8\uB808\uC774\uB4DC";_btnDel.Visible=true;}
|
||||
}
|
||||
|
||||
private async Task Install()
|
||||
{
|
||||
var path = _pathBox.Text.Trim();
|
||||
if(string.IsNullOrEmpty(path)){CustomMessageBox.Show("\uC124\uCE58 \uACBD\uB85C\uB97C \uC785\uB825\uD558\uC138\uC694.");return;}
|
||||
Busy(true);
|
||||
try
|
||||
{
|
||||
St("앱 종료...",5); Kill(); await Task.Delay(500);
|
||||
|
||||
// ── 기존 AX Commander 마이그레이션 ──
|
||||
MigrateFromAxCommander();
|
||||
|
||||
St("파일 설치...",20);
|
||||
Directory.CreateDirectory(path);
|
||||
ExtractZip(path);
|
||||
await Task.Delay(300);
|
||||
|
||||
if(_chkDesk.Checked){St("바로가기...",60);
|
||||
Lnk(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop),"AX Copilot.lnk"),
|
||||
Path.Combine(path,"AxCopilot.exe"));}
|
||||
St("시작 메뉴...",70);
|
||||
var sm=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","AX Copilot");
|
||||
Directory.CreateDirectory(sm);
|
||||
Lnk(Path.Combine(sm,"AX Copilot.lnk"),Path.Combine(path,"AxCopilot.exe"));
|
||||
if(_chkAuto.Checked){St("\uC790\uB3D9 \uC2E4\uD589...",80);
|
||||
using(var k=Registry.CurrentUser.OpenSubKey(RegRun,true)){if(k!=null)k.SetValue("AxCopilot","\""+Path.Combine(path,"AxCopilot.exe")+"\"");}}
|
||||
// 인스톨러를 설치 폴더에 복사 (제거 기능용)
|
||||
St("제거 프로그램 등록...",85);
|
||||
try{var me=Assembly.GetExecutingAssembly().Location;
|
||||
var dest=Path.Combine(path,"AxCopilot_Setup.exe");
|
||||
if(!string.Equals(me,dest,StringComparison.OrdinalIgnoreCase))
|
||||
File.Copy(me,dest,true);}catch{}
|
||||
|
||||
if(_chkReg.Checked){St("\uD504\uB85C\uADF8\uB7A8 \uB4F1\uB85D...",90);RegAdd(path);}
|
||||
|
||||
St("\uC124\uCE58 \uC644\uB8CC!",100); await Task.Delay(400);
|
||||
if(CustomMessageBox.Show(AppName+" \uC124\uCE58 \uC644\uB8CC!\n\n"+Path.Combine(path,"AxCopilot.exe")+"\n\n\uC2E4\uD589\uD558\uC2DC\uACA0\uC2B5\uB2C8\uAE4C?",
|
||||
AppName,MessageBoxButtons.YesNo,MessageBoxIcon.Information)==DialogResult.Yes)
|
||||
Process.Start(new ProcessStartInfo(Path.Combine(path,"AxCopilot.exe")){UseShellExecute=true});
|
||||
Close();
|
||||
}
|
||||
catch(UnauthorizedAccessException){CustomMessageBox.Show("관리자 권한이 필요합니다.\n다른 경로를 선택하거나 관리자로 실행하세요.");Busy(false);}
|
||||
catch(Exception ex){CustomMessageBox.Show("\uC124\uCE58 \uC2E4\uD328:\n"+ex.Message);Busy(false);}
|
||||
}
|
||||
|
||||
private async Task Uninstall()
|
||||
{
|
||||
if(string.IsNullOrEmpty(_exPath))return;
|
||||
if(CustomMessageBox.Show("\uC81C\uAC70\uD558\uC2DC\uACA0\uC2B5\uB2C8\uAE4C?\n\n"+_exPath+"\n\n\uC124\uC815(%APPDATA%)\uC740 \uC720\uC9C0\uB429\uB2C8\uB2E4.",
|
||||
AppName+" \uC81C\uAC70",MessageBoxButtons.YesNo,MessageBoxIcon.Question)!=DialogResult.Yes)return;
|
||||
Busy(true);
|
||||
try{
|
||||
St("\uC571 \uC885\uB8CC...",10);Kill();await Task.Delay(500);
|
||||
St("\uD30C\uC77C \uC0AD\uC81C...",30);
|
||||
if(Directory.Exists(_exPath)){try{Directory.Delete(_exPath,true);}catch{
|
||||
foreach(var f in Directory.GetFiles(_exPath))try{File.Delete(f);}catch{}}}
|
||||
St("\uBC14\uB85C\uAC00\uAE30 \uC0AD\uC81C...",55);
|
||||
Del(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop),"AX Copilot.lnk"));
|
||||
Del(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop),"AX Commander.lnk")); // 레거시
|
||||
var sm=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","AX Copilot");
|
||||
try{var smOld=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","AX Commander");if(Directory.Exists(smOld))Directory.Delete(smOld,true);}catch{}
|
||||
try{if(Directory.Exists(sm))Directory.Delete(sm,true);}catch{}
|
||||
St("\uB808\uC9C0\uC2A4\uD2B8\uB9AC...",75);
|
||||
try{using(var k=Registry.CurrentUser.OpenSubKey(RegRun,true)){if(k!=null)k.DeleteValue("AxCopilot",false);}}catch{}
|
||||
try{Registry.CurrentUser.DeleteSubKey(RegUn,false);}catch{}
|
||||
St("\uC81C\uAC70 \uC644\uB8CC!",100);await Task.Delay(400);
|
||||
CustomMessageBox.Show("\uC81C\uAC70 \uC644\uB8CC.\n\uC124\uC815: %APPDATA%\\AxCopilot",AppName);Close();
|
||||
}catch(Exception ex){CustomMessageBox.Show("\uC81C\uAC70 \uC2E4\uD328:"+ex.Message);Busy(false);}
|
||||
}
|
||||
|
||||
private void Busy(bool b){_btnInst.Enabled=!b;_btnCanc.Enabled=!b;_btnDel.Enabled=!b;_progPnl.Visible=b;}
|
||||
private void St(string t,int p){_status.Text=t;_prog.Value=Math.Min(p,100);Application.DoEvents();}
|
||||
private static void Kill()
|
||||
{
|
||||
foreach(var p in Process.GetProcessesByName("AxCopilot"))try{p.Kill();p.WaitForExit(3000);}catch{}
|
||||
foreach(var p in Process.GetProcessesByName("AxCommander"))try{p.Kill();p.WaitForExit(3000);}catch{} // 레거시
|
||||
}
|
||||
private static void Del(string f){try{if(File.Exists(f))File.Delete(f);}catch{}}
|
||||
|
||||
/// <summary>기존 AX Commander 설치를 감지하여 정리 + AppData 마이그레이션</summary>
|
||||
private void MigrateFromAxCommander()
|
||||
{
|
||||
const string OldRegUn = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\AxCommander";
|
||||
try
|
||||
{
|
||||
using var oldKey = Registry.CurrentUser.OpenSubKey(OldRegUn, false);
|
||||
if (oldKey == null) return; // 기존 설치 없음
|
||||
|
||||
St("AX Commander → AX Copilot 업그레이드...", 8);
|
||||
|
||||
// 기존 설치 폴더 삭제
|
||||
var oldPath = oldKey.GetValue("InstallLocation") as string;
|
||||
if (!string.IsNullOrEmpty(oldPath) && Directory.Exists(oldPath))
|
||||
try { Directory.Delete(oldPath, true); } catch { }
|
||||
|
||||
// 기존 바로가기 삭제
|
||||
Del(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "AX Commander.lnk"));
|
||||
var oldSm = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs", "AX Commander");
|
||||
try { if (Directory.Exists(oldSm)) Directory.Delete(oldSm, true); } catch { }
|
||||
|
||||
// 기존 레지스트리 정리
|
||||
try { using (var k = Registry.CurrentUser.OpenSubKey(RegRun, true)) { k?.DeleteValue("AxCommander", false); } } catch { }
|
||||
try { Registry.CurrentUser.DeleteSubKey(OldRegUn, false); } catch { }
|
||||
|
||||
// %APPDATA%\AxCommander → %APPDATA%\AxCopilot 이동
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var oldDir = Path.Combine(appData, "AxCommander");
|
||||
var newDir = Path.Combine(appData, "AxCopilot");
|
||||
if (Directory.Exists(oldDir) && !Directory.Exists(newDir))
|
||||
{
|
||||
St("설정 데이터 마이그레이션...", 12);
|
||||
try { Directory.Move(oldDir, newDir); } catch { }
|
||||
}
|
||||
|
||||
St("마이그레이션 완료", 15);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void ExtractZip(string dest)
|
||||
{
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
var names = asm.GetManifestResourceNames();
|
||||
string name = null;
|
||||
foreach(var n in names) if(n.IndexOf("payload.zip",StringComparison.OrdinalIgnoreCase)>=0){name=n;break;}
|
||||
if(name==null)throw new FileNotFoundException("payload.zip not found in embedded resources.");
|
||||
using(var s = asm.GetManifestResourceStream(name))
|
||||
using(var zip = new ZipArchive(s, ZipArchiveMode.Read))
|
||||
{
|
||||
int total = 0; foreach(var _ in zip.Entries) total++;
|
||||
int done = 0;
|
||||
foreach(var entry in zip.Entries)
|
||||
{
|
||||
var target = Path.Combine(dest, entry.FullName);
|
||||
if(string.IsNullOrEmpty(entry.Name)){Directory.CreateDirectory(target);continue;}
|
||||
var dir = Path.GetDirectoryName(target);
|
||||
if(dir!=null) Directory.CreateDirectory(dir);
|
||||
entry.ExtractToFile(target, true);
|
||||
done++;
|
||||
int pct = 20 + (done * 35 / Math.Max(total, 1));
|
||||
St("\uD30C\uC77C \uC124\uCE58... ("+done+"/"+total+")", pct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void FillRoundRect(Graphics g, Brush brush, float x, float y, float w, float h, float r)
|
||||
{
|
||||
using var path = new System.Drawing.Drawing2D.GraphicsPath();
|
||||
path.AddArc(x, y, r * 2, r * 2, 180, 90);
|
||||
path.AddArc(x + w - r * 2, y, r * 2, r * 2, 270, 90);
|
||||
path.AddArc(x + w - r * 2, y + h - r * 2, r * 2, r * 2, 0, 90);
|
||||
path.AddArc(x, y + h - r * 2, r * 2, r * 2, 90, 90);
|
||||
path.CloseFigure();
|
||||
g.FillPath(brush, path);
|
||||
}
|
||||
|
||||
private static void Lnk(string lnk,string target)
|
||||
{
|
||||
var iconPath = Path.Combine(Path.GetDirectoryName(target)??"", "Assets", "icon.ico");
|
||||
var iconArg = File.Exists(iconPath) ? "$s.IconLocation='"+iconPath.Replace("'","''")+"';" : "";
|
||||
var ps="$ws=New-Object -ComObject WScript.Shell;$s=$ws.CreateShortcut('"+lnk.Replace("'","''")+"');"+
|
||||
"$s.TargetPath='"+target.Replace("'","''")+"';$s.WorkingDirectory='"+Path.GetDirectoryName(target).Replace("'","''")+"';"+
|
||||
iconArg+"$s.Save()";
|
||||
Process.Start(new ProcessStartInfo("powershell","-NoProfile -Command \""+ps+"\"")
|
||||
{CreateNoWindow=true,UseShellExecute=false}).WaitForExit(5000);
|
||||
}
|
||||
|
||||
private static void RegAdd(string dir)
|
||||
{
|
||||
try{using(var k=Registry.CurrentUser.CreateSubKey(RegUn)){
|
||||
k.SetValue("DisplayName",AppName);
|
||||
k.SetValue("DisplayVersion",AppVer);
|
||||
k.SetValue("Publisher",Org);
|
||||
k.SetValue("InstallLocation",dir);
|
||||
k.SetValue("DisplayIcon",Path.Combine(dir,"AxCopilot.exe")+",0");
|
||||
k.SetValue("UninstallString","\""+Path.Combine(dir,"AxCopilot_Setup.exe")+"\"");
|
||||
k.SetValue("InstallDate",DateTime.Now.ToString("yyyyMMdd"));
|
||||
k.SetValue("NoModify",1,RegistryValueKind.DWord);
|
||||
k.SetValue("NoRepair",1,RegistryValueKind.DWord);
|
||||
k.SetValue("EstimatedSize",72000,RegistryValueKind.DWord);}}catch{}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/AxCopilot.Installer/app.manifest
Normal file
11
src/AxCopilot.Installer/app.manifest
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.1.0" name="AxCopilot.Setup"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
BIN
src/AxCopilot.Installer/bin/Debug/net48/AxCopilot_Setup.exe
Normal file
BIN
src/AxCopilot.Installer/bin/Debug/net48/AxCopilot_Setup.exe
Normal file
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
</configuration>
|
||||
BIN
src/AxCopilot.Installer/bin/Debug/net48/AxCopilot_Setup.pdb
Normal file
BIN
src/AxCopilot.Installer/bin/Debug/net48/AxCopilot_Setup.pdb
Normal file
Binary file not shown.
BIN
src/AxCopilot.Installer/bin/Release/net48/AxCopilot_Setup.exe
Normal file
BIN
src/AxCopilot.Installer/bin/Release/net48/AxCopilot_Setup.exe
Normal file
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
</configuration>
|
||||
BIN
src/AxCopilot.Installer/bin/Release/net48/AxCopilot_Setup.pdb
Normal file
BIN
src/AxCopilot.Installer/bin/Release/net48/AxCopilot_Setup.pdb
Normal file
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"format": 1,
|
||||
"restore": {
|
||||
"E:\\AX Copilot\\src\\AxCopilot.Installer\\AxCopilot.Installer.csproj": {}
|
||||
},
|
||||
"projects": {
|
||||
"E:\\AX Copilot\\src\\AxCopilot.Installer\\AxCopilot.Installer.csproj": {
|
||||
"version": "1.7.2",
|
||||
"restore": {
|
||||
"projectUniqueName": "E:\\AX Copilot\\src\\AxCopilot.Installer\\AxCopilot.Installer.csproj",
|
||||
"projectName": "AxCopilot_Setup",
|
||||
"projectPath": "E:\\AX Copilot\\src\\AxCopilot.Installer\\AxCopilot.Installer.csproj",
|
||||
"packagesPath": "C:\\Users\\admin\\.nuget\\packages\\",
|
||||
"outputPath": "E:\\AX Copilot\\src\\AxCopilot.Installer\\obj\\",
|
||||
"projectStyle": "PackageReference",
|
||||
"configFilePaths": [
|
||||
"C:\\Users\\admin\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||
],
|
||||
"originalTargetFrameworks": [
|
||||
"net48"
|
||||
],
|
||||
"sources": {
|
||||
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
||||
"https://api.nuget.org/v3/index.json": {}
|
||||
},
|
||||
"frameworks": {
|
||||
"net48": {
|
||||
"targetAlias": "net48",
|
||||
"projectReferences": {}
|
||||
}
|
||||
},
|
||||
"warningProperties": {
|
||||
"warnAsError": [
|
||||
"NU1605"
|
||||
]
|
||||
},
|
||||
"restoreAuditProperties": {
|
||||
"enableAudit": "true",
|
||||
"auditLevel": "low",
|
||||
"auditMode": "direct"
|
||||
},
|
||||
"SdkAnalysisLevel": "10.0.200"
|
||||
},
|
||||
"frameworks": {
|
||||
"net48": {
|
||||
"targetAlias": "net48",
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.201\\RuntimeIdentifierGraph.json"
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"win-x86": {
|
||||
"#import": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
|
||||
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
||||
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
||||
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
|
||||
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\admin\.nuget\packages\</NuGetPackageFolders>
|
||||
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<SourceRoot Include="C:\Users\admin\.nuget\packages\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" />
|
||||
@@ -0,0 +1,4 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")]
|
||||
@@ -0,0 +1,22 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("AxCopilot_Setup")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.7.1.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.7.1")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AxCopilot_Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AxCopilot_Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.7.1.0")]
|
||||
|
||||
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
b0ecaa163a313d5af7b0a607fb1d375c818edd550a5facb1dcab54e47d4ef5ba
|
||||
@@ -0,0 +1,14 @@
|
||||
is_global = true
|
||||
build_property.ApplicationManifest = app.manifest
|
||||
build_property.StartupObject =
|
||||
build_property.ApplicationDefaultFont =
|
||||
build_property.ApplicationHighDpiMode =
|
||||
build_property.ApplicationUseCompatibleTextRendering =
|
||||
build_property.ApplicationVisualStyles =
|
||||
build_property.RootNamespace = AxCopilot.Installer
|
||||
build_property.ProjectDir = E:\AX Copilot\src\AxCopilot.Installer\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||
build_property.EffectiveAnalysisLevelStyle =
|
||||
build_property.EnableCodeStyleSeverity =
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
cdeb201644c87f7c493d903f57cc22b1f80777359a33a9d6401d43e2b0005fa8
|
||||
@@ -0,0 +1,20 @@
|
||||
E:\AX Commander\src\AxCopilot.Installer\bin\Debug\net48\AxCopilot_Setup.exe.config
|
||||
E:\AX Commander\src\AxCopilot.Installer\bin\Debug\net48\AxCopilot_Setup.exe
|
||||
E:\AX Commander\src\AxCopilot.Installer\bin\Debug\net48\AxCopilot_Setup.pdb
|
||||
E:\AX Commander\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.csproj.AssemblyReference.cache
|
||||
E:\AX Commander\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.GeneratedMSBuildEditorConfig.editorconfig
|
||||
E:\AX Commander\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.AssemblyInfoInputs.cache
|
||||
E:\AX Commander\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.AssemblyInfo.cs
|
||||
E:\AX Commander\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.csproj.CoreCompileInputs.cache
|
||||
E:\AX Commander\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot_Setup.exe
|
||||
E:\AX Commander\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot_Setup.pdb
|
||||
E:\AX Copilot\src\AxCopilot.Installer\bin\Debug\net48\AxCopilot_Setup.exe.config
|
||||
E:\AX Copilot\src\AxCopilot.Installer\bin\Debug\net48\AxCopilot_Setup.exe
|
||||
E:\AX Copilot\src\AxCopilot.Installer\bin\Debug\net48\AxCopilot_Setup.pdb
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.csproj.AssemblyReference.cache
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.GeneratedMSBuildEditorConfig.editorconfig
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.AssemblyInfoInputs.cache
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.AssemblyInfo.cs
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot.Installer.csproj.CoreCompileInputs.cache
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot_Setup.exe
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Debug\net48\AxCopilot_Setup.pdb
|
||||
BIN
src/AxCopilot.Installer/obj/Debug/net48/AxCopilot_Setup.exe
Normal file
BIN
src/AxCopilot.Installer/obj/Debug/net48/AxCopilot_Setup.exe
Normal file
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
</configuration>
|
||||
BIN
src/AxCopilot.Installer/obj/Debug/net48/AxCopilot_Setup.pdb
Normal file
BIN
src/AxCopilot.Installer/obj/Debug/net48/AxCopilot_Setup.pdb
Normal file
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")]
|
||||
@@ -0,0 +1,22 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("AxCopilot_Setup")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.7.2.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.7.2")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AxCopilot_Setup")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AxCopilot_Setup")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.7.2.0")]
|
||||
|
||||
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
9fc97ba95fb895eafec34720944a95d7ef26ca1178a342b4d606134cf9a22f91
|
||||
@@ -0,0 +1,14 @@
|
||||
is_global = true
|
||||
build_property.ApplicationManifest = app.manifest
|
||||
build_property.StartupObject =
|
||||
build_property.ApplicationDefaultFont =
|
||||
build_property.ApplicationHighDpiMode =
|
||||
build_property.ApplicationUseCompatibleTextRendering =
|
||||
build_property.ApplicationVisualStyles =
|
||||
build_property.RootNamespace = AxCopilot.Installer
|
||||
build_property.ProjectDir = E:\AX Copilot\src\AxCopilot.Installer\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||
build_property.EffectiveAnalysisLevelStyle =
|
||||
build_property.EnableCodeStyleSeverity =
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
d0902e3a45ac1669ecad6c4a76499ce8c92ddecaaca00815b0d092f937f62404
|
||||
@@ -0,0 +1,10 @@
|
||||
E:\AX Copilot\src\AxCopilot.Installer\bin\Release\net48\AxCopilot_Setup.exe.config
|
||||
E:\AX Copilot\src\AxCopilot.Installer\bin\Release\net48\AxCopilot_Setup.exe
|
||||
E:\AX Copilot\src\AxCopilot.Installer\bin\Release\net48\AxCopilot_Setup.pdb
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Release\net48\AxCopilot.Installer.csproj.AssemblyReference.cache
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Release\net48\AxCopilot.Installer.GeneratedMSBuildEditorConfig.editorconfig
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Release\net48\AxCopilot.Installer.AssemblyInfoInputs.cache
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Release\net48\AxCopilot.Installer.AssemblyInfo.cs
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Release\net48\AxCopilot.Installer.csproj.CoreCompileInputs.cache
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Release\net48\AxCopilot_Setup.exe
|
||||
E:\AX Copilot\src\AxCopilot.Installer\obj\Release\net48\AxCopilot_Setup.pdb
|
||||
BIN
src/AxCopilot.Installer/obj/Release/net48/AxCopilot_Setup.exe
Normal file
BIN
src/AxCopilot.Installer/obj/Release/net48/AxCopilot_Setup.exe
Normal file
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
</configuration>
|
||||
BIN
src/AxCopilot.Installer/obj/Release/net48/AxCopilot_Setup.pdb
Normal file
BIN
src/AxCopilot.Installer/obj/Release/net48/AxCopilot_Setup.pdb
Normal file
Binary file not shown.
64
src/AxCopilot.Installer/obj/project.assets.json
Normal file
64
src/AxCopilot.Installer/obj/project.assets.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"version": 3,
|
||||
"targets": {
|
||||
".NETFramework,Version=v4.8": {},
|
||||
".NETFramework,Version=v4.8/win-x86": {}
|
||||
},
|
||||
"libraries": {},
|
||||
"projectFileDependencyGroups": {
|
||||
".NETFramework,Version=v4.8": []
|
||||
},
|
||||
"packageFolders": {
|
||||
"C:\\Users\\admin\\.nuget\\packages\\": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "1.7.2",
|
||||
"restore": {
|
||||
"projectUniqueName": "E:\\AX Copilot\\src\\AxCopilot.Installer\\AxCopilot.Installer.csproj",
|
||||
"projectName": "AxCopilot_Setup",
|
||||
"projectPath": "E:\\AX Copilot\\src\\AxCopilot.Installer\\AxCopilot.Installer.csproj",
|
||||
"packagesPath": "C:\\Users\\admin\\.nuget\\packages\\",
|
||||
"outputPath": "E:\\AX Copilot\\src\\AxCopilot.Installer\\obj\\",
|
||||
"projectStyle": "PackageReference",
|
||||
"configFilePaths": [
|
||||
"C:\\Users\\admin\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||
],
|
||||
"originalTargetFrameworks": [
|
||||
"net48"
|
||||
],
|
||||
"sources": {
|
||||
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
||||
"https://api.nuget.org/v3/index.json": {}
|
||||
},
|
||||
"frameworks": {
|
||||
"net48": {
|
||||
"targetAlias": "net48",
|
||||
"projectReferences": {}
|
||||
}
|
||||
},
|
||||
"warningProperties": {
|
||||
"warnAsError": [
|
||||
"NU1605"
|
||||
]
|
||||
},
|
||||
"restoreAuditProperties": {
|
||||
"enableAudit": "true",
|
||||
"auditLevel": "low",
|
||||
"auditMode": "direct"
|
||||
},
|
||||
"SdkAnalysisLevel": "10.0.200"
|
||||
},
|
||||
"frameworks": {
|
||||
"net48": {
|
||||
"targetAlias": "net48",
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.201\\RuntimeIdentifierGraph.json"
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"win-x86": {
|
||||
"#import": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/AxCopilot.Installer/obj/project.nuget.cache
Normal file
8
src/AxCopilot.Installer/obj/project.nuget.cache
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "szcmjiT3MpM=",
|
||||
"success": true,
|
||||
"projectFilePath": "E:\\AX Copilot\\src\\AxCopilot.Installer\\AxCopilot.Installer.csproj",
|
||||
"expectedPackageFiles": [],
|
||||
"logs": []
|
||||
}
|
||||
12
src/AxCopilot.SDK/AxCopilot.SDK.csproj
Normal file
12
src/AxCopilot.SDK/AxCopilot.SDK.csproj
Normal file
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>AxCopilot.SDK</AssemblyName>
|
||||
<RootNamespace>AxCopilot.SDK</RootNamespace>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>AX Copilot</Authors>
|
||||
<Description>SDK for building AX Copilot plugins</Description>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
55
src/AxCopilot.SDK/IActionHandler.cs
Normal file
55
src/AxCopilot.SDK/IActionHandler.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace AxCopilot.SDK;
|
||||
|
||||
/// <summary>
|
||||
/// AX Copilot 플러그인이 구현해야 하는 핵심 인터페이스.
|
||||
/// 새로운 명령어 타입을 추가하려면 이 인터페이스를 구현하고
|
||||
/// settings.json의 "plugins" 배열에 .dll 경로를 등록하십시오.
|
||||
/// </summary>
|
||||
public interface IActionHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// 이 핸들러를 트리거하는 prefix 문자 (예: "@", "!", "#", "~", ">", "$").
|
||||
/// null이면 prefix 없이 Fuzzy 검색 결과에만 항목을 제공합니다.
|
||||
/// </summary>
|
||||
string? Prefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 플러그인 메타데이터
|
||||
/// </summary>
|
||||
PluginMetadata Metadata { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 런처 결과 리스트에 표시할 항목을 반환합니다.
|
||||
/// </summary>
|
||||
/// <param name="query">prefix 이후의 사용자 입력 텍스트</param>
|
||||
/// <param name="ct">ESC 입력 시 취소되는 CancellationToken</param>
|
||||
Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// 사용자가 항목을 선택(Enter)했을 때 실행되는 동작
|
||||
/// </summary>
|
||||
Task ExecuteAsync(LauncherItem item, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 런처 결과 리스트에 표시될 단일 항목
|
||||
/// </summary>
|
||||
public record LauncherItem(
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string? IconPath, // null이면 기본 아이콘 사용
|
||||
object? Data, // ExecuteAsync에 전달되는 임의 데이터
|
||||
string? ActionUrl = null, // Enter 시 열릴 URL (선택)
|
||||
string? Symbol = null // Segoe MDL2 Assets 유니코드 심볼 (null이면 타입 기반 자동 결정)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 플러그인 식별 메타데이터
|
||||
/// </summary>
|
||||
public record PluginMetadata(
|
||||
string Id,
|
||||
string Name,
|
||||
string Version,
|
||||
string Author,
|
||||
string? Description = null
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v8.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {
|
||||
"AxCopilot.SDK/1.0.0": {
|
||||
"runtime": {
|
||||
"AxCopilot.SDK.dll": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"AxCopilot.SDK/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/AxCopilot.SDK/bin/Debug/net8.0-windows/AxCopilot.SDK.dll
Normal file
BIN
src/AxCopilot.SDK/bin/Debug/net8.0-windows/AxCopilot.SDK.dll
Normal file
Binary file not shown.
BIN
src/AxCopilot.SDK/bin/Debug/net8.0-windows/AxCopilot.SDK.pdb
Normal file
BIN
src/AxCopilot.SDK/bin/Debug/net8.0-windows/AxCopilot.SDK.pdb
Normal file
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v8.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {
|
||||
"AxCopilot.SDK/1.0.0": {
|
||||
"runtime": {
|
||||
"AxCopilot.SDK.dll": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"AxCopilot.SDK/1.0.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/AxCopilot.SDK/bin/Release/net8.0-windows/AxCopilot.SDK.dll
Normal file
BIN
src/AxCopilot.SDK/bin/Release/net8.0-windows/AxCopilot.SDK.dll
Normal file
Binary file not shown.
BIN
src/AxCopilot.SDK/bin/Release/net8.0-windows/AxCopilot.SDK.pdb
Normal file
BIN
src/AxCopilot.SDK/bin/Release/net8.0-windows/AxCopilot.SDK.pdb
Normal file
Binary file not shown.
69
src/AxCopilot.SDK/obj/AxCopilot.SDK.csproj.nuget.dgspec.json
Normal file
69
src/AxCopilot.SDK/obj/AxCopilot.SDK.csproj.nuget.dgspec.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"format": 1,
|
||||
"restore": {
|
||||
"E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\AxCopilot.SDK.csproj": {}
|
||||
},
|
||||
"projects": {
|
||||
"E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\AxCopilot.SDK.csproj": {
|
||||
"version": "1.0.0",
|
||||
"restore": {
|
||||
"projectUniqueName": "E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\AxCopilot.SDK.csproj",
|
||||
"projectName": "AxCopilot.SDK",
|
||||
"projectPath": "E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\AxCopilot.SDK.csproj",
|
||||
"packagesPath": "C:\\Users\\admin\\.nuget\\packages\\",
|
||||
"outputPath": "E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\obj\\",
|
||||
"projectStyle": "PackageReference",
|
||||
"configFilePaths": [
|
||||
"C:\\Users\\admin\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||
],
|
||||
"originalTargetFrameworks": [
|
||||
"net8.0-windows"
|
||||
],
|
||||
"sources": {
|
||||
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
||||
"https://api.nuget.org/v3/index.json": {}
|
||||
},
|
||||
"frameworks": {
|
||||
"net8.0-windows7.0": {
|
||||
"targetAlias": "net8.0-windows",
|
||||
"projectReferences": {}
|
||||
}
|
||||
},
|
||||
"warningProperties": {
|
||||
"warnAsError": [
|
||||
"NU1605"
|
||||
]
|
||||
},
|
||||
"restoreAuditProperties": {
|
||||
"enableAudit": "true",
|
||||
"auditLevel": "low",
|
||||
"auditMode": "direct"
|
||||
},
|
||||
"SdkAnalysisLevel": "10.0.200"
|
||||
},
|
||||
"frameworks": {
|
||||
"net8.0-windows7.0": {
|
||||
"targetAlias": "net8.0-windows",
|
||||
"imports": [
|
||||
"net461",
|
||||
"net462",
|
||||
"net47",
|
||||
"net471",
|
||||
"net472",
|
||||
"net48",
|
||||
"net481"
|
||||
],
|
||||
"assetTargetFallback": true,
|
||||
"warn": true,
|
||||
"frameworkReferences": {
|
||||
"Microsoft.NETCore.App": {
|
||||
"privateAssets": "all"
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.201/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/AxCopilot.SDK/obj/AxCopilot.SDK.csproj.nuget.g.props
Normal file
15
src/AxCopilot.SDK/obj/AxCopilot.SDK.csproj.nuget.g.props
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
|
||||
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
||||
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
||||
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
|
||||
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\admin\.nuget\packages\</NuGetPackageFolders>
|
||||
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<SourceRoot Include="C:\Users\admin\.nuget\packages\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" />
|
||||
@@ -0,0 +1,4 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
|
||||
@@ -0,0 +1,25 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("AX Copilot")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("SDK for building AX Copilot plugins")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9f25d92808b4f98a7ffa19fb6da350ada44e207a")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AxCopilot.SDK")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AxCopilot.SDK")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||
|
||||
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
9707b9574f19cf4c53b6aaaa8739e385a35fc150e40983feeaf78eb5d53acddb
|
||||
@@ -0,0 +1,18 @@
|
||||
is_global = true
|
||||
build_property.TargetFramework = net8.0-windows
|
||||
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||
build_property.TargetFrameworkVersion = v8.0
|
||||
build_property.TargetPlatformMinVersion = 7.0
|
||||
build_property.UsingMicrosoftNETSdkWeb =
|
||||
build_property.ProjectTypeGuids =
|
||||
build_property.InvariantGlobalization =
|
||||
build_property.PlatformNeutralAssembly =
|
||||
build_property.EnforceExtendedAnalyzerRules =
|
||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||
build_property.RootNamespace = AxCopilot.SDK
|
||||
build_property.ProjectDir = E:\AX Copilot - Codex\src\AxCopilot.SDK\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||
build_property.EffectiveAnalysisLevelStyle = 8.0
|
||||
build_property.EnableCodeStyleSeverity =
|
||||
@@ -0,0 +1,8 @@
|
||||
// <auto-generated/>
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
global using System.Net.Http;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
f858a9c01dc195df406d6ab52031704c14e8ab236b899706a36768fde27b8d52
|
||||
@@ -0,0 +1,33 @@
|
||||
E:\AX Commander\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.deps.json
|
||||
E:\AX Commander\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.dll
|
||||
E:\AX Commander\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.pdb
|
||||
E:\AX Commander\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.GeneratedMSBuildEditorConfig.editorconfig
|
||||
E:\AX Commander\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.AssemblyInfoInputs.cache
|
||||
E:\AX Commander\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.AssemblyInfo.cs
|
||||
E:\AX Commander\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.csproj.CoreCompileInputs.cache
|
||||
E:\AX Commander\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.dll
|
||||
E:\AX Commander\src\AxCopilot.SDK\obj\Debug\net8.0-windows\refint\AxCopilot.SDK.dll
|
||||
E:\AX Commander\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.pdb
|
||||
E:\AX Commander\src\AxCopilot.SDK\obj\Debug\net8.0-windows\ref\AxCopilot.SDK.dll
|
||||
E:\AX Copilot\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.deps.json
|
||||
E:\AX Copilot\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.dll
|
||||
E:\AX Copilot\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.pdb
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.GeneratedMSBuildEditorConfig.editorconfig
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.AssemblyInfoInputs.cache
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.AssemblyInfo.cs
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.csproj.CoreCompileInputs.cache
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.dll
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Debug\net8.0-windows\refint\AxCopilot.SDK.dll
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.pdb
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Debug\net8.0-windows\ref\AxCopilot.SDK.dll
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.deps.json
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.dll
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\bin\Debug\net8.0-windows\AxCopilot.SDK.pdb
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.GeneratedMSBuildEditorConfig.editorconfig
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.AssemblyInfoInputs.cache
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.AssemblyInfo.cs
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.csproj.CoreCompileInputs.cache
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.dll
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\obj\Debug\net8.0-windows\refint\AxCopilot.SDK.dll
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\obj\Debug\net8.0-windows\AxCopilot.SDK.pdb
|
||||
E:\AX Copilot - Codex\src\AxCopilot.SDK\obj\Debug\net8.0-windows\ref\AxCopilot.SDK.dll
|
||||
BIN
src/AxCopilot.SDK/obj/Debug/net8.0-windows/AxCopilot.SDK.dll
Normal file
BIN
src/AxCopilot.SDK/obj/Debug/net8.0-windows/AxCopilot.SDK.dll
Normal file
Binary file not shown.
BIN
src/AxCopilot.SDK/obj/Debug/net8.0-windows/AxCopilot.SDK.pdb
Normal file
BIN
src/AxCopilot.SDK/obj/Debug/net8.0-windows/AxCopilot.SDK.pdb
Normal file
Binary file not shown.
BIN
src/AxCopilot.SDK/obj/Debug/net8.0-windows/ref/AxCopilot.SDK.dll
Normal file
BIN
src/AxCopilot.SDK/obj/Debug/net8.0-windows/ref/AxCopilot.SDK.dll
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
|
||||
@@ -0,0 +1,25 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("AX Copilot")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
|
||||
[assembly: System.Reflection.AssemblyDescriptionAttribute("SDK for building AX Copilot plugins")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("AxCopilot.SDK")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("AxCopilot.SDK")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
|
||||
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
|
||||
|
||||
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
738bc7e0540ae4f36b5e492d4e3d178e3d47b839fe0e7005990856e92eba327e
|
||||
@@ -0,0 +1,18 @@
|
||||
is_global = true
|
||||
build_property.TargetFramework = net8.0-windows
|
||||
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||
build_property.TargetFrameworkVersion = v8.0
|
||||
build_property.TargetPlatformMinVersion = 7.0
|
||||
build_property.UsingMicrosoftNETSdkWeb =
|
||||
build_property.ProjectTypeGuids =
|
||||
build_property.InvariantGlobalization =
|
||||
build_property.PlatformNeutralAssembly =
|
||||
build_property.EnforceExtendedAnalyzerRules =
|
||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||
build_property.RootNamespace = AxCopilot.SDK
|
||||
build_property.ProjectDir = E:\AX Copilot\src\AxCopilot.SDK\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||
build_property.EffectiveAnalysisLevelStyle = 8.0
|
||||
build_property.EnableCodeStyleSeverity =
|
||||
@@ -0,0 +1,8 @@
|
||||
// <auto-generated/>
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
global using System.Net.Http;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
27f07c411e7cf633b2b3fae7a3b844fbef6553c99925e60eb5463ef14cc382c0
|
||||
@@ -0,0 +1,11 @@
|
||||
E:\AX Copilot\src\AxCopilot.SDK\bin\Release\net8.0-windows\AxCopilot.SDK.deps.json
|
||||
E:\AX Copilot\src\AxCopilot.SDK\bin\Release\net8.0-windows\AxCopilot.SDK.dll
|
||||
E:\AX Copilot\src\AxCopilot.SDK\bin\Release\net8.0-windows\AxCopilot.SDK.pdb
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Release\net8.0-windows\AxCopilot.SDK.GeneratedMSBuildEditorConfig.editorconfig
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Release\net8.0-windows\AxCopilot.SDK.AssemblyInfoInputs.cache
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Release\net8.0-windows\AxCopilot.SDK.AssemblyInfo.cs
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Release\net8.0-windows\AxCopilot.SDK.csproj.CoreCompileInputs.cache
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Release\net8.0-windows\AxCopilot.SDK.dll
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Release\net8.0-windows\refint\AxCopilot.SDK.dll
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Release\net8.0-windows\AxCopilot.SDK.pdb
|
||||
E:\AX Copilot\src\AxCopilot.SDK\obj\Release\net8.0-windows\ref\AxCopilot.SDK.dll
|
||||
BIN
src/AxCopilot.SDK/obj/Release/net8.0-windows/AxCopilot.SDK.dll
Normal file
BIN
src/AxCopilot.SDK/obj/Release/net8.0-windows/AxCopilot.SDK.dll
Normal file
Binary file not shown.
BIN
src/AxCopilot.SDK/obj/Release/net8.0-windows/AxCopilot.SDK.pdb
Normal file
BIN
src/AxCopilot.SDK/obj/Release/net8.0-windows/AxCopilot.SDK.pdb
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
74
src/AxCopilot.SDK/obj/project.assets.json
Normal file
74
src/AxCopilot.SDK/obj/project.assets.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"version": 3,
|
||||
"targets": {
|
||||
"net8.0-windows7.0": {}
|
||||
},
|
||||
"libraries": {},
|
||||
"projectFileDependencyGroups": {
|
||||
"net8.0-windows7.0": []
|
||||
},
|
||||
"packageFolders": {
|
||||
"C:\\Users\\admin\\.nuget\\packages\\": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "1.0.0",
|
||||
"restore": {
|
||||
"projectUniqueName": "E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\AxCopilot.SDK.csproj",
|
||||
"projectName": "AxCopilot.SDK",
|
||||
"projectPath": "E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\AxCopilot.SDK.csproj",
|
||||
"packagesPath": "C:\\Users\\admin\\.nuget\\packages\\",
|
||||
"outputPath": "E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\obj\\",
|
||||
"projectStyle": "PackageReference",
|
||||
"configFilePaths": [
|
||||
"C:\\Users\\admin\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||
],
|
||||
"originalTargetFrameworks": [
|
||||
"net8.0-windows"
|
||||
],
|
||||
"sources": {
|
||||
"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\": {},
|
||||
"https://api.nuget.org/v3/index.json": {}
|
||||
},
|
||||
"frameworks": {
|
||||
"net8.0-windows7.0": {
|
||||
"targetAlias": "net8.0-windows",
|
||||
"projectReferences": {}
|
||||
}
|
||||
},
|
||||
"warningProperties": {
|
||||
"warnAsError": [
|
||||
"NU1605"
|
||||
]
|
||||
},
|
||||
"restoreAuditProperties": {
|
||||
"enableAudit": "true",
|
||||
"auditLevel": "low",
|
||||
"auditMode": "direct"
|
||||
},
|
||||
"SdkAnalysisLevel": "10.0.200"
|
||||
},
|
||||
"frameworks": {
|
||||
"net8.0-windows7.0": {
|
||||
"targetAlias": "net8.0-windows",
|
||||
"imports": [
|
||||
"net461",
|
||||
"net462",
|
||||
"net47",
|
||||
"net471",
|
||||
"net472",
|
||||
"net48",
|
||||
"net481"
|
||||
],
|
||||
"assetTargetFallback": true,
|
||||
"warn": true,
|
||||
"frameworkReferences": {
|
||||
"Microsoft.NETCore.App": {
|
||||
"privateAssets": "all"
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.201/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/AxCopilot.SDK/obj/project.nuget.cache
Normal file
8
src/AxCopilot.SDK/obj/project.nuget.cache
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "zGrazGhGdDA=",
|
||||
"success": true,
|
||||
"projectFilePath": "E:\\AX Copilot - Codex\\src\\AxCopilot.SDK\\AxCopilot.SDK.csproj",
|
||||
"expectedPackageFiles": [],
|
||||
"logs": []
|
||||
}
|
||||
26
src/AxCopilot.Tests/AxCopilot.Tests.csproj
Normal file
26
src/AxCopilot.Tests/AxCopilot.Tests.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>AxCopilot.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AxCopilot\AxCopilot.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
279
src/AxCopilot.Tests/Core/FuzzyEngineTests.cs
Normal file
279
src/AxCopilot.Tests/Core/FuzzyEngineTests.cs
Normal file
@@ -0,0 +1,279 @@
|
||||
using FluentAssertions;
|
||||
using AxCopilot.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Core;
|
||||
|
||||
public class FuzzyEngineTests
|
||||
{
|
||||
// ─── CalculateScore 기본 매칭 ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_ExactMatch_ReturnsHighestScore()
|
||||
{
|
||||
var score = FuzzyEngine.CalculateScore("notepad", "notepad", 0);
|
||||
score.Should().BeGreaterThanOrEqualTo(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_PrefixMatch_ReturnsHighScore()
|
||||
{
|
||||
var score = FuzzyEngine.CalculateScore("note", "notepad", 0);
|
||||
score.Should().BeGreaterThanOrEqualTo(800);
|
||||
score.Should().BeLessThan(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_ContainsMatch_ReturnsMediumScore()
|
||||
{
|
||||
var score = FuzzyEngine.CalculateScore("pad", "notepad", 0);
|
||||
score.Should().BeGreaterThanOrEqualTo(600);
|
||||
score.Should().BeLessThan(800);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_NoMatch_ReturnsZero()
|
||||
{
|
||||
var score = FuzzyEngine.CalculateScore("xyz", "notepad", 0);
|
||||
score.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_EmptyQuery_ReturnsZero()
|
||||
{
|
||||
var score = FuzzyEngine.CalculateScore("", "notepad", 0);
|
||||
score.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_BaseScore_AddsToResult()
|
||||
{
|
||||
var scoreWithBase = FuzzyEngine.CalculateScore("notepad", "notepad", 100);
|
||||
var scoreWithout = FuzzyEngine.CalculateScore("notepad", "notepad", 0);
|
||||
scoreWithBase.Should().Be(scoreWithout + 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_ExactBeforePrefix()
|
||||
{
|
||||
var exact = FuzzyEngine.CalculateScore("note", "note", 0);
|
||||
var prefix = FuzzyEngine.CalculateScore("not", "note", 0);
|
||||
exact.Should().BeGreaterThan(prefix);
|
||||
}
|
||||
|
||||
// ─── FuzzyMatch ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FuzzyMatch_AllCharsPresent_ReturnsPositive()
|
||||
{
|
||||
var score = FuzzyEngine.FuzzyMatch("ntpd", "notepad");
|
||||
score.Should().BePositive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FuzzyMatch_CharsMissing_ReturnsZero()
|
||||
{
|
||||
var score = FuzzyEngine.FuzzyMatch("xyz", "notepad");
|
||||
score.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FuzzyMatch_ConsecutiveCharsScoreHigher()
|
||||
{
|
||||
var consecutive = FuzzyEngine.FuzzyMatch("not", "notepad");
|
||||
var scattered = FuzzyEngine.FuzzyMatch("ntp", "notepad");
|
||||
consecutive.Should().BeGreaterThan(scattered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FuzzyMatch_EmptyQuery_ReturnsPositive()
|
||||
{
|
||||
var score = FuzzyEngine.FuzzyMatch("", "notepad");
|
||||
score.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FuzzyMatch_FullMatch_ReturnsHighScore()
|
||||
{
|
||||
var full = FuzzyEngine.FuzzyMatch("abcde", "abcde");
|
||||
var partial = FuzzyEngine.FuzzyMatch("ace", "abcde");
|
||||
full.Should().BeGreaterThan(partial);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FuzzyMatch_MinimumScoreGuaranteed()
|
||||
{
|
||||
var score = FuzzyEngine.FuzzyMatch("ntpd", "notepad");
|
||||
score.Should().BeGreaterThanOrEqualTo(50);
|
||||
}
|
||||
|
||||
// ─── 한글 자모 분리 ─────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("가", "ㄱㅏ")]
|
||||
[InlineData("한", "ㅎㅏㄴ")]
|
||||
[InlineData("글", "ㄱㅡㄹ")]
|
||||
[InlineData("abc", "abc")]
|
||||
[InlineData("가a나", "ㄱㅏaㄴㅏ")]
|
||||
public void DecomposeToJamo_ReturnsCorrectJamo(string input, string expected)
|
||||
{
|
||||
FuzzyEngine.DecomposeToJamo(input).Should().Be(expected);
|
||||
}
|
||||
|
||||
// ─── 자모 기반 포함 검색 ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void JamoContainsScore_MiddleWord_ReturnsPositive()
|
||||
{
|
||||
// "모장" → "메모장" (자모 분리 후 연속 매칭)
|
||||
var score = FuzzyEngine.JamoContainsScore("메모장", "모장");
|
||||
score.Should().BePositive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JamoContainsScore_NoMatch_ReturnsZero()
|
||||
{
|
||||
var score = FuzzyEngine.JamoContainsScore("메모장", "가나");
|
||||
score.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JamoContainsScore_SubsequenceMatch_ReturnsPositive()
|
||||
{
|
||||
// "메장" → 메-모-장에서 비연속 자모 매칭
|
||||
var score = FuzzyEngine.JamoContainsScore("메모장", "메장");
|
||||
score.Should().BePositive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JamoContainsScore_NonKorean_ReturnsZero()
|
||||
{
|
||||
var score = FuzzyEngine.JamoContainsScore("notepad", "pad");
|
||||
score.Should().Be(0); // 영어는 Contains에서 이미 처리
|
||||
}
|
||||
|
||||
// ─── 한글 초성 검색 ──────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("ㄴ", true)]
|
||||
[InlineData("ㄴㅌ", true)]
|
||||
[InlineData("ㄱㄴㄷ", true)]
|
||||
public void IsChosung_ValidChosung_ReturnsTrue(string text, bool expected)
|
||||
{
|
||||
FuzzyEngine.IsChosung(text).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("notepad", false)]
|
||||
[InlineData("노트패드", false)]
|
||||
[InlineData("a", false)]
|
||||
[InlineData("ㄴa", false)]
|
||||
public void IsChosung_NonChosung_ReturnsFalse(string text, bool expected)
|
||||
{
|
||||
FuzzyEngine.IsChosung(text).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChosung_HangulChar_ReturnsCorrectChosung()
|
||||
{
|
||||
FuzzyEngine.GetChosung('나').Should().Be('ㄴ');
|
||||
FuzzyEngine.GetChosung('가').Should().Be('ㄱ');
|
||||
FuzzyEngine.GetChosung('하').Should().Be('ㅎ');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChosung_NonHangul_ReturnsNull()
|
||||
{
|
||||
FuzzyEngine.GetChosung('a').Should().Be('\0');
|
||||
FuzzyEngine.GetChosung('1').Should().Be('\0');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsChosung_ConsecutiveMatch_ReturnsTrue()
|
||||
{
|
||||
FuzzyEngine.ContainsChosung("노트패드", "ㄴㅌ").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsChosung_NonMatchingChosung_ReturnsFalse()
|
||||
{
|
||||
FuzzyEngine.ContainsChosung("노트패드", "ㅅ").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsChosung_PartialMatch_ReturnsTrue()
|
||||
{
|
||||
FuzzyEngine.ContainsChosung("계산기", "ㄱㅅ").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsChosung_QueryLongerThanTarget_ReturnsFalse()
|
||||
{
|
||||
FuzzyEngine.ContainsChosung("가", "ㄱㄴㄷㄹ").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsChosung_NonConsecutive_ReturnsTrue()
|
||||
{
|
||||
// "ㅁㅊ" → 메모장(ㅁㅁㅈ) — 안 맞음 (ㅊ가 없으므로)
|
||||
FuzzyEngine.ContainsChosung("메모장", "ㅁㅊ").Should().BeFalse();
|
||||
|
||||
// "ㅁㅈ" → 메모장(ㅁㅁㅈ) — 비연속 매칭
|
||||
FuzzyEngine.ContainsChosung("메모장", "ㅁㅈ").Should().BeTrue();
|
||||
}
|
||||
|
||||
// ─── 초성 점수 매칭 ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ChosungMatchScore_PureChosung_Consecutive()
|
||||
{
|
||||
var score = FuzzyEngine.ChosungMatchScore("계산기", "ㄱㅅ");
|
||||
score.Should().BeGreaterThanOrEqualTo(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChosungMatchScore_PureChosung_Subsequence()
|
||||
{
|
||||
// "ㅁㅈ" → 메모장 (ㅁ...ㅈ 비연속)
|
||||
var score = FuzzyEngine.ChosungMatchScore("메모장", "ㅁㅈ");
|
||||
score.Should().BePositive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChosungMatchScore_MixedQuery()
|
||||
{
|
||||
// "ㅁ장" → 혼합: ㅁ은 초성, 장은 완성형
|
||||
var score = FuzzyEngine.ChosungMatchScore("메모장", "ㅁ장");
|
||||
score.Should().BePositive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChosungMatchScore_NoMatch_ReturnsZero()
|
||||
{
|
||||
var score = FuzzyEngine.ChosungMatchScore("메모장", "ㅋㅋ");
|
||||
score.Should().Be(0);
|
||||
}
|
||||
|
||||
// ─── 통합 점수 우선순위 ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_ScoreHierarchy()
|
||||
{
|
||||
// 정확 > 시작 > 포함 > 자모포함 > 초성 > fuzzy
|
||||
var exact = FuzzyEngine.CalculateScore("메모장", "메모장", 0);
|
||||
var prefix = FuzzyEngine.CalculateScore("메모", "메모장", 0);
|
||||
var contains = FuzzyEngine.CalculateScore("모장", "메모장", 0);
|
||||
|
||||
exact.Should().BeGreaterThan(prefix);
|
||||
prefix.Should().BeGreaterThan(contains);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScore_JamoBeforeChosung()
|
||||
{
|
||||
// "모장" (자모 포함) > "ㅁㅈ" (초성 비연속)
|
||||
var jamo = FuzzyEngine.CalculateScore("모장", "메모장", 0);
|
||||
var chosung = FuzzyEngine.CalculateScore("ㅁㅈ", "메모장", 0);
|
||||
jamo.Should().BeGreaterThan(chosung);
|
||||
}
|
||||
}
|
||||
215
src/AxCopilot.Tests/Handlers/ClipboardTransformTests.cs
Normal file
215
src/AxCopilot.Tests/Handlers/ClipboardTransformTests.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using AxCopilot.Handlers;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Handlers;
|
||||
|
||||
public class ClipboardTransformTests
|
||||
{
|
||||
// ─── 대소문자 변환 ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Upper_ConvertsToUppercase()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$upper", "hello world").Should().Be("HELLO WORLD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lower_ConvertsToLowercase()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$lower", "HELLO WORLD").Should().Be("hello world");
|
||||
}
|
||||
|
||||
// ─── Base64 ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Base64Encode_EncodesCorrectly()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$b64e", "hello").Should().Be("aGVsbG8=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Base64Decode_DecodesCorrectly()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$b64d", "aGVsbG8=").Should().Be("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Base64_RoundTrip()
|
||||
{
|
||||
var original = "AX Copilot 테스트";
|
||||
var encoded = ClipboardHandler.ExecuteBuiltin("$b64e", original)!;
|
||||
var decoded = ClipboardHandler.ExecuteBuiltin("$b64d", encoded);
|
||||
decoded.Should().Be(original);
|
||||
}
|
||||
|
||||
// ─── URL 인코딩 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UrlEncode_EncodesSpaces()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$urle", "hello world").Should().Be("hello%20world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UrlDecode_DecodesSpaces()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$urld", "hello%20world").Should().Be("hello world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UrlEncode_EncodesSpecialChars()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$urle", "a=b&c=d");
|
||||
result.Should().Be("a%3Db%26c%3Dd");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UrlDecode_DecodesSpecialChars()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$urld", "a%3Db%26c%3Dd");
|
||||
result.Should().Be("a=b&c=d");
|
||||
}
|
||||
|
||||
// ─── 문자열 처리 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Trim_RemovesLeadingTrailingWhitespace()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$trim", " hello ").Should().Be("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trim_PreservesInternalSpaces()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$trim", " hello world ").Should().Be("hello world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lines_RemovesEmptyLines()
|
||||
{
|
||||
var input = "a\n\nb\n \nc";
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$lines", input);
|
||||
result.Should().NotContain("\n\n");
|
||||
result.Should().Contain("a");
|
||||
result.Should().Contain("b");
|
||||
result.Should().Contain("c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lines_TrimsEachLine()
|
||||
{
|
||||
var input = " hello \n world ";
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$lines", input)!;
|
||||
result.Should().Contain("hello");
|
||||
result.Should().Contain("world");
|
||||
result.Should().NotContain(" hello");
|
||||
}
|
||||
|
||||
// ─── 타임스탬프 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_EpochZero_Returns1970()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$ts", "0");
|
||||
result.Should().NotBeNullOrEmpty();
|
||||
result.Should().Contain("1970");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_ValidEpoch_ReturnsDateString()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$ts", "1700000000");
|
||||
result.Should().NotBeNullOrEmpty();
|
||||
result.Should().MatchRegex(@"\d{4}-\d{2}-\d{2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_InvalidInput_ReturnsNull()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$ts", "not-a-number").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Epoch_ValidDate_ReturnsNumber()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$epoch", "1970-01-01");
|
||||
result.Should().NotBeNullOrEmpty();
|
||||
long.TryParse(result, out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Epoch_InvalidDate_ReturnsNull()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$epoch", "not-a-date").Should().BeNull();
|
||||
}
|
||||
|
||||
// ─── JSON 포맷팅 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void JsonFormat_MinifiedJson_AddsIndentation()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$json", "{\"a\":1,\"b\":2}");
|
||||
result.Should().NotBeNullOrEmpty();
|
||||
result.Should().Contain("\n");
|
||||
result.Should().Contain("\"a\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonFormat_AlreadyFormatted_StaysValid()
|
||||
{
|
||||
var formatted = "{\n \"a\": 1\n}";
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$json", formatted);
|
||||
result.Should().NotBeNullOrEmpty();
|
||||
result.Should().Contain("\"a\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonFormat_InvalidJson_ReturnsOriginal()
|
||||
{
|
||||
var invalid = "not json";
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$json", invalid);
|
||||
result.Should().Be(invalid); // 파싱 실패 시 원본 반환
|
||||
}
|
||||
|
||||
// ─── 마크다운 제거 ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void StripMarkdown_RemovesBold()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$md", "**bold text**");
|
||||
result.Should().NotContain("**");
|
||||
result.Should().Contain("bold text");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripMarkdown_RemovesItalic()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$md", "*italic*");
|
||||
result.Should().NotContain("*italic*");
|
||||
result.Should().Contain("italic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripMarkdown_RemovesInlineCode()
|
||||
{
|
||||
var result = ClipboardHandler.ExecuteBuiltin("$md", "`code`");
|
||||
result.Should().NotContain("`");
|
||||
result.Should().Contain("code");
|
||||
}
|
||||
|
||||
// ─── 알 수 없는 키 ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UnknownKey_ReturnsNull()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("$unknown", "input").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyKey_ReturnsNull()
|
||||
{
|
||||
ClipboardHandler.ExecuteBuiltin("", "input").Should().BeNull();
|
||||
}
|
||||
}
|
||||
92
src/AxCopilot.Tests/Services/AgentHookRunnerTests.cs
Normal file
92
src/AxCopilot.Tests/Services/AgentHookRunnerTests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class AgentHookRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParseStructuredPayload_ParsesExtendedPermissionAndContextShapes()
|
||||
{
|
||||
const string payload =
|
||||
"""
|
||||
{"updatedInput":{"path":"src/app.cs"},"updatedPermissions":{"file_write":"auto","http_tool":{"permission":"deny"}},"additionalContext":["line1","line2"],"message":"ok"}
|
||||
""";
|
||||
|
||||
var parsed = InvokeTryParseStructuredPayload(
|
||||
payload,
|
||||
out var updatedInput,
|
||||
out var updatedPermissions,
|
||||
out var additionalContext,
|
||||
out var message);
|
||||
|
||||
parsed.Should().BeTrue();
|
||||
updatedInput.HasValue.Should().BeTrue();
|
||||
updatedInput!.Value.TryGetProperty("path", out var pathProp).Should().BeTrue();
|
||||
pathProp.GetString().Should().Be("src/app.cs");
|
||||
updatedPermissions.Should().NotBeNull();
|
||||
updatedPermissions!["file_write"].Should().Be("auto");
|
||||
updatedPermissions["http_tool"].Should().Be("deny");
|
||||
additionalContext.Should().Be("line1\nline2");
|
||||
message.Should().Be("ok");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseStructuredPayload_ParsesPermissionUpdatesArrayAlias()
|
||||
{
|
||||
const string payload =
|
||||
"""
|
||||
{"permissionUpdates":[{"tool":"file_edit","permission":"ask"},{"tool":"*","permission":"deny"}]}
|
||||
""";
|
||||
|
||||
var parsed = InvokeTryParseStructuredPayload(
|
||||
payload,
|
||||
out _,
|
||||
out var updatedPermissions,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
parsed.Should().BeTrue();
|
||||
updatedPermissions.Should().NotBeNull();
|
||||
updatedPermissions!["file_edit"].Should().Be("ask");
|
||||
updatedPermissions["*"].Should().Be("deny");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseStructuredPayload_ReturnsFalseForNonJsonOutput()
|
||||
{
|
||||
var parsed = InvokeTryParseStructuredPayload(
|
||||
"plain output without json",
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
parsed.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static bool InvokeTryParseStructuredPayload(
|
||||
string rawOutput,
|
||||
out JsonElement? updatedInput,
|
||||
out Dictionary<string, string>? updatedPermissions,
|
||||
out string? additionalContext,
|
||||
out string? message)
|
||||
{
|
||||
var method = typeof(AgentHookRunner).GetMethod(
|
||||
"TryParseStructuredPayload",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var args = new object?[] { rawOutput, null, null, null, null };
|
||||
var result = (bool)method!.Invoke(null, args)!;
|
||||
|
||||
updatedInput = (JsonElement?)args[1];
|
||||
updatedPermissions = (Dictionary<string, string>?)args[2];
|
||||
additionalContext = (string?)args[3];
|
||||
message = (string?)args[4];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
2060
src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs
Normal file
2060
src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
456
src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs
Normal file
456
src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs
Normal file
@@ -0,0 +1,456 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
[Trait("Suite", "ParityBenchmark")]
|
||||
public class AgentLoopE2ETests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_ExecutesToolCall_AndCompletesWithFinalText()
|
||||
{
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("math_eval", new { expression = "1+2" }, "계산 중"),
|
||||
BuildTextResponse("최종 답변: 3"),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "1+2 계산해줘" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("3");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "math_eval");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "math_eval" && e.Success);
|
||||
events.Should().Contain(e => e.Type == AgentEventType.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UnknownTool_RecoversAndCompletes()
|
||||
{
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("UnknownTool", new { path = "x.txt" }, "unknown tool call"),
|
||||
BuildTextResponse("알 수 없는 도구를 정정하고 완료했습니다."),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "테스트 작업" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("완료");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.Error && e.ToolName == "UnknownTool");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_PlanModeAlways_EmitsPlanningThenExecutesTool()
|
||||
{
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildTextOnlyResponse("1. math_eval 도구로 계산\n2. 결과를 검증하고 보고"),
|
||||
BuildToolCallResponse("math_eval", new { expression = "10/2" }, "계획 실행"),
|
||||
BuildTextResponse("완료: 결과는 5"),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
settings.Settings.Llm.PlanMode = "always";
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Chat" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "10/2 계산해줘" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("5");
|
||||
server.RequestCount.Should().BeGreaterThanOrEqualTo(3);
|
||||
events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "math_eval");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AskPermissionDenied_EmitsPermissionEvents_AndCompletes()
|
||||
{
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("file_write", new { path = "deny-test.txt", content = "x" }, "파일 작성 시도"),
|
||||
BuildTextResponse("권한 거부를 확인하고 완료했습니다."),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
settings.Settings.Llm.DefaultAgentPermission = "Ask";
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Chat" };
|
||||
loop.AskPermissionCallback = (_, _) => Task.FromResult(false);
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "파일을 저장해줘" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("완료");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.PermissionRequest && e.ToolName == "file_write");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.PermissionDenied && e.ToolName == "file_write");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_PreHookInputMutation_ChangesToolArguments()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-hook-e2e-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var scriptPath = Path.Combine(tempDir, "mutate_math.cmd");
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
scriptPath,
|
||||
"@echo {\"updatedInput\":{\"expression\":\"2+3\"},\"message\":\"hook applied\"}",
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("math_eval", new { expression = "1+1" }, "hook test"),
|
||||
BuildTextResponse("최종 완료"),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
settings.Settings.Llm.EnableToolHooks = true;
|
||||
settings.Settings.Llm.EnableHookInputMutation = true;
|
||||
settings.Settings.Llm.AgentHooks =
|
||||
[
|
||||
new AgentHookEntry
|
||||
{
|
||||
Name = "mutate-math",
|
||||
ToolName = "math_eval",
|
||||
Timing = "pre",
|
||||
ScriptPath = scriptPath,
|
||||
Enabled = true
|
||||
}
|
||||
];
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Chat" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "1+1 계산" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("완료");
|
||||
events.Should().Contain(e => e.Type == AgentEventType.HookResult && e.Success);
|
||||
events.Should().Contain(e =>
|
||||
e.Type == AgentEventType.ToolResult &&
|
||||
e.ToolName == "math_eval" &&
|
||||
e.Summary.Contains("Result: 5", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DisallowedTool_ByRuntimePolicy_EmitsPolicyRecoveryError()
|
||||
{
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("file_read", new { path = "README.md" }, "disallowed tool call"),
|
||||
BuildTextResponse("정책 위반 복구 후 종료"),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage
|
||||
{
|
||||
Role = "system",
|
||||
Content = """
|
||||
[Skill Runtime Policy]
|
||||
- allowed_tools: math_eval
|
||||
"""
|
||||
},
|
||||
new ChatMessage { Role = "user", Content = "파일 읽기" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("종료");
|
||||
events.Should().Contain(e =>
|
||||
e.Type == AgentEventType.Error &&
|
||||
e.ToolName == "file_read" &&
|
||||
e.Summary.Contains("허용되지 않은 도구", StringComparison.OrdinalIgnoreCase));
|
||||
events.Should().Contain(e => e.Type == AgentEventType.Complete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_HookFilters_ExecuteOnlyMatchingHookForToolAndTiming()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-hook-filter-e2e-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var hookAScript = Path.Combine(tempDir, "hook_a.cmd");
|
||||
var hookBScript = Path.Combine(tempDir, "hook_b.cmd");
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
hookAScript,
|
||||
"@echo {\"message\":\"hook-a hit\"}",
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
await File.WriteAllTextAsync(
|
||||
hookBScript,
|
||||
"@echo {\"message\":\"hook-b hit\"}",
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
|
||||
using var server = new FakeOllamaServer(
|
||||
[
|
||||
BuildToolCallResponse("math_eval", new { expression = "2+2" }, "hook filter test"),
|
||||
BuildTextResponse("완료"),
|
||||
]);
|
||||
|
||||
var settings = BuildLoopSettings(server.Endpoint);
|
||||
settings.Settings.Llm.EnableToolHooks = true;
|
||||
settings.Settings.Llm.AgentHooks =
|
||||
[
|
||||
new AgentHookEntry
|
||||
{
|
||||
Name = "hook-a",
|
||||
ToolName = "math_eval",
|
||||
Timing = "pre",
|
||||
ScriptPath = hookAScript,
|
||||
Enabled = true
|
||||
},
|
||||
new AgentHookEntry
|
||||
{
|
||||
Name = "hook-b",
|
||||
ToolName = "math_eval",
|
||||
Timing = "pre",
|
||||
ScriptPath = hookBScript,
|
||||
Enabled = true
|
||||
}
|
||||
];
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
using var tools = ToolRegistry.CreateDefault();
|
||||
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||
|
||||
var events = new List<AgentEvent>();
|
||||
loop.EventOccurred += evt => events.Add(evt);
|
||||
|
||||
var result = await loop.RunAsync(
|
||||
[
|
||||
new ChatMessage
|
||||
{
|
||||
Role = "system",
|
||||
Content = """
|
||||
[Skill Runtime Policy]
|
||||
- allowed_tools: math_eval
|
||||
- hook_filters: hook-a@pre@math_eval
|
||||
"""
|
||||
},
|
||||
new ChatMessage { Role = "user", Content = "2+2 계산" }
|
||||
]);
|
||||
|
||||
result.Should().Contain("완료");
|
||||
events.Should().Contain(e =>
|
||||
e.Type == AgentEventType.HookResult &&
|
||||
e.Summary.Contains("[Hook:hook-a]", StringComparison.OrdinalIgnoreCase));
|
||||
events.Should().NotContain(e =>
|
||||
e.Type == AgentEventType.HookResult &&
|
||||
e.Summary.Contains("[Hook:hook-b]", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static SettingsService BuildLoopSettings(string endpoint)
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.OperationMode = OperationModePolicy.ExternalMode;
|
||||
settings.Settings.Llm.Service = "ollama";
|
||||
settings.Settings.Llm.OllamaEndpoint = endpoint;
|
||||
settings.Settings.Llm.Endpoint = endpoint;
|
||||
settings.Settings.Llm.Model = "test-model";
|
||||
settings.Settings.Llm.MaxAgentIterations = 6;
|
||||
settings.Settings.Llm.MaxRetryOnError = 1;
|
||||
settings.Settings.Llm.PlanMode = "off";
|
||||
settings.Settings.Llm.EnableToolHooks = false;
|
||||
settings.Settings.Llm.EnableAutoRouter = false;
|
||||
settings.Settings.Llm.EnableForkSkillDelegationEnforcement = true;
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static string BuildToolCallResponse(string toolName, object args, string content)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
message = new
|
||||
{
|
||||
content,
|
||||
tool_calls = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "call_1",
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = toolName,
|
||||
arguments = args
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
private static string BuildTextResponse(string content)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
message = new
|
||||
{
|
||||
content
|
||||
}
|
||||
};
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
private static string BuildTextOnlyResponse(string content)
|
||||
=> BuildTextResponse(content);
|
||||
|
||||
private sealed class FakeOllamaServer : IDisposable
|
||||
{
|
||||
private readonly HttpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Task _serveTask;
|
||||
private readonly Queue<string> _responses;
|
||||
private readonly string _fallbackResponse;
|
||||
private int _requestCount;
|
||||
|
||||
public string Endpoint { get; }
|
||||
public int RequestCount => _requestCount;
|
||||
|
||||
public FakeOllamaServer(IEnumerable<string> responses)
|
||||
{
|
||||
_responses = new Queue<string>(responses);
|
||||
_fallbackResponse = _responses.Count > 0 ? _responses.Last() : BuildTextResponse("(empty)");
|
||||
|
||||
var port = GetFreePort();
|
||||
Endpoint = $"http://127.0.0.1:{port}";
|
||||
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add($"{Endpoint}/");
|
||||
_listener.Start();
|
||||
|
||||
_serveTask = Task.Run(() => ServeLoopAsync(_cts.Token));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try { _listener.Stop(); } catch { }
|
||||
try { _listener.Close(); } catch { }
|
||||
try { _serveTask.Wait(TimeSpan.FromSeconds(2)); } catch { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task ServeLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
HttpListenerContext? context = null;
|
||||
try
|
||||
{
|
||||
context = await _listener.GetContextAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _requestCount);
|
||||
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding ?? Encoding.UTF8);
|
||||
_ = await reader.ReadToEndAsync();
|
||||
|
||||
var body = _responses.Count > 0 ? _responses.Dequeue() : _fallbackResponse;
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
|
||||
context.Response.StatusCode = 200;
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.ContentEncoding = Encoding.UTF8;
|
||||
context.Response.ContentLength64 = bytes.Length;
|
||||
await context.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 응답 실패는 테스트 종료 과정에서 발생 가능
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { context.Response.OutputStream.Close(); } catch { }
|
||||
try { context.Response.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/AxCopilot.Tests/Services/AgentStatsServiceTests.cs
Normal file
78
src/AxCopilot.Tests/Services/AgentStatsServiceTests.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Reflection;
|
||||
using AxCopilot.Services;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class AgentStatsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void CalculateRetryQualityRate_ReturnsOneWhenNoSignals()
|
||||
{
|
||||
var rate = InvokePrivateStatic<double>(
|
||||
"CalculateRetryQualityRate",
|
||||
0,
|
||||
0);
|
||||
|
||||
rate.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateRetryQualityRate_ReturnsRecoveryRatio()
|
||||
{
|
||||
var rate = InvokePrivateStatic<double>(
|
||||
"CalculateRetryQualityRate",
|
||||
3,
|
||||
1);
|
||||
|
||||
rate.Should().BeApproximately(0.75, 0.0001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSummary_ComputesTaskTypeRetryQualityBreakdown()
|
||||
{
|
||||
var records = new List<AgentStatsService.AgentSessionRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TaskType = "bugfix",
|
||||
ToolCalls = 3,
|
||||
InputTokens = 10,
|
||||
OutputTokens = 5,
|
||||
RecoveredAfterFailureCount = 2,
|
||||
RepeatedFailureBlockedCount = 1
|
||||
},
|
||||
new()
|
||||
{
|
||||
TaskType = "feature",
|
||||
ToolCalls = 4,
|
||||
InputTokens = 12,
|
||||
OutputTokens = 7,
|
||||
RecoveredAfterFailureCount = 1,
|
||||
RepeatedFailureBlockedCount = 1
|
||||
}
|
||||
};
|
||||
|
||||
var summary = InvokePrivateStatic<AgentStatsService.AgentStatsSummary>(
|
||||
"BuildSummary",
|
||||
records);
|
||||
|
||||
summary.TaskTypeBreakdown["bugfix"].Should().Be(1);
|
||||
summary.TaskTypeBreakdown["feature"].Should().Be(1);
|
||||
summary.RetryQualityByTaskType["bugfix"].Should().BeApproximately(2.0 / 3.0, 0.0001);
|
||||
summary.RetryQualityByTaskType["feature"].Should().BeApproximately(0.5, 0.0001);
|
||||
}
|
||||
|
||||
private static T InvokePrivateStatic<T>(string methodName, params object?[] arguments)
|
||||
{
|
||||
var method = typeof(AgentStatsService).GetMethod(
|
||||
methodName,
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
method.Should().NotBeNull($"{methodName} should exist on AgentStatsService");
|
||||
|
||||
var result = method!.Invoke(null, arguments);
|
||||
result.Should().NotBeNull();
|
||||
return (T)result!;
|
||||
}
|
||||
}
|
||||
877
src/AxCopilot.Tests/Services/AppStateServiceTests.cs
Normal file
877
src/AxCopilot.Tests/Services/AppStateServiceTests.cs
Normal file
@@ -0,0 +1,877 @@
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class AppStateServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadFromSettings_ReflectsPermissionAndMcpSummary()
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.Llm.FilePermission = "Auto";
|
||||
settings.Settings.Llm.AgentDecisionLevel = "normal";
|
||||
settings.Settings.Llm.PlanMode = "always";
|
||||
settings.Settings.Llm.ToolPermissions["process"] = "Deny";
|
||||
settings.Settings.Llm.EnableSkillSystem = true;
|
||||
settings.Settings.Llm.SkillsFolderPath = @"C:\skills";
|
||||
settings.Settings.Llm.McpServers =
|
||||
[
|
||||
new McpServerEntry { Name = "one", Enabled = true, Transport = "stdio" },
|
||||
new McpServerEntry { Name = "two", Enabled = false, Transport = "stdio" },
|
||||
];
|
||||
|
||||
var state = new AppStateService();
|
||||
|
||||
state.LoadFromSettings(settings);
|
||||
|
||||
state.Permissions.FilePermission.Should().Be("Auto");
|
||||
state.Permissions.AgentDecisionLevel.Should().Be("normal");
|
||||
state.Permissions.PlanMode.Should().Be("always");
|
||||
state.Permissions.ToolOverrideCount.Should().Be(1);
|
||||
state.Permissions.ToolOverrides.Should().ContainSingle();
|
||||
state.Skills.Enabled.Should().BeTrue();
|
||||
state.Skills.FolderPath.Should().Be(@"C:\skills");
|
||||
state.Mcp.ConfiguredServerCount.Should().Be(2);
|
||||
state.Mcp.EnabledServerCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshSkillCatalog_ComputesCounts()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var skills = new[]
|
||||
{
|
||||
new SkillDefinition { Name = "alpha", IsAvailable = true },
|
||||
new SkillDefinition { Name = "beta", IsAvailable = false },
|
||||
new SkillDefinition { Name = "gamma", IsAvailable = true },
|
||||
};
|
||||
|
||||
state.RefreshSkillCatalog(skills, @"D:\skills", enabled: true);
|
||||
|
||||
state.Skills.LoadedCount.Should().Be(3);
|
||||
state.Skills.AvailableCount.Should().Be(2);
|
||||
state.Skills.UnavailableCount.Should().Be(1);
|
||||
state.Skills.FolderPath.Should().Be(@"D:\skills");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshAgentCatalog_ComputesToolTypeBreakdown()
|
||||
{
|
||||
using var registry = new ToolRegistry();
|
||||
registry.Register(new McpTool(new McpClientService(new McpServerEntry
|
||||
{
|
||||
Name = "fake",
|
||||
Enabled = true,
|
||||
Transport = "stdio",
|
||||
Command = "fake"
|
||||
}), new McpToolDefinition
|
||||
{
|
||||
Name = "mcp_lookup",
|
||||
Description = "lookup"
|
||||
}));
|
||||
registry.Register(new ExcelSkill());
|
||||
registry.Register(new FileReadTool());
|
||||
|
||||
var state = new AppStateService();
|
||||
state.RefreshAgentCatalog(registry);
|
||||
|
||||
state.AgentCatalog.RegisteredToolCount.Should().Be(3);
|
||||
state.AgentCatalog.McpToolCount.Should().Be(1);
|
||||
state.AgentCatalog.SkillToolCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertTask_UpdatesExistingTaskInsteadOfDuplicating()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.UpsertTask("tool:1", "tool", "file_read", "first", "running");
|
||||
state.UpsertTask("tool:1", "tool", "file_read", "updated", "running", @"E:\a.txt");
|
||||
|
||||
state.ActiveTasks.Should().HaveCount(1);
|
||||
state.ActiveTasks[0].Summary.Should().Be("updated");
|
||||
state.ActiveTasks[0].FilePath.Should().Be(@"E:\a.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteTask_MovesTaskFromActiveToRecent()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
state.UpsertTask("agent:run1", "agent", "main", "working", "running");
|
||||
|
||||
state.CompleteTask("agent:run1", "done", "completed");
|
||||
|
||||
state.ActiveTasks.Should().BeEmpty();
|
||||
state.RecentTasks.Should().ContainSingle();
|
||||
state.RecentTasks[0].Status.Should().Be("completed");
|
||||
state.RecentTasks[0].Summary.Should().Be("done");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearTasksByPrefix_MovesMatchingTasksOnly()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
state.UpsertTask("tool:run1:file_read", "tool", "file_read", "reading", "running");
|
||||
state.UpsertTask("tool:run1:grep", "tool", "grep", "searching", "running");
|
||||
state.UpsertTask("agent:run1", "agent", "main", "planning", "running");
|
||||
|
||||
state.ClearTasksByPrefix("tool:run1:", "tool batch done", "completed");
|
||||
|
||||
state.ActiveTasks.Should().ContainSingle(t => t.Id == "agent:run1");
|
||||
state.RecentTasks.Should().HaveCount(2);
|
||||
state.RecentTasks.Should().OnlyContain(t => t.Status == "completed");
|
||||
state.RecentTasks.Should().OnlyContain(t => t.Summary == "tool batch done");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteTask_KeepsOnlyRecentTwentyFiveItems()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var id = $"tool:{i}";
|
||||
state.UpsertTask(id, "tool", $"tool-{i}", $"summary-{i}", "running");
|
||||
state.CompleteTask(id, $"done-{i}", "completed");
|
||||
}
|
||||
|
||||
state.RecentTasks.Should().HaveCount(25);
|
||||
state.RecentTasks[0].Id.Should().Be("tool:29");
|
||||
state.RecentTasks[^1].Id.Should().Be("tool:5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateChanged_FiresWhenTaskStateChanges()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var count = 0;
|
||||
state.StateChanged += () => count++;
|
||||
|
||||
state.UpsertTask("agent:run1", "agent", "main", "thinking", "running");
|
||||
state.CompleteTask("agent:run1", "done", "completed");
|
||||
|
||||
count.Should().BeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatExecutionEvent_Serialization_PreservesRunId()
|
||||
{
|
||||
var original = new ChatExecutionEvent
|
||||
{
|
||||
RunId = "run-123",
|
||||
Type = "ToolCall",
|
||||
ToolName = "file_read",
|
||||
Summary = "reading"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var restored = JsonSerializer.Deserialize<ChatExecutionEvent>(json);
|
||||
|
||||
restored.Should().NotBeNull();
|
||||
restored!.RunId.Should().Be("run-123");
|
||||
restored.ToolName.Should().Be("file_read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDraftQueueItems_ReadsAttachedChatSessionItems()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var session = new ChatSessionStateService();
|
||||
state.AttachChatSession(session);
|
||||
session.EnqueueDraft("Chat", "draft one", "next");
|
||||
|
||||
var items = state.GetDraftQueueItems("Chat");
|
||||
|
||||
items.Should().ContainSingle();
|
||||
items[0].Text.Should().Be("draft one");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDraftQueueSummary_MapsBlockedDraftCounts()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var session = new ChatSessionStateService();
|
||||
state.AttachChatSession(session);
|
||||
var item = session.EnqueueDraft("Chat", "draft one", "next");
|
||||
session.MarkDraftRunning("Chat", item!.Id);
|
||||
session.ScheduleDraftRetry("Chat", item.Id, "temporary", maxAutoRetries: 3);
|
||||
|
||||
var summary = state.GetDraftQueueSummary("Chat");
|
||||
|
||||
summary.BlockedCount.Should().Be(1);
|
||||
summary.NextReadyAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAgentEvent_StoresRecentPermissionHistory()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = AgentEventType.PermissionRequest,
|
||||
ToolName = "file_write",
|
||||
Summary = "권한 확인 필요 · 대상: a.txt",
|
||||
Timestamp = DateTime.Now.AddSeconds(-2),
|
||||
});
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = AgentEventType.PermissionDenied,
|
||||
ToolName = "file_write",
|
||||
Summary = "권한 거부",
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
|
||||
state.GetRecentPermissionEvents().Should().HaveCount(2);
|
||||
state.GetRecentPermissionEvents()[0].Status.Should().Be("denied");
|
||||
state.GetLatestDeniedPermission().Should().NotBeNull();
|
||||
state.FormatPermissionEventLine(state.GetRecentPermissionEvents()[0]).Should().Contain("권한 거부");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAgentEvent_StoresRecentHookHistory()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-hook",
|
||||
Type = AgentEventType.HookResult,
|
||||
ToolName = "file_write",
|
||||
Summary = "[Hook:precheck] ok",
|
||||
Timestamp = DateTime.Now,
|
||||
Success = true,
|
||||
});
|
||||
|
||||
state.GetRecentHookEvents().Should().ContainSingle();
|
||||
state.GetRecentHookEvents()[0].ToolName.Should().Be("file_write");
|
||||
state.FormatHookEventLine(state.GetRecentHookEvents()[0]).Should().Contain("hook");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplySubAgentStatus_StoresBackgroundJobHistory()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "worker-1",
|
||||
Task = "scan",
|
||||
Status = SubAgentRunStatus.Started,
|
||||
Summary = "started",
|
||||
});
|
||||
state.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "worker-1",
|
||||
Task = "scan",
|
||||
Status = SubAgentRunStatus.Completed,
|
||||
Summary = "completed",
|
||||
});
|
||||
|
||||
state.GetBackgroundJobSummary().ActiveCount.Should().Be(0);
|
||||
state.GetRecentBackgroundJobs().Should().ContainSingle();
|
||||
state.GetRecentBackgroundJobs()[0].Title.Should().Be("worker-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBackgroundJobSummary_ReturnsActiveJobCount()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "worker-2",
|
||||
Task = "index",
|
||||
Status = SubAgentRunStatus.Started,
|
||||
Summary = "running",
|
||||
});
|
||||
|
||||
var summary = state.GetBackgroundJobSummary();
|
||||
|
||||
summary.ActiveCount.Should().Be(1);
|
||||
summary.LatestRecent.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActiveBackgroundJobs_ReturnsActiveItems()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "worker-2",
|
||||
Task = "index",
|
||||
Status = SubAgentRunStatus.Started,
|
||||
Summary = "running",
|
||||
});
|
||||
|
||||
state.GetActiveBackgroundJobs().Should().ContainSingle();
|
||||
state.GetActiveBackgroundJobs()[0].Id.Should().Contain("worker-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOperationalStatus_PrioritizesQueueAndBackgroundSignals()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var session = new ChatSessionStateService();
|
||||
state.AttachChatSession(session);
|
||||
|
||||
var draft = session.EnqueueDraft("Chat", "follow up", "next");
|
||||
state.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "worker-3",
|
||||
Task = "scan",
|
||||
Status = SubAgentRunStatus.Started,
|
||||
Summary = "running",
|
||||
});
|
||||
|
||||
var status = state.GetOperationalStatus("Chat");
|
||||
|
||||
status.ShowRuntimeBadge.Should().BeTrue();
|
||||
status.RuntimeLabel.Should().Be("실행 중 2");
|
||||
status.StripKind.Should().Be("running");
|
||||
status.StripText.Should().Contain("진행 중 2");
|
||||
draft.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPermissionSummary_UsesConversationOverrideWhenPresent()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.Llm.FilePermission = "Ask";
|
||||
settings.Settings.Llm.ToolPermissions["process"] = "Deny";
|
||||
state.LoadFromSettings(settings);
|
||||
|
||||
var summary = state.GetPermissionSummary(new ChatConversation { Permission = "Auto" });
|
||||
|
||||
summary.EffectiveMode.Should().Be("Auto");
|
||||
summary.DefaultMode.Should().Be("Ask");
|
||||
summary.OverrideCount.Should().Be(1);
|
||||
summary.RiskLevel.Should().Be("high");
|
||||
summary.TopOverrides.Should().ContainSingle();
|
||||
summary.TopOverrides[0].Key.Should().Be("process");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertAgentRun_TracksLatestRunState()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.UpsertAgentRun("run-42", "running", "planning", 3);
|
||||
|
||||
state.AgentRun.RunId.Should().Be("run-42");
|
||||
state.AgentRun.Status.Should().Be("running");
|
||||
state.AgentRun.Summary.Should().Be("planning");
|
||||
state.AgentRun.LastIteration.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteAgentRun_StoresFinalState()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
state.UpsertAgentRun("run-42", "running", "planning", 2);
|
||||
|
||||
state.CompleteAgentRun("run-42", "completed", "done", 5);
|
||||
|
||||
state.AgentRun.RunId.Should().Be("run-42");
|
||||
state.AgentRun.Status.Should().Be("completed");
|
||||
state.AgentRun.Summary.Should().Be("done");
|
||||
state.AgentRun.LastIteration.Should().Be(5);
|
||||
state.AgentRunHistory.Should().ContainSingle();
|
||||
state.AgentRunHistory[0].RunId.Should().Be("run-42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteAgentRun_KeepsOnlyRecentTwelveHistoryItems()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
for (var i = 0; i < 15; i++)
|
||||
{
|
||||
state.UpsertAgentRun($"run-{i}", "running", $"summary-{i}", i);
|
||||
state.CompleteAgentRun($"run-{i}", "completed", $"done-{i}", i);
|
||||
}
|
||||
|
||||
state.AgentRunHistory.Should().HaveCount(12);
|
||||
state.AgentRunHistory[0].RunId.Should().Be("run-14");
|
||||
state.AgentRunHistory[^1].RunId.Should().Be("run-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreAgentRunHistory_LoadsLatestItemsInDescendingOrder()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
state.RestoreAgentRunHistory(
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-1", Status = "completed", Summary = "first", LastIteration = 1, UpdatedAt = now.AddMinutes(-2) },
|
||||
new ChatAgentRunRecord { RunId = "run-2", Status = "failed", Summary = "second", LastIteration = 2, UpdatedAt = now.AddMinutes(-1) },
|
||||
]);
|
||||
|
||||
state.AgentRunHistory.Should().HaveCount(2);
|
||||
state.AgentRunHistory[0].RunId.Should().Be("run-2");
|
||||
state.AgentRunHistory[1].RunId.Should().Be("run-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void RestoreCurrentAgentRun_PrefersRunningExecutionEventOverHistory()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
state.RestoreCurrentAgentRun(
|
||||
[
|
||||
new ChatExecutionEvent { RunId = "run-live", Type = "Thinking", Summary = "진행 중", Iteration = 5, Timestamp = now }
|
||||
],
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-old", Status = "completed", Summary = "완료", LastIteration = 2, UpdatedAt = now.AddMinutes(-1) }
|
||||
]);
|
||||
|
||||
state.AgentRun.RunId.Should().Be("run-live");
|
||||
state.AgentRun.Status.Should().Be("running");
|
||||
state.AgentRun.Summary.Should().Be("진행 중");
|
||||
state.AgentRun.LastIteration.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void RestoreCurrentAgentRun_UsesLatestRunHistoryWhenEventIsTerminal()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
state.RestoreCurrentAgentRun(
|
||||
[
|
||||
new ChatExecutionEvent { RunId = "run-x", Type = "Complete", Summary = "끝", Iteration = 6, Timestamp = now }
|
||||
],
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-x", Status = "completed", Summary = "끝", LastIteration = 6, StartedAt = now.AddMinutes(-2), UpdatedAt = now }
|
||||
]);
|
||||
|
||||
state.AgentRun.RunId.Should().Be("run-x");
|
||||
state.AgentRun.Status.Should().Be("completed");
|
||||
state.AgentRun.Summary.Should().Be("끝");
|
||||
state.AgentRun.LastIteration.Should().Be(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void RestoreRecentTasks_RebuildsRecentTaskTimelineFromExecutionEvents()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
state.RestoreRecentTasks(
|
||||
[
|
||||
new ChatExecutionEvent { Timestamp = now.AddMinutes(-3), RunId = "run-1", Type = "ToolResult", ToolName = "file_read", Summary = "읽기 완료", Success = true },
|
||||
new ChatExecutionEvent { Timestamp = now.AddMinutes(-2), RunId = "run-1", Type = "PermissionDenied", ToolName = "file_write", Summary = "권한 거부", Success = false },
|
||||
new ChatExecutionEvent { Timestamp = now.AddMinutes(-1), RunId = "run-1", Type = "Complete", Summary = "작업 완료", Success = true },
|
||||
]);
|
||||
|
||||
state.ActiveTasks.Should().BeEmpty();
|
||||
state.RecentTasks.Should().HaveCount(3);
|
||||
state.RecentTasks[0].Kind.Should().Be("agent");
|
||||
state.RecentTasks[1].Kind.Should().Be("permission");
|
||||
state.RecentTasks[1].Status.Should().Be("failed");
|
||||
state.RecentTasks[2].Kind.Should().Be("tool");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAgentEvent_TracksPermissionLifecycleInTaskStore()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var request = new AgentEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = AgentEventType.PermissionRequest,
|
||||
ToolName = "file_write",
|
||||
Summary = "권한 확인 필요",
|
||||
};
|
||||
|
||||
state.ApplyAgentEvent(request);
|
||||
state.ActiveTasks.Should().ContainSingle();
|
||||
state.ActiveTasks[0].Kind.Should().Be("permission");
|
||||
state.ActiveTasks[0].Status.Should().Be("waiting");
|
||||
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = AgentEventType.PermissionGranted,
|
||||
ToolName = "file_write",
|
||||
Summary = "승인됨",
|
||||
});
|
||||
|
||||
state.ActiveTasks.Should().BeEmpty();
|
||||
state.RecentTasks.Should().ContainSingle();
|
||||
state.RecentTasks[0].Kind.Should().Be("permission");
|
||||
state.RecentTasks[0].Status.Should().Be("completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAgentEvent_TracksAgentRunCompletion()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-42",
|
||||
Type = AgentEventType.Thinking,
|
||||
Summary = "작업 준비",
|
||||
Iteration = 1,
|
||||
});
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-42",
|
||||
Type = AgentEventType.Complete,
|
||||
Summary = "작업 완료",
|
||||
Iteration = 3,
|
||||
});
|
||||
|
||||
state.AgentRun.RunId.Should().Be("run-42");
|
||||
state.AgentRun.Status.Should().Be("completed");
|
||||
state.RecentTasks.Should().Contain(t => t.Kind == "agent" && t.Status == "completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplySubAgentStatus_TracksSubAgentLifecycle()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "research-1",
|
||||
Task = "scan project",
|
||||
Status = SubAgentRunStatus.Started,
|
||||
Summary = "started",
|
||||
});
|
||||
|
||||
state.ActiveTasks.Should().HaveCount(2);
|
||||
state.ActiveTasks.Should().Contain(x => x.Kind == "subagent");
|
||||
state.ActiveTasks.Should().Contain(x => x.Kind == "background");
|
||||
|
||||
state.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "research-1",
|
||||
Task = "scan project",
|
||||
Status = SubAgentRunStatus.Completed,
|
||||
Summary = "done",
|
||||
});
|
||||
|
||||
state.ActiveTasks.Should().BeEmpty();
|
||||
state.RecentTasks.Should().Contain(t => t.Kind == "subagent" && t.Status == "completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTaskSummary_ReturnsPendingPermissionAndLatestFailure()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-0",
|
||||
Type = AgentEventType.ToolCall,
|
||||
ToolName = "file_read",
|
||||
Summary = "읽는 중",
|
||||
});
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-0",
|
||||
Type = AgentEventType.ToolResult,
|
||||
ToolName = "file_read",
|
||||
Summary = "완료",
|
||||
Success = true,
|
||||
});
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = AgentEventType.PermissionRequest,
|
||||
ToolName = "file_write",
|
||||
Summary = "권한 확인 필요",
|
||||
});
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-2",
|
||||
Type = AgentEventType.Thinking,
|
||||
Summary = "실패 전 준비",
|
||||
Iteration = 1,
|
||||
});
|
||||
state.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-2",
|
||||
Type = AgentEventType.Error,
|
||||
Summary = "실패",
|
||||
Iteration = 2,
|
||||
});
|
||||
|
||||
var summary = state.GetTaskSummary();
|
||||
|
||||
summary.ActiveCount.Should().Be(1);
|
||||
summary.PendingPermissionCount.Should().Be(1);
|
||||
summary.LatestRecentTask.Should().NotBeNull();
|
||||
summary.LatestFailedRun.Should().NotBeNull();
|
||||
summary.LatestFailedRun!.RunId.Should().Be("run-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRecentAgentRuns_AndLatestFailedRun_ReturnExpectedItems()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
|
||||
state.UpsertAgentRun("run-1", "running", "one", 1);
|
||||
state.CompleteAgentRun("run-1", "completed", "done-1", 1);
|
||||
state.UpsertAgentRun("run-2", "running", "two", 2);
|
||||
state.CompleteAgentRun("run-2", "failed", "done-2", 2);
|
||||
state.UpsertAgentRun("run-3", "running", "three", 3);
|
||||
state.CompleteAgentRun("run-3", "completed", "done-3", 3);
|
||||
|
||||
var recent = state.GetRecentAgentRuns(2);
|
||||
var latestFailed = state.GetLatestFailedRun();
|
||||
|
||||
recent.Should().HaveCount(2);
|
||||
recent[0].RunId.Should().Be("run-3");
|
||||
recent[1].RunId.Should().Be("run-2");
|
||||
latestFailed.Should().NotBeNull();
|
||||
latestFailed!.RunId.Should().Be("run-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDraftQueueSummary_ReadsAttachedChatSessionQueue()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var session = new ChatSessionStateService();
|
||||
state.AttachChatSession(session);
|
||||
|
||||
var first = session.EnqueueDraft("Chat", "first", "next");
|
||||
var second = session.EnqueueDraft("Chat", "second", "now");
|
||||
session.MarkDraftRunning("Chat", second!.Id);
|
||||
session.MarkDraftCompleted("Chat", second.Id);
|
||||
|
||||
var summary = state.GetDraftQueueSummary("Chat");
|
||||
|
||||
summary.TotalCount.Should().Be(2);
|
||||
summary.QueuedCount.Should().Be(1);
|
||||
summary.CompletedCount.Should().Be(1);
|
||||
summary.NextItem.Should().NotBeNull();
|
||||
summary.NextItem!.Id.Should().Be(first!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConversationRunSummary_ComputesConversationSidebarMeta()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
var summary = state.GetConversationRunSummary(
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-3", Status = "completed", Summary = "latest", UpdatedAt = now },
|
||||
new ChatAgentRunRecord { RunId = "run-2", Status = "failed", Summary = "failed", UpdatedAt = now.AddMinutes(-1) },
|
||||
new ChatAgentRunRecord { RunId = "run-1", Status = "completed", Summary = "older", UpdatedAt = now.AddMinutes(-2) },
|
||||
]);
|
||||
|
||||
summary.AgentRunCount.Should().Be(3);
|
||||
summary.FailedAgentRunCount.Should().Be(1);
|
||||
summary.LastAgentRunSummary.Should().Be("latest");
|
||||
summary.LastFailedAt.Should().Be(now.AddMinutes(-1));
|
||||
summary.LastCompletedAt.Should().Be(now);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAgentRunById_AndLatestConversationRun_ReturnExpectedRun()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
state.UpsertAgentRun("run-1", "running", "one", 1);
|
||||
state.CompleteAgentRun("run-1", "completed", "one-done", 1);
|
||||
state.UpsertAgentRun("run-2", "running", "two", 2);
|
||||
state.CompleteAgentRun("run-2", "failed", "two-failed", 2);
|
||||
|
||||
var byId = state.GetAgentRunById("run-2");
|
||||
var now = DateTime.Now;
|
||||
var latestConversation = state.GetLatestConversationRun(
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-2", UpdatedAt = now.AddMinutes(1) },
|
||||
new ChatAgentRunRecord { RunId = "run-1", UpdatedAt = now },
|
||||
]);
|
||||
|
||||
byId.Should().NotBeNull();
|
||||
byId!.RunId.Should().Be("run-2");
|
||||
latestConversation.Should().NotBeNull();
|
||||
latestConversation!.RunId.Should().Be("run-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLatestConversationRun_UsesTimestampWhenHistoryOrderIsUnsorted()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
state.UpsertAgentRun("run-1", "running", "one", 1);
|
||||
state.CompleteAgentRun("run-1", "completed", "one-done", 1);
|
||||
state.UpsertAgentRun("run-2", "running", "two", 2);
|
||||
state.CompleteAgentRun("run-2", "failed", "two-failed", 2);
|
||||
|
||||
var now = DateTime.Now;
|
||||
var latestConversation = state.GetLatestConversationRun(
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-1", UpdatedAt = now },
|
||||
new ChatAgentRunRecord { RunId = "run-2", UpdatedAt = now.AddMinutes(1) },
|
||||
]);
|
||||
|
||||
latestConversation.Should().NotBeNull();
|
||||
latestConversation!.RunId.Should().Be("run-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConversationRunSummary_UsesLatestByTimestampWhenUnsorted()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
var summary = state.GetConversationRunSummary(
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-1", Status = "completed", Summary = "older shown first", UpdatedAt = now.AddMinutes(-5) },
|
||||
new ChatAgentRunRecord { RunId = "run-2", Status = "failed", Summary = "latest failed", UpdatedAt = now.AddMinutes(2) },
|
||||
new ChatAgentRunRecord { RunId = "run-3", Status = "completed", Summary = "latest completed", UpdatedAt = now.AddMinutes(1) },
|
||||
]);
|
||||
|
||||
summary.AgentRunCount.Should().Be(3);
|
||||
summary.FailedAgentRunCount.Should().Be(1);
|
||||
summary.LastAgentRunSummary.Should().Be("latest failed");
|
||||
summary.LastFailedAt.Should().Be(now.AddMinutes(2));
|
||||
summary.LastCompletedAt.Should().Be(now.AddMinutes(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRunDetailSummary_ReturnsRecentEventsAndDistinctFiles()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
var summary = state.GetRunDetailSummary(
|
||||
[
|
||||
new ChatExecutionEvent { RunId = "run-1", Type = "ToolCall", Timestamp = now.AddMinutes(-3), FilePath = @"E:\a.cs" },
|
||||
new ChatExecutionEvent { RunId = "run-1", Type = "ToolResult", Timestamp = now.AddMinutes(-2), FilePath = @"E:\a.cs" },
|
||||
new ChatExecutionEvent { RunId = "run-1", Type = "Complete", Timestamp = now.AddMinutes(-1), FilePath = @"E:\b.cs" },
|
||||
new ChatExecutionEvent { RunId = "run-2", Type = "ToolCall", Timestamp = now, FilePath = @"E:\c.cs" },
|
||||
], "run-1", eventTake: 2, fileTake: 3);
|
||||
|
||||
summary.RunId.Should().Be("run-1");
|
||||
summary.Events.Should().HaveCount(2);
|
||||
summary.Events[0].Type.Should().Be("Complete");
|
||||
summary.Events[1].Type.Should().Be("ToolResult");
|
||||
summary.FilePaths.Should().Equal(@"E:\b.cs", @"E:\a.cs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRunDetailSummary_FilePathsFollowLatestEventOrderEvenWhenHistoryIsUnsorted()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
var summary = state.GetRunDetailSummary(
|
||||
[
|
||||
new ChatExecutionEvent { RunId = "run-x", Type = "ToolResult", Timestamp = now.AddMinutes(-10), FilePath = @"E:\old.cs" },
|
||||
new ChatExecutionEvent { RunId = "run-x", Type = "ToolResult", Timestamp = now.AddMinutes(-1), FilePath = @"E:\latest.cs" },
|
||||
new ChatExecutionEvent { RunId = "run-x", Type = "ToolResult", Timestamp = now.AddMinutes(-5), FilePath = @"E:\mid.cs" },
|
||||
new ChatExecutionEvent { RunId = "run-x", Type = "ToolResult", Timestamp = now.AddMinutes(-2), FilePath = @"E:\latest.cs" },
|
||||
], "run-x", eventTake: 4, fileTake: 3);
|
||||
|
||||
summary.FilePaths.Should().Equal(@"E:\latest.cs", @"E:\mid.cs", @"E:\old.cs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRunDisplay_AndFormatExecutionEventLine_ReturnFormattedText()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var run = new AppStateService.AgentRunState
|
||||
{
|
||||
RunId = "run-123456789",
|
||||
Status = "completed",
|
||||
Summary = "요약",
|
||||
LastIteration = 4,
|
||||
UpdatedAt = new DateTime(2026, 4, 2, 9, 30, 0),
|
||||
};
|
||||
var evt = new ChatExecutionEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = "ToolResult",
|
||||
ToolName = "file_read",
|
||||
Summary = "읽기 완료",
|
||||
Timestamp = new DateTime(2026, 4, 2, 9, 31, 0),
|
||||
};
|
||||
|
||||
var display = state.GetRunDisplay(run);
|
||||
var line = state.FormatExecutionEventLine(evt);
|
||||
|
||||
display.HeaderText.Should().Be("run run-1234 · 완료");
|
||||
display.MetaText.Should().Be("iteration 4 · 09:30:00");
|
||||
display.SummaryText.Should().Be("요약");
|
||||
line.Should().Be("09:31:00 · file_read 결과 · 읽기 완료");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatExecutionEventLine_UsesDecisionLabel()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var evt = new ChatExecutionEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = "Decision",
|
||||
Summary = "계획 승인",
|
||||
Timestamp = new DateTime(2026, 4, 2, 9, 32, 0),
|
||||
};
|
||||
|
||||
var line = state.FormatExecutionEventLine(evt);
|
||||
|
||||
line.Should().Be("09:32:00 · 계획 승인 · 계획 승인");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRunPlanHistory_ReturnsOriginalRevisedAndFinalApprovedSnapshots()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var now = new DateTime(2026, 4, 3, 10, 0, 0);
|
||||
|
||||
var history = new List<ChatExecutionEvent>
|
||||
{
|
||||
new() { RunId = "run-1", Type = "Planning", Summary = "작업 계획: 2단계", Steps = new List<string> { "요구사항 분석", "초안 작성" }, Timestamp = now.AddSeconds(1) },
|
||||
new() { RunId = "run-1", Type = "Decision", Summary = "계획 수정 요청", Timestamp = now.AddSeconds(2) },
|
||||
new() { RunId = "run-1", Type = "Planning", Summary = "수정된 계획: 3단계", Steps = new List<string> { "요구사항 분석", "초안 작성", "검증" }, Timestamp = now.AddSeconds(3) },
|
||||
new() { RunId = "run-1", Type = "Decision", Summary = "계획 승인(편집 반영) · 3단계", Steps = new List<string> { "요구사항 분석", "초안 작성", "검증" }, Timestamp = now.AddSeconds(4) },
|
||||
new() { RunId = "run-2", Type = "Planning", Summary = "다른 실행", Steps = new List<string> { "무시" }, Timestamp = now.AddSeconds(5) },
|
||||
};
|
||||
|
||||
var planHistory = state.GetRunPlanHistory(history, "run-1");
|
||||
|
||||
planHistory.OriginalSummary.Should().Be("작업 계획: 2단계");
|
||||
planHistory.OriginalSteps.Should().Equal("요구사항 분석", "초안 작성");
|
||||
planHistory.RevisedSummary.Should().Be("수정된 계획: 3단계");
|
||||
planHistory.RevisedSteps.Should().Equal("요구사항 분석", "초안 작성", "검증");
|
||||
planHistory.FinalApprovedSummary.Should().Be("계획 승인(편집 반영) · 3단계");
|
||||
planHistory.FinalApprovedSteps.Should().Equal("요구사항 분석", "초안 작성", "검증");
|
||||
planHistory.HasAny.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatExecutionEventLine_UsesDecisionRejectedLabel()
|
||||
{
|
||||
var state = new AppStateService();
|
||||
var evt = new ChatExecutionEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = "Decision",
|
||||
Summary = "계획 반려 · 수정 요청",
|
||||
Timestamp = new DateTime(2026, 4, 2, 9, 33, 0),
|
||||
};
|
||||
|
||||
var line = state.FormatExecutionEventLine(evt);
|
||||
|
||||
line.Should().Be("09:33:00 · 계획 반려 · 계획 반려 · 수정 요청");
|
||||
}
|
||||
}
|
||||
21
src/AxCopilot.Tests/Services/BackgroundJobServiceTests.cs
Normal file
21
src/AxCopilot.Tests/Services/BackgroundJobServiceTests.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class BackgroundJobServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpsertAndComplete_MoveJobFromActiveToRecent()
|
||||
{
|
||||
var service = new AxCopilot.Services.BackgroundJobService();
|
||||
|
||||
service.Upsert("job-1", "subagent", "worker-1", "scan");
|
||||
service.Complete("job-1", "done", "completed");
|
||||
|
||||
service.ActiveJobs.Should().BeEmpty();
|
||||
service.RecentJobs.Should().ContainSingle();
|
||||
service.RecentJobs[0].Status.Should().Be("completed");
|
||||
service.RecentJobs[0].Summary.Should().Be("done");
|
||||
}
|
||||
}
|
||||
704
src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs
Normal file
704
src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs
Normal file
@@ -0,0 +1,704 @@
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class ChatSessionStateServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void AppendExecutionEvent_CreatesConversationAndTrimsToLatest400()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
for (var i = 0; i < 405; i++)
|
||||
{
|
||||
session.AppendExecutionEvent("Code", new AgentEvent
|
||||
{
|
||||
RunId = $"run-{i}",
|
||||
Type = AgentEventType.ToolResult,
|
||||
ToolName = "file_read",
|
||||
Summary = $"event-{i}",
|
||||
Timestamp = DateTime.Now.AddSeconds(i),
|
||||
Success = true,
|
||||
});
|
||||
}
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.Tab.Should().Be("Code");
|
||||
session.CurrentConversation.ExecutionEvents.Should().HaveCount(400);
|
||||
session.CurrentConversation.ExecutionEvents[0].RunId.Should().Be("run-5");
|
||||
session.CurrentConversation.ExecutionEvents[^1].RunId.Should().Be("run-404");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendExecutionEvent_MergesNearDuplicateEvents()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
session.AppendExecutionEvent("Code", new AgentEvent
|
||||
{
|
||||
RunId = "run-dup",
|
||||
Type = AgentEventType.ToolResult,
|
||||
ToolName = "file_read",
|
||||
Summary = "same summary",
|
||||
Timestamp = now,
|
||||
Success = true,
|
||||
StepCurrent = 1,
|
||||
StepTotal = 3,
|
||||
InputTokens = 12,
|
||||
OutputTokens = 8,
|
||||
ElapsedMs = 20,
|
||||
});
|
||||
session.AppendExecutionEvent("Code", new AgentEvent
|
||||
{
|
||||
RunId = "run-dup",
|
||||
Type = AgentEventType.ToolResult,
|
||||
ToolName = "file_read",
|
||||
Summary = "same summary",
|
||||
Timestamp = now.AddSeconds(1),
|
||||
Success = true,
|
||||
StepCurrent = 2,
|
||||
StepTotal = 3,
|
||||
InputTokens = 18,
|
||||
OutputTokens = 11,
|
||||
ElapsedMs = 40,
|
||||
});
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.ExecutionEvents.Should().HaveCount(1);
|
||||
session.CurrentConversation.ExecutionEvents[0].StepCurrent.Should().Be(2);
|
||||
session.CurrentConversation.ExecutionEvents[0].ElapsedMs.Should().Be(40);
|
||||
session.CurrentConversation.ExecutionEvents[0].InputTokens.Should().Be(18);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendAgentRun_KeepsLatestTwelveRuns()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
for (var i = 0; i < 15; i++)
|
||||
{
|
||||
session.AppendAgentRun("Cowork", new AgentEvent
|
||||
{
|
||||
RunId = $"run-{i}",
|
||||
Type = AgentEventType.Complete,
|
||||
Summary = $"summary-{i}",
|
||||
Timestamp = DateTime.Now.AddMinutes(-i),
|
||||
Iteration = i,
|
||||
}, "completed", $"done-{i}");
|
||||
}
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.AgentRunHistory.Should().HaveCount(12);
|
||||
session.CurrentConversation.AgentRunHistory[0].RunId.Should().Be("run-14");
|
||||
session.CurrentConversation.AgentRunHistory[^1].RunId.Should().Be("run-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendAgentRun_UpsertsByRunIdKeepingLatestState()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var baseTime = DateTime.Now;
|
||||
|
||||
session.AppendAgentRun("Code", new AgentEvent
|
||||
{
|
||||
RunId = "run-dup",
|
||||
Type = AgentEventType.Thinking,
|
||||
Summary = "running",
|
||||
Timestamp = baseTime,
|
||||
Iteration = 1,
|
||||
}, "running", "running");
|
||||
|
||||
session.AppendAgentRun("Code", new AgentEvent
|
||||
{
|
||||
RunId = "run-dup",
|
||||
Type = AgentEventType.Complete,
|
||||
Summary = "completed",
|
||||
Timestamp = baseTime.AddSeconds(5),
|
||||
Iteration = 2,
|
||||
}, "completed", "completed");
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.AgentRunHistory.Should().HaveCount(1);
|
||||
session.CurrentConversation.AgentRunHistory[0].RunId.Should().Be("run-dup");
|
||||
session.CurrentConversation.AgentRunHistory[0].Status.Should().Be("completed");
|
||||
session.CurrentConversation.AgentRunHistory[0].LastIteration.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnqueueDraft_AndToggleExecutionHistory_UpdateConversationState()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
var item = session.EnqueueDraft("Chat", " follow up draft ", "now");
|
||||
var visible = session.ToggleExecutionHistory("Chat");
|
||||
|
||||
item.Should().NotBeNull();
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.DraftQueueItems.Should().ContainSingle();
|
||||
session.CurrentConversation.DraftQueueItems[0].Text.Should().Be("follow up draft");
|
||||
session.CurrentConversation.DraftQueueItems[0].Priority.Should().Be("now");
|
||||
visible.Should().BeFalse();
|
||||
session.CurrentConversation.ShowExecutionHistory.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDraftQueueItems_ReturnsSnapshotOfCurrentConversationQueue()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
session.EnqueueDraft("Chat", "first draft", "next");
|
||||
session.EnqueueDraft("Chat", "second draft", "later");
|
||||
|
||||
var items = session.GetDraftQueueItems("Chat");
|
||||
|
||||
items.Should().HaveCount(2);
|
||||
items[0].Text.Should().Be("first draft");
|
||||
items[1].Priority.Should().Be("later");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleDraftRetry_RequeuesBeforeMaxAttempts()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var item = session.EnqueueDraft("Chat", "retry me", "next");
|
||||
session.MarkDraftRunning("Chat", item!.Id);
|
||||
|
||||
var scheduled = session.ScheduleDraftRetry("Chat", item.Id, "temporary", maxAutoRetries: 3);
|
||||
|
||||
scheduled.Should().BeTrue();
|
||||
session.CurrentConversation!.DraftQueueItems[0].State.Should().Be("queued");
|
||||
session.CurrentConversation!.DraftQueueItems[0].NextRetryAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveConversationListPreferences_PersistsFilterFlags()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
session.SaveConversationListPreferences("Chat", failedOnly: true, runningOnly: true, sortByRecent: true);
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.ConversationFailedOnlyFilter.Should().BeTrue();
|
||||
session.CurrentConversation.ConversationRunningOnlyFilter.Should().BeTrue();
|
||||
session.CurrentConversation.ConversationSortMode.Should().Be("recent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearCurrentConversation_RemovesRememberedConversationIdForTab()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
session.RememberConversation("Code", "conv-1");
|
||||
session.ClearCurrentConversation("Code");
|
||||
|
||||
session.GetConversationId("Code").Should().BeNull();
|
||||
session.CurrentConversation.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCurrentConversation_RemembersConversationId_WhenOnlyExecutionHistoryExists()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var storage = new ChatStorageService();
|
||||
var conv = session.EnsureCurrentConversation("Code");
|
||||
conv.Messages.Clear();
|
||||
conv.ExecutionEvents = new List<ChatExecutionEvent>
|
||||
{
|
||||
new()
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = "ToolResult",
|
||||
ToolName = "file_read",
|
||||
Summary = "read",
|
||||
Timestamp = DateTime.Now
|
||||
}
|
||||
};
|
||||
conv.AgentRunHistory.Clear();
|
||||
conv.DraftQueueItems.Clear();
|
||||
|
||||
session.SaveCurrentConversation(storage, "Code");
|
||||
|
||||
session.GetConversationId("Code").Should().Be(conv.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCurrentConversation_RemembersConversationId_WhenOnlyAgentRunHistoryExists()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var storage = new ChatStorageService();
|
||||
var conv = session.EnsureCurrentConversation("Code");
|
||||
conv.Messages.Clear();
|
||||
conv.ExecutionEvents.Clear();
|
||||
conv.AgentRunHistory = new List<ChatAgentRunRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
RunId = "run-2",
|
||||
Status = "completed",
|
||||
Summary = "done",
|
||||
UpdatedAt = DateTime.Now
|
||||
}
|
||||
};
|
||||
conv.DraftQueueItems.Clear();
|
||||
|
||||
session.SaveCurrentConversation(storage, "Code");
|
||||
|
||||
session.GetConversationId("Code").Should().Be(conv.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCurrentConversation_RemembersConversationId_WhenOnlyDraftQueueExists()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var storage = new ChatStorageService();
|
||||
var conv = session.EnsureCurrentConversation("Code");
|
||||
conv.Messages.Clear();
|
||||
conv.ExecutionEvents.Clear();
|
||||
conv.AgentRunHistory.Clear();
|
||||
conv.DraftQueueItems = new List<DraftQueueItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "draft-1",
|
||||
Text = "todo",
|
||||
Priority = "next",
|
||||
State = "queued",
|
||||
CreatedAt = DateTime.Now
|
||||
}
|
||||
};
|
||||
|
||||
session.SaveCurrentConversation(storage, "Code");
|
||||
|
||||
session.GetConversationId("Code").Should().Be(conv.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCurrentConversation_UsesConversationTabToAvoidCrossTabContamination()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var storage = new ChatStorageService();
|
||||
var conv = new ChatConversation
|
||||
{
|
||||
Tab = "Code",
|
||||
Messages = [new ChatMessage { Role = "user", Content = "code message" }]
|
||||
};
|
||||
session.CurrentConversation = conv;
|
||||
|
||||
session.SaveCurrentConversation(storage, "Chat");
|
||||
|
||||
session.GetConversationId("Code").Should().Be(conv.Id);
|
||||
session.GetConversationId("Chat").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCurrentConversation_UpdatesCurrentConversationAndRememberedId()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var conversation = new ChatConversation { Id = "conv-42", Tab = "", Title = "test title" };
|
||||
|
||||
var current = session.SetCurrentConversation("Cowork", conversation);
|
||||
|
||||
current.Tab.Should().Be("Cowork");
|
||||
session.CurrentConversation.Should().BeSameAs(conversation);
|
||||
session.GetConversationId("Cowork").Should().Be("conv-42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCurrentConversation_ForcesConversationTabToRequestedTab()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var conversation = new ChatConversation { Id = "conv-99", Tab = "Chat", Title = "wrong tab" };
|
||||
|
||||
var current = session.SetCurrentConversation("Code", conversation);
|
||||
|
||||
current.Tab.Should().Be("Code");
|
||||
session.GetConversationId("Code").Should().Be("conv-99");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureCurrentConversation_WhenTabDiffers_CreatesIsolatedConversation()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
session.CurrentConversation = new ChatConversation
|
||||
{
|
||||
Tab = "Chat",
|
||||
Messages = [new ChatMessage { Role = "user", Content = "chat message" }]
|
||||
};
|
||||
|
||||
var codeConversation = session.EnsureCurrentConversation("Code");
|
||||
|
||||
codeConversation.Tab.Should().Be("Code");
|
||||
codeConversation.Messages.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreateConversation_WhenRememberedIdPointsToDifferentTab_CreatesFreshConversation()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var storage = new ChatStorageService();
|
||||
var settings = new SettingsService();
|
||||
|
||||
var chatConversation = new ChatConversation
|
||||
{
|
||||
Id = "conv-chat-tab",
|
||||
Tab = "Chat",
|
||||
Title = "chat only"
|
||||
};
|
||||
storage.Save(chatConversation);
|
||||
session.RememberConversation("Code", chatConversation.Id);
|
||||
|
||||
var loaded = session.LoadOrCreateConversation("Code", storage, settings);
|
||||
|
||||
loaded.Tab.Should().Be("Code");
|
||||
loaded.Id.Should().NotBe(chatConversation.Id);
|
||||
session.GetConversationId("Code").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void LoadOrCreateConversation_NormalizesHistoryOrderAndCompactsSize()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var storage = new ChatStorageService();
|
||||
var settings = new SettingsService();
|
||||
var baseTime = new DateTime(2026, 4, 3, 1, 0, 0, DateTimeKind.Local);
|
||||
|
||||
var conversation = new ChatConversation
|
||||
{
|
||||
Id = $"conv-history-normalize-{Guid.NewGuid():N}",
|
||||
Tab = "Code",
|
||||
Title = "history normalize",
|
||||
};
|
||||
|
||||
for (var i = 0; i < 420; i++)
|
||||
{
|
||||
conversation.ExecutionEvents.Add(new ChatExecutionEvent
|
||||
{
|
||||
RunId = $"run-{i % 20}",
|
||||
Type = "ToolResult",
|
||||
ToolName = "file_read",
|
||||
Summary = $"event-{i}",
|
||||
Timestamp = baseTime.AddSeconds(420 - i), // 역순 저장
|
||||
});
|
||||
}
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
conversation.AgentRunHistory.Add(new ChatAgentRunRecord
|
||||
{
|
||||
RunId = $"run-{i}",
|
||||
Status = i % 2 == 0 ? "completed" : "failed",
|
||||
Summary = $"summary-{i}",
|
||||
UpdatedAt = baseTime.AddMinutes(i - 20), // 오래된 순 저장
|
||||
StartedAt = baseTime.AddMinutes(i - 21),
|
||||
});
|
||||
}
|
||||
|
||||
storage.Save(conversation);
|
||||
session.RememberConversation("Code", conversation.Id);
|
||||
|
||||
var loaded = session.LoadOrCreateConversation("Code", storage, settings);
|
||||
|
||||
loaded.ExecutionEvents.Should().HaveCount(400);
|
||||
loaded.ExecutionEvents.First().Timestamp.Should().BeBefore(loaded.ExecutionEvents.Last().Timestamp);
|
||||
loaded.AgentRunHistory.Should().HaveCount(12);
|
||||
loaded.AgentRunHistory.First().RunId.Should().Be("run-19");
|
||||
loaded.AgentRunHistory.Last().RunId.Should().Be("run-8");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void LoadOrCreateConversation_NormalizesAgentRunDuplicatesByRunId()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var storage = new ChatStorageService();
|
||||
var settings = new SettingsService();
|
||||
var baseTime = new DateTime(2026, 4, 3, 1, 0, 0, DateTimeKind.Local);
|
||||
|
||||
var conversation = new ChatConversation
|
||||
{
|
||||
Id = $"conv-run-dedupe-{Guid.NewGuid():N}",
|
||||
Tab = "Code",
|
||||
Title = "run dedupe",
|
||||
AgentRunHistory =
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-a", Status = "running", Summary = "old", UpdatedAt = baseTime.AddMinutes(-2), StartedAt = baseTime.AddMinutes(-3), LastIteration = 1 },
|
||||
new ChatAgentRunRecord { RunId = "run-a", Status = "completed", Summary = "new", UpdatedAt = baseTime.AddMinutes(-1), StartedAt = baseTime.AddMinutes(-3), LastIteration = 2 },
|
||||
new ChatAgentRunRecord { RunId = "run-b", Status = "failed", Summary = "other", UpdatedAt = baseTime, StartedAt = baseTime.AddMinutes(-1), LastIteration = 1 },
|
||||
]
|
||||
};
|
||||
|
||||
storage.Save(conversation);
|
||||
session.RememberConversation("Code", conversation.Id);
|
||||
|
||||
var loaded = session.LoadOrCreateConversation("Code", storage, settings);
|
||||
|
||||
loaded.AgentRunHistory.Should().HaveCount(2);
|
||||
loaded.AgentRunHistory[0].RunId.Should().Be("run-b");
|
||||
loaded.AgentRunHistory[1].RunId.Should().Be("run-a");
|
||||
loaded.AgentRunHistory[1].Status.Should().Be("completed");
|
||||
loaded.AgentRunHistory[1].Summary.Should().Be("new");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_NormalizesLegacyAndCaseInsensitiveTabKeys()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.Llm.LastActiveTab = "code";
|
||||
settings.Settings.Llm.LastConversationIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["coworkcode"] = "conv-cowork-legacy",
|
||||
["code"] = "conv-code-lower",
|
||||
["chat"] = "conv-chat-lower",
|
||||
};
|
||||
|
||||
session.Load(settings);
|
||||
|
||||
session.ActiveTab.Should().Be("Code");
|
||||
session.GetConversationId("Cowork").Should().Be("conv-cowork-legacy");
|
||||
session.GetConversationId("Code").Should().Be("conv-code-lower");
|
||||
session.GetConversationId("Chat").Should().Be("conv-chat-lower");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendMessage_FirstUserMessageUpdatesConversationTitle()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
session.AppendMessage("Chat", new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "first user request"
|
||||
}, useForTitle: true);
|
||||
session.AppendMessage("Chat", new ChatMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = "answer"
|
||||
});
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.Messages.Should().HaveCount(2);
|
||||
session.CurrentConversation.Title.Should().Be("first user request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppendMessage_RemembersConversationIdForActiveTab()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
session.AppendMessage("Code", new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "run build"
|
||||
}, useForTitle: true);
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.Tab.Should().Be("Code");
|
||||
session.GetConversationId("Code").Should().Be(session.CurrentConversation.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateConversationMetadata_UpdatesCurrentConversationFields()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
session.EnsureCurrentConversation("Code");
|
||||
|
||||
session.UpdateConversationMetadata("Code", conv =>
|
||||
{
|
||||
conv.Title = "new title";
|
||||
conv.Category = ChatCategory.Product;
|
||||
conv.SystemCommand = "system prompt";
|
||||
});
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.Title.Should().Be("new title");
|
||||
session.CurrentConversation.Category.Should().Be(ChatCategory.Product);
|
||||
session.CurrentConversation.SystemCommand.Should().Be("system prompt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveConversationSettings_UpdatesConversationScopedPreferences()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
session.SaveConversationSettings("Cowork", "Ask", "active", "markdown", "modern");
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.Permission.Should().Be("Ask");
|
||||
session.CurrentConversation.DataUsage.Should().Be("active");
|
||||
session.CurrentConversation.OutputFormat.Should().Be("markdown");
|
||||
session.CurrentConversation.Mood.Should().Be("modern");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveLastAssistantMessage_RemovesOnlyAssistantTail()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
session.AppendMessage("Chat", new ChatMessage { Role = "user", Content = "u1" });
|
||||
session.AppendMessage("Chat", new ChatMessage { Role = "assistant", Content = "a1" });
|
||||
|
||||
var removed = session.RemoveLastAssistantMessage("Chat");
|
||||
|
||||
removed.Should().BeTrue();
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.Messages.Should().ContainSingle();
|
||||
session.CurrentConversation.Messages[0].Role.Should().Be("user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateUserMessageAndTrim_RewritesTailFromTargetIndex()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
session.AppendMessage("Chat", new ChatMessage { Role = "user", Content = "u1" });
|
||||
session.AppendMessage("Chat", new ChatMessage { Role = "assistant", Content = "a1" });
|
||||
session.AppendMessage("Chat", new ChatMessage { Role = "user", Content = "u2" });
|
||||
session.AppendMessage("Chat", new ChatMessage { Role = "assistant", Content = "a2" });
|
||||
|
||||
var updated = session.UpdateUserMessageAndTrim("Chat", 2, "u2-edited");
|
||||
|
||||
updated.Should().BeTrue();
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.Messages.Should().HaveCount(3);
|
||||
session.CurrentConversation.Messages[2].Content.Should().Be("u2-edited");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateMessageFeedback_UpdatesStoredMessageFeedback()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var message = new ChatMessage { Role = "assistant", Content = "answer" };
|
||||
session.AppendMessage("Chat", message);
|
||||
|
||||
var updated = session.UpdateMessageFeedback("Chat", message, "like");
|
||||
|
||||
updated.Should().BeTrue();
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.Messages[0].Feedback.Should().Be("like");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFreshConversation_AppliesDefaultWorkFolderOutsideChatTab()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.Llm.WorkFolder = @"E:\workspace";
|
||||
|
||||
var conversation = session.CreateFreshConversation("Code", settings);
|
||||
|
||||
conversation.Tab.Should().Be("Code");
|
||||
conversation.WorkFolder.Should().Be(@"E:\workspace");
|
||||
session.CurrentConversation.Should().BeSameAs(conversation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBranchConversation_ClonesConversationContextUpToBranchPoint()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var source = new ChatConversation
|
||||
{
|
||||
Id = "source-1",
|
||||
Title = "Main",
|
||||
Tab = "Code",
|
||||
Category = ChatCategory.Product,
|
||||
WorkFolder = @"E:\workspace",
|
||||
SystemCommand = "system",
|
||||
ConversationFailedOnlyFilter = true,
|
||||
ConversationRunningOnlyFilter = true,
|
||||
ConversationSortMode = "recent",
|
||||
AgentRunHistory =
|
||||
[
|
||||
new ChatAgentRunRecord { RunId = "run-1", Status = "completed", Summary = "done", LastIteration = 2 }
|
||||
],
|
||||
Messages =
|
||||
[
|
||||
new ChatMessage { Role = "user", Content = "u1", Timestamp = DateTime.Now.AddMinutes(-3) },
|
||||
new ChatMessage { Role = "assistant", Content = "a1", Timestamp = DateTime.Now.AddMinutes(-2), MetaKind = "meta", MetaRunId = "run-1" },
|
||||
new ChatMessage { Role = "user", Content = "u2", Timestamp = DateTime.Now.AddMinutes(-1) }
|
||||
]
|
||||
};
|
||||
|
||||
var branch = session.CreateBranchConversation(source, 1, 2, "follow-up", "context message", "run-ctx");
|
||||
|
||||
branch.ParentId.Should().Be("source-1");
|
||||
branch.Tab.Should().Be("Code");
|
||||
branch.WorkFolder.Should().Be(@"E:\workspace");
|
||||
branch.BranchLabel.Should().Contain("2");
|
||||
branch.Messages.Should().HaveCount(3);
|
||||
branch.Messages[1].MetaRunId.Should().Be("run-1");
|
||||
branch.Messages[2].MetaKind.Should().Be("branch_context");
|
||||
branch.AgentRunHistory.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraftStateHelpers_SelectAndTransitionQueuedItems()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var first = session.EnqueueDraft("Chat", "first", "next");
|
||||
var second = session.EnqueueDraft("Chat", "second", "now");
|
||||
|
||||
var next = session.GetNextQueuedDraft("Chat");
|
||||
|
||||
next.Should().NotBeNull();
|
||||
next!.Id.Should().Be(second!.Id);
|
||||
session.MarkDraftRunning("Chat", second.Id).Should().BeTrue();
|
||||
session.MarkDraftFailed("Chat", second.Id, "error").Should().BeTrue();
|
||||
session.MarkDraftCompleted("Chat", first!.Id).Should().BeTrue();
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.DraftQueueItems.Should().Contain(x => x.Id == second.Id && x.State == "failed" && x.LastError == "error");
|
||||
session.CurrentConversation.DraftQueueItems.Should().Contain(x => x.Id == first.Id && x.State == "completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraftStateHelpers_ResetAndRemoveDraft()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var item = session.EnqueueDraft("Chat", "queued item", "next");
|
||||
|
||||
session.MarkDraftRunning("Chat", item!.Id).Should().BeTrue();
|
||||
session.MarkDraftFailed("Chat", item.Id, "boom").Should().BeTrue();
|
||||
session.ResetDraftToQueued("Chat", item.Id).Should().BeTrue();
|
||||
session.RemoveDraft("Chat", item.Id).Should().BeTrue();
|
||||
|
||||
session.CurrentConversation.Should().NotBeNull();
|
||||
session.CurrentConversation!.DraftQueueItems.Should().NotContain(x => x.Id == item.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDraftQueueSummary_ReturnsConversationQueueSnapshot()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var first = session.EnqueueDraft("Chat", "first", "next");
|
||||
var second = session.EnqueueDraft("Chat", "second", "now");
|
||||
|
||||
session.MarkDraftRunning("Chat", second!.Id);
|
||||
session.MarkDraftCompleted("Chat", second.Id);
|
||||
|
||||
var summary = session.GetDraftQueueSummary("Chat");
|
||||
|
||||
summary.TotalCount.Should().Be(2);
|
||||
summary.QueuedCount.Should().Be(1);
|
||||
summary.CompletedCount.Should().Be(1);
|
||||
summary.NextItem.Should().NotBeNull();
|
||||
summary.NextItem!.Id.Should().Be(first!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RememberConversation_NormalizesCoworkAliasesToSingleBucket()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
|
||||
session.RememberConversation("Cowork Code", "conv-1");
|
||||
session.RememberConversation("cowork/code", "conv-2");
|
||||
session.RememberConversation("코워크/코드", "conv-3");
|
||||
|
||||
session.GetConversationId("Cowork").Should().Be("conv-3");
|
||||
session.GetConversationId("Code").Should().BeNull();
|
||||
session.GetConversationId("Chat").Should().BeNull();
|
||||
}
|
||||
}
|
||||
95
src/AxCopilot.Tests/Services/ContextCondenserTests.cs
Normal file
95
src/AxCopilot.Tests/Services/ContextCondenserTests.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Reflection;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class ContextCondenserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TruncateToolResults_PreservesMessageMetadataOnCompression()
|
||||
{
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = "{\"type\":\"tool_result\",\"output\":\"" + new string('a', 4200) + "\"}",
|
||||
Timestamp = new DateTime(2026, 4, 3, 1, 0, 0),
|
||||
MetaKind = "tool_result",
|
||||
MetaRunId = "run-1",
|
||||
Feedback = "like",
|
||||
AttachedFiles = [@"E:\sample\a.txt"],
|
||||
Images =
|
||||
[
|
||||
new ImageAttachment
|
||||
{
|
||||
FileName = "image.png",
|
||||
MimeType = "image/png",
|
||||
Base64 = "AAA"
|
||||
}
|
||||
]
|
||||
},
|
||||
new() { Role = "user", Content = "recent-1" },
|
||||
new() { Role = "assistant", Content = "recent-2" },
|
||||
new() { Role = "user", Content = "recent-3" },
|
||||
new() { Role = "assistant", Content = "recent-4" },
|
||||
new() { Role = "user", Content = "recent-5" },
|
||||
new() { Role = "assistant", Content = "recent-6" },
|
||||
};
|
||||
|
||||
var changed = InvokePrivateStatic<bool>("TruncateToolResults", messages);
|
||||
|
||||
changed.Should().BeTrue();
|
||||
messages[0].MetaKind.Should().Be("tool_result");
|
||||
messages[0].MetaRunId.Should().Be("run-1");
|
||||
messages[0].Feedback.Should().Be("like");
|
||||
messages[0].AttachedFiles.Should().ContainSingle().Which.Should().Be(@"E:\sample\a.txt");
|
||||
messages[0].Images.Should().ContainSingle();
|
||||
messages[0].Images![0].FileName.Should().Be("image.png");
|
||||
messages[0].Content.Length.Should().BeLessThan(4200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncateToolResults_PreservesMetadataForLongAssistantMessage()
|
||||
{
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = new string('b', 5000),
|
||||
Timestamp = new DateTime(2026, 4, 3, 1, 5, 0),
|
||||
MetaKind = "analysis",
|
||||
MetaRunId = "run-2",
|
||||
AttachedFiles = [@"E:\sample\b.txt"],
|
||||
},
|
||||
new() { Role = "user", Content = "recent-1" },
|
||||
new() { Role = "assistant", Content = "recent-2" },
|
||||
new() { Role = "user", Content = "recent-3" },
|
||||
new() { Role = "assistant", Content = "recent-4" },
|
||||
new() { Role = "user", Content = "recent-5" },
|
||||
new() { Role = "assistant", Content = "recent-6" },
|
||||
};
|
||||
|
||||
var changed = InvokePrivateStatic<bool>("TruncateToolResults", messages);
|
||||
|
||||
changed.Should().BeTrue();
|
||||
messages[0].MetaKind.Should().Be("analysis");
|
||||
messages[0].MetaRunId.Should().Be("run-2");
|
||||
messages[0].AttachedFiles.Should().ContainSingle().Which.Should().Be(@"E:\sample\b.txt");
|
||||
messages[0].Content.Length.Should().BeLessThan(5000);
|
||||
}
|
||||
|
||||
private static T InvokePrivateStatic<T>(string methodName, params object?[] arguments)
|
||||
{
|
||||
var method = typeof(ContextCondenser).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static);
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var result = method!.Invoke(null, arguments);
|
||||
result.Should().NotBeNull();
|
||||
return (T)result!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using AxCopilot.Services;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class DraftQueueProcessorServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryStartNext_PicksPreferredDraftAndMarksItRunning()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var processor = new DraftQueueProcessorService();
|
||||
var first = session.EnqueueDraft("Chat", "first", "next");
|
||||
var second = session.EnqueueDraft("Chat", "second", "later");
|
||||
|
||||
var started = processor.TryStartNext(session, "Chat", preferredDraftId: second!.Id);
|
||||
|
||||
started.Should().NotBeNull();
|
||||
started!.Id.Should().Be(second.Id);
|
||||
session.GetDraftQueueItems("Chat").Single(x => x.Id == second.Id).State.Should().Be("running");
|
||||
session.GetDraftQueueItems("Chat").Single(x => x.Id == first!.Id).State.Should().Be("queued");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleFailure_SchedulesRetryBeforeRetryLimit()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var processor = new DraftQueueProcessorService();
|
||||
var item = session.EnqueueDraft("Chat", "retry me", "next");
|
||||
processor.TryStartNext(session, "Chat", preferredDraftId: item!.Id);
|
||||
|
||||
var handled = processor.HandleFailure(session, "Chat", item.Id, "boom", cancelled: false, maxAutoRetries: 3);
|
||||
|
||||
handled.Should().BeTrue();
|
||||
var stored = session.GetDraftQueueItems("Chat").Single(x => x.Id == item.Id);
|
||||
stored.State.Should().Be("queued");
|
||||
stored.NextRetryAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromoteReadyBlockedItems_ClearsExpiredRetryWindow()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var processor = new DraftQueueProcessorService();
|
||||
var item = session.EnqueueDraft("Chat", "retry me", "next");
|
||||
processor.TryStartNext(session, "Chat", preferredDraftId: item!.Id);
|
||||
processor.HandleFailure(session, "Chat", item.Id, "boom", cancelled: false, maxAutoRetries: 3);
|
||||
session.CurrentConversation!.DraftQueueItems[0].NextRetryAt = DateTime.Now.AddSeconds(-1);
|
||||
|
||||
var promoted = processor.PromoteReadyBlockedItems(session, "Chat");
|
||||
|
||||
promoted.Should().Be(1);
|
||||
session.CurrentConversation!.DraftQueueItems[0].NextRetryAt.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearCompleted_RemovesCompletedItems()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var processor = new DraftQueueProcessorService();
|
||||
var item = session.EnqueueDraft("Chat", "done item", "next");
|
||||
processor.TryStartNext(session, "Chat", preferredDraftId: item!.Id);
|
||||
processor.Complete(session, "Chat", item.Id);
|
||||
|
||||
var removed = processor.ClearCompleted(session, "Chat");
|
||||
|
||||
removed.Should().Be(1);
|
||||
session.GetDraftQueueItems("Chat").Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryStartNext_AndHandleFailure_RecordQueueTasks()
|
||||
{
|
||||
var session = new ChatSessionStateService();
|
||||
var processor = new DraftQueueProcessorService();
|
||||
var taskRuns = new TaskRunService();
|
||||
var item = session.EnqueueDraft("Chat", "retry me", "next");
|
||||
|
||||
processor.TryStartNext(session, "Chat", preferredDraftId: item!.Id, taskRuns: taskRuns);
|
||||
processor.HandleFailure(session, "Chat", item.Id, "boom", cancelled: false, maxAutoRetries: 3, taskRuns: taskRuns);
|
||||
|
||||
taskRuns.ActiveTasks.Should().BeEmpty();
|
||||
taskRuns.RecentTasks.Should().ContainSingle(t => t.Kind == "queue" && t.Status == "blocked");
|
||||
}
|
||||
}
|
||||
124
src/AxCopilot.Tests/Services/DraftQueueServiceTests.cs
Normal file
124
src/AxCopilot.Tests/Services/DraftQueueServiceTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class DraftQueueServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetNextQueuedItem_UsesPriorityThenCreatedAt()
|
||||
{
|
||||
var service = new DraftQueueService();
|
||||
var later = service.CreateItem("later item", "later");
|
||||
later.CreatedAt = DateTime.Now.AddMinutes(-3);
|
||||
var next = service.CreateItem("next item", "next");
|
||||
next.CreatedAt = DateTime.Now.AddMinutes(-2);
|
||||
var now = service.CreateItem("now item", "now");
|
||||
now.CreatedAt = DateTime.Now.AddMinutes(-1);
|
||||
|
||||
var selected = service.GetNextQueuedItem(new[] { later, next, now });
|
||||
|
||||
selected.Should().NotBeNull();
|
||||
selected!.Text.Should().Be("now item");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkRunningAndCompleted_UpdateState()
|
||||
{
|
||||
var service = new DraftQueueService();
|
||||
var item = service.CreateItem("task");
|
||||
|
||||
service.MarkRunning(item).Should().BeTrue();
|
||||
item.State.Should().Be("running");
|
||||
item.AttemptCount.Should().Be(1);
|
||||
|
||||
service.MarkCompleted(item).Should().BeTrue();
|
||||
item.State.Should().Be("completed");
|
||||
item.LastError.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkFailed_SetsErrorAndKeepsAttemptHistory()
|
||||
{
|
||||
var service = new DraftQueueService();
|
||||
var item = service.CreateItem("task");
|
||||
|
||||
service.MarkRunning(item);
|
||||
service.MarkFailed(item, "boom").Should().BeTrue();
|
||||
|
||||
item.State.Should().Be("failed");
|
||||
item.LastError.Should().Be("boom");
|
||||
item.AttemptCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetToQueued_ClearsErrorAndReturnsQueuedState()
|
||||
{
|
||||
var service = new DraftQueueService();
|
||||
var item = service.CreateItem("task");
|
||||
|
||||
service.MarkRunning(item);
|
||||
service.MarkFailed(item, "boom");
|
||||
service.ResetToQueued(item).Should().BeTrue();
|
||||
|
||||
item.State.Should().Be("queued");
|
||||
item.LastError.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_ReturnsCountsAndNextItem()
|
||||
{
|
||||
var service = new DraftQueueService();
|
||||
var queued = service.CreateItem("queued", "next");
|
||||
var running = service.CreateItem("running", "now");
|
||||
var failed = service.CreateItem("failed", "later");
|
||||
|
||||
service.MarkRunning(running);
|
||||
service.MarkFailed(failed, "boom");
|
||||
|
||||
var summary = service.GetSummary(new[] { queued, running, failed });
|
||||
|
||||
summary.TotalCount.Should().Be(3);
|
||||
summary.QueuedCount.Should().Be(1);
|
||||
summary.RunningCount.Should().Be(1);
|
||||
summary.FailedCount.Should().Be(1);
|
||||
summary.CompletedCount.Should().Be(0);
|
||||
summary.NextItem.Should().NotBeNull();
|
||||
summary.NextItem!.Text.Should().Be("queued");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleRetry_BlocksItemUntilRetryTime()
|
||||
{
|
||||
var service = new DraftQueueService();
|
||||
var item = service.CreateItem("task");
|
||||
|
||||
service.MarkRunning(item);
|
||||
service.ScheduleRetry(item, "temporary");
|
||||
|
||||
item.State.Should().Be("queued");
|
||||
item.LastError.Should().Be("temporary");
|
||||
item.NextRetryAt.Should().NotBeNull();
|
||||
service.CanRunNow(item, item.NextRetryAt!.Value.AddSeconds(-1)).Should().BeFalse();
|
||||
service.CanRunNow(item, item.NextRetryAt!.Value.AddSeconds(1)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_IncludesBlockedItemsAndNextReadyAt()
|
||||
{
|
||||
var service = new DraftQueueService();
|
||||
var blocked = service.CreateItem("blocked");
|
||||
blocked.NextRetryAt = DateTime.Now.AddMinutes(2);
|
||||
var ready = service.CreateItem("ready");
|
||||
|
||||
var summary = service.GetSummary(new[] { blocked, ready });
|
||||
|
||||
summary.QueuedCount.Should().Be(1);
|
||||
summary.BlockedCount.Should().Be(1);
|
||||
summary.NextReadyAt.Should().NotBeNull();
|
||||
summary.NextItem.Should().NotBeNull();
|
||||
summary.NextItem!.Text.Should().Be("ready");
|
||||
}
|
||||
}
|
||||
68
src/AxCopilot.Tests/Services/LlmOperationModeTests.cs
Normal file
68
src/AxCopilot.Tests/Services/LlmOperationModeTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class LlmOperationModeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendAsync_InternalMode_BlocksExternalGemini()
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.OperationMode = OperationModePolicy.InternalMode;
|
||||
settings.Settings.Llm.Service = "gemini";
|
||||
settings.Settings.Llm.Model = "gemini-2.5-flash";
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
|
||||
var action = async () => await llm.SendAsync(
|
||||
[new ChatMessage { Role = "user", Content = "test" }]);
|
||||
|
||||
var ex = await action.Should().ThrowAsync<InvalidOperationException>();
|
||||
ex.Which.Message.Should().Contain("사내 모드");
|
||||
ex.Which.Message.Should().Contain("외부 LLM 호출이 차단");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendWithToolsAsync_InternalMode_BlocksExternalClaude()
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.OperationMode = OperationModePolicy.InternalMode;
|
||||
settings.Settings.Llm.Service = "claude";
|
||||
settings.Settings.Llm.Model = "claude-sonnet-4-5";
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
var tools = new List<IAgentTool> { new FileReadTool() };
|
||||
|
||||
var action = async () => await llm.SendWithToolsAsync(
|
||||
[new ChatMessage { Role = "user", Content = "test" }],
|
||||
tools);
|
||||
|
||||
var ex = await action.Should().ThrowAsync<InvalidOperationException>();
|
||||
ex.Which.Message.Should().Contain("사내 모드");
|
||||
ex.Which.Message.Should().Contain("Claude");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ExternalMode_DoesNotUseInternalModeBlockMessage()
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.OperationMode = OperationModePolicy.ExternalMode;
|
||||
settings.Settings.Llm.Service = "gemini";
|
||||
settings.Settings.Llm.Model = "gemini-2.5-flash";
|
||||
settings.Settings.Llm.GeminiApiKey = "";
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
|
||||
var action = async () => await llm.SendAsync(
|
||||
[new ChatMessage { Role = "user", Content = "test" }]);
|
||||
|
||||
var ex = await action.Should().ThrowAsync<InvalidOperationException>();
|
||||
ex.Which.Message.Should().NotContain("사내 모드에서는 외부 LLM 호출이 차단");
|
||||
ex.Which.Message.Should().Contain("API 키");
|
||||
}
|
||||
}
|
||||
|
||||
111
src/AxCopilot.Tests/Services/LlmRuntimeOverrideTests.cs
Normal file
111
src/AxCopilot.Tests/Services/LlmRuntimeOverrideTests.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class LlmRuntimeOverrideTests
|
||||
{
|
||||
[Fact]
|
||||
public void PushInferenceOverride_PopInferenceOverride_RestoresPreviousState()
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.Llm.Service = "ollama";
|
||||
settings.Settings.Llm.Model = "base-model";
|
||||
settings.Settings.Llm.Temperature = 0.7;
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
|
||||
llm.PushRouteOverride("gemini", "gemini-2.5-pro");
|
||||
llm.PushInferenceOverride(temperature: 0.2, reasoningEffort: "high");
|
||||
|
||||
llm.GetCurrentModelInfo().service.Should().Be("gemini");
|
||||
llm.GetCurrentModelInfo().model.Should().Be("gemini-2.5-pro");
|
||||
InvokePrivate<double>(llm, "ResolveTemperature").Should().Be(0.2);
|
||||
InvokePrivate<string?>(llm, "ResolveReasoningEffort").Should().Be("high");
|
||||
|
||||
llm.PopInferenceOverride();
|
||||
llm.GetCurrentModelInfo().service.Should().Be("gemini");
|
||||
llm.GetCurrentModelInfo().model.Should().Be("gemini-2.5-pro");
|
||||
InvokePrivate<double>(llm, "ResolveTemperature").Should().Be(0.7);
|
||||
InvokePrivate<string?>(llm, "ResolveReasoningEffort").Should().BeNull();
|
||||
|
||||
llm.ClearRouteOverride();
|
||||
llm.GetCurrentModelInfo().service.Should().Be("ollama");
|
||||
llm.GetCurrentModelInfo().model.Should().Be("base-model");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentLoop_ResolveSkillRuntimeOverrides_MapsModelAndEffort()
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Settings.Llm.Service = "ollama";
|
||||
settings.Settings.Llm.Model = "base-model";
|
||||
settings.Settings.Llm.RegisteredModels =
|
||||
[
|
||||
new RegisteredModel
|
||||
{
|
||||
Alias = "gpt-5.4",
|
||||
EncryptedModelName = "gpt-5.4",
|
||||
Service = "vllm",
|
||||
}
|
||||
];
|
||||
|
||||
using var llm = new LlmService(settings);
|
||||
var loop = new AgentLoopService(llm, ToolRegistry.CreateDefault(), settings);
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Role = "system",
|
||||
Content = """
|
||||
[Skill Runtime Policy]
|
||||
- preferred_model: gpt-5.4
|
||||
- reasoning_effort: high
|
||||
- execution_context: fork
|
||||
- allowed_tools: Read, process
|
||||
- hook_names: lint-pre, verify-post
|
||||
- hook_filters: lint-pre@pre@file_edit, verify-post@post@*
|
||||
"""
|
||||
},
|
||||
new() { Role = "user", Content = "test" }
|
||||
};
|
||||
|
||||
var method = typeof(AgentLoopService).GetMethod(
|
||||
"ResolveSkillRuntimeOverrides",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var result = method!.Invoke(loop, [messages]);
|
||||
result.Should().NotBeNull();
|
||||
|
||||
var resultType = result!.GetType();
|
||||
resultType.GetProperty("Service")!.GetValue(result)!.Should().Be("vllm");
|
||||
resultType.GetProperty("Model")!.GetValue(result)!.Should().Be("gpt-5.4");
|
||||
resultType.GetProperty("ReasoningEffort")!.GetValue(result)!.Should().Be("high");
|
||||
resultType.GetProperty("Temperature")!.GetValue(result)!.Should().Be(0.2);
|
||||
resultType.GetProperty("RequireForkExecution")!.GetValue(result)!.Should().Be(true);
|
||||
|
||||
var allowedSet = (IReadOnlySet<string>)resultType.GetProperty("AllowedToolNames")!.GetValue(result)!;
|
||||
allowedSet.Should().Contain("file_read");
|
||||
allowedSet.Should().Contain("process");
|
||||
|
||||
var hookSet = (IReadOnlySet<string>)resultType.GetProperty("HookNames")!.GetValue(result)!;
|
||||
hookSet.Should().Contain("lint-pre");
|
||||
hookSet.Should().Contain("verify-post");
|
||||
|
||||
var filters = (IReadOnlyList<object>)resultType.GetProperty("HookFilters")!.GetValue(result)!;
|
||||
filters.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
private static T InvokePrivate<T>(object instance, string methodName)
|
||||
{
|
||||
var method = instance.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
method.Should().NotBeNull();
|
||||
return (T)method!.Invoke(instance, null)!;
|
||||
}
|
||||
}
|
||||
62
src/AxCopilot.Tests/Services/OperationModePolicyTests.cs
Normal file
62
src/AxCopilot.Tests/Services/OperationModePolicyTests.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class OperationModePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_DefaultsToInternalWhenMissingOrUnknown()
|
||||
{
|
||||
OperationModePolicy.Normalize(null).Should().Be(OperationModePolicy.InternalMode);
|
||||
OperationModePolicy.Normalize("").Should().Be(OperationModePolicy.InternalMode);
|
||||
OperationModePolicy.Normalize("unknown").Should().Be(OperationModePolicy.InternalMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_MapsExternalMode()
|
||||
{
|
||||
OperationModePolicy.Normalize("external").Should().Be(OperationModePolicy.ExternalMode);
|
||||
OperationModePolicy.Normalize("EXTERNAL").Should().Be(OperationModePolicy.ExternalMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsBlockedAgentToolInInternalMode_BlocksExternalHttpAndUrlOpen()
|
||||
{
|
||||
OperationModePolicy.IsBlockedAgentToolInInternalMode("http_tool", "https://example.com").Should().BeTrue();
|
||||
OperationModePolicy.IsBlockedAgentToolInInternalMode("open_external", "https://example.com").Should().BeTrue();
|
||||
OperationModePolicy.IsBlockedAgentToolInInternalMode("open_external", @"E:\work\report.html").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentContext_CheckToolPermissionAsync_BlocksRestrictedToolsInInternalMode()
|
||||
{
|
||||
var context = new AgentContext
|
||||
{
|
||||
OperationMode = OperationModePolicy.InternalMode,
|
||||
Permission = "Auto"
|
||||
};
|
||||
|
||||
var blocked = await context.CheckToolPermissionAsync("http_tool", "https://example.com");
|
||||
var allowed = await context.CheckToolPermissionAsync("file_read", @"E:\work\a.txt");
|
||||
|
||||
blocked.Should().BeFalse();
|
||||
allowed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentContext_CheckToolPermissionAsync_AllowsRestrictedToolsInExternalMode()
|
||||
{
|
||||
var context = new AgentContext
|
||||
{
|
||||
OperationMode = OperationModePolicy.ExternalMode,
|
||||
Permission = "Auto"
|
||||
};
|
||||
|
||||
var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com");
|
||||
allowed.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
278
src/AxCopilot.Tests/Services/SettingsServiceTests.cs
Normal file
278
src/AxCopilot.Tests/Services/SettingsServiceTests.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using AxCopilot.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class SettingsServiceTests
|
||||
{
|
||||
// ─── AppSettings 기본값 ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_DefaultHotkey_IsAltSpace()
|
||||
{
|
||||
new AppSettings().Hotkey.Should().Be("Alt+Space");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherSettings_DefaultMaxResults_IsSeven()
|
||||
{
|
||||
new LauncherSettings().MaxResults.Should().Be(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherSettings_DefaultTheme_IsSystem()
|
||||
{
|
||||
new LauncherSettings().Theme.Should().Be("system");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherSettings_DefaultOpacity_IsValid()
|
||||
{
|
||||
var opacity = new LauncherSettings().Opacity;
|
||||
opacity.Should().BeInRange(0.0, 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_DefaultMonitorMismatch_IsWarn()
|
||||
{
|
||||
new AppSettings().MonitorMismatch.Should().Be("warn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_DefaultCleanupPeriodDays_IsThirty()
|
||||
{
|
||||
new AppSettings().CleanupPeriodDays.Should().Be(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_DefaultIndexPaths_NotEmpty()
|
||||
{
|
||||
new AppSettings().IndexPaths.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
// ─── LauncherSettings 테마 ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("system")]
|
||||
[InlineData("dark")]
|
||||
[InlineData("light")]
|
||||
public void LauncherSettings_Theme_AcceptsValidValues(string theme)
|
||||
{
|
||||
var settings = new LauncherSettings { Theme = theme };
|
||||
settings.Theme.Should().Be(theme);
|
||||
}
|
||||
|
||||
// ─── JSON 직렬화 라운드트립 ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_Serialization_PreservesHotkey()
|
||||
{
|
||||
var original = new AppSettings { Hotkey = "Ctrl+Space" };
|
||||
var restored = RoundTrip(original);
|
||||
restored.Hotkey.Should().Be("Ctrl+Space");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_Serialization_PreservesTheme()
|
||||
{
|
||||
var original = new AppSettings
|
||||
{
|
||||
Launcher = new LauncherSettings { Theme = "dark" }
|
||||
};
|
||||
var restored = RoundTrip(original);
|
||||
restored.Launcher.Theme.Should().Be("dark");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_Serialization_PreservesMaxResults()
|
||||
{
|
||||
var original = new AppSettings
|
||||
{
|
||||
Launcher = new LauncherSettings { MaxResults = 15 }
|
||||
};
|
||||
var restored = RoundTrip(original);
|
||||
restored.Launcher.MaxResults.Should().Be(15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_Serialization_PreservesAliases()
|
||||
{
|
||||
var original = new AppSettings
|
||||
{
|
||||
Aliases =
|
||||
[
|
||||
new() { Key = "@test", Type = "url", Target = "https://example.com" }
|
||||
]
|
||||
};
|
||||
var restored = RoundTrip(original);
|
||||
restored.Aliases.Should().HaveCount(1);
|
||||
restored.Aliases[0].Key.Should().Be("@test");
|
||||
restored.Aliases[0].Target.Should().Be("https://example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_Serialization_PreservesCleanupPeriodDays()
|
||||
{
|
||||
var original = new AppSettings { CleanupPeriodDays = 14 };
|
||||
|
||||
var restored = RoundTrip(original);
|
||||
|
||||
restored.CleanupPeriodDays.Should().Be(14);
|
||||
}
|
||||
|
||||
// ─── AliasEntry ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AliasEntry_DefaultShowWindow_IsFalse()
|
||||
{
|
||||
new AliasEntry().ShowWindow.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("url")]
|
||||
[InlineData("folder")]
|
||||
[InlineData("app")]
|
||||
[InlineData("batch")]
|
||||
[InlineData("api")]
|
||||
[InlineData("clipboard")]
|
||||
public void AliasEntry_Type_AcceptsAllTypes(string type)
|
||||
{
|
||||
var entry = new AliasEntry { Type = type };
|
||||
entry.Type.Should().Be(type);
|
||||
}
|
||||
|
||||
// ─── WorkspaceProfile / WindowSnapshot ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WorkspaceProfile_DefaultWindows_IsEmpty()
|
||||
{
|
||||
new WorkspaceProfile().Windows.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowSnapshot_DefaultShowCmd_IsNormal()
|
||||
{
|
||||
new WindowSnapshot().ShowCmd.Should().Be("Normal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowSnapshot_DefaultMonitor_IsZero()
|
||||
{
|
||||
new WindowSnapshot().Monitor.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowRect_DefaultValues_AreZero()
|
||||
{
|
||||
var rect = new WindowRect();
|
||||
rect.X.Should().Be(0);
|
||||
rect.Y.Should().Be(0);
|
||||
rect.Width.Should().Be(0);
|
||||
rect.Height.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkspaceProfile_Serialization_RoundTrip()
|
||||
{
|
||||
var profile = new WorkspaceProfile
|
||||
{
|
||||
Name = "작업 프로필",
|
||||
Windows =
|
||||
[
|
||||
new() { Exe = "notepad.exe", ShowCmd = "Maximized", Monitor = 1 }
|
||||
]
|
||||
};
|
||||
var json = JsonSerializer.Serialize(profile, JsonOptions);
|
||||
var restored = JsonSerializer.Deserialize<WorkspaceProfile>(json, JsonOptions)!;
|
||||
|
||||
restored.Name.Should().Be("작업 프로필");
|
||||
restored.Windows.Should().HaveCount(1);
|
||||
restored.Windows[0].Exe.Should().Be("notepad.exe");
|
||||
restored.Windows[0].ShowCmd.Should().Be("Maximized");
|
||||
restored.Windows[0].Monitor.Should().Be(1);
|
||||
}
|
||||
|
||||
// ─── ClipboardTransformer ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ClipboardTransformer_DefaultTimeout_IsFiveSeconds()
|
||||
{
|
||||
new ClipboardTransformer().Timeout.Should().Be(5000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipboardTransformer_DefaultType_IsRegex()
|
||||
{
|
||||
new ClipboardTransformer().Type.Should().Be("regex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeLlmThresholds_ClampsConfiguredValues()
|
||||
{
|
||||
var llm = new LlmSettings
|
||||
{
|
||||
ReadOnlySignatureLoopThreshold = 1,
|
||||
ReadOnlyStagnationThreshold = 99,
|
||||
NoProgressRecoveryThreshold = 2,
|
||||
NoProgressAbortThreshold = 200,
|
||||
NoProgressRecoveryMaxRetries = 9,
|
||||
ToolExecutionTimeoutMs = 1000
|
||||
};
|
||||
|
||||
InvokeNormalizeLlmThresholds(llm);
|
||||
|
||||
llm.ReadOnlySignatureLoopThreshold.Should().Be(2);
|
||||
llm.ReadOnlyStagnationThreshold.Should().Be(20);
|
||||
llm.NoProgressRecoveryThreshold.Should().Be(4);
|
||||
llm.NoProgressAbortThreshold.Should().Be(50);
|
||||
llm.NoProgressRecoveryMaxRetries.Should().Be(5);
|
||||
llm.ToolExecutionTimeoutMs.Should().Be(5000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeLlmThresholds_PreservesZeroAsUnset()
|
||||
{
|
||||
var llm = new LlmSettings
|
||||
{
|
||||
ReadOnlySignatureLoopThreshold = 0,
|
||||
ReadOnlyStagnationThreshold = 0,
|
||||
NoProgressRecoveryThreshold = 0,
|
||||
NoProgressAbortThreshold = 0,
|
||||
NoProgressRecoveryMaxRetries = 0,
|
||||
ToolExecutionTimeoutMs = 0
|
||||
};
|
||||
|
||||
InvokeNormalizeLlmThresholds(llm);
|
||||
|
||||
llm.ReadOnlySignatureLoopThreshold.Should().Be(0);
|
||||
llm.ReadOnlyStagnationThreshold.Should().Be(0);
|
||||
llm.NoProgressRecoveryThreshold.Should().Be(0);
|
||||
llm.NoProgressAbortThreshold.Should().Be(0);
|
||||
llm.NoProgressRecoveryMaxRetries.Should().Be(0);
|
||||
llm.ToolExecutionTimeoutMs.Should().Be(0);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static AppSettings RoundTrip(AppSettings original)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(original, JsonOptions);
|
||||
return JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)!;
|
||||
}
|
||||
|
||||
private static void InvokeNormalizeLlmThresholds(LlmSettings llm)
|
||||
{
|
||||
var method = typeof(AxCopilot.Services.SettingsService)
|
||||
.GetMethod("NormalizeLlmThresholds", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
method.Should().NotBeNull();
|
||||
method!.Invoke(null, [llm]);
|
||||
}
|
||||
}
|
||||
165
src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs
Normal file
165
src/AxCopilot.Tests/Services/SkillServiceRuntimePolicyTests.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class SkillServiceRuntimePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildRuntimeDirective_ReturnsEmpty_WhenNoRuntimeMetadata()
|
||||
{
|
||||
var skill = new SkillDefinition
|
||||
{
|
||||
Name = "plain-skill",
|
||||
SystemPrompt = "do work"
|
||||
};
|
||||
|
||||
var directive = SkillService.BuildRuntimeDirective(skill);
|
||||
|
||||
directive.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuntimeDirective_ContainsForkAgentEffortAndModelHints()
|
||||
{
|
||||
var skill = new SkillDefinition
|
||||
{
|
||||
Name = "advanced-skill",
|
||||
ExecutionContext = "fork",
|
||||
Agent = "worker",
|
||||
Effort = "high",
|
||||
Model = "gpt-5.4",
|
||||
DisableModelInvocation = true,
|
||||
AllowedTools = "Read, process, WebFetch",
|
||||
Hooks = "lint-pre, verify-post",
|
||||
HookFilters = "lint-pre@pre@file_edit, verify-post@post@*"
|
||||
};
|
||||
|
||||
var directive = SkillService.BuildRuntimeDirective(skill);
|
||||
|
||||
directive.Should().Contain("[Skill Runtime Policy]");
|
||||
directive.Should().Contain("execution_context: fork");
|
||||
directive.Should().Contain("preferred_agent: worker");
|
||||
directive.Should().Contain("reasoning_effort: high");
|
||||
directive.Should().Contain("preferred_model: gpt-5.4");
|
||||
directive.Should().Contain("allowed_tools: file_read, http_tool, process");
|
||||
directive.Should().Contain("hook_names: lint-pre, verify-post");
|
||||
directive.Should().Contain("hook_filters: lint-pre@pre@file_edit, verify-post@post@*");
|
||||
directive.Should().Contain("disable_model_invocation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSkillFile_HooksMapAndList_AreNormalizedIntoHooksField()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-hooks-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var skillPath = Path.Combine(tempDir, "SKILL.md");
|
||||
try
|
||||
{
|
||||
var content = """
|
||||
---
|
||||
name: hook-skill
|
||||
hooks:
|
||||
pre: lint-pre, verify-pre
|
||||
post:
|
||||
- verify-post
|
||||
- report-post
|
||||
---
|
||||
|
||||
body
|
||||
""";
|
||||
File.WriteAllText(skillPath, content, Encoding.UTF8);
|
||||
|
||||
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Hooks.Should().Contain("lint-pre");
|
||||
parsed.Hooks.Should().Contain("verify-pre");
|
||||
parsed.Hooks.Should().Contain("verify-post");
|
||||
parsed.Hooks.Should().Contain("report-post");
|
||||
parsed.HookFilters.Should().Contain("lint-pre@pre@*");
|
||||
parsed.HookFilters.Should().Contain("verify-pre@pre@*");
|
||||
parsed.HookFilters.Should().Contain("*@post@*");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSkillFile_NestedHooksMap_PreservesToolTimingFilters()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-hooks-nested-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var skillPath = Path.Combine(tempDir, "SKILL.md");
|
||||
try
|
||||
{
|
||||
var content = """
|
||||
---
|
||||
name: nested-hook-skill
|
||||
hooks:
|
||||
file_edit:
|
||||
pre:
|
||||
- lint-pre
|
||||
post: verify-post
|
||||
---
|
||||
|
||||
body
|
||||
""";
|
||||
File.WriteAllText(skillPath, content, Encoding.UTF8);
|
||||
|
||||
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.Hooks.Should().Contain("lint-pre");
|
||||
parsed.Hooks.Should().Contain("verify-post");
|
||||
parsed.HookFilters.Should().Contain("lint-pre@pre@file_edit");
|
||||
parsed.HookFilters.Should().Contain("verify-post@post@file_edit");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSkillFile_SampleFlag_SetsIsSample()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-sample-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var skillPath = Path.Combine(tempDir, "SKILL.md");
|
||||
try
|
||||
{
|
||||
var content = """
|
||||
---
|
||||
name: sample-skill
|
||||
sample: true
|
||||
---
|
||||
|
||||
body
|
||||
""";
|
||||
File.WriteAllText(skillPath, content, Encoding.UTF8);
|
||||
|
||||
var method = typeof(SkillService).GetMethod("ParseSkillFile", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var parsed = method!.Invoke(null, [skillPath]) as SkillDefinition;
|
||||
parsed.Should().NotBeNull();
|
||||
parsed!.IsSample.Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
276
src/AxCopilot.Tests/Services/TaskRunServiceTests.cs
Normal file
276
src/AxCopilot.Tests/Services/TaskRunServiceTests.cs
Normal file
@@ -0,0 +1,276 @@
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class TaskRunServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void StartOrUpdate_UsesUnderlyingStore()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
|
||||
service.StartOrUpdate("agent:1", "agent", "main", "thinking");
|
||||
service.StartOrUpdate("agent:1", "agent", "main", "done", "running", @"E:\a.txt");
|
||||
|
||||
service.ActiveTasks.Should().ContainSingle();
|
||||
service.ActiveTasks[0].Summary.Should().Be("done");
|
||||
service.ActiveTasks[0].FilePath.Should().Be(@"E:\a.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteAndRestoreRecent_ExposeStoreState()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
service.StartOrUpdate("tool:1", "tool", "file_read", "reading");
|
||||
|
||||
service.Complete("tool:1", "done");
|
||||
|
||||
service.ActiveTasks.Should().BeEmpty();
|
||||
service.RecentTasks.Should().ContainSingle();
|
||||
|
||||
service.RestoreRecent(
|
||||
[
|
||||
new TaskRunStore.TaskRun
|
||||
{
|
||||
Id = "agent:2",
|
||||
Kind = "agent",
|
||||
Title = "main",
|
||||
Summary = "restored",
|
||||
Status = "completed",
|
||||
UpdatedAt = DateTime.Now
|
||||
}
|
||||
]);
|
||||
|
||||
service.RecentTasks.Should().ContainSingle();
|
||||
service.RecentTasks[0].Id.Should().Be("agent:2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_FiresWhenUnderlyingStoreChanges()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
var count = 0;
|
||||
service.Changed += () => count++;
|
||||
|
||||
service.StartOrUpdate("tool:1", "tool", "grep", "searching");
|
||||
service.Complete("tool:1", "done");
|
||||
|
||||
count.Should().BeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAgentEvent_TracksPermissionAndToolLifecycle()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
|
||||
service.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = AgentEventType.PermissionRequest,
|
||||
ToolName = "file_write",
|
||||
Summary = "ask"
|
||||
});
|
||||
service.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = AgentEventType.ToolCall,
|
||||
ToolName = "file_read",
|
||||
Summary = "reading"
|
||||
});
|
||||
service.ApplyAgentEvent(new AgentEvent
|
||||
{
|
||||
RunId = "run-1",
|
||||
Type = AgentEventType.ToolResult,
|
||||
ToolName = "file_read",
|
||||
Summary = "done",
|
||||
Success = true
|
||||
});
|
||||
|
||||
service.ActiveTasks.Should().ContainSingle(t => t.Kind == "permission");
|
||||
service.RecentTasks.Should().ContainSingle(t => t.Kind == "tool" && t.Status == "completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplySubAgentStatus_TracksSubAgentLifecycle()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
|
||||
service.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "worker-1",
|
||||
Task = "scan",
|
||||
Status = SubAgentRunStatus.Started,
|
||||
Summary = "started"
|
||||
});
|
||||
service.ApplySubAgentStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = "worker-1",
|
||||
Task = "scan",
|
||||
Status = SubAgentRunStatus.Completed,
|
||||
Summary = "completed"
|
||||
});
|
||||
|
||||
service.ActiveTasks.Should().BeEmpty();
|
||||
service.RecentTasks.Should().ContainSingle(t => t.Kind == "subagent" && t.Status == "completed");
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "background" && t.Status == "completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitLifecycleApis_TrackPermissionHookAndBackgroundTasks()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
|
||||
service.StartPermissionRequest("run-2", "file_write", "ask");
|
||||
service.CompletePermissionRequest("run-2", "file_write", "granted", true);
|
||||
service.RecordHookResult("run-2", "file_write", "pre hook ok", true);
|
||||
service.StartBackgroundRun("worker-2", "worker-2", "running");
|
||||
service.CompleteBackgroundRun("worker-2", "worker-2", "done", true);
|
||||
|
||||
service.ActiveTasks.Should().BeEmpty();
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "permission" && t.Status == "completed");
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "hook" && t.Status == "completed");
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "background" && t.Status == "completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueueLifecycleApis_TrackQueueTasks()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
|
||||
service.StartQueueRun("Chat", "draft-1", "first draft");
|
||||
service.CompleteQueueRun("Chat", "draft-1", "retry wait", "blocked");
|
||||
|
||||
service.ActiveTasks.Should().BeEmpty();
|
||||
service.RecentTasks.Should().ContainSingle(t => t.Kind == "queue" && t.Status == "blocked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRecentFromExecutionEvents_BuildsRecentTaskHistory()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
service.RestoreRecentFromExecutionEvents(
|
||||
[
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddMinutes(-3), RunId = "run-1", Type = "ToolResult", ToolName = "file_read", Summary = "read", Success = true },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddMinutes(-2), RunId = "run-1", Type = "PermissionDenied", ToolName = "file_write", Summary = "deny", Success = false },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddMinutes(-1), RunId = "run-1", Type = "Complete", Summary = "done", Success = true },
|
||||
]);
|
||||
|
||||
service.ActiveTasks.Should().BeEmpty();
|
||||
service.RecentTasks.Should().HaveCount(3);
|
||||
service.RecentTasks[0].Kind.Should().Be("agent");
|
||||
service.RecentTasks[1].Kind.Should().Be("permission");
|
||||
service.RecentTasks[2].Kind.Should().Be("tool");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void RestoreRecentFromExecutionEvents_PrefersTerminalEventsWhenTimestampsEqual()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
service.RestoreRecentFromExecutionEvents(
|
||||
[
|
||||
new Models.ChatExecutionEvent { Timestamp = now, RunId = "run-1", Type = "ToolCall", ToolName = "file_read", Summary = "call", Success = true, Iteration = 1 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now, RunId = "run-1", Type = "ToolResult", ToolName = "file_read", Summary = "done", Success = false, Iteration = 2 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now, RunId = "run-1", Type = "PermissionRequest", ToolName = "file_write", Summary = "ask", Success = true, Iteration = 1 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now, RunId = "run-1", Type = "PermissionDenied", ToolName = "file_write", Summary = "deny", Success = false, Iteration = 2 },
|
||||
]);
|
||||
|
||||
service.RecentTasks.Should().HaveCount(2);
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "tool" && t.Status == "failed" && t.Summary == "done");
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "permission" && t.Status == "failed" && t.Summary == "deny");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void RestoreRecentFromExecutionEvents_RebuildsActiveTasksFromNonTerminalEvents()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
service.RestoreRecentFromExecutionEvents(
|
||||
[
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-3), RunId = "run-2", Type = "Thinking", Summary = "thinking", Iteration = 1 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-2), RunId = "run-2", Type = "ToolCall", ToolName = "file_read", Summary = "call", Iteration = 2 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-1), RunId = "run-2", Type = "PermissionRequest", ToolName = "file_write", Summary = "ask", Iteration = 3 },
|
||||
]);
|
||||
|
||||
service.ActiveTasks.Should().HaveCount(3);
|
||||
service.ActiveTasks.Should().Contain(t => t.Kind == "agent" && t.Status == "running");
|
||||
service.ActiveTasks.Should().Contain(t => t.Kind == "tool" && t.Status == "running");
|
||||
service.ActiveTasks.Should().Contain(t => t.Kind == "permission" && t.Status == "waiting");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRecentFromExecutionEvents_RemovesActiveTaskWhenTerminalEventAppears()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
service.RestoreRecentFromExecutionEvents(
|
||||
[
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-2), RunId = "run-3", Type = "ToolCall", ToolName = "file_read", Summary = "call", Iteration = 1 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-1), RunId = "run-3", Type = "ToolResult", ToolName = "file_read", Summary = "done", Success = true, Iteration = 2 },
|
||||
]);
|
||||
|
||||
service.ActiveTasks.Should().NotContain(t => t.Kind == "tool" && t.Title == "file_read");
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "tool" && t.Status == "completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Suite", "ReplayStability")]
|
||||
public void RestoreRecentFromExecutionEvents_CompleteClearsDanglingRunScopedActiveTasks()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
service.RestoreRecentFromExecutionEvents(
|
||||
[
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-3), RunId = "run-4", Type = "ToolCall", ToolName = "file_edit", Summary = "call", Iteration = 1 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-2), RunId = "run-4", Type = "PermissionRequest", ToolName = "file_edit", Summary = "ask", Iteration = 2 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-1), RunId = "run-4", Type = "Complete", Summary = "done", Iteration = 3, Success = true },
|
||||
]);
|
||||
|
||||
service.ActiveTasks.Should().BeEmpty();
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "agent" && t.Status == "completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRecentFromExecutionEvents_RunLevelErrorClearsDanglingRunScopedActiveTasks()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
var now = DateTime.Now;
|
||||
|
||||
service.RestoreRecentFromExecutionEvents(
|
||||
[
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-3), RunId = "run-5", Type = "ToolCall", ToolName = "file_write", Summary = "call", Iteration = 1 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-2), RunId = "run-5", Type = "PermissionRequest", ToolName = "file_write", Summary = "ask", Iteration = 2 },
|
||||
new Models.ChatExecutionEvent { Timestamp = now.AddSeconds(-1), RunId = "run-5", Type = "Error", Summary = "fatal", Iteration = 3, Success = false },
|
||||
]);
|
||||
|
||||
service.ActiveTasks.Should().BeEmpty();
|
||||
service.RecentTasks.Should().Contain(t => t.Kind == "agent" && t.Status == "failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSummary_ReturnsActiveAndPendingPermissionCounts()
|
||||
{
|
||||
var service = new TaskRunService();
|
||||
service.StartOrUpdate("agent:1", "agent", "main", "thinking");
|
||||
service.StartOrUpdate("permission:1:file_write", "permission", "file_write 권한", "ask", "waiting");
|
||||
service.Complete("agent:1", "done", "completed");
|
||||
|
||||
var summary = service.GetSummary();
|
||||
|
||||
summary.ActiveCount.Should().Be(1);
|
||||
summary.PendingPermissionCount.Should().Be(1);
|
||||
summary.LatestRecentTask.Should().NotBeNull();
|
||||
summary.LatestRecentTask!.Id.Should().Be("agent:1");
|
||||
}
|
||||
}
|
||||
87
src/AxCopilot.Tests/Services/TaskRunStoreTests.cs
Normal file
87
src/AxCopilot.Tests/Services/TaskRunStoreTests.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using AxCopilot.Services;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class TaskRunStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Upsert_UpdatesExistingTaskWithoutDuplicating()
|
||||
{
|
||||
var store = new TaskRunStore();
|
||||
|
||||
store.Upsert("tool:1", "tool", "file_read", "first", "running");
|
||||
store.Upsert("tool:1", "tool", "file_read", "updated", "running", @"E:\a.txt");
|
||||
|
||||
store.ActiveTasks.Should().HaveCount(1);
|
||||
store.ActiveTasks[0].Summary.Should().Be("updated");
|
||||
store.ActiveTasks[0].FilePath.Should().Be(@"E:\a.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_MovesTaskToRecent()
|
||||
{
|
||||
var store = new TaskRunStore();
|
||||
store.Upsert("agent:1", "agent", "main", "working", "running");
|
||||
|
||||
store.Complete("agent:1", "done", "completed");
|
||||
|
||||
store.ActiveTasks.Should().BeEmpty();
|
||||
store.RecentTasks.Should().ContainSingle();
|
||||
store.RecentTasks[0].Summary.Should().Be("done");
|
||||
store.RecentTasks[0].Status.Should().Be("completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteByPrefix_MovesMatchingTasksOnly()
|
||||
{
|
||||
var store = new TaskRunStore();
|
||||
store.Upsert("tool:run1:file_read", "tool", "file_read", "reading", "running");
|
||||
store.Upsert("tool:run1:grep", "tool", "grep", "searching", "running");
|
||||
store.Upsert("agent:run1", "agent", "main", "planning", "running");
|
||||
|
||||
store.CompleteByPrefix("tool:run1:", "tool batch done", "completed");
|
||||
|
||||
store.ActiveTasks.Should().ContainSingle(t => t.Id == "agent:run1");
|
||||
store.RecentTasks.Should().HaveCount(2);
|
||||
store.RecentTasks.Should().OnlyContain(t => t.Summary == "tool batch done");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_FiresWhenStoreChanges()
|
||||
{
|
||||
var store = new TaskRunStore();
|
||||
var count = 0;
|
||||
store.Changed += () => count++;
|
||||
|
||||
store.Upsert("agent:1", "agent", "main", "thinking", "running");
|
||||
store.Complete("agent:1", "done", "completed");
|
||||
|
||||
count.Should().BeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRecent_ReplacesRecentSnapshotAndClearsActive()
|
||||
{
|
||||
var store = new TaskRunStore();
|
||||
store.Upsert("agent:1", "agent", "main", "thinking", "running");
|
||||
|
||||
store.RestoreRecent(
|
||||
[
|
||||
new TaskRunStore.TaskRun
|
||||
{
|
||||
Id = "tool:1",
|
||||
Kind = "tool",
|
||||
Title = "file_read",
|
||||
Summary = "done",
|
||||
Status = "completed",
|
||||
UpdatedAt = DateTime.Now,
|
||||
}
|
||||
]);
|
||||
|
||||
store.ActiveTasks.Should().BeEmpty();
|
||||
store.RecentTasks.Should().ContainSingle();
|
||||
store.RecentTasks[0].Id.Should().Be("tool:1");
|
||||
}
|
||||
}
|
||||
44
src/AxCopilot.Tests/Services/TaskTypePolicyTests.cs
Normal file
44
src/AxCopilot.Tests/Services/TaskTypePolicyTests.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class TaskTypePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromTaskType_Bugfix_ProvidesExpectedFields()
|
||||
{
|
||||
var policy = TaskTypePolicy.FromTaskType("bugfix");
|
||||
|
||||
policy.TaskType.Should().Be("bugfix");
|
||||
policy.GuidanceMessage.Should().Contain("bug-fix");
|
||||
policy.FailurePatternFocus.Should().Contain("재현 조건");
|
||||
policy.FollowUpTaskLine.Should().Contain("작업 유형: bugfix");
|
||||
policy.FinalReportTaskLine.Should().Contain("버그 수정");
|
||||
policy.IsReviewTask.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTaskType_Review_SetsReviewFlag()
|
||||
{
|
||||
var policy = TaskTypePolicy.FromTaskType("review");
|
||||
|
||||
policy.TaskType.Should().Be("review");
|
||||
policy.IsReviewTask.Should().BeTrue();
|
||||
policy.GuidanceMessage.Should().Contain("review task");
|
||||
policy.FailureInvestigationTaskLine.Should().Contain("리뷰에서 지적된 위험");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTaskType_Unknown_FallsBackToGeneral()
|
||||
{
|
||||
var policy = TaskTypePolicy.FromTaskType("unknown_type");
|
||||
|
||||
policy.TaskType.Should().Be("general");
|
||||
policy.GuidanceMessage.Should().Contain("cautious analyze");
|
||||
policy.FollowUpTaskLine.Should().BeEmpty();
|
||||
policy.FailureInvestigationTaskLine.Should().BeEmpty();
|
||||
policy.FinalReportTaskLine.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
78
src/AxCopilot.Tests/Services/TextFileCodecTests.cs
Normal file
78
src/AxCopilot.Tests/Services/TextFileCodecTests.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using AxCopilot.Services.Agent;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Services;
|
||||
|
||||
public sealed class TextFileCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAllTextAsync_DetectsUtf8WithoutBom()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var content = "한글 UTF8 테스트\nline2";
|
||||
await TextFileCodec.WriteAllTextAsync(path, content, TextFileCodec.Utf8NoBom);
|
||||
|
||||
var result = await TextFileCodec.ReadAllTextAsync(path);
|
||||
|
||||
result.Text.Should().Be(content);
|
||||
result.Encoding.CodePage.Should().Be(Encoding.UTF8.CodePage);
|
||||
result.HasBom.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveWriteEncoding_PreservesUtf8Bom()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var bomUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
|
||||
await TextFileCodec.WriteAllTextAsync(path, "first", bomUtf8);
|
||||
|
||||
var read = await TextFileCodec.ReadAllTextAsync(path);
|
||||
var writeEnc = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
|
||||
await TextFileCodec.WriteAllTextAsync(path, "second", writeEnc);
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(path);
|
||||
bytes.Length.Should().BeGreaterThan(3);
|
||||
bytes[0].Should().Be(0xEF);
|
||||
bytes[1].Should().Be(0xBB);
|
||||
bytes[2].Should().Be(0xBF);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAllTextAsync_ReadsLegacyKoreanEncoding()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
var cp949 = Encoding.GetEncoding(949);
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var source = "가나다 테스트";
|
||||
await File.WriteAllBytesAsync(path, cp949.GetBytes(source));
|
||||
|
||||
var result = await TextFileCodec.ReadAllTextAsync(path);
|
||||
|
||||
result.Text.Should().Be(source);
|
||||
result.HasBom.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user