feat: schedule tab 'Automatisch' with dialog, pause, delete and run-now
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a4e4c2e88
commit
8978cebd17
7 files changed
+940
No files matched your search
@@ -160,4 +160,34 @@ public static class LearningFormat
|
||||
timeLimitMinutes is > 0
|
||||
? $"Du hast {timeLimitMinutes} Minuten. Die Uhr startet sofort und läuft weiter, auch wenn du die Seite schließt. Hinweise und Lösungen gibt es erst nach der Abgabe."
|
||||
: "Hinweise und Lösungen gibt es erst nach der Abgabe.";
|
||||
|
||||
// ---- Automatische Übungsblätter (Phase 11c) ----
|
||||
|
||||
public static string ScheduleStatus(LearningScheduleStatus status) => status switch
|
||||
{
|
||||
LearningScheduleStatus.Created => "Blatt erzeugt",
|
||||
LearningScheduleStatus.CreatedAsDraft => "Als Entwurf erzeugt",
|
||||
LearningScheduleStatus.Deferred => "Aufgeschoben (Tageslimit)",
|
||||
LearningScheduleStatus.Failed => "Fehlgeschlagen",
|
||||
LearningScheduleStatus.Skipped => "Übersprungen",
|
||||
_ => "Noch nicht gelaufen",
|
||||
};
|
||||
|
||||
public static Color ScheduleStatusColor(LearningScheduleStatus status) => status switch
|
||||
{
|
||||
LearningScheduleStatus.Created => Color.Success,
|
||||
LearningScheduleStatus.CreatedAsDraft or LearningScheduleStatus.Deferred or LearningScheduleStatus.Skipped => Color.Warning,
|
||||
LearningScheduleStatus.Failed => Color.Error,
|
||||
_ => Color.Default,
|
||||
};
|
||||
|
||||
/// <summary>„Mo–Fr 15:00“.</summary>
|
||||
public static string ScheduleTimes(LearningWeekdays days, TimeOnly time) => LearningScheduleRules.DescribeWeekdays(days) + " " + time.ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>„Di., 13.10. 15:00“ in Europe/Berlin.</summary>
|
||||
public static string LocalMoment(DateTimeOffset moment) =>
|
||||
FamilyTime.ToFamilyTime(moment).ToString("ddd", German).TrimEnd('.') + "., " + FamilyTime.ToFamilyTime(moment).ToString("dd.MM. HH:mm", German);
|
||||
|
||||
public static string DifficultyPlan(int start, int raiseEveryRuns) =>
|
||||
raiseEveryRuns > 0 ? $"{Difficulty(start)}, danach alle {(raiseEveryRuns == 1 ? "Blatt" : $"{raiseEveryRuns} Blätter")} eine Stufe schwerer" : $"{Difficulty(start)}, bleibt gleich";
|
||||
}
|
||||
@@ -30,6 +30,11 @@
|
||||
role="tab" aria-selected="@(_tab == Tab.Sets ? "true" : "false")" Class="fp-learning-tab-sets">Blätter (@_sets.Count)</MudChip>
|
||||
<MudChip T="string" Color="@(_tab == Tab.Review ? Color.Primary : _queue.Count > 0 ? Color.Warning : Color.Default)" Variant="@(_tab == Tab.Review ? Variant.Filled : Variant.Outlined)"
|
||||
OnClick="() => _tab = Tab.Review" role="tab" aria-selected="@(_tab == Tab.Review ? "true" : "false")" Class="fp-learning-tab-review">Zu prüfen (@_queue.Count)</MudChip>
|
||||
@if (_ai is not null)
|
||||
{
|
||||
<MudChip T="string" Color="@(_tab == Tab.Auto ? Color.Primary : Color.Default)" Variant="@(_tab == Tab.Auto ? Variant.Filled : Variant.Outlined)" OnClick="() => _tab = Tab.Auto"
|
||||
role="tab" aria-selected="@(_tab == Tab.Auto ? "true" : "false")" Class="fp-learning-tab-auto">Automatisch</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
@@ -43,6 +48,10 @@
|
||||
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_tab == Tab.Auto && _ai is not null)
|
||||
{
|
||||
<ScheduleTab Children="_children" CanCreate="_ai.CanGenerateLearning" IsCloud="_ai.LearningGenerationIsCloud" />
|
||||
}
|
||||
else if (_tab == Tab.Review)
|
||||
{
|
||||
@if (_queue.Count == 0)
|
||||
@@ -151,6 +160,7 @@
|
||||
{
|
||||
Sets,
|
||||
Review,
|
||||
Auto,
|
||||
}
|
||||
|
||||
private List<LearningSetDto> _sets = [];
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
@using System.Globalization
|
||||
@using FamilyPlanner.Domain.Learning
|
||||
@using FamilyPlanner.Shared.Admin
|
||||
@using FamilyPlanner.Shared.Learning
|
||||
@inject ILearningApi Api
|
||||
|
||||
@* Zeitplan für automatisch erzeugte Übungsblätter anlegen oder ändern (Phase 11c). Verbindlich ist die serverseitige Prüfung; hier gelten dieselben Validatoren. *@
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<EditForm Model="this" OnSubmit="SaveAsync" FormName="learning-schedule-form" id="learning-schedule-form">
|
||||
<MudStack Spacing="2">
|
||||
@if (IsCloud)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-sched-privacy">
|
||||
Datenschutz: Die KI läuft bei einem Cloud-Anbieter außerhalb des Heimnetzes. Fach, Klasse und dein Zusatzwunsch werden bei jedem automatischen Lauf dorthin gesendet –
|
||||
schreibe keine Namen oder privaten Angaben hinein.
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_error</MudAlert>
|
||||
}
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<MudSelect T="Guid" @bind-Value="_target" Label="Für Kind" Variant="Variant.Outlined" Class="fp-task-field fp-sched-target" Disabled="_busy"
|
||||
ToStringFunc="@(id => Children.FirstOrDefault(c => c.Id == id)?.DisplayName ?? string.Empty)"
|
||||
Error="@HasError("TargetUserId")" ErrorText="@ErrorFor("TargetUserId")">
|
||||
@foreach (var child in Children)
|
||||
{
|
||||
<MudSelectItem T="Guid" Value="@child.Id">@child.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="string" @bind-Value="_subject" Label="Fach" Variant="Variant.Outlined" Class="fp-task-field fp-sched-subject" Disabled="_busy"
|
||||
Error="@HasError("Subject")" ErrorText="@ErrorFor("Subject")">
|
||||
@foreach (var subject in LearningSubjects.All)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@subject">@subject</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<MudSelect T="int" @bind-Value="_gradeLevel" Label="Klassenstufe" Variant="Variant.Outlined" Class="fp-task-field fp-sched-grade" Disabled="_busy"
|
||||
ToStringFunc="@(g => LearningFormat.GradeLevel(g))" Error="@HasError("GradeLevel")" ErrorText="@ErrorFor("GradeLevel")">
|
||||
@for (var level = LearningSet.MinGradeLevel; level <= LearningSet.MaxGradeLevel; level++)
|
||||
{
|
||||
<MudSelectItem T="int" Value="@level">@LearningFormat.GradeLevel(level)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudNumericField T="int" @bind-Value="_count" Label="Aufgaben pro Blatt" Min="LearningSchedule.MinCount" Max="LearningSchedule.MaxCount" Variant="Variant.Outlined"
|
||||
Class="fp-task-field fp-sched-count" Disabled="_busy" Error="@HasError("ItemCount")" ErrorText="@ErrorFor("ItemCount")"
|
||||
HelperText="@($"{LearningSchedule.MinCount} bis {LearningSchedule.MaxCount}")" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<MudSelect T="int" @bind-Value="_start" Label="Start-Schwierigkeit" Variant="Variant.Outlined" Class="fp-task-field fp-sched-start" Disabled="_busy"
|
||||
ToStringFunc="@(d => LearningFormat.Difficulty(d))" Error="@HasError("StartDifficulty")" ErrorText="@ErrorFor("StartDifficulty")">
|
||||
@for (var d = 1; d <= 3; d++)
|
||||
{
|
||||
<MudSelectItem T="int" Value="@d">@LearningFormat.Difficulty(d)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudNumericField T="int" @bind-Value="_raise" Label="Eine Stufe schwerer nach … Blättern" Min="0" Max="LearningSchedule.MaxRaiseEveryRuns" Variant="Variant.Outlined"
|
||||
Class="fp-task-field fp-sched-raise" Disabled="_busy" Error="@HasError("RaiseEveryRuns")" ErrorText="@ErrorFor("RaiseEveryRuns")"
|
||||
HelperText="0 = Schwierigkeit bleibt gleich. Höchstens „Schwer“." />
|
||||
</div>
|
||||
|
||||
<fieldset class="fp-sched-days-box">
|
||||
<legend class="mud-typography mud-typography-subtitle2">Wochentage</legend>
|
||||
<div class="d-flex flex-wrap gap-1 fp-sched-days" role="group" aria-label="Wochentage">
|
||||
@for (var i = 0; i < DayNames.Length; i++)
|
||||
{
|
||||
var day = i;
|
||||
var flag = (LearningWeekdays)(1 << day);
|
||||
var selected = (_weekdays & flag) != 0;
|
||||
<MudChip T="string" Size="Size.Medium" Color="@(selected ? Color.Primary : Color.Default)" Variant="@(selected ? Variant.Filled : Variant.Outlined)"
|
||||
OnClick="() => ToggleDay(flag)" Disabled="_busy" role="checkbox" aria-checked="@(selected ? "true" : "false")"
|
||||
Class="@($"fp-sched-day fp-sched-day-{day}")">@DayNames[day]</MudChip>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-1 mt-1">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" OnClick="() => _weekdays = LearningWeekdays.All" Disabled="_busy" Class="fp-sched-preset-all">Täglich</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" OnClick="() => _weekdays = LearningWeekdays.Weekdays" Disabled="_busy" Class="fp-sched-preset-weekdays">Mo–Fr</MudButton>
|
||||
</div>
|
||||
@if (HasError("Weekdays"))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Error" Class="fp-sched-days-error">@ErrorFor("Weekdays")</MudText>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<MudTextField T="string" @bind-Value="_time" InputType="InputType.Time" Label="Uhrzeit (Berlin)" Variant="Variant.Outlined" Class="fp-sched-time" Disabled="_busy"
|
||||
Error="@HasError("TimeOfDay")" ErrorText="@ErrorFor("TimeOfDay")" />
|
||||
|
||||
<MudTextField @bind-Value="_extra" Label="Zusatzwunsch (optional)" Variant="Variant.Outlined" Lines="2" Class="fp-sched-extra" Disabled="_busy"
|
||||
MaxLength="LearningScheduleLimits.ExtraMaxLength" Counter="LearningScheduleLimits.ExtraMaxLength" Immediate="true"
|
||||
HelperText="Thema oder Stil – die KI behandelt das nur als Wunsch." Error="@HasError("Extra")" ErrorText="@ErrorFor("Extra")" />
|
||||
|
||||
<MudSwitch T="bool" @bind-Value="_autoAssign" Label="Blatt direkt dem Kind zuweisen" Color="Color.Warning" Disabled="_busy" Class="fp-sched-assign" />
|
||||
@if (_autoAssign)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-sched-assign-warning">
|
||||
Achtung: Das Kind sieht das Blatt sofort, ohne dass du es vorher durchgesehen hast. Die KI-Aufgaben werden zwar geprüft und Rechenaufgaben nachgerechnet,
|
||||
sie können aber trotzdem Fehler enthalten. Ohne diese Option landet jedes Blatt als Entwurf bei dir zur Prüfung.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="fp-sched-draft-hint">Jedes Blatt landet als <b>Entwurf</b> bei dir und geht erst nach deiner Prüfung an das Kind.</MudText>
|
||||
}
|
||||
|
||||
<MudSwitch T="bool" @bind-Value="_active" Label="Zeitplan aktiv" Color="Color.Primary" Disabled="_busy" Class="fp-sched-active" />
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel" Disabled="_busy" Class="fp-sched-cancel">Abbrechen</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="@(_busy || Children.Count == 0)" Class="fp-sched-save">Speichern</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private static readonly string[] DayNames = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance? Dialog { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<UserDirectoryEntry> Children { get; set; } = [];
|
||||
|
||||
/// <summary>Der Anbieter liegt außerhalb des Heimnetzes: Datenschutzhinweis anzeigen.</summary>
|
||||
[Parameter]
|
||||
public bool IsCloud { get; set; }
|
||||
|
||||
/// <summary>Zeitplan zum Ändern; <c>null</c> = neuer Zeitplan.</summary>
|
||||
[Parameter]
|
||||
public LearningScheduleDto? Existing { get; set; }
|
||||
|
||||
private Guid _target;
|
||||
private string _subject = LearningSubjects.Math;
|
||||
private int _gradeLevel = 3;
|
||||
private int _count = 10;
|
||||
private int _start = 1;
|
||||
private int _raise = 4;
|
||||
private LearningWeekdays _weekdays = LearningWeekdays.Weekdays;
|
||||
private string _time = "15:00";
|
||||
private string? _extra;
|
||||
private bool _autoAssign;
|
||||
private bool _active = true;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
private Dictionary<string, string> _errors = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Existing is { } e)
|
||||
{
|
||||
(_target, _subject, _gradeLevel, _count, _start, _raise) = (e.TargetUserId, e.Subject, e.GradeLevel, e.ItemCount, e.StartDifficulty, e.RaiseEveryRuns);
|
||||
(_weekdays, _extra, _autoAssign, _active) = (e.Weekdays, e.Extra, e.AutoAssign, e.IsActive);
|
||||
_time = e.TimeOfDay.ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
else if (Children.Count > 0)
|
||||
{
|
||||
_target = Children[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleDay(LearningWeekdays flag) => _weekdays ^= flag;
|
||||
|
||||
private bool HasError(string field) => _errors.ContainsKey(field);
|
||||
|
||||
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_error = null;
|
||||
_errors = [];
|
||||
if (!TimeOnly.TryParseExact(_time, ["HH:mm", "H:mm", "HH:mm:ss"], CultureInfo.InvariantCulture, DateTimeStyles.None, out var time))
|
||||
{
|
||||
_errors["TimeOfDay"] = "Bitte eine Uhrzeit angeben.";
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new SaveLearningScheduleRequest(
|
||||
_target, _subject, _gradeLevel, _count, _start, _raise, string.IsNullOrWhiteSpace(_extra) ? null : _extra.Trim(),
|
||||
_weekdays, time, _autoAssign, _active, Existing?.Version);
|
||||
foreach (var failure in new SaveLearningScheduleRequestValidator().Validate(request).Errors)
|
||||
{
|
||||
_errors.TryAdd(failure.PropertyName, failure.ErrorMessage);
|
||||
}
|
||||
|
||||
if (_errors.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var response = Existing is null ? await Api.CreateScheduleAsync(request) : await Api.UpdateScheduleAsync(Existing.Id, request);
|
||||
if (response.Succeeded && response.Value is { } saved)
|
||||
{
|
||||
Dialog?.Close(DialogResult.Ok(saved));
|
||||
}
|
||||
else if (response.Problem?.Errors is { Count: > 0 } errors)
|
||||
{
|
||||
foreach (var (field, messages) in errors)
|
||||
{
|
||||
_errors[field] = string.Join(" ", messages);
|
||||
}
|
||||
|
||||
_error = "Bitte die Eingaben prüfen.";
|
||||
}
|
||||
else if (response.Problem?.Status == 409 && Existing is not null)
|
||||
{
|
||||
_error = "Der Zeitplan wurde inzwischen geändert. Bitte schließe den Dialog und öffne ihn neu.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = response.Problem?.Message ?? "Der Zeitplan konnte nicht gespeichert werden.";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => Dialog?.Close(DialogResult.Cancel());
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
@using FamilyPlanner.Domain.Learning
|
||||
@using FamilyPlanner.Shared.Admin
|
||||
@using FamilyPlanner.Shared.Learning
|
||||
@inject ILearningApi Api
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@* Reiter „Automatisch“ (Phase 11c): Zeitpläne für automatisch erzeugte Übungsblätter. Alle KI-/Fehlertexte werden nur als Text angezeigt. *@
|
||||
<section class="fp-sched" aria-label="Automatische Übungsblätter">
|
||||
<div class="d-flex align-center flex-wrap gap-2 mb-2">
|
||||
<MudText Typo="Typo.body2" Class="fp-sched-intro">
|
||||
Die App erzeugt zur eingestellten Zeit selbst ein neues Blatt per KI. Standardmäßig landet es als <b>Entwurf</b> bei dir.
|
||||
</MudText>
|
||||
<MudSpacer />
|
||||
@if (CanCreate)
|
||||
{
|
||||
<MudButton OnClick="() => OpenDialogAsync(null)" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Disabled="@(Children.Count == 0)" Class="fp-sched-new">
|
||||
Neuer Zeitplan
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!CanCreate)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2 fp-sched-no-ai">
|
||||
Es ist noch keine KI für Übungsblätter eingerichtet (Verwaltung → KI). Bestehende Zeitpläne lassen sich ansehen, pausieren und löschen.
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" role="alert" Class="mb-2 fp-sched-error">@_error</MudAlert>
|
||||
}
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else if (_schedules.Count == 0)
|
||||
{
|
||||
<MudText Class="my-6 text-center fp-sched-empty">Noch kein Zeitplan. Lege einen an, damit jede Woche automatisch neue Aufgaben bereitliegen.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="fp-sched-list" aria-label="Zeitpläne">
|
||||
@foreach (var schedule in _schedules)
|
||||
{
|
||||
var item = schedule;
|
||||
var running = _running == item.Id;
|
||||
<li class="fp-sched-item" data-schedule-id="@item.Id">
|
||||
<div class="d-flex align-center flex-wrap gap-2">
|
||||
<span class="fp-sched-title">@item.Subject · @LearningFormat.GradeLevel(item.GradeLevel) · @item.ChildName</span>
|
||||
@if (!item.IsActive)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="fp-sched-paused">Pausiert</MudChip>
|
||||
}
|
||||
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@(item.AutoAssign ? Color.Warning : Color.Default)" Class="fp-sched-mode">
|
||||
@(item.AutoAssign ? "Direkt zuweisen" : "Entwurf zur Prüfung")
|
||||
</MudChip>
|
||||
</div>
|
||||
<div class="fp-learning-meta fp-sched-meta">
|
||||
@LearningFormat.ScheduleTimes(item.Weekdays, item.TimeOfDay) · @(item.ItemCount == 1 ? "1 Aufgabe" : $"{item.ItemCount} Aufgaben") ·
|
||||
Schwierigkeit: @LearningFormat.DifficultyPlan(item.StartDifficulty, item.RaiseEveryRuns) (aktuell @LearningFormat.Difficulty(item.CurrentDifficulty))
|
||||
</div>
|
||||
<div class="fp-learning-meta fp-sched-next">
|
||||
@if (item.NextRunAt is { } next)
|
||||
{
|
||||
<span>Nächster Lauf: @(item.RetryAfter is { } retry && retry > DateTimeOffset.UtcNow ? $"ab {LearningFormat.LocalMoment(retry)} (erneuter Versuch)" : LearningFormat.LocalMoment(next))</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Kein Lauf geplant (pausiert).</span>
|
||||
}
|
||||
</div>
|
||||
<div class="fp-learning-meta fp-sched-last">
|
||||
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.ScheduleStatusColor(item.LastStatus)" Variant="Variant.Outlined" Class="fp-sched-status">
|
||||
@LearningFormat.ScheduleStatus(item.LastStatus)
|
||||
</MudChip>
|
||||
@if (item.LastRunAt is { } last)
|
||||
{
|
||||
<span> @LearningFormat.LocalMoment(last)</span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(item.LastMessage))
|
||||
{
|
||||
<span class="fp-sched-message"> – @item.LastMessage</span>
|
||||
}
|
||||
|
||||
@if (item.LastSetId is { } setId)
|
||||
{
|
||||
<MudLink Href="@($"lernen/blatt/{setId}")" Class="fp-sched-open-set">Letztes Blatt ansehen</MudLink>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-center flex-wrap gap-1 fp-sched-actions">
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" StartIcon="@Icons.Material.Filled.PlayArrow" OnClick="() => RunAsync(item)"
|
||||
Disabled="@(_running is not null || !CanCreate)" Class="fp-sched-run">
|
||||
@(running ? "KI erstellt das Blatt …" : "Jetzt ausführen")
|
||||
</MudButton>
|
||||
<MudSwitch T="bool" Value="item.IsActive" ValueChanged="(bool on) => SetActiveAsync(item, on)" Color="Color.Primary" Label="Aktiv" Disabled="@(_running is not null)" Class="fp-sched-toggle" />
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => OpenDialogAsync(item)" Disabled="@(!CanCreate)" aria-label="Zeitplan ändern" Class="fp-sched-edit" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteAsync(item)" aria-label="Zeitplan löschen" Class="fp-sched-delete" />
|
||||
</div>
|
||||
@if (running)
|
||||
{
|
||||
<div class="d-flex align-center gap-2 mt-1 fp-sched-busy" role="status" aria-live="polite">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
<span>Das kann bis zu einer Minute dauern.</span>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>Kinder, für die Zeitpläne angelegt werden können.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<UserDirectoryEntry> Children { get; set; } = [];
|
||||
|
||||
/// <summary>Die KI für Übungsblätter ist eingerichtet (<c>CanGenerateLearning</c>): neu anlegen und ausführen.</summary>
|
||||
[Parameter]
|
||||
public bool CanCreate { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsCloud { get; set; }
|
||||
|
||||
private List<LearningScheduleDto> _schedules = [];
|
||||
private bool _loading = true;
|
||||
private string? _error;
|
||||
private Guid? _running;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
var result = await Api.GetSchedulesAsync();
|
||||
if (result.Succeeded && result.Value is { } list)
|
||||
{
|
||||
_schedules = list;
|
||||
_error = null;
|
||||
}
|
||||
else if (_schedules.Count == 0)
|
||||
{
|
||||
_error = result.Problem?.Message ?? "Die Zeitpläne konnten nicht geladen werden.";
|
||||
}
|
||||
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task OpenDialogAsync(LearningScheduleDto? existing)
|
||||
{
|
||||
var parameters = new DialogParameters<ScheduleDialog>
|
||||
{
|
||||
{ x => x.Children, Children },
|
||||
{ x => x.IsCloud, IsCloud },
|
||||
{ x => x.Existing, existing },
|
||||
};
|
||||
var dialog = await DialogService.ShowAsync<ScheduleDialog>(
|
||||
existing is null ? "Neuer Zeitplan" : "Zeitplan ändern", parameters, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseOnEscapeKey = true });
|
||||
var result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetActiveAsync(LearningScheduleDto schedule, bool active)
|
||||
{
|
||||
_error = null;
|
||||
var request = new SaveLearningScheduleRequest(
|
||||
schedule.TargetUserId, schedule.Subject, schedule.GradeLevel, schedule.ItemCount, schedule.StartDifficulty, schedule.RaiseEveryRuns, schedule.Extra,
|
||||
schedule.Weekdays, schedule.TimeOfDay, schedule.AutoAssign, active, schedule.Version);
|
||||
var result = await Api.UpdateScheduleAsync(schedule.Id, request);
|
||||
var message = result.Succeeded ? null : result.Problem?.Message ?? "Der Zeitplan konnte nicht geändert werden.";
|
||||
await LoadAsync();
|
||||
_error = message;
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(LearningScheduleDto schedule)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBoxAsync(
|
||||
"Zeitplan löschen?",
|
||||
$"Den Zeitplan „{schedule.Subject} für {schedule.ChildName}“ wirklich löschen? Bereits erzeugte Blätter bleiben bestehen.",
|
||||
yesText: "Löschen", cancelText: "Abbrechen");
|
||||
if (confirmed != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await Api.DeleteScheduleAsync(schedule.Id);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
_schedules.RemoveAll(s => s.Id == schedule.Id);
|
||||
_error = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = result.Problem?.Message ?? "Der Zeitplan konnte nicht gelöscht werden.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync(LearningScheduleDto schedule)
|
||||
{
|
||||
if (_running is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBoxAsync(
|
||||
"Jetzt ein Blatt erzeugen?",
|
||||
$"Die KI erstellt jetzt ein neues Blatt für {schedule.ChildName}. Das verbraucht einen KI-Aufruf deines Tageslimits. "
|
||||
+ (schedule.AutoAssign ? "Das Blatt wird direkt zugewiesen." : "Das Blatt landet als Entwurf bei dir."),
|
||||
yesText: "Jetzt erzeugen", cancelText: "Abbrechen");
|
||||
if (confirmed != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_running = schedule.Id;
|
||||
_error = null;
|
||||
StateHasChanged(); // Wartezustand sofort zeigen (die Dialog-Fortsetzung rendert nicht von selbst)
|
||||
try
|
||||
{
|
||||
var result = await Api.RunScheduleAsync(schedule.Id);
|
||||
if (result.Succeeded && result.Value is { } run)
|
||||
{
|
||||
Snackbar.Add(run.Message, Severity.Success);
|
||||
Navigation.NavigateTo($"lernen/blatt/{run.SetId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var message = result.Problem?.Message ?? "Das Blatt konnte nicht erzeugt werden.";
|
||||
await LoadAsync(); // der Lauf hat den Zeitplan (Status, Meldung) ggf. verändert
|
||||
_error = message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,19 @@ public interface ILearningApi
|
||||
/// </summary>
|
||||
Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default);
|
||||
|
||||
// ---- Automatische Übungsblätter (Phase 11c, nur mit LearningManage und AiUse) ----
|
||||
Task<ApiResult<List<LearningScheduleDto>>> GetSchedulesAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<LearningScheduleDto>> CreateScheduleAsync(SaveLearningScheduleRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Bei 409 (veralteter Stand) steht die Meldung in <see cref="ApiResult{T}.Problem"/>; die Oberfläche lädt dann neu.</summary>
|
||||
Task<ApiResult<LearningScheduleDto>> UpdateScheduleAsync(Guid id, SaveLearningScheduleRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<bool>> DeleteScheduleAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>„Jetzt ausführen“: erzeugt sofort ein Blatt (ein KI-Aufruf, kann dauern). Fehler (Tageslimit, keine KI …) stehen im Problem.</summary>
|
||||
Task<ApiResult<LearningScheduleRunResultDto>> RunScheduleAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
// ---- Kinder ----
|
||||
Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -168,6 +181,35 @@ public sealed class LearningApi(HttpClient http) : ILearningApi
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ApiResult<List<LearningScheduleDto>>> GetSchedulesAsync(CancellationToken ct = default) =>
|
||||
Send<List<LearningScheduleDto>>(() => http.GetAsync($"{Base}/schedules", ct), ct);
|
||||
|
||||
public Task<ApiResult<LearningScheduleDto>> CreateScheduleAsync(SaveLearningScheduleRequest request, CancellationToken ct = default) =>
|
||||
Send<LearningScheduleDto>(() => http.PostAsJsonAsync($"{Base}/schedules", request, ct), ct);
|
||||
|
||||
public Task<ApiResult<LearningScheduleDto>> UpdateScheduleAsync(Guid id, SaveLearningScheduleRequest request, CancellationToken ct = default) =>
|
||||
Send<LearningScheduleDto>(() => http.PutAsJsonAsync($"{Base}/schedules/{id}", request, ct), ct);
|
||||
|
||||
public Task<ApiResult<bool>> DeleteScheduleAsync(Guid id, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/schedules/{id}", ct), ct);
|
||||
|
||||
public async Task<ApiResult<LearningScheduleRunResultDto>> RunScheduleAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await Send<LearningScheduleRunResultDto>(() => http.PostAsync($"{Base}/schedules/{id}/run", null, ct), ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Zeitlimit des Browsers (langsame KI): der Server arbeitet eventuell weiter, ein fertiges Blatt erscheint dann in der Liste.
|
||||
return ApiResult<LearningScheduleRunResultDto>.Fail(new ApiProblem
|
||||
{
|
||||
Status = 504,
|
||||
Detail = "Die KI braucht zu lange. Ein fertiges Blatt erscheint gleich in der Liste; sonst versuche es später noch einmal.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default) =>
|
||||
Send<List<MyLearningSetDto>>(() => http.GetAsync($"{Base}/my", ct), ct);
|
||||
|
||||
|
||||
@@ -209,6 +209,78 @@ internal sealed class FakeLearningApi : ILearningApi
|
||||
return Result(() => detail);
|
||||
}
|
||||
|
||||
// ---- Automatische Übungsblätter (Phase 11c) ----
|
||||
public List<LearningScheduleDto> Schedules { get; } = [];
|
||||
|
||||
public List<(Guid? Id, SaveLearningScheduleRequest Request)> ScheduleSaves { get; } = [];
|
||||
|
||||
public List<Guid> ScheduleRuns { get; } = [];
|
||||
|
||||
public List<Guid> ScheduleDeletes { get; } = [];
|
||||
|
||||
/// <summary>Antwort auf „Jetzt ausführen“; Standard: Blatt erzeugt.</summary>
|
||||
public Func<Guid, ApiResult<LearningScheduleRunResultDto>>? ScheduleRunBehavior { get; set; }
|
||||
|
||||
/// <summary>Wird bei „Jetzt ausführen“ abgewartet (Wartezustand); <c>null</c> = sofort.</summary>
|
||||
public TaskCompletionSource? ScheduleRunGate { get; set; }
|
||||
|
||||
public static LearningScheduleDto Schedule(
|
||||
Guid? child = null, string childName = "Mia", bool active = true, bool autoAssign = false, LearningScheduleStatus last = LearningScheduleStatus.Never, string? message = null) =>
|
||||
new(Guid.NewGuid(), child ?? Mia, childName, LearningSubjects.Math, 3, 10, 1, 4, 1, null, LearningWeekdays.Weekdays, new TimeOnly(15, 0), autoAssign, active,
|
||||
active ? new DateTimeOffset(2026, 10, 13, 13, 0, 0, TimeSpan.Zero) : null, null, last == LearningScheduleStatus.Never ? null : Now, last, message, null, 0, [1]);
|
||||
|
||||
public Task<ApiResult<List<LearningScheduleDto>>> GetSchedulesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(Offline ? ApiResult<List<LearningScheduleDto>>.Fail(ApiProblem.Offline) : ApiResult<List<LearningScheduleDto>>.Ok([.. Schedules]));
|
||||
|
||||
public Task<ApiResult<LearningScheduleDto>> CreateScheduleAsync(SaveLearningScheduleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ScheduleSaves.Add((null, request));
|
||||
var created = FromRequest(Guid.NewGuid(), request);
|
||||
Schedules.Add(created);
|
||||
return Task.FromResult(ApiResult<LearningScheduleDto>.Ok(created));
|
||||
}
|
||||
|
||||
public Task<ApiResult<LearningScheduleDto>> UpdateScheduleAsync(Guid id, SaveLearningScheduleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ScheduleSaves.Add((id, request));
|
||||
var index = Schedules.FindIndex(s => s.Id == id);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(ApiResult<LearningScheduleDto>.Fail(new ApiProblem { Status = 404 }));
|
||||
}
|
||||
|
||||
Schedules[index] = FromRequest(id, request);
|
||||
return Task.FromResult(ApiResult<LearningScheduleDto>.Ok(Schedules[index]));
|
||||
}
|
||||
|
||||
public Task<ApiResult<bool>> DeleteScheduleAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
ScheduleDeletes.Add(id);
|
||||
Schedules.RemoveAll(s => s.Id == id);
|
||||
return Task.FromResult(ApiResult<bool>.Ok(true));
|
||||
}
|
||||
|
||||
public async Task<ApiResult<LearningScheduleRunResultDto>> RunScheduleAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
ScheduleRuns.Add(id);
|
||||
if (ScheduleRunGate is { } gate)
|
||||
{
|
||||
await gate.Task;
|
||||
}
|
||||
|
||||
if (ScheduleRunBehavior is { } behavior)
|
||||
{
|
||||
return behavior(id);
|
||||
}
|
||||
|
||||
var detail = AddSet("Automatisch (12.10.)", itemCount: 3);
|
||||
return ApiResult<LearningScheduleRunResultDto>.Ok(new LearningScheduleRunResultDto(Schedules.Single(s => s.Id == id), detail.Set.Id, false, "Blatt als Entwurf erzeugt – bitte prüfen."));
|
||||
}
|
||||
|
||||
private LearningScheduleDto FromRequest(Guid id, SaveLearningScheduleRequest r) =>
|
||||
new(id, r.TargetUserId, Users.FirstOrDefault(u => u.Id == r.TargetUserId)?.DisplayName ?? "?", r.Subject, r.GradeLevel, r.ItemCount, r.StartDifficulty, r.RaiseEveryRuns, r.StartDifficulty,
|
||||
r.Extra, r.Weekdays, r.TimeOfDay, r.AutoAssign, r.IsActive, r.IsActive ? Now.AddDays(1) : null, null, null, LearningScheduleStatus.Never, null, null, 0, [2]);
|
||||
|
||||
// ---- KI-Entwurf (Phase 10b) ----
|
||||
public List<CreateAiDraftRequest> AiDrafts { get; } = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Shared.Ai;
|
||||
using FamilyPlanner.Shared.Learning;
|
||||
using FamilyPlanner.Web.Client.Components.Learning;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Learning;
|
||||
|
||||
/// <summary>Reiter „Automatisch“ (Phase 11c): Liste, Dialog, Pause, Löschen, „Jetzt ausführen“, Fehlertexte nur als Text.</summary>
|
||||
public sealed class ScheduleTests : LearningTestBase
|
||||
{
|
||||
private string CurrentUri => Services.GetRequiredService<NavigationManager>().Uri;
|
||||
|
||||
private IRenderedComponent<LearningHost> OpenTab(AiStatusDto? status = null)
|
||||
{
|
||||
AiApi.Status = status ?? new AiStatusDto(true, true, false);
|
||||
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
|
||||
var cut = Render<LearningHost>(p => p.AddChildContent<ParentOverview>());
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-learning-tab-auto")));
|
||||
cut.Find(".fp-learning-tab-auto").Click();
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched")));
|
||||
return cut;
|
||||
}
|
||||
|
||||
private static void Confirm(IRenderedComponent<LearningHost> cut, string button)
|
||||
{
|
||||
cut.WaitForAssertion(() => Assert.Contains(cut.FindAll("button"), b => Text(b) == button));
|
||||
cut.FindAll("button").First(b => Text(b) == button).Click();
|
||||
}
|
||||
|
||||
// ---------- Sichtbarkeit und Liste ----------
|
||||
|
||||
[Fact]
|
||||
public void The_tab_is_hidden_without_ai_access()
|
||||
{
|
||||
AiApi.Status = null;
|
||||
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
|
||||
|
||||
var cut = Render<LearningHost>(p => p.AddChildContent<ParentOverview>());
|
||||
|
||||
cut.WaitForAssertion(() => Assert.True(AiApi.StatusCalls > 0));
|
||||
Assert.Empty(cut.FindAll(".fp-learning-tab-auto"));
|
||||
Assert.NotEmpty(cut.FindAll(".fp-learning-new"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_list_explains_what_to_do()
|
||||
{
|
||||
var cut = OpenTab();
|
||||
|
||||
Assert.Contains("Noch kein Zeitplan", cut.Find(".fp-sched-empty").TextContent);
|
||||
Assert.NotEmpty(cut.FindAll(".fp-sched-new"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schedules_show_times_mode_difficulty_next_run_and_the_last_result()
|
||||
{
|
||||
Api.Schedules.Add(FakeLearningApi.Schedule(last: LearningScheduleStatus.Created, message: "Blatt als Entwurf erzeugt – bitte prüfen.") with { LastSetId = Guid.NewGuid(), RunCount = 2 });
|
||||
Api.Schedules.Add(FakeLearningApi.Schedule(FakeLearningApi.Ben, "Ben", active: false, autoAssign: true));
|
||||
|
||||
var cut = OpenTab();
|
||||
|
||||
var items = cut.FindAll(".fp-sched-item");
|
||||
Assert.Equal(2, items.Count);
|
||||
Assert.Contains("Mathe · 3. Klasse · Mia", Text(items[0].QuerySelector(".fp-sched-title")!));
|
||||
Assert.Contains("Mo–Fr 15:00", Text(items[0].QuerySelector(".fp-sched-meta")!));
|
||||
Assert.Contains("10 Aufgaben", Text(items[0].QuerySelector(".fp-sched-meta")!));
|
||||
Assert.Contains("Leicht, danach alle 4 Blätter eine Stufe schwerer", Text(items[0].QuerySelector(".fp-sched-meta")!));
|
||||
Assert.Equal("Entwurf zur Prüfung", Text(items[0].QuerySelector(".fp-sched-mode")!));
|
||||
Assert.Equal("Nächster Lauf: Di., 13.10. 15:00", Text(items[0].QuerySelector(".fp-sched-next")!));
|
||||
Assert.Contains("Blatt erzeugt", Text(items[0].QuerySelector(".fp-sched-last")!));
|
||||
Assert.Contains("bitte prüfen", Text(items[0].QuerySelector(".fp-sched-message")!));
|
||||
Assert.NotEmpty(items[0].QuerySelectorAll(".fp-sched-open-set"));
|
||||
|
||||
Assert.Contains("Pausiert", Text(items[1].QuerySelector(".fp-sched-paused")!));
|
||||
Assert.Equal("Direkt zuweisen", Text(items[1].QuerySelector(".fp-sched-mode")!));
|
||||
Assert.Contains("Kein Lauf geplant", Text(items[1].QuerySelector(".fp-sched-next")!));
|
||||
Assert.Contains("Noch nicht gelaufen", Text(items[1].QuerySelector(".fp-sched-status")!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Messages_of_the_last_run_are_shown_as_plain_text()
|
||||
{
|
||||
Api.Schedules.Add(FakeLearningApi.Schedule(last: LearningScheduleStatus.Failed, message: "<b>fett</b><img src=x onerror=alert(1)>"));
|
||||
|
||||
var cut = OpenTab();
|
||||
|
||||
Assert.Equal("– <b>fett</b><img src=x onerror=alert(1)>", Text(cut.Find(".fp-sched-message")));
|
||||
Assert.Empty(cut.FindAll(".fp-sched-message b"));
|
||||
Assert.Empty(cut.FindAll(".fp-sched-message img"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_generation_provider_creating_and_running_are_unavailable_but_pausing_stays_possible()
|
||||
{
|
||||
Api.Schedules.Add(FakeLearningApi.Schedule());
|
||||
|
||||
var cut = OpenTab(new AiStatusDto(true, false, false));
|
||||
|
||||
Assert.Empty(cut.FindAll(".fp-sched-new"));
|
||||
Assert.NotEmpty(cut.FindAll(".fp-sched-no-ai"));
|
||||
Assert.True(cut.Find(".fp-sched-run").HasAttribute("disabled"));
|
||||
Assert.NotEmpty(cut.FindAll(".fp-sched-toggle"));
|
||||
Assert.NotEmpty(cut.FindAll(".fp-sched-delete"));
|
||||
}
|
||||
|
||||
// ---------- Anlegen und Ändern ----------
|
||||
|
||||
[Fact]
|
||||
public void Creating_a_schedule_sends_the_entered_values_and_lists_it()
|
||||
{
|
||||
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
|
||||
var cut = OpenTab();
|
||||
|
||||
cut.Find(".fp-sched-new").Click();
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-save")));
|
||||
Assert.Empty(cut.FindAll(".fp-sched-privacy"));
|
||||
Assert.NotEmpty(cut.FindAll(".fp-sched-draft-hint")); // Standard: Entwurf zur Prüfung
|
||||
Assert.Empty(cut.FindAll(".fp-sched-assign-warning"));
|
||||
cut.Find(".fp-sched-count input").Change("12");
|
||||
cut.Find(".fp-sched-raise input").Change("2");
|
||||
cut.Find(".fp-sched-time input").Change("16:30");
|
||||
cut.Find(".fp-sched-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
|
||||
var (id, request) = Api.ScheduleSaves[0];
|
||||
Assert.Null(id);
|
||||
Assert.Equal((FakeLearningApi.Mia, LearningSubjects.Math, 3, 12, 1, 2), (request.TargetUserId, request.Subject, request.GradeLevel, request.ItemCount, request.StartDifficulty, request.RaiseEveryRuns));
|
||||
Assert.Equal((LearningWeekdays.Weekdays, new TimeOnly(16, 30), false, true), (request.Weekdays, request.TimeOfDay, request.AutoAssign, request.IsActive));
|
||||
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-sched-item")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weekday_chips_and_presets_change_the_selection()
|
||||
{
|
||||
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
|
||||
var cut = OpenTab();
|
||||
cut.Find(".fp-sched-new").Click();
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-day")));
|
||||
|
||||
cut.Find(".fp-sched-preset-all").Click();
|
||||
cut.Find(".fp-sched-day-1").Click(); // Dienstag abwählen
|
||||
cut.Find(".fp-sched-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
|
||||
Assert.Equal(LearningWeekdays.All & ~LearningWeekdays.Tuesday, Api.ScheduleSaves[0].Request.Weekdays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Direct_assignment_shows_a_warning_and_is_sent_only_when_chosen()
|
||||
{
|
||||
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
|
||||
var cut = OpenTab();
|
||||
cut.Find(".fp-sched-new").Click();
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-assign")));
|
||||
|
||||
cut.Find(".fp-sched-assign input").Change(true);
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("ohne dass du es vorher durchgesehen hast", cut.Find(".fp-sched-assign-warning").TextContent));
|
||||
cut.Find(".fp-sched-save").Click();
|
||||
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
|
||||
Assert.True(Api.ScheduleSaves[0].Request.AutoAssign);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_input_is_reported_without_calling_the_api()
|
||||
{
|
||||
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
|
||||
var cut = OpenTab();
|
||||
cut.Find(".fp-sched-new").Click();
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-day")));
|
||||
|
||||
// alle Tage abwählen (Standard Mo–Fr)
|
||||
foreach (var index in new[] { 0, 1, 2, 3, 4 })
|
||||
{
|
||||
cut.Find($".fp-sched-day-{index}").Click();
|
||||
}
|
||||
|
||||
cut.Find(".fp-sched-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("mindestens einen Wochentag", cut.Find(".fp-sched-days-error").TextContent));
|
||||
Assert.Empty(Api.ScheduleSaves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_cloud_privacy_note_appears_for_cloud_providers()
|
||||
{
|
||||
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
|
||||
var cut = OpenTab(new AiStatusDto(true, true, false, LearningGenerationIsCloud: true));
|
||||
|
||||
cut.Find(".fp-sched-new").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Cloud-Anbieter", cut.Find(".fp-sched-privacy").TextContent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editing_a_schedule_prefills_the_dialog_and_sends_the_version()
|
||||
{
|
||||
var existing = FakeLearningApi.Schedule(autoAssign: false) with { ItemCount = 7, Weekdays = LearningWeekdays.Monday | LearningWeekdays.Wednesday, TimeOfDay = new TimeOnly(9, 5) };
|
||||
Api.Schedules.Add(existing);
|
||||
var cut = OpenTab();
|
||||
|
||||
cut.Find(".fp-sched-edit").Click();
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-save")));
|
||||
Assert.Equal("7", cut.Find(".fp-sched-count input").GetAttribute("value"));
|
||||
Assert.Equal("09:05", cut.Find(".fp-sched-time input").GetAttribute("value"));
|
||||
cut.Find(".fp-sched-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
|
||||
var (id, request) = Api.ScheduleSaves[0];
|
||||
Assert.Equal(existing.Id, id);
|
||||
Assert.Equal(existing.Version, request.Version);
|
||||
Assert.Equal((7, LearningWeekdays.Monday | LearningWeekdays.Wednesday, new TimeOnly(9, 5)), (request.ItemCount, request.Weekdays, request.TimeOfDay));
|
||||
}
|
||||
|
||||
// ---------- Pause, Löschen ----------
|
||||
|
||||
[Fact]
|
||||
public void The_switch_pauses_and_resumes_a_schedule()
|
||||
{
|
||||
var existing = FakeLearningApi.Schedule();
|
||||
Api.Schedules.Add(existing);
|
||||
var cut = OpenTab();
|
||||
|
||||
cut.Find(".fp-sched-toggle input").Change(false);
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
|
||||
Assert.Equal((existing.Id, false, existing.Version), (Api.ScheduleSaves[0].Id, Api.ScheduleSaves[0].Request.IsActive, Api.ScheduleSaves[0].Request.Version));
|
||||
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-paused")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleting_asks_first_and_removes_the_schedule()
|
||||
{
|
||||
var existing = FakeLearningApi.Schedule();
|
||||
Api.Schedules.Add(existing);
|
||||
var cut = OpenTab();
|
||||
|
||||
cut.Find(".fp-sched-delete").Click();
|
||||
Confirm(cut, "Abbrechen");
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-dialog")));
|
||||
Assert.Empty(Api.ScheduleDeletes);
|
||||
|
||||
cut.Find(".fp-sched-delete").Click();
|
||||
Confirm(cut, "Löschen");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal([existing.Id], Api.ScheduleDeletes));
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-sched-item")));
|
||||
}
|
||||
|
||||
// ---------- Jetzt ausführen ----------
|
||||
|
||||
[Fact]
|
||||
public void Run_now_confirms_shows_the_waiting_state_and_opens_the_new_sheet()
|
||||
{
|
||||
var existing = FakeLearningApi.Schedule();
|
||||
Api.Schedules.Add(existing);
|
||||
Api.ScheduleRunGate = new TaskCompletionSource();
|
||||
var cut = OpenTab();
|
||||
|
||||
cut.Find(".fp-sched-run").Click();
|
||||
Confirm(cut, "Jetzt erzeugen");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("KI erstellt das Blatt", cut.Find(".fp-sched-run").TextContent));
|
||||
Assert.NotEmpty(cut.FindAll(".fp-sched-busy"));
|
||||
Assert.True(cut.Find(".fp-sched-run").HasAttribute("disabled")); // kein zweiter Lauf während der Wartezeit
|
||||
Assert.True(cut.Find(".fp-sched-edit").HasAttribute("disabled") || cut.Find(".fp-sched-toggle input").HasAttribute("disabled"));
|
||||
Api.ScheduleRunGate.SetResult();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("lernen/blatt/", CurrentUri));
|
||||
Assert.Equal([existing.Id], Api.ScheduleRuns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_now_can_be_cancelled_in_the_confirmation()
|
||||
{
|
||||
Api.Schedules.Add(FakeLearningApi.Schedule());
|
||||
var cut = OpenTab();
|
||||
|
||||
cut.Find(".fp-sched-run").Click();
|
||||
Confirm(cut, "Abbrechen");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-dialog")));
|
||||
Assert.Empty(Api.ScheduleRuns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failed_run_shows_the_message_as_text_and_stays_on_the_page()
|
||||
{
|
||||
var existing = FakeLearningApi.Schedule();
|
||||
Api.Schedules.Add(existing);
|
||||
Api.ScheduleRunBehavior = _ => ApiResult<LearningScheduleRunResultDto>.Fail(new ApiProblem { Status = 429, Detail = "Das Tageslimit von 5 KI-Aufrufen ist <b>erreicht</b>." });
|
||||
var cut = OpenTab();
|
||||
|
||||
cut.Find(".fp-sched-run").Click();
|
||||
Confirm(cut, "Jetzt erzeugen");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Tageslimit von 5 KI-Aufrufen ist <b>erreicht</b>.", cut.Find(".fp-sched-error").TextContent));
|
||||
Assert.Empty(cut.FindAll(".fp-sched-error b"));
|
||||
Assert.DoesNotContain("lernen/blatt/", CurrentUri);
|
||||
Assert.False(cut.Find(".fp-sched-run").HasAttribute("disabled")); // wieder bedienbar
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user