Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions PolyPilot.Tests/AgentSessionInfoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,22 @@ public void UnreadCount_HandlesLastReadBeyondHistory()

Assert.Equal(0, session.UnreadCount);
}

[Fact]
public void NotifyOnComplete_DefaultsFalse()
{
var session = new AgentSessionInfo { Name = "test", Model = "gpt-5" };
Assert.False(session.NotifyOnComplete);
}

[Fact]
public void NotifyOnComplete_CanBeSetAndCleared()
{
var session = new AgentSessionInfo { Name = "test", Model = "gpt-5" };
session.NotifyOnComplete = true;
Assert.True(session.NotifyOnComplete);

session.NotifyOnComplete = false;
Assert.False(session.NotifyOnComplete);
}
}
42 changes: 42 additions & 0 deletions PolyPilot.Tests/ConnectionSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,48 @@ public void NormalizeRemoteUrl_DoesNotDoubleScheme()
Assert.Equal("http://http://example.com", result);
}

[Fact]
public void EnableSessionNotifications_DefaultsFalse()
{
var settings = new ConnectionSettings();
Assert.False(settings.EnableSessionNotifications);
}

[Fact]
public void NotificationReminderIntervalMinutes_DefaultsZero()
{
var settings = new ConnectionSettings();
Assert.Equal(0, settings.NotificationReminderIntervalMinutes);
}

[Fact]
public void NotificationReminderIntervalMinutes_CanBeSet()
{
var settings = new ConnectionSettings { NotificationReminderIntervalMinutes = 5 };
Assert.Equal(5, settings.NotificationReminderIntervalMinutes);
}

[Fact]
public void NotificationReminderIntervalMinutes_RoundTripsViaSerialization()
{
var original = new ConnectionSettings { NotificationReminderIntervalMinutes = 10 };
var json = JsonSerializer.Serialize(original);
var loaded = JsonSerializer.Deserialize<ConnectionSettings>(json);

Assert.NotNull(loaded);
Assert.Equal(10, loaded!.NotificationReminderIntervalMinutes);
}

[Fact]
public void NotificationReminderIntervalMinutes_BackwardCompatibility_DefaultsZero()
{
var json = """{"Mode":0,"Host":"localhost","Port":4321}""";
var loaded = JsonSerializer.Deserialize<ConnectionSettings>(json);

Assert.NotNull(loaded);
Assert.Equal(0, loaded!.NotificationReminderIntervalMinutes);
}

