[Perl] DAQ system for the FAPG.
test focus page coverage on core contracts
- reduce page assertions to essential content, accessibility, and JavaScript hooks - cover probes compactly and centralize shared layout expectations - separate downloads page rendering from CSV endpoint behavior - document the dashboard page-testing policy and assertion ceiling
Changed files
AGENTS.org
@@ -175,6 +175,27 @@
175
175
- =roles/*/t/= :: role-specific tests.
176
176
177
177
178
Added:
*** Dashboard page tests
179
Added:
180
Added:
- Page-rendering tests SHOULD focus on route health, page identity,
181
Added:
core user-facing content and actions, essential accessibility
182
Added:
semantics, and =data-*= hooks required by JavaScript behavior.
183
Added:
- Shared layout behavior SHALL be tested on one representative page
184
Added:
rather than repeated on every page.
185
Added:
- Collections of configured probes SHOULD cover every probe key using
186
Added:
compact, data-driven assertions.
187
Added:
- Negative page assertions SHOULD be limited to meaningful boundaries,
188
Added:
such as unknown routes, deprecated routes, cross-probe content
189
Added:
isolation, or a documented regression.
190
Added:
- Incidental wrappers, styling-only classes, and the presence or
191
Added:
absence of every unrelated page element SHOULD NOT be asserted.
192
Added:
- Page-rendering tests and endpoint behavior tests SHOULD be kept
193
Added:
separate when a page also exposes downloads or another non-HTML
194
Added:
response.
195
Added:
- A page-rendering test SHOULD use no more than 50 assertions per
196
Added:
rendered page.
197
Added:
198
Added:
178
199
** Version control system
179
200
180
201
- The project SHALL use Git as its version control system.
roles/dashboard/t/00-homepage.t
@@ -13,67 +13,26 @@
13
13
14
14
$t->get_ok('/')
15
15
->status_is(200)
16
Removed:
->element_exists('body > header.page-header')
17
16
->element_exists(
18
17
'body > header.page-header + div.toolbar + main.site-main')
19
Removed:
->element_exists_not('main.site-main > header.page-header')
20
Removed:
->text_is( 'nav.breadcrumbs [aria-current="page"]', 'Home' )
21
Removed:
->element_exists_not('nav.breadcrumbs a[href="/"]')
22
Removed:
->element_exists('input.nav-toggle[type="checkbox"]')
23
Removed:
->text_is( 'a.page-brand[href="/"]', 'FAPG' )
18
Added:
->text_is( 'a.page-brand[href="/"]', 'FAPG' )
19
Added:
->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
20
Added:
->text_is( 'div.nav-links a[href="/downloads"]', 'Downloads' )
24
21
->element_exists(
25
22
'a.header-status-link[href="/status"][data-header-status]')
23
Added:
->text_is( 'nav.breadcrumbs [aria-current="page"]', 'Home' )
26
24
->element_exists(
27
25
'div.toolbar-items a.status-summary[href="/status"][data-overview-status]'
28
26
)
29
Removed:
->element_exists_not(
30
Removed:
'main.site-main a.status-summary[data-overview-status]')
31
Removed:
->element_exists_not('div.toolbar div.graph-control')
32
Removed:
->element_exists('a.header-status-link span.header-status-led')
33
Removed:
->element_exists('[data-header-status-item="hub"]')
34
Removed:
->element_exists('[data-header-status-item="ph"]')
35
Removed:
->element_exists('[data-header-status-item="do"]')
36
Removed:
->element_exists('[data-header-status-item="orp"]')
37
Removed:
->element_exists('[data-header-status-item="ec"]')
38
Removed:
->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
39
Removed:
->element_exists_not('div.nav-links a[href^="/graphs/"]')
40
27
->element_exists(
41
Removed:
'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
42
Removed:
->content_like(qr/Copyright © 2026 Marius Peter/)
43
Removed:
->element_exists_not('section#probe-ph[data-panel="ph"]')
44
Removed:
->element_exists_not('div.card')
45
Removed:
->content_like(qr/Checking sensors/)
46
Removed:
->content_like(qr/data-overview-status/)
47
Removed:
->content_like(qr/data-sensor-status="ph"/)
48
Removed:
->content_unlike(qr/Last seen online/)
49
Removed:
->content_like(qr/data-reading-quick-probe="ph"/)
50
Removed:
->content_like(qr/data-reading-quick-probe="do"/)
51
Removed:
->content_like(qr/data-reading-quick-probe="orp"/)
52
Removed:
->content_like(qr/data-reading-quick-probe="ec"/)
28
Added:
'section.reading-quick-status [data-reading-quick-status]')
53
29
->element_exists(
54
Removed:
'a.reading-quick-link[href="/probes/ph"] article[data-reading-quick-probe="ph"]'
55
Removed:
)
56
Removed:
->element_exists(
57
Removed:
'a.reading-quick-link[href="/probes/do"] article[data-reading-quick-probe="do"]'
58
Removed:
)
59
Removed:
->element_exists(
60
Removed:
'a.reading-quick-link[href="/probes/orp"] article[data-reading-quick-probe="orp"]'
61
Removed:
)
62
Removed:
->element_exists(
63
Removed:
'a.reading-quick-link[href="/probes/ec"] article[data-reading-quick-probe="ec"]'
64
Removed:
)
65
Removed:
->element_exists_not('article[data-reading-quick-probe="ph"] h3 a')
66
Removed:
->content_like(qr/Dissolved Oxygen/)
67
Removed:
->content_unlike(qr/Hourly/)
68
Removed:
->content_unlike(qr/Daily/)
69
Removed:
->content_unlike(qr/Weekly/)
70
Removed:
->content_unlike(qr/Yearly/)
71
Removed:
->content_unlike(qr/data-smoothing-toggle checked/)
72
Removed:
->content_unlike(qr/smoothing/)
73
Removed:
->content_unlike(qr/Last 10 pH Reading Messages/)
74
Removed:
->content_unlike(qr/Last 10 Dissolved Oxygen Reading Messages/)
75
Removed:
->content_unlike(qr/status-pill-list/)
76
Removed:
->content_unlike(qr/Pi Zero 01/)
77
Removed:
->content_unlike(qr/Connection Status/);
30
Added:
'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]');
31
Added:
32
Added:
for my $probe (qw(ph do orp ec)) {
33
Added:
$t->element_exists(
34
Added:
qq{a.reading-quick-link[href="/probes/$probe"] [data-reading-quick-probe="$probe"]}
35
Added:
);
36
Added:
}
78
37
79
38
done_testing;
roles/dashboard/t/01-graph-page.t
@@ -13,92 +13,42 @@
13
13
14
14
$t->get_ok('/probes/ph')
15
15
->status_is(200)
16
Removed:
->element_exists(
17
Removed:
'body > header.page-header + div.toolbar + main.site-main')
18
16
->text_is( 'nav.breadcrumbs a[href="/"]', 'Home' )
19
17
->text_is( 'nav.breadcrumbs a[href="/probes"]', 'Probes' )
20
18
->text_is( 'nav.breadcrumbs [aria-current="page"]', 'pH' )
21
Removed:
->text_is( 'a.page-brand[href="/"]', 'FAPG' )
19
Added:
->text_is( 'div.card h1 a[href="/probes/ph"]', 'pH' )
20
Added:
->element_exists('section#probe-ph[data-panel="ph"] canvas#chart-ph')
21
Added:
->element_exists('[data-status="ph"]')
22
22
->element_exists(
23
Removed:
'a.header-status-link[href="/status"][data-header-status]')
24
Removed:
->element_exists('a.header-status-link span.header-status-led')
25
Removed:
->element_exists('[data-header-status-item="hub"]')
26
Removed:
->element_exists('[data-header-status-item="ph"]')
27
Removed:
->element_exists('[data-header-status-item="do"]')
28
Removed:
->element_exists('[data-header-status-item="orp"]')
29
Removed:
->element_exists('[data-header-status-item="ec"]')
30
Removed:
->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
31
Removed:
->element_exists_not('div.nav-links a[href^="/graphs/"]')
32
Removed:
->element_exists(
33
Removed:
'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
34
Removed:
->element_exists('div.card > div.card-heading')
35
Removed:
->element_exists('div.card > div.card-body')
36
Removed:
->text_is( 'div.card h1 a[href="/probes/ph"]', 'pH' )
37
Removed:
->element_exists('section#probe-ph[data-panel="ph"]')
38
Removed:
->element_exists('canvas#chart-ph')
39
Removed:
->text_is( 'a.secondary-button[data-raw-download][data-probe="ph"]',
40
Removed:
'Download raw data' )
41
Removed:
->element_exists(
42
Removed:
'a[data-raw-download][href="/downloads/readings.csv?probe=ph&timeframe=day&range=1"]'
23
Added:
'div.toolbar-items div.graph-control > div.timeframe-control[role="group"]'
43
24
)
44
Removed:
->element_exists_not('section#probe-do[data-panel="do"]')
45
Removed:
->content_unlike(qr/Sensor Reading Quick Status/)
46
Removed:
->content_like(qr/Hourly/)
47
Removed:
->content_like(qr/Daily/)
48
Removed:
->content_like(qr/Weekly/)
49
Removed:
->content_like(qr/Monthly/)
50
Removed:
->content_like(qr/Yearly/)
51
Removed:
->element_exists(
52
Removed:
'div.toolbar-items div.graph-control > div.timeframe-control')
53
Removed:
->element_exists_not('main.site-main div.graph-control')
54
Removed:
->element_exists('[data-timeframe-option="week"]')
55
Removed:
->element_exists('[data-timeframe-option="month"]')
56
Removed:
->element_exists(
57
Removed:
'[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
58
Removed:
->element_exists(
59
Removed:
'[data-timeframe-adjustment="increase"][data-timeframe="week"]')
60
Removed:
->content_like(qr/data-smoothing-toggle checked/)
61
Removed:
->content_like(qr/Latest Readings/)
62
Removed:
->element_exists('section.card.message-log > div.card-heading')
63
Removed:
->element_exists('section.card.message-log > div.card-body');
25
Added:
->element_exists('input[data-smoothing-toggle][checked]')
26
Added:
->text_is( 'a[data-raw-download][data-probe="ph"]', 'Download raw data' )
27
Added:
->element_exists('tbody[data-reading-messages="ph"]')
28
Added:
->element_exists_not('section#probe-do[data-panel="do"]');
64
29
65
Removed:
$t->get_ok('/probes/unknown')->status_is(404);
30
Added:
for my $timeframe (qw(hour day week month year)) {
31
Added:
$t->element_exists(
32
Added:
qq{[data-timeframe-option="$timeframe"] [data-timeframe="$timeframe"]}
33
Added:
);
34
Added:
}
66
35
36
Added:
$t->get_ok('/probes/unknown')->status_is(404);
67
37
$t->get_ok('/graphs/ph')->status_is(404);
68
38
69
39
$t->get_ok('/probes')
70
40
->status_is(200)
71
41
->text_is( 'nav.breadcrumbs a[href="/"]', 'Home' )
72
42
->text_is( 'nav.breadcrumbs [aria-current="page"]', 'Probes' )
73
Removed:
->element_exists_not('nav.breadcrumbs a[href="/probes"]')
74
Removed:
->element_exists('div.card > div.card-heading')
75
Removed:
->element_exists('div.card > div.card-body')
76
Removed:
->text_is( 'h1 a[href="/probes/ph"]', 'pH' )
77
Removed:
->text_is( 'h1 a[href="/probes/do"]', 'Dissolved Oxygen' )
78
Removed:
->text_is( 'h1 a[href="/probes/orp"]', 'ORP' )
79
Removed:
->text_is( 'h1 a[href="/probes/ec"]', 'Electrical Conductivity' )
80
Removed:
->element_exists('section#probe-ph[data-panel="ph"]')
81
Removed:
->element_exists('section#probe-do[data-panel="do"]')
82
Removed:
->element_exists('section#probe-orp[data-panel="orp"]')
83
Removed:
->element_exists('section#probe-ec[data-panel="ec"]')
84
Removed:
->element_exists('canvas#chart-ph')
85
Removed:
->element_exists('canvas#chart-do')
86
Removed:
->element_exists('canvas#chart-orp')
87
Removed:
->element_exists('canvas#chart-ec')
88
Removed:
->element_exists('a[data-raw-download][data-probe="ph"]')
89
Removed:
->element_exists('a[data-raw-download][data-probe="do"]')
90
Removed:
->element_exists('a[data-raw-download][data-probe="orp"]')
91
Removed:
->element_exists('a[data-raw-download][data-probe="ec"]')
92
43
->element_exists('div.probes-grid')
93
Removed:
->element_exists(
94
Removed:
'div.toolbar-items div.graph-control > div.timeframe-control')
95
Removed:
->element_exists_not('div.card div.timeframe-control')
96
Removed:
->element_exists('[data-timeframe-option="week"]')
97
Removed:
->element_exists(
98
Removed:
'[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
99
Removed:
->element_exists(
100
Removed:
'[data-timeframe-adjustment="increase"][data-timeframe="week"]')
101
Removed:
->element_exists_not('section.message-log')
102
Removed:
->content_unlike(qr/Reading Messages/);
44
Added:
->element_exists('div.toolbar-items div.graph-control')
45
Added:
->element_exists('input[data-smoothing-toggle][checked]');
46
Added:
47
Added:
for my $probe (qw(ph do orp ec)) {
48
Added:
$t->element_exists(qq{div.probes-grid h1 a[href="/probes/$probe"]});
49
Added:
$t->element_exists(
50
Added:
qq{section#probe-${probe}[data-panel="$probe"] canvas#chart-$probe});
51
Added:
$t->element_exists(qq{a[data-raw-download][data-probe="$probe"]});
52
Added:
}
103
53
104
54
done_testing;
roles/dashboard/t/05-status-page.t
@@ -13,23 +13,15 @@
13
13
14
14
$t->get_ok('/status')
15
15
->status_is(200)
16
Removed:
->element_exists(
17
Removed:
'body > header.page-header + div.toolbar + main.site-main')
18
16
->text_is( 'nav.breadcrumbs a[href="/"]', 'Home' )
19
17
->text_is( 'nav.breadcrumbs [aria-current="page"]', 'Status' )
20
Removed:
->element_exists_not('div.toolbar-items')
21
Removed:
->text_is( 'h1', 'DAQ Status' )
22
Removed:
->element_exists('div.card.status-page-card > div.card-heading')
23
Removed:
->element_exists('div.card.status-page-card > div.card-body')
24
Removed:
->element_exists(
25
Removed:
'a.header-status-link[href="/status"][data-header-status]')
18
Added:
->text_is( 'h1', 'DAQ Status' )
26
19
->element_exists('table.status-table')
27
Removed:
->element_exists('tr[data-status-page-item="hub"]')
28
Removed:
->element_exists('tr[data-status-page-item="ph"]')
29
Removed:
->element_exists('tr[data-status-page-item="do"]')
30
Removed:
->element_exists('tr[data-status-page-item="orp"]')
31
Removed:
->element_exists('tr[data-status-page-item="ec"]')
32
Removed:
->content_like(qr/Last seen online/)
33
Removed:
->content_like(qr/Checking status/);
20
Added:
->element_exists('table.status-table thead th[scope="col"]');
21
Added:
22
Added:
for my $item (qw(hub ph do orp ec)) {
23
Added:
$t->element_exists(
24
Added:
qq{tr[data-status-page-item="$item"] [data-status-page-state]});
25
Added:
}
34
26
35
27
done_testing;
roles/dashboard/t/06-downloads.t
@@ -10,128 +10,39 @@
10
10
use Dashboard::Test qw(test_app test_empty_app);
11
11
12
12
my $t = test_app();
13
Removed:
seed_recent_readings($t);
14
13
15
14
$t->get_ok('/downloads')
16
15
->status_is(200)
17
Removed:
->element_exists(
18
Removed:
'body > header.page-header + div.toolbar + main.site-main')
19
16
->text_is( 'nav.breadcrumbs a[href="/"]', 'Home' )
20
17
->text_is( 'nav.breadcrumbs [aria-current="page"]', 'Downloads' )
21
Removed:
->element_exists_not('div.toolbar-items')
22
Removed:
->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
23
Removed:
->text_is( 'div.nav-links a[href="/downloads"]', 'Downloads' )
24
Removed:
->element_exists(
25
Removed:
'a.header-status-link[href="/status"][data-header-status]')
26
Removed:
->element_exists('a.header-status-link span.header-status-led')
27
Removed:
->element_exists('div.card.downloads-card > div.card-heading')
28
Removed:
->element_exists('div.card.downloads-card > div.card-body')
18
Added:
->text_is( 'h1', 'Downloads' )
29
19
->element_exists('form.downloads-form[action="/downloads/readings.csv"]')
30
20
->text_is(
31
21
'select#download-probe[name="probe"] option[value=""][selected]',
32
22
'All probes' )
33
Removed:
->text_is( 'select#download-probe option[value="ph"]', 'pH' )
34
Removed:
->text_is( 'select#download-probe option[value="do"]',
35
Removed:
'Dissolved Oxygen' )
36
Removed:
->text_is( 'select#download-probe option[value="orp"]', 'ORP' )
37
Removed:
->text_is( 'select#download-probe option[value="ec"]',
38
Removed:
'Electrical Conductivity' )
39
Removed:
->element_exists('fieldset.download-range-fieldset')
40
Removed:
->text_is( 'div.download-range-control label:nth-child(1) span', 'Today' )
23
Added:
->text_is( 'fieldset.download-range-fieldset legend', 'Date range' )
24
Added:
->element_exists('input[name="date_range"][value="week"][checked]')
41
25
->element_exists(
42
Removed:
'input[type="radio"][name="date_range"][value="today"][data-download-range]'
26
Added:
'fieldset.download-range-fieldset [data-custom-date-fields][hidden] input[name="from"][type="date"]'
43
27
)
44
Removed:
->text_is( 'div.download-range-control label:nth-child(2) span',
45
Removed:
'Past week' )
46
28
->element_exists(
47
Removed:
'input[type="radio"][name="date_range"][value="week"][data-download-range][checked]'
48
Removed:
)
49
Removed:
->text_is( 'div.download-range-control label:nth-child(3) span',
50
Removed:
'Past month' )
51
Removed:
->text_is( 'div.download-range-control label:nth-child(4) span',
52
Removed:
'All time' )
53
Removed:
->text_is( 'div.download-range-control label:nth-child(5) span',
54
Removed:
'Custom' )
55
Removed:
->element_exists(
56
Removed:
'input[type="radio"][name="date_range"][value="custom"][data-download-range]'
57
Removed:
)
58
Removed:
->element_exists(
59
Removed:
'fieldset.download-range-fieldset div.download-date-fields[data-custom-date-fields][hidden]'
60
Removed:
)
61
Removed:
->element_exists('input[name="from"][type="date"][required]')
62
Removed:
->element_exists('input[name="to"][type="date"][required]')
63
Removed:
->element_exists_not('button[data-date-shortcut]')
64
Removed:
->text_is( 'button.downloads-button[type="submit"]', 'Download CSV' );
29
Added:
'fieldset.download-range-fieldset [data-custom-date-fields][hidden] input[name="to"][type="date"]'
30
Added:
)->text_is( 'button.downloads-button[type="submit"]', 'Download CSV' );
65
31
66
Removed:
$t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
67
Removed:
->status_is(200)
68
Removed:
->header_like( 'Content-Type' => qr{text/csv} )
69
Removed:
->header_like( 'Content-Disposition' => qr{attachment} )
70
Removed:
->content_like(qr/^timestamp,received_at,node,probe,value,unit/m)
71
Removed:
->content_like(qr/2026-07-05T12:00:00Z/)
72
Removed:
->content_like(qr/2026-07-05T12:00:15Z/);
32
Added:
for my $probe (qw(ph do orp ec)) {
33
Added:
$t->element_exists(qq{select#download-probe option[value="$probe"]});
34
Added:
}
73
35
74
Removed:
$t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05&probe=ph')
75
Removed:
->status_is(200)
76
Removed:
->content_like(qr/2026-07-05T12:00:00Z/)
77
Removed:
->content_unlike(qr/2026-07-05T12:00:15Z/);
36
Added:
for my $range (qw(today week month all custom)) {
37
Added:
$t->element_exists(
38
Added:
qq{input[type="radio"][name="date_range"][value="$range"][data-download-range]}
39
Added:
);
40
Added:
}
78
41
79
Removed:
$t->get_ok('/downloads/readings.csv?probe=ph&timeframe=hour&range=1')
80
Removed:
->status_is(200)
81
Removed:
->header_like( 'Content-Disposition' => qr/fapg-daq-ph-hour-1\.csv/ )
82
Removed:
->content_like(qr/,"ph",/)
83
Removed:
->content_unlike(qr/,"do",/);
84
Removed:
85
Removed:
$t->get_ok('/downloads/readings.csv?probe=unknown&timeframe=day&range=1')
86
Removed:
->status_is(400);
87
Removed:
88
Removed:
$t->get_ok(
89
Removed:
'/downloads/readings.csv?probe=unknown&from=2026-07-05&to=2026-07-05')
90
Removed:
->status_is(400);
91
Removed:
92
Removed:
$t->get_ok('/downloads/readings.csv?from=2026-07-06&to=2026-07-05')
93
Removed:
->status_is(400)
94
Removed:
->content_like(qr/from date must not be after the to date/);
95
Removed:
96
42
my $empty_t = test_empty_app();
97
43
98
44
$empty_t->get_ok('/downloads')
99
45
->status_is(200)
100
Removed:
->element_exists('select#download-probe option[value=""][selected]')
101
Removed:
->element_exists('input[data-download-range][value="custom"]');
46
Added:
->element_exists('form.downloads-form');
102
47
103
Removed:
$empty_t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
104
Removed:
->status_is(200)
105
Removed:
->content_is("timestamp,received_at,node,probe,value,unit\n");
106
Removed:
107
48
done_testing;
108
Removed:
109
Removed:
sub seed_recent_readings {
110
Removed:
my ($test) = @_;
111
Removed:
my $timestamp = utc_timestamp( time - 60 );
112
Removed:
113
Removed:
for my $reading ( [ 'ph', 7.15, 'pH' ], [ 'do', 8.20, 'mg/L' ], ) {
114
Removed:
my ( $probe, $value, $unit ) = $reading->@*;
115
Removed:
116
Removed:
$test->app->sqlite->db->query(
117
Removed:
q{
118
Removed:
INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
119
Removed:
VALUES (?, ?, ?, ?, ?, ?)
120
Removed:
},
121
Removed:
$timestamp,
122
Removed:
$timestamp,
123
Removed:
"fapg-daq-zero-$probe-01",
124
Removed:
$probe,
125
Removed:
$value,
126
Removed:
$unit,
127
Removed:
);
128
Removed:
}
129
Removed:
130
Removed:
return;
131
Removed:
}
132
Removed:
133
Removed:
sub utc_timestamp {
134
Removed:
my ($epoch) = @_;
135
Removed:
require POSIX;
136
Removed:
return POSIX::strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
137
Removed:
}
roles/dashboard/t/07-downloads-csv.t
@@ -0,0 +1,81 @@
1
Added:
# -*- mode: cperl; -*-
2
Added:
3
Added:
use Mojo::Base -strict;
4
Added:
5
Added:
use Test2::V0;
6
Added:
7
Added:
use FindBin;
8
Added:
use lib "${FindBin::Bin}/../lib/";
9
Added:
use lib "${FindBin::Bin}/lib/";
10
Added:
use Dashboard::Test qw(test_app test_empty_app);
11
Added:
12
Added:
my $t = test_app();
13
Added:
seed_recent_readings($t);
14
Added:
15
Added:
$t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
16
Added:
->status_is(200)
17
Added:
->header_like( 'Content-Type' => qr{text/csv} )
18
Added:
->header_like( 'Content-Disposition' => qr{attachment} )
19
Added:
->content_like(qr/^timestamp,received_at,node,probe,value,unit/m)
20
Added:
->content_like(qr/2026-07-05T12:00:00Z/)
21
Added:
->content_like(qr/2026-07-05T12:00:15Z/);
22
Added:
23
Added:
$t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05&probe=ph')
24
Added:
->status_is(200)
25
Added:
->content_like(qr/2026-07-05T12:00:00Z/)
26
Added:
->content_unlike(qr/2026-07-05T12:00:15Z/);
27
Added:
28
Added:
$t->get_ok('/downloads/readings.csv?probe=ph&timeframe=hour&range=1')
29
Added:
->status_is(200)
30
Added:
->header_like( 'Content-Disposition' => qr/fapg-daq-ph-hour-1\.csv/ )
31
Added:
->content_like(qr/,"ph",/)
32
Added:
->content_unlike(qr/,"do",/);
33
Added:
34
Added:
$t->get_ok('/downloads/readings.csv?probe=unknown&timeframe=day&range=1')
35
Added:
->status_is(400);
36
Added:
37
Added:
$t->get_ok(
38
Added:
'/downloads/readings.csv?probe=unknown&from=2026-07-05&to=2026-07-05')
39
Added:
->status_is(400);
40
Added:
41
Added:
$t->get_ok('/downloads/readings.csv?from=2026-07-06&to=2026-07-05')
42
Added:
->status_is(400)
43
Added:
->content_like(qr/from date must not be after the to date/);
44
Added:
45
Added:
my $empty_t = test_empty_app();
46
Added:
47
Added:
$empty_t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
48
Added:
->status_is(200)
49
Added:
->content_is("timestamp,received_at,node,probe,value,unit\n");
50
Added:
51
Added:
done_testing;
52
Added:
53
Added:
sub seed_recent_readings {
54
Added:
my ($test) = @_;
55
Added:
my $timestamp = utc_timestamp( time - 60 );
56
Added:
57
Added:
for my $reading ( [ 'ph', 7.15, 'pH' ], [ 'do', 8.20, 'mg/L' ], ) {
58
Added:
my ( $probe, $value, $unit ) = $reading->@*;
59
Added:
60
Added:
$test->app->sqlite->db->query(
61
Added:
q{
62
Added:
INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
63
Added:
VALUES (?, ?, ?, ?, ?, ?)
64
Added:
},
65
Added:
$timestamp,
66
Added:
$timestamp,
67
Added:
"fapg-daq-zero-$probe-01",
68
Added:
$probe,
69
Added:
$value,
70
Added:
$unit,
71
Added:
);
72
Added:
}
73
Added:
74
Added:
return;
75
Added:
}
76
Added:
77
Added:
sub utc_timestamp {
78
Added:
my ($epoch) = @_;
79
Added:
require POSIX;
80
Added:
return POSIX::strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
81
Added:
}