List the earlier contacts with the call being typed under the log
N1MM's log window has two panes. The lower one lists every contact already logged with the call in the callsign box, on any band and in any mode, so an apparent dupe can be read off the screen rather than looked for. EarlierContacts.Matching holds the rule, so it is unit-tested without a window: three characters before anything is listed, a call that holds what is typed, `*` for any run of characters and `?` for one, the grid square instead when nothing is typed, ordered by call, mode, band and time, and fifty at most. That is N1MM's DupeSQL, except that N1MM passes `?` to SQLite as a plain character, which finds nothing. The two grids share their columns, because a value in the pane has to sit under the column it belongs to. Each column is now as wide as the longest value it can hold, measured in the font it is drawn in, and the last column takes what is left over. The values and the header are in different fonts, so each is measured on its own. This also settles the column widths, which used to change as rows scrolled into view. A DataGrid does not pass its own font down to its cells, so the cell font is set in a style. Without that the cells draw larger than the width measured for them and every column is a character short. Looked at under Xvfb with a call typed: the pane lists the two contacts that hold it, under columns that line up with the log above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
12
README.md
12
README.md
@@ -266,7 +266,17 @@ otherwise, which puts the station in the middle of its country. Sunrise and
|
|||||||
sunset are worked out from that position for today; inside the polar circles the
|
sunset are worked out from that position for today; inside the polar circles the
|
||||||
line says the sun does not rise or set.
|
line says the sun does not rise or set.
|
||||||
|
|
||||||
### Editing the log
|
### The log window
|
||||||
|
|
||||||
|
The pane under the log lists what has already been worked with the call being
|
||||||
|
typed: every earlier contact whose call holds it, on any band and in any mode,
|
||||||
|
so an apparent dupe can be read off the screen. `*` stands for any run of
|
||||||
|
characters and `?` for one. With nothing typed and a grid square copied, it
|
||||||
|
lists the contacts from that grid square instead. N1MM has the same pane, and
|
||||||
|
stops at fifty contacts as this does.
|
||||||
|
|
||||||
|
Both grids get the same columns, each as wide as the longest value it can hold,
|
||||||
|
with the last column taking what is left over.
|
||||||
|
|
||||||
Double-click a cell in the log window to change it. The columns follow the
|
Double-click a cell in the log window to change it. The columns follow the
|
||||||
contest exchange, so CQ WW shows a Zone column and Sweepstakes shows Nr, Prec,
|
contest exchange, so CQ WW shows a Zone column and Sweepstakes shows Nr, Prec,
|
||||||
|
|||||||
@@ -114,9 +114,6 @@ beyond country, prefix, zone, section, exchange, grid and continent.
|
|||||||
|
|
||||||
## Known rough edges
|
## Known rough edges
|
||||||
|
|
||||||
**The log window's column widths** are the grid's own automatic sizing with a
|
|
||||||
character-count floor. A column can change width as rows scroll into view.
|
|
||||||
|
|
||||||
**Contacts arriving over the station network** are applied from the socket
|
**Contacts arriving over the station network** are applied from the socket
|
||||||
thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it.
|
thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it.
|
||||||
That is correct but heavy, and it discards what is typed in an entry window on
|
That is correct but heavy, and it discards what is typed in an entry window on
|
||||||
|
|||||||
@@ -5,14 +5,28 @@
|
|||||||
Title="Log" Width="1000" Height="420">
|
Title="Log" Width="1000" Height="420">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
|
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
|
||||||
<DataGrid Name="Rows" GridLinesVisibility="Horizontal" CanUserSortColumns="False"
|
<Grid RowDefinitions="2*,Auto,*">
|
||||||
FontSize="12" FontFamily="monospace">
|
<Grid.Styles>
|
||||||
<DataGrid.ContextMenu>
|
<!-- the DataGrid's own font settings do not reach the cells, and the
|
||||||
<ContextMenu>
|
columns are sized from the width of the text in this font -->
|
||||||
<MenuItem Header="Edit contact…" Click="OnEditContact" InputGesture="Enter" />
|
<Style Selector="DataGridCell TextBlock">
|
||||||
<MenuItem Header="Delete contact" Click="OnDelete" InputGesture="Delete" />
|
<Setter Property="FontFamily" Value="monospace" />
|
||||||
</ContextMenu>
|
<Setter Property="FontSize" Value="12" />
|
||||||
</DataGrid.ContextMenu>
|
</Style>
|
||||||
</DataGrid>
|
</Grid.Styles>
|
||||||
|
<DataGrid Name="Rows" Grid.Row="0" GridLinesVisibility="Horizontal" CanUserSortColumns="False"
|
||||||
|
FontSize="12" FontFamily="monospace">
|
||||||
|
<DataGrid.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Edit contact…" Click="OnEditContact" InputGesture="Enter" />
|
||||||
|
<MenuItem Header="Delete contact" Click="OnDelete" InputGesture="Delete" />
|
||||||
|
</ContextMenu>
|
||||||
|
</DataGrid.ContextMenu>
|
||||||
|
</DataGrid>
|
||||||
|
<GridSplitter Grid.Row="1" Height="3" ResizeDirection="Rows" />
|
||||||
|
<DataGrid Name="Earlier" Grid.Row="2" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
||||||
|
CanUserSortColumns="False" HeadersVisibility="None"
|
||||||
|
FontSize="12" FontFamily="monospace" />
|
||||||
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</local:RefreshableWindow>
|
</local:RefreshableWindow>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using System.Globalization;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Data;
|
using Avalonia.Data;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
using Nonemm.App.Dialogs;
|
using Nonemm.App.Dialogs;
|
||||||
|
using Nonemm.Contests;
|
||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
using Nonemm.Session;
|
using Nonemm.Session;
|
||||||
|
|
||||||
@@ -12,11 +15,8 @@ namespace Nonemm.App.Windows;
|
|||||||
/// change it; the Delete key removes the contact.
|
/// change it; the Delete key removes the contact.
|
||||||
public sealed partial class LogWindow : RefreshableWindow
|
public sealed partial class LogWindow : RefreshableWindow
|
||||||
{
|
{
|
||||||
/// Roughly the width of one character of the grid font, in device pixels.
|
/// What a cell takes either side of its text, in device pixels.
|
||||||
private const double CharacterWidth = 7.5;
|
private const double CellPadding = 30;
|
||||||
|
|
||||||
/// What a cell takes either side of its text.
|
|
||||||
private const double CellPadding = 20;
|
|
||||||
|
|
||||||
private readonly AppSession session;
|
private readonly AppSession session;
|
||||||
private string builtFor = "";
|
private string builtFor = "";
|
||||||
@@ -28,13 +28,8 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
this.session = session;
|
this.session = session;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
Rows.KeyDown += OnKeyDown;
|
Rows.KeyDown += OnKeyDown;
|
||||||
Rows.LoadingRow += (_, e) =>
|
Colour(Rows);
|
||||||
{
|
Colour(Earlier);
|
||||||
if (e.Row.DataContext is LogRow row)
|
|
||||||
{
|
|
||||||
e.Row.Foreground = row.Colour;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +38,7 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
if (session.Logging is null)
|
if (session.Logging is null)
|
||||||
{
|
{
|
||||||
Rows.ItemsSource = Array.Empty<LogRow>();
|
Rows.ItemsSource = Array.Empty<LogRow>();
|
||||||
|
Earlier.ItemsSource = Array.Empty<LogRow>();
|
||||||
SummaryText.Text = "no contest is open";
|
SummaryText.Text = "no contest is open";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -55,16 +51,46 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
{
|
{
|
||||||
Rows.ScrollIntoView(rows[^1], null);
|
Rows.ScrollIntoView(rows[^1], null);
|
||||||
}
|
}
|
||||||
|
ShowEarlier();
|
||||||
SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging);
|
SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging);
|
||||||
message = "";
|
message = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pane under the log: what has already been worked with the call being
|
||||||
|
/// typed, so an apparent dupe can be read off the screen.
|
||||||
|
private void ShowEarlier()
|
||||||
|
{
|
||||||
|
OperatingPosition? position = session.Position;
|
||||||
|
if (position is null || session.Logging is null)
|
||||||
|
{
|
||||||
|
Earlier.ItemsSource = Array.Empty<LogRow>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Earlier.ItemsSource = EarlierContacts
|
||||||
|
.Matching(
|
||||||
|
session.Logging.Log.Qsos,
|
||||||
|
position.Entry.Call,
|
||||||
|
position.Entry.ValueOf(ExchangeSlot.GridSquare))
|
||||||
|
.Select(q => new LogRow(q, VerdictFor(q), Edit))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Colour(DataGrid grid) =>
|
||||||
|
grid.LoadingRow += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Row.DataContext is LogRow row)
|
||||||
|
{
|
||||||
|
e.Row.Foreground = row.Colour;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private static string Summary(ContestSession logging) =>
|
private static string Summary(ContestSession logging) =>
|
||||||
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
|
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
|
||||||
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
|
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
|
||||||
|
|
||||||
/// The columns follow the contest exchange, so they are rebuilt when a
|
/// The columns follow the contest exchange, so they are rebuilt when a
|
||||||
/// different contest is opened.
|
/// different contest is opened. Both grids get the same columns: a value in
|
||||||
|
/// the pane has to sit under the column it belongs to.
|
||||||
private void BuildColumns(QsoEditor editor)
|
private void BuildColumns(QsoEditor editor)
|
||||||
{
|
{
|
||||||
string wanted = string.Join('|', editor.Columns.Select(c => c.Label));
|
string wanted = string.Join('|', editor.Columns.Select(c => c.Label));
|
||||||
@@ -73,38 +99,60 @@ public sealed partial class LogWindow : RefreshableWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
builtFor = wanted;
|
builtFor = wanted;
|
||||||
Rows.Columns.Clear();
|
List<ColumnSpec> spec =
|
||||||
foreach (QsoColumn column in editor.Columns)
|
[
|
||||||
{
|
.. editor.Columns.Select(c => new ColumnSpec(c.Label, $"[{c.Field}]", true, c.Width)),
|
||||||
Rows.Columns.Add(new DataGridTextColumn
|
new ColumnSpec("Cty", nameof(LogRow.Country), false, 6),
|
||||||
{
|
new ColumnSpec("Pts", nameof(LogRow.Points), false, 4),
|
||||||
Header = column.Label,
|
new ColumnSpec("Mult", nameof(LogRow.Multipliers), false, 5),
|
||||||
Width = DataGridLength.Auto,
|
];
|
||||||
MinWidth = MinimumFor(column.Width),
|
FillColumns(Rows, spec);
|
||||||
Binding = new Binding($"[{column.Field}]") { Mode = BindingMode.TwoWay },
|
FillColumns(Earlier, spec);
|
||||||
});
|
|
||||||
}
|
|
||||||
Rows.Columns.Add(ReadOnlyColumn("Cty", nameof(LogRow.Country), 6));
|
|
||||||
Rows.Columns.Add(ReadOnlyColumn("Pts", nameof(LogRow.Points), 4));
|
|
||||||
Rows.Columns.Add(ReadOnlyColumn("Mult", nameof(LogRow.Multipliers), 5));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private DataGridTextColumn ReadOnlyColumn(string header, string property, int characters) =>
|
/// N1MM gives each column the width of the longest value it can hold, and
|
||||||
new()
|
/// the last column what is left over. A fixed width also keeps a column from
|
||||||
|
/// changing size as rows scroll into view.
|
||||||
|
private static void FillColumns(DataGrid grid, IReadOnlyList<ColumnSpec> spec)
|
||||||
|
{
|
||||||
|
grid.Columns.Clear();
|
||||||
|
double Width(ColumnSpec column) => WidthFor(column, grid);
|
||||||
|
for (int at = 0; at < spec.Count; at++)
|
||||||
{
|
{
|
||||||
Header = header,
|
ColumnSpec column = spec[at];
|
||||||
IsReadOnly = true,
|
grid.Columns.Add(new DataGridTextColumn
|
||||||
Width = DataGridLength.Auto,
|
{
|
||||||
MinWidth = MinimumFor(characters),
|
Header = column.Header,
|
||||||
Binding = new Binding(property),
|
IsReadOnly = !column.IsEditable,
|
||||||
};
|
Width = at == spec.Count - 1
|
||||||
|
? new DataGridLength(1, DataGridLengthUnitType.Star)
|
||||||
|
: new DataGridLength(Width(column)),
|
||||||
|
MinWidth = Width(column),
|
||||||
|
Binding = column.IsEditable
|
||||||
|
? new Binding(column.Path) { Mode = BindingMode.TwoWay }
|
||||||
|
: new Binding(column.Path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The grid sizes a column to the wider of its header and the values it can
|
/// Wide enough for both the longest value and the header. They are in
|
||||||
/// see. The header is in the theme's own font, not the grid's monospace, so
|
/// different fonts — the values in the grid's monospace, the header in the
|
||||||
/// counting characters gets it wrong; the count is only a floor, so a
|
/// theme's own font — so each is measured in the font it is drawn in.
|
||||||
/// column of short values does not collapse.
|
private static double WidthFor(ColumnSpec column, DataGrid grid) =>
|
||||||
private static double MinimumFor(int characters) =>
|
Math.Max(
|
||||||
(characters * CharacterWidth) + CellPadding;
|
TextWidth(new string('0', column.Characters + 1), grid.FontFamily, grid.FontSize),
|
||||||
|
TextWidth(column.Header + "0", FontFamily.Default, grid.FontSize)) + CellPadding;
|
||||||
|
|
||||||
|
private static double TextWidth(string text, FontFamily family, double size) =>
|
||||||
|
new FormattedText(
|
||||||
|
text,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
FlowDirection.LeftToRight,
|
||||||
|
new Typeface(family),
|
||||||
|
size,
|
||||||
|
null).Width;
|
||||||
|
|
||||||
|
private sealed record ColumnSpec(string Header, string Path, bool IsEditable, int Characters);
|
||||||
|
|
||||||
/// N1MM's Ctrl+F: shows the next contact with this call and selects it,
|
/// N1MM's Ctrl+F: shows the next contact with this call and selects it,
|
||||||
/// starting again from the top once the last one has been shown. Null when
|
/// starting again from the top once the last one has been shown. Null when
|
||||||
|
|||||||
100
src/Nonemm.Session/EarlierContacts.cs
Normal file
100
src/Nonemm.Session/EarlierContacts.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Session;
|
||||||
|
|
||||||
|
/// The contacts already logged with the callsign being typed. N1MM lists them
|
||||||
|
/// under the log, so an apparent dupe can be read off the screen: which band,
|
||||||
|
/// which mode, and how long ago.
|
||||||
|
public static class EarlierContacts
|
||||||
|
{
|
||||||
|
/// N1MM's limit on the list.
|
||||||
|
public const int Limit = 50;
|
||||||
|
|
||||||
|
/// Two characters match too much of any log to be worth listing.
|
||||||
|
public const int ShortestQuery = 3;
|
||||||
|
|
||||||
|
/// With a call typed, the contacts whose call holds it. With nothing typed,
|
||||||
|
/// the contacts whose grid square holds the one typed for the station being
|
||||||
|
/// worked; with neither, nothing.
|
||||||
|
///
|
||||||
|
/// `*` stands for any run of characters and `?` for one, as in the check
|
||||||
|
/// window. N1MM passes `?` to SQLite, which reads it as a plain character;
|
||||||
|
/// that finds nothing, because no callsign holds a question mark.
|
||||||
|
public static IReadOnlyList<Qso> Matching(
|
||||||
|
IReadOnlyList<Qso> logged,
|
||||||
|
string typed,
|
||||||
|
string gridSquare = "",
|
||||||
|
int limit = Limit)
|
||||||
|
{
|
||||||
|
string query = typed.Trim().ToUpperInvariant();
|
||||||
|
string grid = gridSquare.Trim();
|
||||||
|
IEnumerable<Qso> found;
|
||||||
|
if (query.Length == 0)
|
||||||
|
{
|
||||||
|
if (grid.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
found = logged.Where(q => q.GridSquare.Contains(grid, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
else if (query.Length < ShortestQuery)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
else if (query.Contains('*') || query.Contains('?'))
|
||||||
|
{
|
||||||
|
found = logged.Where(q => MatchesPattern(query, q.Call.Text));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
found = logged.Where(q => q.Call.Text.Contains(query, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. found
|
||||||
|
.OrderBy(q => q.Call.Text, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(q => q.Mode.Name, StringComparer.Ordinal)
|
||||||
|
.ThenBy(q => q.Frequency.Hertz)
|
||||||
|
.ThenBy(q => q.TimestampUtc)
|
||||||
|
.Take(limit),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wildcard pattern covers the whole call, as N1MM's `like` does.
|
||||||
|
private static bool MatchesPattern(string pattern, string call)
|
||||||
|
{
|
||||||
|
int at = 0;
|
||||||
|
int character = 0;
|
||||||
|
int lastStar = -1;
|
||||||
|
int afterStar = 0;
|
||||||
|
while (character < call.Length)
|
||||||
|
{
|
||||||
|
if (at < pattern.Length &&
|
||||||
|
(pattern[at] == '?' || pattern[at] == char.ToUpperInvariant(call[character])))
|
||||||
|
{
|
||||||
|
at++;
|
||||||
|
character++;
|
||||||
|
}
|
||||||
|
else if (at < pattern.Length && pattern[at] == '*')
|
||||||
|
{
|
||||||
|
lastStar = at++;
|
||||||
|
afterStar = character;
|
||||||
|
}
|
||||||
|
else if (lastStar >= 0)
|
||||||
|
{
|
||||||
|
// the run the last `*` stood for was too short: give it one more
|
||||||
|
at = lastStar + 1;
|
||||||
|
character = ++afterStar;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (at < pattern.Length && pattern[at] == '*')
|
||||||
|
{
|
||||||
|
at++;
|
||||||
|
}
|
||||||
|
return at == pattern.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
tests/Nonemm.Session.Tests/EarlierContactsTests.cs
Normal file
118
tests/Nonemm.Session.Tests/EarlierContactsTests.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
using Nonemm.Core;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
public class EarlierContactsTests
|
||||||
|
{
|
||||||
|
private static Qso Contact(
|
||||||
|
string call,
|
||||||
|
double kilohertz = 14_030,
|
||||||
|
Mode? mode = null,
|
||||||
|
int minute = 0,
|
||||||
|
string grid = "")
|
||||||
|
{
|
||||||
|
return new Qso
|
||||||
|
{
|
||||||
|
Id = Qso.NewId(),
|
||||||
|
TimestampUtc = new DateTime(2026, 1, 1, 0, minute, 0, DateTimeKind.Utc),
|
||||||
|
Call = Callsign.Parse(call),
|
||||||
|
Frequency = Frequency.FromKilohertz(kilohertz),
|
||||||
|
Mode = mode ?? Modes.Cw,
|
||||||
|
ContestName = "CQWW",
|
||||||
|
GridSquare = grid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindsEveryContactWhoseCallHoldsWhatIsTyped()
|
||||||
|
{
|
||||||
|
List<Qso> log = [Contact("DL1ABC"), Contact("OM3KFF"), Contact("W1ABCD")];
|
||||||
|
|
||||||
|
IReadOnlyList<Qso> found = EarlierContacts.Matching(log, "ABC");
|
||||||
|
|
||||||
|
Assert.Equal(["DL1ABC", "W1ABCD"], found.Select(q => q.Call.Text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaysNothingUntilThreeCharactersAreTyped()
|
||||||
|
{
|
||||||
|
List<Qso> log = [Contact("DL1ABC")];
|
||||||
|
|
||||||
|
Assert.Empty(EarlierContacts.Matching(log, "DL"));
|
||||||
|
Assert.Single(EarlierContacts.Matching(log, "DL1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AStarStandsForAnyRunOfCharacters()
|
||||||
|
{
|
||||||
|
List<Qso> log = [Contact("DL1ABC"), Contact("DL2XYZ"), Contact("OM3DL1")];
|
||||||
|
|
||||||
|
IReadOnlyList<Qso> found = EarlierContacts.Matching(log, "DL*C");
|
||||||
|
|
||||||
|
Assert.Equal(["DL1ABC"], found.Select(q => q.Call.Text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AQuestionMarkStandsForOneCharacter()
|
||||||
|
{
|
||||||
|
List<Qso> log = [Contact("DL1ABC"), Contact("DL11ABC")];
|
||||||
|
|
||||||
|
IReadOnlyList<Qso> found = EarlierContacts.Matching(log, "DL?ABC");
|
||||||
|
|
||||||
|
Assert.Equal(["DL1ABC"], found.Select(q => q.Call.Text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AWildcardPatternCoversTheWholeCall()
|
||||||
|
{
|
||||||
|
List<Qso> log = [Contact("DL1ABC"), Contact("OM3DL1")];
|
||||||
|
|
||||||
|
Assert.Equal(["DL1ABC"], EarlierContacts.Matching(log, "DL1*").Select(q => q.Call.Text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithNothingTypedItFollowsTheGridSquare()
|
||||||
|
{
|
||||||
|
List<Qso> log =
|
||||||
|
[
|
||||||
|
Contact("DL1ABC", grid: "JN88MD"),
|
||||||
|
Contact("OM3KFF", grid: "JO70AA"),
|
||||||
|
];
|
||||||
|
|
||||||
|
IReadOnlyList<Qso> found = EarlierContacts.Matching(log, "", "JN88");
|
||||||
|
|
||||||
|
Assert.Equal(["DL1ABC"], found.Select(q => q.Call.Text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithNeitherACallNorAGridItSaysNothing()
|
||||||
|
{
|
||||||
|
Assert.Empty(EarlierContacts.Matching([Contact("DL1ABC", grid: "JN88MD")], "", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OrdersByCallThenModeThenFrequencyThenTime()
|
||||||
|
{
|
||||||
|
List<Qso> log =
|
||||||
|
[
|
||||||
|
Contact("OM3KFF", kilohertz: 14_200, mode: Modes.Usb, minute: 4),
|
||||||
|
Contact("DL1ABC", kilohertz: 21_030, minute: 3),
|
||||||
|
Contact("DL1ABC", kilohertz: 14_030, mode: Modes.Usb, minute: 2),
|
||||||
|
Contact("DL1ABC", kilohertz: 14_030, minute: 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
IReadOnlyList<Qso> found = EarlierContacts.Matching(log, "??????");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[(1, "DL1ABC"), (3, "DL1ABC"), (2, "DL1ABC"), (4, "OM3KFF")],
|
||||||
|
found.Select(q => (q.TimestampUtc.Minute, q.Call.Text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeepsAtMostTheLimit()
|
||||||
|
{
|
||||||
|
List<Qso> log = [.. Enumerable.Range(0, 60).Select(n => Contact($"DL1AB{n:00}"))];
|
||||||
|
|
||||||
|
Assert.Equal(EarlierContacts.Limit, EarlierContacts.Matching(log, "DL1").Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user