228 lines
10 KiB
C++
228 lines
10 KiB
C++
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
||
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||
|
|
|
||
|
|
// lint-disable-file fixed-width-types
|
||
|
|
/*
|
||
|
|
Fingerprintd:Engine unit tests.
|
||
|
|
|
||
|
|
The accounting half replays the three recorded authentication runs as ordered
|
||
|
|
sequences, not just as totals, because press structure only exists in the
|
||
|
|
order. That is what lets the per-press claim be re-derived here rather than
|
||
|
|
taken from the journal: the enrolled finger matched on every press even though
|
||
|
|
five of its twenty frames did not.
|
||
|
|
|
||
|
|
It also pins the distinction one level up from the -11 mistake. At the stock
|
||
|
|
rescan budget a press whose frames all answered "not identified yet" ran out of
|
||
|
|
frames without reaching a verdict. It is undecided, not failed, and lumping it
|
||
|
|
in with failures is the same error in a new place.
|
||
|
|
*/
|
||
|
|
import std;
|
||
|
|
import Fingerprintd;
|
||
|
|
|
||
|
|
using namespace fingerprintd::engine;
|
||
|
|
using fingerprintd::ta::Verdict;
|
||
|
|
using fingerprintd::ta::Event;
|
||
|
|
|
||
|
|
namespace {
|
||
|
|
int Failures = 0;
|
||
|
|
void Check(bool cond, std::string_view msg) {
|
||
|
|
if (!cond) {
|
||
|
|
std::println(std::cerr, "FAIL: {}", msg);
|
||
|
|
++Failures;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A recorded run, in order. The fixtures live with the Ta suite; these are
|
||
|
|
// the same three files.
|
||
|
|
std::vector<Verdict> LoadRun(std::string_view name) {
|
||
|
|
std::vector<Verdict> out;
|
||
|
|
std::string path = std::format("tests/Ta/fixtures/{}", name);
|
||
|
|
std::ifstream f(path);
|
||
|
|
if (!f) {
|
||
|
|
std::println(std::cerr, "FAIL: cannot open fixture {}", path);
|
||
|
|
++Failures;
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
std::string line;
|
||
|
|
while (std::getline(f, line)) {
|
||
|
|
if (line.starts_with("#") || !line.contains("AUTH ")) continue;
|
||
|
|
if (line.contains("*** MATCH ***")) out.push_back(Verdict::Match);
|
||
|
|
else if (line.contains("matcher never ran")) out.push_back(Verdict::MatcherNeverRan);
|
||
|
|
else if (line.contains("REJECTED")) out.push_back(Verdict::Rejected);
|
||
|
|
else if (line.contains("no match")) out.push_back(Verdict::NotIdentifiedYet);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
// The matcher never running is exactly the released-finger frame, so it is
|
||
|
|
// also the finger-present signal for press accounting.
|
||
|
|
AuthTally Replay(const std::vector<Verdict>& run) {
|
||
|
|
AuthTally t;
|
||
|
|
for (Verdict v : run)
|
||
|
|
t.Observe(v, v != Verdict::MatcherNeverRan);
|
||
|
|
return t;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
int main() {
|
||
|
|
// ---- Baseline: never a fixed threshold
|
||
|
|
{
|
||
|
|
Baseline b;
|
||
|
|
Check(!b.Ready(), "uncalibrated");
|
||
|
|
Check(!b.IsFinger(1000000), "an uncalibrated baseline calls nothing a finger");
|
||
|
|
|
||
|
|
for (int i = 0; i < 5; i++) b.Observe(132);
|
||
|
|
Check(b.Ready(), "calibrated after five idle samples");
|
||
|
|
Check(b.Floor() == 132, "floor");
|
||
|
|
Check(b.Threshold() == 264, "threshold is 2x the floor");
|
||
|
|
|
||
|
|
// The real measurement: idle 132-133, finger 345-366.
|
||
|
|
Check(!b.IsFinger(133), "an idle frame is not a finger");
|
||
|
|
Check(b.IsFinger(345) && b.IsFinger(366), "a pressed frame is");
|
||
|
|
|
||
|
|
// The floor is the max of the idle samples. A drifting idle must not
|
||
|
|
// become a false finger.
|
||
|
|
Baseline drift;
|
||
|
|
for (std::int32_t m : {18, 20, 22, 24, 26}) drift.Observe(m);
|
||
|
|
Check(drift.Floor() == 26, "floor takes the maximum, not the first sample");
|
||
|
|
Check(!drift.IsFinger(24), "drift within the idle range is not a finger");
|
||
|
|
|
||
|
|
// Extra samples after calibration do not move it.
|
||
|
|
Baseline fixed;
|
||
|
|
for (int i = 0; i < 5; i++) fixed.Observe(100);
|
||
|
|
fixed.Observe(9999);
|
||
|
|
Check(fixed.Floor() == 100, "calibration closes after its sample count");
|
||
|
|
|
||
|
|
// A per-frame metric scales with the frame count, so a threshold from
|
||
|
|
// one configuration is meaningless in another. Two baselines, same
|
||
|
|
// sensor, different capture counts:
|
||
|
|
Baseline one, four;
|
||
|
|
for (int i = 0; i < 5; i++) { one.Observe(18); four.Observe(68); }
|
||
|
|
Check(one.Threshold() != four.Threshold(), "the threshold is not portable between configs");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Edge detection
|
||
|
|
{
|
||
|
|
TouchTracker t;
|
||
|
|
// Enrolment: touch on the rising edge, release on the falling one,
|
||
|
|
// and nothing at all while held.
|
||
|
|
auto e1 = t.Observe(true, Mode::Enrol);
|
||
|
|
Check(e1.size() == 1 && e1[0] == Event::FingerTouched, "enrol: rising edge -> touched");
|
||
|
|
auto e2 = t.Observe(true, Mode::Enrol);
|
||
|
|
Check(e2.empty(), "enrol: a held frame reports nothing");
|
||
|
|
auto e3 = t.Observe(false, Mode::Enrol);
|
||
|
|
Check(e3.size() == 1 && e3[0] == Event::FingerReleased, "enrol: falling edge -> released");
|
||
|
|
auto e4 = t.Observe(false, Mode::Enrol);
|
||
|
|
Check(e4.empty(), "enrol: idle reports nothing");
|
||
|
|
|
||
|
|
// Authentication: every frame with a finger reaches the matcher.
|
||
|
|
TouchTracker a;
|
||
|
|
auto a1 = a.Observe(true, Mode::Authenticate);
|
||
|
|
Check(a1.size() == 2 && a1[0] == Event::FingerTouched && a1[1] == Event::ImageReady,
|
||
|
|
"auth: rising edge reports touched then image-ready");
|
||
|
|
auto a2 = a.Observe(true, Mode::Authenticate);
|
||
|
|
Check(a2.size() == 1 && a2[0] == Event::ImageReady, "auth: a held frame still reports image-ready");
|
||
|
|
auto a3 = a.Observe(false, Mode::Authenticate);
|
||
|
|
Check(a3.size() == 1 && a3[0] == Event::FingerReleased, "auth: release");
|
||
|
|
|
||
|
|
// The modes genuinely differ: enrolling every held frame is what feeds
|
||
|
|
// the algorithm near-duplicate images from one press.
|
||
|
|
TouchTracker x, y;
|
||
|
|
x.Observe(true, Mode::Enrol);
|
||
|
|
y.Observe(true, Mode::Authenticate);
|
||
|
|
Check(x.Observe(true, Mode::Enrol).empty(), "enrol emits nothing while held");
|
||
|
|
Check(!y.Observe(true, Mode::Authenticate).empty(), "auth emits while held");
|
||
|
|
|
||
|
|
Check(a.FingerDown() == false, "tracker reports lifted");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Enrolment progress, read from the response
|
||
|
|
{
|
||
|
|
EnrolSession e;
|
||
|
|
Check(!e.Started(), "not started");
|
||
|
|
e.Observe(-1);
|
||
|
|
Check(!e.Started(), "an unpopulated field does not start the session");
|
||
|
|
e.Observe(10);
|
||
|
|
Check(e.Started() && e.Total() == 10 && e.Stages() == 10, "first response sets the total");
|
||
|
|
Check(e.Accepted() == 0 && !e.Complete(), "nothing accepted yet");
|
||
|
|
e.Observe(9);
|
||
|
|
Check(e.Accepted() == 1, "rem 10 -> 9 is one accepted sample");
|
||
|
|
for (std::int32_t r : {8, 7, 6, 5, 4, 3, 2, 1}) e.Observe(r);
|
||
|
|
Check(!e.Complete() && e.Remaining() == 1, "not complete at one remaining");
|
||
|
|
e.Observe(0);
|
||
|
|
Check(e.Complete() && e.Accepted() == 10, "complete at zero");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- The three recorded runs, replayed in order
|
||
|
|
{
|
||
|
|
auto enrolled = LoadRun("auth-enrolled-finger.txt");
|
||
|
|
auto wrong = LoadRun("auth-wrong-finger.txt");
|
||
|
|
auto stock = LoadRun("auth-stock-budget.txt");
|
||
|
|
Check(enrolled.size() == 25 && wrong.size() == 21 && stock.size() == 48,
|
||
|
|
"all three runs loaded in order");
|
||
|
|
|
||
|
|
// The enrolled finger, forced-terminal. 15 of 20 frames matched...
|
||
|
|
AuthTally e = Replay(enrolled);
|
||
|
|
Check(e.Matches() == 15 && e.Rejections() == 5, "enrolled: 15 match / 5 reject");
|
||
|
|
Check(e.TerminalFrames() == 20, "enrolled: 20 terminal frames");
|
||
|
|
// ...but every press did, which is the number a user experiences.
|
||
|
|
Check(e.Presses() == 5, "enrolled: five presses");
|
||
|
|
Check(e.PressesMatched() == 5, "enrolled: every press matched");
|
||
|
|
Check(e.PressesDecided() == 5, "enrolled: every press reached a verdict");
|
||
|
|
Check(e.Identified(), "enrolled: the finger was identified");
|
||
|
|
// The distinction the journal insists on.
|
||
|
|
Check(e.Matches() != e.TerminalFrames(), "the frame rate is not 100%");
|
||
|
|
Check(e.PressesMatched() == e.PressesDecided(), "the press rate is");
|
||
|
|
|
||
|
|
// The control. This is the claim that matters most about the device.
|
||
|
|
AuthTally w = Replay(wrong);
|
||
|
|
Check(w.Matches() == 0, "wrong finger: zero false accepts");
|
||
|
|
Check(w.Rejections() == 19, "wrong finger: 19 rejections");
|
||
|
|
Check(w.PressesMatched() == 0, "wrong finger: no press matched");
|
||
|
|
Check(w.PressesDecided() == 2 && w.Presses() == 2, "wrong finger: both presses decided");
|
||
|
|
Check(!w.Identified(), "wrong finger: not identified");
|
||
|
|
|
||
|
|
// The stock-budget run: most frames are "not identified yet".
|
||
|
|
AuthTally s = Replay(stock);
|
||
|
|
Check(s.Matches() == 8, "stock budget: 8 matches");
|
||
|
|
Check(s.NotIdentifiedYet() == 31, "stock budget: 31 rescan frames");
|
||
|
|
Check(s.Rejections() == 0, "stock budget: not one real rejection");
|
||
|
|
Check(s.TerminalFrames() == 8, "stock budget: 8 terminal frames, all matches");
|
||
|
|
|
||
|
|
// Five of its ten presses ran out of frames without a verdict. They
|
||
|
|
// are undecided, not failures -- and every press that DID reach a
|
||
|
|
// verdict matched.
|
||
|
|
Check(s.Presses() == 10, "stock budget: ten presses");
|
||
|
|
Check(s.PressesDecided() == 5, "stock budget: five reached a verdict");
|
||
|
|
Check(s.PressesUndecided() == 5, "stock budget: five ran out of frames");
|
||
|
|
Check(s.PressesMatched() == 5, "stock budget: every decided press matched");
|
||
|
|
Check(s.PressesMatched() == s.PressesDecided(),
|
||
|
|
"stock budget: the decided-press rate is 5/5, not 5/10");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Undecided presses must not be counted as failures
|
||
|
|
{
|
||
|
|
AuthTally t;
|
||
|
|
// One press, all rescan frames, then a lift.
|
||
|
|
for (int i = 0; i < 4; i++) t.Observe(Verdict::NotIdentifiedYet, true);
|
||
|
|
t.Observe(Verdict::MatcherNeverRan, false);
|
||
|
|
Check(t.Presses() == 1, "one press");
|
||
|
|
Check(t.PressesDecided() == 0, "it reached no verdict");
|
||
|
|
Check(t.PressesMatched() == 0, "and matched nothing");
|
||
|
|
Check(t.Rejections() == 0, "but it produced no rejection either");
|
||
|
|
Check(t.TerminalFrames() == 0, "and no terminal frame");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Session state: a failure needs a power cycle, not a retry
|
||
|
|
{
|
||
|
|
Check(CanInit(SessionState::Powered), "init from powered");
|
||
|
|
Check(!CanInit(SessionState::Ready), "no second init on a live session");
|
||
|
|
Check(!CanInit(SessionState::Cold), "no init before power");
|
||
|
|
Check(!CanInit(SessionState::Failed), "a failed session may not simply re-init");
|
||
|
|
Check(NeedsPowerCycle(SessionState::Failed), "it needs the rail cycled");
|
||
|
|
Check(!NeedsPowerCycle(SessionState::Ready), "a healthy session does not");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Failures == 0) std::println("Engine: all tests passed");
|
||
|
|
return Failures;
|
||
|
|
}
|