private void Dispose()
{
try { Directory.Delete(_testDir, true); } catch { }
Expand Down
2 changes: 2 additions & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
<Compile Include="../PolyPilot/Models/TutorialStep.cs" Link="Shared/TutorialStep.cs" />
<Compile Include="../PolyPilot/Services/TutorialService.cs" Link="Shared/TutorialService.cs" />
<Compile Include="../PolyPilot/BuildInfo.cs" Link="Shared/BuildInfo.cs" />
<Compile Include="../PolyPilot/Models/ScheduledPrompt.cs" Link="Shared/ScheduledPrompt.cs" />
<Compile Include="../PolyPilot/Services/ScheduledPromptService.cs" Link="Shared/ScheduledPromptService.cs" />
</ItemGroup>

</Project>
192 changes: 192 additions & 0 deletions PolyPilot.Tests/ScheduledPromptServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
using PolyPilot.Models;
using PolyPilot.Services;
using Microsoft.Extensions.DependencyInjection;

namespace PolyPilot.Tests;

public class ScheduledPromptServiceTests : IDisposable
{
private readonly string _testDir;
private readonly string _storeFile;

public ScheduledPromptServiceTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"polypilot-sched-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
_storeFile = Path.Combine(_testDir, "scheduled-prompts.json");
ScheduledPromptService.SetStorePathForTesting(_storeFile);
}

public void Dispose()
{
// Reset to an isolated non-shared path so parallel tests are unaffected
ScheduledPromptService.SetStorePathForTesting(Path.Combine(_testDir, "noop.json"));
try { Directory.Delete(_testDir, true); } catch { }
}

private static CopilotService CreateCopilotService()
{
var sp = new ServiceCollection().BuildServiceProvider();
return new CopilotService(
new StubChatDatabase(),
new StubServerManager(),
new StubWsBridgeClient(),
new RepoManager(),
sp,
new StubDemoService());
}

[Fact]
public void ScheduledPrompt_DefaultValues()
{
var p = new ScheduledPrompt();
Assert.False(string.IsNullOrEmpty(p.Id));
Assert.Equal("", p.Label);
Assert.Equal("", p.Prompt);
Assert.Equal("", p.SessionName);
Assert.Null(p.NextRunAt);
Assert.Equal(0, p.RepeatIntervalMinutes);
Assert.Null(p.LastRunAt);
Assert.True(p.IsEnabled);
}

[Fact]
public void DisplayName_UsesLabel_WhenSet()
{
var p = new ScheduledPrompt { Label = "My check", Prompt = "How are things going?" };
Assert.Equal("My check", p.DisplayName);
}

[Fact]
public void DisplayName_TruncatesLongPrompt_WhenNoLabel()
{
var p = new ScheduledPrompt { Label = "", Prompt = "This is a very long prompt that exceeds forty characters in length" };
Assert.EndsWith("…", p.DisplayName);
Assert.True(p.DisplayName.Length <= 40);
}

[Fact]
public void DisplayName_ShortPrompt_UsedDirectly()
{
var p = new ScheduledPrompt { Label = "", Prompt = "Short prompt" };
Assert.Equal("Short prompt", p.DisplayName);
}

[Fact]
public void Add_AddsPromptAndPersists()
{
var svc = new ScheduledPromptService(CreateCopilotService());
var p = svc.Add("session1", "Check status", "Status check", DateTime.UtcNow.AddMinutes(5));

Assert.Single(svc.Prompts);
Assert.Equal("session1", p.SessionName);
Assert.Equal("Check status", p.Prompt);
Assert.Equal("Status check", p.Label);
Assert.True(p.IsEnabled);
Assert.True(File.Exists(_storeFile));
}

[Fact]
public void Add_WithRepeat_SetsRepeatInterval()
{
var svc = new ScheduledPromptService(CreateCopilotService());
var p = svc.Add("session1", "Daily check", repeatIntervalMinutes: 1440);
Assert.Equal(1440, p.RepeatIntervalMinutes);
}

[Fact]
public void Remove_RemovesPromptAndPersists()
{
var svc = new ScheduledPromptService(CreateCopilotService());
var p = svc.Add("session1", "Check status");
Assert.True(svc.Remove(p.Id));
Assert.Empty(svc.Prompts);
}

[Fact]
public void Remove_ReturnsFalse_WhenNotFound()
{
var svc = new ScheduledPromptService(CreateCopilotService());
Assert.False(svc.Remove("nonexistent-id"));
}

[Fact]
public void SetEnabled_TogglesEnabledState()
{
var svc = new ScheduledPromptService(CreateCopilotService());
var p = svc.Add("session1", "Check status");

svc.SetEnabled(p.Id, false);
Assert.False(svc.Prompts[0].IsEnabled);

svc.SetEnabled(p.Id, true);
Assert.True(svc.Prompts[0].IsEnabled);
}

[Fact]
public void Persistence_RoundTrip()
{
var svc = new ScheduledPromptService(CreateCopilotService());
var p = svc.Add("session1", "Hello world", "Test", DateTime.UtcNow.AddMinutes(10), 60);

var svc2 = new ScheduledPromptService(CreateCopilotService());
Assert.Single(svc2.Prompts);
var loaded = svc2.Prompts[0];
Assert.Equal(p.Id, loaded.Id);
Assert.Equal("session1", loaded.SessionName);
Assert.Equal("Hello world", loaded.Prompt);
Assert.Equal("Test", loaded.Label);
Assert.Equal(60, loaded.RepeatIntervalMinutes);
Assert.True(loaded.IsEnabled);
}

[Fact]
public void FirePromptAsync_OneShot_DisablesAfterFiring_WhenSessionNotFound_Retries()
{
// When session doesn't exist, the prompt should stay enabled for retry
var svc = new ScheduledPromptService(CreateCopilotService());
var p = svc.Add("nonexistent-session", "One-shot", runAt: DateTime.UtcNow, repeatIntervalMinutes: 0);

svc.FirePromptAsync(p).GetAwaiter().GetResult();

// Session not found → still enabled (will retry on next check)
Assert.True(p.IsEnabled);
Assert.NotNull(p.NextRunAt);
}

[Fact]
public void FirePromptAsync_Repeat_WhenSessionNotFound_Retries()
{
// When session doesn't exist, keeps the original NextRunAt for retry
var svc = new ScheduledPromptService(CreateCopilotService());
var runAt = DateTime.UtcNow;
var p = svc.Add("nonexistent-session", "Repeating", runAt: runAt, repeatIntervalMinutes: 30);

svc.FirePromptAsync(p).GetAwaiter().GetResult();

// Session not found → stays enabled, NextRunAt unchanged
Assert.True(p.IsEnabled);
Assert.Equal(runAt, p.NextRunAt);
}

[Fact]
public void OnStateChanged_FiresOnAdd()
{
var svc = new ScheduledPromptService(CreateCopilotService());
var fired = false;
svc.OnStateChanged += () => fired = true;
svc.Add("s", "p");
Assert.True(fired);
}

[Fact]
public void OnStateChanged_FiresOnRemove()
{
var svc = new ScheduledPromptService(CreateCopilotService());
var p = svc.Add("s", "p");
var fired = false;
svc.OnStateChanged += () => fired = true;
svc.Remove(p.Id);
Assert.True(fired);
}
}
3 changes: 2 additions & 1 deletion PolyPilot/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ namespace PolyPilot;

