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

Commit
88f40c8d3c29d2aa94bab2a412b33aa9b3a35f3c
Author
GPT-5 Codex high <codex@openai.com>
Author date
Committer
GPT-5 Codex high <codex@openai.com>
Committer date
Changed files
AGENTS.org
index 6ca8f09a..5dab16b4 100644..100644
@@ -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
index 0bcdfe42..744264cf 100644..100644
@@ -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 &copy; 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
index d788d506..de412346 100644..100644
@@ -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
index 032b6360..74501d50 100644..100644
@@ -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
index 376e48cd..59c0adcc 100644..100644
@@ -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
index 00000000..d4f69fb9 000000..100644
@@ -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: }