A three-tap enrolment declared itself complete. The transcript says why:
frame 2: metric=308 FINGER ev5 rem=10
frame 3: metric=187 ev6 rem=0
samples: 10 of 10 accepted
The release event never enters do_enroll, so its response leaves
samples-remaining untouched at 0 -- which is indistinguishable from "none
remaining, you are finished". The session believed it, stopped after one press,
and called SAVE_DATA on an algorithm holding no template. That answered -1 and
wrote nothing, so the store was undamaged, but only by luck: the guard meant to
prevent a partial save was itself satisfied by the bogus count.
A reading is only meaningful when it came from the event that runs the enrol
path, and nothing about the value says so -- the caller has to. Observe now
takes that as an argument. Two further guards: a FIRST reading of 0 is an
unpopulated field rather than a finished enrolment, and the count only ever
falls, so an increase is noise.
Verified by mutation: trusting the release event's count, and accepting a
leading zero, each fail the suite.
264 lines
12 KiB
C++
264 lines
12 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, true);
|
|
Check(!e.Started(), "an unpopulated field does not start the session");
|
|
e.Observe(10, true);
|
|
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, true);
|
|
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, true);
|
|
Check(!e.Complete() && e.Remaining() == 1, "not complete at one remaining");
|
|
e.Observe(0, true);
|
|
Check(e.Complete() && e.Accepted() == 10, "complete at zero");
|
|
}
|
|
|
|
// ---- A release event must not end the enrolment
|
|
//
|
|
// This shipped: the release event never enters do_enroll, so its response
|
|
// leaves samples-remaining at 0, and a session that trusts it declares
|
|
// itself finished after ONE press. SAVE_DATA is then called on an
|
|
// algorithm holding no template and answers -1.
|
|
{
|
|
EnrolSession e;
|
|
e.Observe(10, true); // touch: ten to go
|
|
e.Observe(0, false); // release: reads zero, means nothing
|
|
Check(!e.Complete(), "a release reading of 0 does NOT complete the enrolment");
|
|
Check(e.Remaining() == 10, "and does not move the count");
|
|
Check(e.Accepted() == 0, "nothing was accepted");
|
|
}
|
|
|
|
// A first reading of 0 is an unpopulated field, not a finished enrolment.
|
|
{
|
|
EnrolSession e;
|
|
e.Observe(0, true);
|
|
Check(!e.Started(), "a leading zero does not start a session");
|
|
Check(!e.Complete(), "and certainly does not finish one");
|
|
e.Observe(10, true);
|
|
Check(e.Started() && e.Total() == 10, "a real count still starts it");
|
|
}
|
|
|
|
// The count only falls; a jump back up is noise, not progress.
|
|
{
|
|
EnrolSession e;
|
|
e.Observe(10, true);
|
|
e.Observe(9, true);
|
|
e.Observe(7, true);
|
|
Check(e.Remaining() == 7, "decreases are taken");
|
|
e.Observe(9, true);
|
|
Check(e.Remaining() == 7, "an increase is ignored");
|
|
}
|
|
|
|
// ---- 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;
|
|
}
|