public partial class App : Application
{
public App(INotificationManagerService notificationService)
public App(INotificationManagerService notificationService, ScheduledPromptService scheduledPrompts)
{
InitializeComponent();
_ = notificationService.InitializeAsync();
scheduledPrompts.Start();
}

protected override Window CreateWindow(IActivationState? activationState)
Expand Down
20 changes: 20 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
{
<span class="pin-badge" title="Pinned" @onclick="() => OnPin.InvokeAsync(false)" @onclick:stopPropagation="true">📌</span>
}
@if (Session.NotifyOnComplete)
{
<span class="pin-badge" title="Notify when done" @onclick="() => { CopilotService.SetSessionNotifyOnComplete(Session.Name, false); }" @onclick:stopPropagation="true">🔔</span>
}
@if (IsCompleted)
{
<span class="done-badge">✅</span>
Expand Down Expand Up @@ -113,6 +117,21 @@
📌 Pin
</button>
}
@if (Session.NotifyOnComplete)
{
<button class="menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); CopilotService.SetSessionNotifyOnComplete(Session.Name, false); }">
🔔 Watching (tap to stop)
</button>
}
else
{
<button class="menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); CopilotService.SetSessionNotifyOnComplete(Session.Name, true); }">
🔔 Notify when done
</button>
}
<button class="menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); OnSchedulePrompt.InvokeAsync(Session.Name); }">
📅 Schedule a prompt…
</button>
@if (Groups != null && Groups.Count > 1)
{
<div class="menu-separator"></div>
Expand Down Expand Up @@ -228,6 +247,7 @@
[Parameter] public EventCallback OnCloseMenu { get; set; }
[Parameter] public EventCallback OnReportBug { get; set; }
[Parameter] public EventCallback OnFixWithCopilot { get; set; }
[Parameter] public EventCallback<string> OnSchedulePrompt { get; set; }

/// <summary>
/// Show close confirmation via a JS-created dialog (outside Blazor's DOM tree).
Expand Down
Loading