Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/core/BandUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ QString usualModeForFrequencyHz(qint64 hz)
return QString();
}

bool isUkwOrUnknownHz(qint64 hz)
{
if (hz <= 0) {
return true; // keine Frequenz -- freie Chatzeile
}
if (bandLabelForFrequencyHz(hz).isEmpty()) {
return true; // kein bekanntes Band -- nicht wegwerfen
}
return hz >= kUkwUntergrenzeHz;
}

QString bandLabelForFrequencyHz(qint64 hz)
{
for (const BandRange& band : kBands) {
Expand Down
11 changes: 11 additions & 0 deletions src/core/BandUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ namespace Contestprogramm {
// antenna, and both ends need a range and a base frequency.
QString bandLabelForFrequencyHz(qint64 hz);

// Die UKW-Grenze: 50 MHz und darueber (6 m, 4 m, 2 m, 70 cm, 23 cm und
// hoeher); alles darunter ist Kurzwelle.
//
// Eine Frequenz von 0 oder eine, die in kein bekanntes Band faellt,
// gilt NICHT als Kurzwelle -- eine freie Chatzeile hat keine Frequenz
// und darf nicht wegen Unkenntnis verschwinden. Dieselbe Haltung wie in
// ChatFeedModel::bandBelongsToContest: unbekanntes Band nicht heimlich
// wegwerfen.
constexpr qint64 kUkwUntergrenzeHz = 50000000LL;
bool isUkwOrUnknownHz(qint64 hz);

// The bands bandLabelForFrequencyHz() knows, lowest first.
QStringList knownBands();

Expand Down
28 changes: 27 additions & 1 deletion src/models/ChatFeedModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,32 @@ ChatFeedModel::FeedLine ChatFeedModel::lineAt(int index) const
// Ein Spot OHNE Frequenz (jede Chatzeile) bleibt drin: über den sagt
// die Frequenz nichts aus. Und ohne bekannte Contestbänder wird nichts
// aussortiert -- lieber zu viel zeigen als etwas Richtiges verschlucken.
bool ChatFeedModel::bandPassesChatFilter(qint64 freqHz) const
{
if (!isUkwOrUnknownHz(freqHz)) {
return false; // Kurzwelle: nie im Chat
}
if (freqHz <= 0) {
return true; // freie Chatzeile
}
// Hat der aktive Contest ueberhaupt ein UKW-Band? Wenn nicht, darf
// er die UKW-Sicht nicht verengen -- sonst blendete ein
// Kurzwellen-Contest genau das aus, was der Betreiber sehen will.
bool contestHatUkwBand = false;
for (const QString& band : m_contestBands) {
qint64 lowHz = 0;
qint64 highHz = 0;
if (bandRangeHz(band, lowHz, highHz) && lowHz >= kUkwUntergrenzeHz) {
contestHatUkwBand = true;
break;
}
}
if (!contestHatUkwBand) {
return true; // ganz UKW
}
return bandBelongsToContest(freqHz); // innerhalb UKW auf die Baender verengen
}

bool ChatFeedModel::bandBelongsToContest(qint64 freqHz) const
{
if (freqHz <= 0 || m_contestBands.isEmpty()) {
Expand Down Expand Up @@ -359,7 +385,7 @@ QVector<int> ChatFeedModel::sichtbareZeilen() const
QVector<double> survivorScores;
for (int i = 0; i < m_allEntries.size(); ++i) {
const Entry& entry = m_allEntries.at(i);
if (entry.geo.inRange && !entry.worked && bandBelongsToContest(entry.candidate.freqHz)) {
if (entry.geo.inRange && !entry.worked && bandPassesChatFilter(entry.candidate.freqHz)) {
survivorIndices.append(i);
survivorScores.append(entry.score);
}
Expand Down
19 changes: 19 additions & 0 deletions src/models/ChatFeedModel.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ class ChatFeedModel : public QAbstractTableModel {
// Clusters haben in einem UKW-Contest nichts im Chat verloren.
bool bandBelongsToContest(qint64 freqHz) const;

// Das Bandtor des Chats. Betreiber am 2026-11-04: "chat ist fuer
// mich nur ukw wichtig ... immer UKW".
//
// Kurzwelle kommt nie durch -- auch dann nicht, wenn gerade ein
// KURZWELLEN-Contest eingestellt ist. Genau das war seine Lage:
// active_contest_id = CQ_WW_SSB, active_bands = ["14"], und
// bandBelongsToContest() liess folgerichtig 20 m durch und blendete
// UKW aus. Der Chat zeigte also korrekt, was eingestellt war, und
// trotzdem das Falsche.
//
// Die Verengung auf die Contest-Baender bleibt -- aber nur
// INNERHALB von UKW. Hat der aktive Contest kein UKW-Band (oder ist
// keiner aktiv), gilt ganz UKW statt "alles".
//
// Sitzt im Sichtbarkeitsfilter, nicht am Eingang: gefiltert heisst
// verborgen, nicht weggeworfen -- die Ansicht "alle Zeilen zeigen"
// hat weiter jede Zeile (test_chat_panel verlangt das ausdruecklich).
bool bandPassesChatFilter(qint64 freqHz) const;

// Optional (nullptr = no multiplier boost, e.g. before
// AppController has recomputed one yet). Non-owning; the caller
// keeps it alive. Lets the importance scorer tell a still-needed
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ contestprogramm_add_test(test_next_target_suggester)
contestprogramm_add_test(test_message_drafter)
contestprogramm_add_test(test_weather_client)
contestprogramm_add_test(test_chatfeedmodel_refresh)
contestprogramm_add_test(test_chat_nur_ukw)
contestprogramm_add_test(test_recent_propagation_tracker)
contestprogramm_add_test(test_color_theme)
contestprogramm_add_test(test_country_borders)
Expand Down
178 changes: 178 additions & 0 deletions tests/test_chat_nur_ukw.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Chat immer UKW -- Betreiber am 2026-11-04: "chat ist fuer mich nur
// ukw wichtig", auf die Rueckfrage "immer UKW".
//
// Der Befund, der dahinter steckt: in seinen Einstellungen stand
// active_contest_id = CQ_WW_SSB und active_bands = ["14"]. Der
// Chatfilter folgte diesen Baendern, liess also 20 m durch und blendete
// UKW aus. Das Programm zeigte korrekt, was eingestellt war, und
// trotzdem das Falsche -- zwei Quellen speisen den Strom, ON4KST
// (reiner UKW-Chat) und der DX-Cluster ve7cc.net (fast nur Kurzwelle).
//
// Das Tor sitzt im Sichtbarkeitsfilter, NICHT am Eingang: gefiltert
// heisst verborgen, nicht weggeworfen. test_chat_panel verlangt
// ausdruecklich, dass ein ausgefilterter Kurzwellen-Spot in der Ansicht
// "alle Zeilen zeigen" noch da ist -- ein erster Versuch, ihn in
// addCandidate() abzuweisen, hat genau daran zu Recht scheitern.

#include <QtTest>

#include <QCoreApplication>
#include <QDateTime>
#include <QTemporaryDir>

#include "core/BandUtils.h"
#include "core/GeoFilter.h"
#include "core/SpotCandidate.h"
#include "data/ContestDatabase.h"
#include "data/DupeChecker.h"
#include "models/ChatFeedModel.h"

using namespace Contestprogramm;

namespace {
SpotCandidate spot(const QString& call, qint64 hz)
{
SpotCandidate c;
c.callsign = call;
c.freqHz = hz;
c.timestampUtc = QDateTime::currentDateTimeUtc();
return c;
}

// Alle Rufzeichen, die der Filter gerade sichtbar laesst.
QStringList sichtbar(ChatFeedModel& model)
{
QStringList raus;
for (int r = 0; r < model.rowCount(); ++r) {
raus << model.data(model.index(r, 0)).toString();
}
return raus;
}
} // namespace

class TestChatNurUkw : public QObject
{
Q_OBJECT

private slots:
void theUkwEdgeSitsAtFiftyMegahertz();
void unknownFrequenciesAreNotThrownAway();
void anHfContestDoesNotDragShortwaveIntoTheChat();
void aVhfContestStillNarrowsToItsOwnBands();
void filteredLinesAreOnlyHiddenNotDiscarded();
};

void TestChatNurUkw::theUkwEdgeSitsAtFiftyMegahertz()
{
QVERIFY(!isUkwOrUnknownHz(1810000LL)); // 160 m
QVERIFY(!isUkwOrUnknownHz(3650000LL)); // 80 m
QVERIFY(!isUkwOrUnknownHz(7050000LL)); // 40 m
QVERIFY(!isUkwOrUnknownHz(10120000LL)); // 30 m
QVERIFY(!isUkwOrUnknownHz(14080000LL)); // 20 m
QVERIFY(!isUkwOrUnknownHz(18100000LL)); // 17 m
QVERIFY(!isUkwOrUnknownHz(21200000LL)); // 15 m
QVERIFY(!isUkwOrUnknownHz(24930000LL)); // 12 m
QVERIFY(!isUkwOrUnknownHz(28400000LL)); // 10 m
QVERIFY(!isUkwOrUnknownHz(29700000LL)); // die Oberkante von 10 m

QVERIFY(isUkwOrUnknownHz(kUkwUntergrenzeHz)); // genau die Kante
QVERIFY(isUkwOrUnknownHz(50150000LL)); // 6 m zaehlt mit
QVERIFY(isUkwOrUnknownHz(70200000LL)); // 4 m
QVERIFY(isUkwOrUnknownHz(144300000LL)); // 2 m
QVERIFY(isUkwOrUnknownHz(432200000LL)); // 70 cm
QVERIFY(isUkwOrUnknownHz(1296200000LL)); // 23 cm
QVERIFY(isUkwOrUnknownHz(10368000000LL)); // 3 cm
}

void TestChatNurUkw::unknownFrequenciesAreNotThrownAway()
{
QVERIFY(isUkwOrUnknownHz(0));
QVERIFY(isUkwOrUnknownHz(-1));
// 40 MHz liegt in keinem Amateurband -- unbekannt, nicht
// "Kurzwelle". Lieber eine Zeile zu viel als eine zu wenig.
QVERIFY(isUkwOrUnknownHz(40000000LL));
}

void TestChatNurUkw::anHfContestDoesNotDragShortwaveIntoTheChat()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
ContestDatabase db;
QVERIFY(db.open(dir.filePath(QStringLiteral("ukw1.sqlite")), QStringLiteral("chat_ukw_hf")));
GeoFilter geo;
geo.setOwnGrid(QStringLiteral("JN67VV"));
geo.setRadiusKm(20000.0);
DupeChecker dupes(db);
ChatFeedModel model(geo, dupes, nullptr);

// Genau Martins Lage am 2026-11-04.
model.setContestBands(QStringList{QStringLiteral("14")});

model.addCandidate(spot(QStringLiteral("XE1AOH"), 7164000LL)); // 40 m
model.addCandidate(spot(QStringLiteral("K1ABC"), 14080000LL)); // 20 m: das Contestband!
model.addCandidate(spot(QStringLiteral("OE1ABC"), 144300000LL)); // 2 m
model.addCandidate(spot(QStringLiteral("DL1DEF"), 432200000LL)); // 70 cm
model.addCandidate(spot(QStringLiteral("HB9JKL"), 0)); // freie Zeile

const QStringList zuSehen = sichtbar(model);
QVERIFY2(!zuSehen.contains(QStringLiteral("XE1AOH")), qPrintable(zuSehen.join(QLatin1Char(','))));
// Das eigene Contestband hilft nichts, wenn es Kurzwelle ist.
QVERIFY2(!zuSehen.contains(QStringLiteral("K1ABC")), qPrintable(zuSehen.join(QLatin1Char(','))));
QVERIFY2(zuSehen.contains(QStringLiteral("OE1ABC")), qPrintable(zuSehen.join(QLatin1Char(','))));
QVERIFY2(zuSehen.contains(QStringLiteral("DL1DEF")), qPrintable(zuSehen.join(QLatin1Char(','))));
QVERIFY2(zuSehen.contains(QStringLiteral("HB9JKL")), qPrintable(zuSehen.join(QLatin1Char(','))));
}

void TestChatNurUkw::aVhfContestStillNarrowsToItsOwnBands()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
ContestDatabase db;
QVERIFY(db.open(dir.filePath(QStringLiteral("ukw2.sqlite")), QStringLiteral("chat_ukw_vhf")));
GeoFilter geo;
geo.setOwnGrid(QStringLiteral("JN67VV"));
geo.setRadiusKm(20000.0);
DupeChecker dupes(db);
ChatFeedModel model(geo, dupes, nullptr);

// Ein reiner 2-m-Contest: die Verengung innerhalb UKW bleibt, sonst
// waere die Contest-Disziplin weg.
model.setContestBands(QStringList{QStringLiteral("144")});

model.addCandidate(spot(QStringLiteral("OE1ABC"), 144300000LL)); // sein Band
model.addCandidate(spot(QStringLiteral("DL1DEF"), 432200000LL)); // UKW, aber nicht sein Band
model.addCandidate(spot(QStringLiteral("XE1AOH"), 7164000LL)); // Kurzwelle

const QStringList zuSehen = sichtbar(model);
QVERIFY2(zuSehen.contains(QStringLiteral("OE1ABC")), qPrintable(zuSehen.join(QLatin1Char(','))));
QVERIFY2(!zuSehen.contains(QStringLiteral("DL1DEF")), qPrintable(zuSehen.join(QLatin1Char(','))));
QVERIFY2(!zuSehen.contains(QStringLiteral("XE1AOH")), qPrintable(zuSehen.join(QLatin1Char(','))));
}

void TestChatNurUkw::filteredLinesAreOnlyHiddenNotDiscarded()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
ContestDatabase db;
QVERIFY(db.open(dir.filePath(QStringLiteral("ukw3.sqlite")), QStringLiteral("chat_ukw_roh")));
GeoFilter geo;
geo.setOwnGrid(QStringLiteral("JN67VV"));
geo.setRadiusKm(20000.0);
DupeChecker dupes(db);
ChatFeedModel model(geo, dupes, nullptr);
model.setContestBands(QStringList{QStringLiteral("144")});

model.addCandidate(spot(QStringLiteral("XE1AOH"), 7164000LL));
model.addCandidate(spot(QStringLiteral("OE1ABC"), 144300000LL));

QVERIFY(!sichtbar(model).contains(QStringLiteral("XE1AOH")));
// Beide Zeilen liegen weiter im Strom ...
QCOMPARE(model.lineCount(), 2);
// ... und "alle Zeilen zeigen" holt die Kurzwelle wieder hervor.
model.setShowRawFeed(true);
QVERIFY2(sichtbar(model).contains(QStringLiteral("XE1AOH")),
"der Kurzwellen-Spot ist ganz verschwunden");
}

QTEST_MAIN(TestChatNurUkw)
#include "test_chat_nur_ukw.moc"
Loading