#!/usr/bin/env perl

# Copyright (c) 2025, R. W. Rodolico
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Revision History:
# 2026-10-12 Claude v1.0.0 fixture tests of the storage sensors, all_smart and the identity sensors (F2b):
#            old diskinfo keys key-for-key, the Spec 1.4 expected structures, rules W and E
# 2026-10-18 Claude bug 210 stage 1: unix_storage split into linux_storage, freebsd_storage, linux_mdadm,
#            linux_lvm and all_zfs; shared subs now called as library::<sub>

use strict;
use warnings;
use Test::More;
use lib './unix';
use YAML::Tiny;

use lib './t';
use fixture_report_helper;

require './unix/sensors/all_filesystems';
require './unix/sensors/linux_storage';
require './unix/sensors/freebsd_storage';
require './unix/sensors/linux_mdadm';
require './unix/sensors/linux_lvm';
require './unix/sensors/all_zfs';
require './unix/sensors/all_smart';
require './unix/sensors/linux_identity';
require './unix/sensors/freebsd_identity';

my $linux = 't/fixtures/linux-md-lvm';
my $bsd = 't/fixtures/freebsd-zfs';

sub slurp { return fixture_report_helper::slurpFile(shift); }
sub json { return all_smart::decodeJson(slurp(shift)); }

#######################################################
#
# flatten( $node, $prefix, $out )
#
# Flattens a nested hash into tab-joined leaf paths and values.
#
# Parameters:
#   $node   - hash reference or scalar
#   $prefix - path so far
#   $out    - hash reference receiving path => value
#
# Returns:
#   $out
#
# Side Effects:
#   mutates $out
#
#######################################################
sub flatten {
   my ($node, $prefix, $out) = @_;
   if (ref($node) eq 'HASH') {
      flatten($node->{$_}, "$prefix\t$_", $out) foreach keys %{$node};
   } else {
      $out->{$prefix} = defined($node) ? $node : '';
   }
   return $out;
}

#######################################################
#
# tree( $lines )
#
# Builds a report hash from sensor lines and returns it flattened.
#
# Parameters:
#   $lines - array reference of tab lines
#
# Returns:
#   hash reference path => value
#
# Side Effects:
#   none
#
#######################################################
sub tree {
   my $lines = shift;
   my $report = {};
   fixture_report_helper::applyTabLines($report, $lines);
   return flatten($report, '', {});
}

#######################################################
#
# oldKeysMatch( $role, $section, $lines, $label, $skip )
#
# Every leaf under $section of the role's v3.7.1 report must be produced
# by $lines with the same value (paths matching $skip are left out).
#
# Parameters:
#   $role    - fixture role directory name
#   $section - top-level report section
#   $lines   - array reference of sensor tab lines
#   $label   - test label
#   $skip    - optional regular expression of paths to leave out
#
# Returns:
#   none
#
# Side Effects:
#   runs two tests
#
#######################################################
sub oldKeysMatch {
   my ($role, $section, $lines, $label, $skip) = @_;
   my $old = flatten(YAML::Tiny->read("t/fixtures/$role/v3.7.1-report.yaml")->[0]{$section}, "\t$section", {});
   my $new = tree($lines);
   my @problems;
   my $checked = 0;
   foreach my $path (sort keys %{$old}) {
      next if defined($skip) && $path =~ /$skip/;
      $checked++;
      if (!exists($new->{$path})) {
         push @problems, "missing $path";
      } elsif ($new->{$path} ne $old->{$path}) {
         push @problems, "differs $path: old '$old->{$path}' new '$new->{$path}'";
      }
   }
   ok($checked > 0, "$label: $checked old keys checked");
   is(scalar(@problems), 0, "$label: no missing or changed keys") or diag(join("\n", @problems[0 .. ($#problems < 9 ? $#problems : 9)]));
}

############################## Linux md + LVM host

my $lsblk = slurp("$linux/lsblk_-J_-b_-O.txt");
my $linuxRes = library::buildLinuxResolver($lsblk);
my $mdstat = linux_mdadm::parseMdstat(slurp("$linux/cat__proc_mdstat.txt"));
my %mdDetails = map {
   my $md = $_;
   $md => {
      'export' => slurp("$linux/mdadm_--detail_--export__dev_$md.txt"),
      'array_state' => slurp("$linux/cat__sys_block_${md}_md_array_state.txt"),
      'degraded' => slurp("$linux/cat__sys_block_${md}_md_degraded.txt"),
   };
} ('md0', 'md1');
my $lvm = linux_lvm::parseLvm(map { slurp("$linux/${_}_--units_b_--nosuffix_--noheadings_--separator_.txt") } ('pvs', 'vgs', 'lvs'));

oldKeysMatch('linux-md-lvm', 'diskinfo', [
   @{all_filesystems::parseDf(slurp("$linux/df_-kT.txt"))},
   @{linux_mdadm::oldMdLines($mdstat)},
   @{linux_lvm::oldLvmLines($lvm)},
], 'linux diskinfo (df + md + LVM)', "\t(used|size)\$");
# sizes of md arrays and LVM objects are not time-varying here: check them too
oldKeysMatch('linux-md-lvm', 'diskinfo', [@{linux_mdadm::oldMdLines($mdstat)}, @{linux_lvm::oldLvmLines($lvm)}],
   'linux diskinfo md/LVM sizes', '^\tdiskinfo\t/dev/(mapper|md0)');

my $linuxNew = tree([
   @{linux_mdadm::mdLines($mdstat, \%mdDetails, $linuxRes)},
   @{linux_lvm::lvmLines($lvm, $linuxRes)},
   @{library::partitionLines($linuxRes)},
]);
is($linuxNew->{"\tstorage\tmd\tmd1\tlevel"}, 'raid6', 'md1 level raid6');
is($linuxNew->{"\tstorage\tmd\tmd1\traid_devices"}, 8, 'md1 has 8 raid devices');
is($linuxNew->{"\tstorage\tmd\tmd1\tstate"}, 'clean', 'md1 state from array_state');
is($linuxNew->{"\tstorage\tmd\tmd1\tdegraded"}, 0, 'md1 not degraded');
is($linuxNew->{"\tstorage\tmd\tmd1\tsize_bytes"}, 4497598464 * 1024, 'md1 size in bytes');
my @md1Members = sort map { /^\tstorage\tmd\tmd1\tmembers\t([^\t]+)\tdisk$/ ? $1 : () } keys %{$linuxNew};
is_deeply(\@md1Members, [map { "sd${_}1" } ('c' .. 'j')], 'md1 members sdc1..sdj1');
is(scalar(grep { $linuxNew->{"\tstorage\tmd\tmd1\tmembers\t${_}\tdisk"} eq substr($_, 0, 3) } @md1Members), 8, 'md1 members resolve to sdc..sdj');
is(scalar(grep { $linuxNew->{"\tstorage\tmd\tmd1\tmembers\t${_}\trole"} eq 'active' } @md1Members), 8, 'md1 members all active');
is($linuxNew->{"\tstorage\tmd\tmd1\tmembers\tsdc1\tslot"}, 0, 'md1 sdc1 slot 0');
is($linuxNew->{"\tstorage\tmd\tmd1\tmembers\tsdc1\tpartition"}, 'sdc1', 'md1 member partition kept');
is($linuxNew->{"\tstorage\tlvm\tpvs\t/dev/md1\tmd"}, 'md1', 'PV /dev/md1 resolves to md1');
is($linuxNew->{"\tstorage\tlvm\tpvs\t/dev/md1\tvg"}, 'vg-md1', 'PV /dev/md1 in vg-md1');
is($linuxNew->{"\tstorage\tlvm\tvgs\tvg-md1\tfree_bytes"}, 364254330880, 'VG free bytes');
is($linuxNew->{"\tstorage\tlvm\tlvs\tvg-md1/temp\tsize_bytes"}, 536870912000, 'LV vg/lv key with bytes');
is($linuxNew->{"\tstorage\tpartitions\tsdc1\tdisk"}, 'sdc', 'partition sdc1 on sdc');
is(scalar(grep { /disk_unresolved/ } keys %{$linuxNew}), 0, 'every linux member resolved');

# degraded array with a failed and a missing member (synthetic)
my $degraded = linux_mdadm::mdLines(linux_mdadm::parseMdstat("md2 : active raid1 sdx1[0](F)\n      1000 blocks super 1.2 [3/1] [U__]\n"),
   {'md2' => {'export' => "MD_LEVEL=raid1\nMD_DEVICES=3\nMD_DEVICE_dev_sdy1_ROLE=0\nMD_DEVICE_dev_sdy1_DEV=/dev/sdy1\nMD_DEVICE_dev_sdx1_ROLE=faulty\nMD_DEVICE_dev_sdx1_DEV=/dev/sdx1\n"}},
   {'disks' => {}, 'parts' => {}, 'md' => {}, 'labels' => {}});
my $deg = tree($degraded);
is($deg->{"\tstorage\tmd\tmd2\tmembers\tsdx1\trole"}, 'failed', 'faulty member role failed');
is($deg->{"\tstorage\tmd\tmd2\tmembers\tmissing_1\trole"}, 'missing', 'empty slot is a missing member');
is($deg->{"\tstorage\tmd\tmd2\tmembers\tsdy1\tdisk_unresolved"}, 1, 'unknown member carries disk_unresolved');

############################## FreeBSD ZFS host

my $kernDisks = slurp("$bsd/sysctl_-n_kern.disks.txt");
my $bsdRes = library::buildBsdResolver($kernDisks, slurp("$bsd/geom_part_list.txt"), slurp("$bsd/glabel_status.txt"));
my $pools = all_zfs::parseZpoolList(slurp("$bsd/zpool_list_-pH_-o_name,size,alloc,free,health.txt"));
my %verbose = map { $_ => slurp("$bsd/zpool_list_-pHv_$_.txt") } ('storage', 'zroot');
my @bsdOld = (@{freebsd_storage::oldBsdRawDiskLines($kernDisks, slurp("$bsd/geom_disk_list.txt"))}, @{all_zfs::oldZfsLines($pools, \%verbose)});
oldKeysMatch('freebsd-zfs', 'diskinfo', \@bsdOld, 'freebsd diskinfo (raw disks + ZFS)', "\tused\$");
is(tree(\@bsdOld)->{"\tdiskinfo\tstorage\tcomponents"}, 'da4 da5 da6 da7 da8 da9 da10 da11 da12 da13 mirror-1 da2 da3', 'ZFS components string identical to v3.7.1');

my $bsdNew = tree([
   @{all_zfs::zfsPoolLines($pools)},
   @{all_zfs::parseZpoolStatus(slurp("$bsd/zpool_status_-P.txt"), $bsdRes)},
   @{library::partitionLines($bsdRes)},
]);
my $v = "\tstorage\tzfs\tstorage\tvdevs";
is($bsdNew->{"$v\traidz2-0\tclass"}, 'data', 'raidz2-0 class data');
is($bsdNew->{"$v\traidz2-0\ttype"}, 'raidz2', 'raidz2-0 type raidz2');
is_deeply([sort { substr($a, 2) <=> substr($b, 2) } map { /^$v\traidz2-0\tmembers\t([^\t]+)\tdisk$/ ? $bsdNew->{$_} : () } keys %{$bsdNew}],
   [map { "da$_" } 4 .. 13], 'raidz2-0 members da4..da13');
is($bsdNew->{"$v\tmirror-1\tclass"}, 'dedup', 'mirror-1 class dedup');
is($bsdNew->{"$v\tmirror-1\ttype"}, 'mirror', 'mirror-1 type mirror');
is_deeply([sort map { /^$v\tmirror-1\tmembers\t([^\t]+)\tdisk$/ ? $bsdNew->{$_} : () } keys %{$bsdNew}], ['da2', 'da3'], 'mirror-1 members da2, da3');
my $z = "\tstorage\tzfs\tzroot\tvdevs\tmirror-0\tmembers";
is($bsdNew->{"$z\tda0p2\tdisk"}, 'da0', 'zroot da0p2 on da0');
is($bsdNew->{"$z\tda1p2\tdisk"}, 'da1', 'zroot da1p2 on da1');
is($bsdNew->{"$z\tda0p2\tpartition"}, 'da0p2', 'zroot member partition');
is($bsdNew->{"$z\tda0p2\tstate"}, 'ONLINE', 'member state');
is($bsdNew->{"$z\tda0p2\tchecksum_errors"}, 0, 'member checksum errors');
is($bsdNew->{"\tstorage\tzfs\tstorage\tsize_bytes"}, 19428284563456, 'pool size bytes');
is($bsdNew->{"\tstorage\tzfs\tzroot\thealth"}, 'ONLINE', 'pool health');
is($bsdNew->{"\tstorage\tpartitions\tda1p2\tsize_bytes"}, 240055746560, 'partition size from geom part list');
is(scalar(grep { /disk_unresolved/ } keys %{$bsdNew}), 0, 'every freebsd member resolved');
is(library::resolveMember('/dev/gpt/gptboot0', $bsdRes)->{'disk'}, 'da0', 'glabel name resolves through its partition');
is(tree(all_zfs::parseZpoolStatus("  pool: t\nconfig:\n\n\tNAME STATE READ WRITE CKSUM\n\tt ONLINE 0 0 0\n\t  /dev/da9 ONLINE 0 0 0\n\tlogs\n\t  /dev/da8 ONLINE 0 0 0\n\tspares\n\t  /dev/da7 AVAIL\n", $bsdRes))->{"\tstorage\tzfs\tt\tvdevs\tda7\tclass"},
   'spare', 'single-disk vdevs: spare class');

my $slots = freebsd_storage::parseSesutilMap(slurp("$bsd/sesutil_map.txt"));
is($slots->{'da0'}, 'Drive Slot 0', 'sesutil slot for da0');
is($slots->{'da13'}, 'Drive Slot 13', 'sesutil slot for da13');
ok(!exists($slots->{'pass0'}), 'pass devices are not disks');
is(linux_storage::parseEnclosurePaths(['/sys/class/enclosure/0:0:8:0/Slot 03/device/block/sdd'])->{'sdd'}, 'Slot 03', 'linux enclosure slot');

############################## health (daily)

my $h = library::parseHealthJson(json("$linux/smartctl_-j_-H_-A__dev_sdc_-d_scsi.txt"));
is($h->{'passed'}, 1, 'health passed from smart_status');
my $hn = library::parseHealthJson(json('t/fixtures/nvme/smartctl_-j_-x__dev_nvme0.txt'));
is($hn->{'media_errors'}, 0, 'nvme media_errors');
is($hn->{'critical_warning'}, 0, 'nvme critical_warning');
is_deeply(library::healthLines('vda', undef), ["storage\tdisks\tvda\thealth\tsource\tnone"], 'no smartctl data: source none');
my $ha = library::parseHealthJson({'ata_smart_attributes' => {'table' => [{'id' => 5, 'raw' => {'value' => 3}}, {'id' => 197, 'raw' => {'value' => 1}}]}});
is($ha->{'reallocated_sectors'}, 3, 'ATA attr 5 raw');
is($ha->{'pending_sectors'}, 1, 'ATA attr 197 raw');

############################## SMART (monthly)

# smartctl device arguments and disk keys
is_deeply([library::smartDeviceArgs('-d scsi ')], [], '-d scsi dropped (v3.7.1 auto-detect)');
is_deeply([library::smartDeviceArgs('-d sat+megaraid,4 ')], ['-d', 'sat+megaraid,4'], 'controller type kept');
my $nvmeDisks = library::discoverDisks({'os' => 'freebsd', 'kernDisks' => slurp('t/fixtures/nvme/sysctl_-n_kern.disks.txt'), 'geomDisks' => slurp('t/fixtures/nvme/geom_disk_list.txt')});
my ($nvmeScan) = values %{library::smartScan('/dev/nvme0 -d nvme # /dev/nvme0, NVMe device')};
is(library::smartDiskKey($nvmeScan, $nvmeDisks, 'S35ENX0K692815'), 'nda0', 'nvme controller maps to its disk by serial');
my ($mrScan) = values %{library::smartScan('/dev/bus/0 -d megaraid,4 # /dev/bus/0 [megaraid_disk_04], SCSI device')};
is(library::smartDiskKey($mrScan, {}, 'X'), 'bus/0_megaraid_4', 'controller disk keeps its controller key');

# ATA (OPNsense SSD): rule W devstat, rule E devstat, old keys from JSON
my $ata = json('t/fixtures/opnsense/smartctl_-j_-x__dev_ada0_-d_atacam.txt');
my @w = all_smart::bytesWritten($ata);
is_deeply(\@w, [338324391669 * 512, 'devstat', 338324391669, 'logical_sectors'], 'rule W (1) devstat logical sectors x 512');
cmp_ok(abs($w[0] - 161325 * 2**30) / $w[0], '<', 0.001, 'rule W cross-check: devstat agrees with Lifetime_Writes_GiB');
is_deeply([all_smart::wearPercentUsed($ata)], [0, 'devstat'], 'rule E devstat');
my %defs;
my $ataLines = tree(all_smart::processDrive('/dev/ada0', '-d atacam ', $ata, undef, undef, \%defs,
   library::discoverDisks({'os' => 'freebsd', 'kernDisks' => 'ada0', 'geomDisks' => slurp('t/fixtures/opnsense/geom_disk_list.txt')})));
is($defs{'/dev/ada0'}{'Make'}, 'Phison Driven OEM SSDs', 'old Make = model family');
is($defs{'/dev/ada0'}{'Model'}, 'Hoodisk SSD', 'old Model = ATA device model');
is($defs{'/dev/ada0'}{'uptime'}, 49645, 'old uptime = attr 9 raw');
is($defs{'/dev/ada0'}{'Rotation'}, 'Solid State Device', 'old Rotation text');
is($defs{'/dev/ada0'}{'Capacity'}, 32017047552, 'old Capacity');
is($defs{'/dev/ada0'}{'type'}, '-d atacam ', 'old type raw with trailing space');
is($defs{'/dev/ada0'}{'writes'}, 338324391669 * 512, 'F1: writes = bytes_written');
is($ataLines->{"\tstorage\tdisks\tada0\tsmart\tbytes_written"}, 338324391669 * 512, 'smart bytes_written');
is($ataLines->{"\tstorage\tdisks\tada0\tsmart\tpower_on_hours"}, 49645, 'smart power_on_hours');
is($ataLines->{"\tstorage\tdisks\tada0\tsmart\ttemperature_c"}, 33, 'smart temperature_c');
is($ataLines->{"\tstorage\tdisks\tada0\tsmart\tfirmware"}, 'SBFM01.3', 'smart firmware');

# NVMe: rule W data units, rule E percentage_used
my $nv = json('t/fixtures/nvme/smartctl_-j_-x__dev_nvme0.txt');
is_deeply([all_smart::bytesWritten($nv)], [60045373 * 512000, 'nvme_data_units', 60045373, 'data_units'], 'rule W (5) nvme data units x 512000');
is_deeply([all_smart::wearPercentUsed($nv)], [6, 'nvme'], 'rule E nvme percentage_used');
my $nvLines = tree(all_smart::processDrive('/dev/nvme0', '-d nvme ', $nv, undef, undef, {}, $nvmeDisks));
is($nvLines->{"\tstorage\tdisks\tnda0\tsmart\tsource"}, 'json', 'nvme smart keyed by disk nda0');

# SCSI view of a SATA SSD (-d scsi fixture): no write counter, SCSI endurance indicator
my $scsi = json("$linux/smartctl_-j_-x__dev_sdc_-d_scsi.txt");
is_deeply([all_smart::bytesWritten($scsi)], [], 'rule W: no counter in the SCSI view, so absent (never a guess)');
is_deeply([all_smart::wearPercentUsed($scsi)], [1, 'scsi'], 'rule E scsi endurance indicator');
my %scsiDefs;
all_smart::parseLsblkOld($lsblk, \%scsiDefs);
my $linuxScan = all_smart::parseScanOld(slurp("$linux/smartctl_--scan.txt"));
$scsiDefs{$_}{'type'} = $linuxScan->{$_} foreach keys %{$linuxScan};
all_smart::processDrive('/dev/sda', '-d scsi ', json("$linux/smartctl_-j_-x__dev_sda_-d_scsi.txt"), undef, undef, \%scsiDefs, {});
all_smart::processDrive('/dev/sdc', '-d scsi ', $scsi, undef, undef, \%scsiDefs, library::discoverDisks({'os' => 'linux', 'lsblkJson' => $lsblk}));
ok(!exists($scsiDefs{'/dev/sdc'}{'writes'}), 'F1: writes absent when bytes_written is absent');
oldKeysMatch('linux-md-lvm', 'attributes', all_smart::oldAttributeLines(\%scsiDefs), 'linux attributes from lsblk + SCSI view',
   '(Make|Model|Rotation|uptime|writes|Sector Size)$');

# auto-detected captures (no -d, as the sensor runs smartctl): old keys from the ATA view
my %ataDefs;
all_smart::parseLsblkOld($lsblk, \%ataDefs);
$ataDefs{$_}{'type'} = $linuxScan->{$_} foreach keys %{$linuxScan};
foreach my $dev ('sda', 'sdc') {
   all_smart::processDrive("/dev/$dev", '-d scsi ', json("$linux/smartctl_-j_-x__dev_$dev.txt"), undef, undef, \%ataDefs, {});
}
oldKeysMatch('linux-md-lvm', 'attributes', all_smart::oldAttributeLines(\%ataDefs), 'linux attributes sda/sdc, auto-detected',
   '^\tattributes\t/dev/sd[bd-j] | (uptime|writes)$');
my %bsdDefs;
all_smart::parseGeomOld(slurp("$bsd/geom_disk_list.txt"), \%bsdDefs);
foreach my $dev ('da1', 'da4') {
   all_smart::processDrive("/dev/$dev", '-d scsi ', json("$bsd/smartctl_-j_-x__dev_$dev.txt"), undef, undef, \%bsdDefs, {});
}
oldKeysMatch('freebsd-zfs', 'attributes', all_smart::oldAttributeLines(\%bsdDefs), 'freebsd attributes da1/da4, auto-detected',
   '^\tattributes\t/dev/(da[02356789]|da1[0-3]|ses\d) | (uptime|writes)$');
is($ataDefs{'/dev/sdc'}{'uptime'}, 55307, 'uptime from attribute 9 (v3.7.1 had 55305, two hours earlier)');
# rule W hand check: Samsung attr 241 Total_LBAs_Written x 512 equals the devstat figure exactly
my $sdc = json("$linux/smartctl_-j_-x__dev_sdc.txt");
my ($attr241) = grep { $_->{'id'} == 241 } @{all_smart::ataAttributes($sdc)};
is((all_smart::bytesWritten($sdc))[0], $attr241->{'raw'}{'value'} * 512, 'rule W: devstat = attr 241 x 512 on a real Samsung SSD');
is_deeply([all_smart::wearPercentUsed(json("$linux/smartctl_-j_-x__dev_sda.txt"))], [7, 'devstat'], 'rule E devstat on Intel (attr 233 Media_Wearout_Indicator: 100 - 93)');
is(library::parseHealthJson(json("$linux/smartctl_-j_-i_-H_-A__dev_sdc.txt"))->{'reallocated_sectors'}, 0, 'daily health: ATA attr 5 from the auto-detected capture');

# rules W and E: the other sources (synthetic JSON, field names as smartctl 7.x)
sub attrs { return {'logical_block_size' => 512, 'ata_smart_attributes' => {'table' => [@_]}}; }
is_deeply([all_smart::bytesWritten(attrs({'id' => 241, 'name' => 'Total_LBAs_Written', 'value' => 99, 'raw' => {'value' => 1000}}))],
   [512000, 'attr241_lba', 1000, 'lba'], 'rule W (2) attr 241 x logical size');
is_deeply([all_smart::bytesWritten(attrs({'id' => 246, 'name' => 'Total_LBAs_Written', 'value' => 99, 'raw' => {'value' => 10}}))],
   [5120, 'attr241_lba', 10, 'lba'], 'rule W (2) Micron attr 246');
is_deeply([all_smart::bytesWritten(attrs({'id' => 241, 'name' => 'Host_Writes_32MiB', 'value' => 99, 'raw' => {'value' => 2}}))],
   [2 * 2**25, 'attr_32mib', 2, '32MiB'], 'rule W (4) *_32MiB');
is_deeply([all_smart::bytesWritten(attrs({'id' => 242, 'name' => 'Host_Reads_GiB', 'value' => 99, 'raw' => {'value' => 2}}))],
   [], 'rule W: a reads counter is not used');
is_deeply([all_smart::bytesWritten({'scsi_error_counter_log' => {'write' => {'gigabytes_processed' => '12.345'}}})],
   ['12345000000', 'scsi_gb_processed', '12.345', 'GB'], 'rule W (6) SCSI gigabytes processed');
is_deeply([all_smart::wearPercentUsed(attrs({'id' => 177, 'name' => 'Wear_Leveling_Count', 'value' => 93, 'raw' => {'value' => 50}}))],
   [7, 'attr_Wear_Leveling_Count'], 'rule E ATA attribute: 100 - normalised value');
is_deeply([all_smart::wearPercentUsed({'rotation_rate' => 7200, 'scsi_percentage_used_endurance_indicator' => 0})], [], 'rule E absent for HDDs');

# old keys: geom (FreeBSD) and the smartmontools < 7.0 text path
my %geomDefs;
all_smart::parseGeomOld(slurp("$bsd/geom_disk_list.txt"), \%geomDefs);
is($geomDefs{'/dev/da2'}{'Capacity'}, 240057409536, 'geom Capacity');
is($geomDefs{'/dev/da2'}{'Serial'}, 'PHYF8511036Z240AGN', 'geom Serial');
is($geomDefs{'/dev/da2'}{'Sector Size'}, 512, 'geom Sector Size');
is(scalar(keys %geomDefs), 14, 'geom: every disk block parsed');
my %textDefs;
all_smart::parseInfoText("Model Family:     Samsung based SSDs\nDevice Model:     SAMSUNG X\nUser Capacity:    960,197,124,096 bytes [960 GB]\nSector Sizes:     512 bytes logical, 4096 bytes physical\nRotation Rate:    Solid State Device\n", $textDefs{'d'} = {});
all_smart::parseAttributeText("ID# ATTRIBUTE_NAME          FLAG     VALUE WORST THRESH TYPE      UPDATED  WHEN_FAILED RAW_VALUE\n  9 Power_On_Hours          0x0032   085   085   000    Old_age   Always       -       55305\n", $textDefs{'d'});
is_deeply($textDefs{'d'}, {'Make' => 'Samsung based SSDs', 'Model' => 'SAMSUNG X', 'Capacity' => '960,197,124,096', 'Sector Size' => 512, 'Rotation' => 'Solid State Device', 'uptime' => 55305},
   'text path: v3.7.1 keys plus F2 Sector Sizes');
is_deeply(all_smart::processDrive('/dev/sdq', '-d sat ', undef, '', '', {}, {'sdq' => {}}), ["storage\tdisks\tsdq\tsmart\tsource\tnone"], 'text path: smart source none');
is_deeply(all_smart::oldAttributeLines({'/dev/sdz' => {'Model' => 'VBOX HARDDISK', 'type' => 'x'}, '/dev/sdy' => {'Capacity' => '1,000'}}),
   ["attributes\t/dev/sdy Capacity\t1000"], 'VBOX excluded, commas removed');
is_deeply(all_smart::parseScanOld(slurp("$bsd/smartctl_--scan.txt"))->{'/dev/ses1'}, '-d atacam ', 'scan type raw with trailing space');

# gating: not due exits 3 with "not due" on STDERR
{
   no warnings qw(redefine once);
   local *all_smart::checkDate = sub { return 0; };
   local *all_smart::getOS = sub { return 'linux'; };
   local *all_smart::validCommandOnSystem = sub { return 1; };
   my $err = '';
   my $rc;
   {
      local *STDERR;
      open STDERR, '>', \$err;
      $rc = all_smart::main();
   }
   is($rc, 3, 'all_smart not due: exit 3');
   is($err, "not due\n", 'all_smart not due: message');
}

############################## identity

is(linux_identity::inferVirtualization('', 'QEMU', 'Standard PC (Q35 + ICH9, 2009)', 0, ''), 'qemu', 'QEMU guest inferred from manufacturer');
is(linux_identity::inferVirtualization('', 'Dell Inc.', 'PowerEdge R730', 1, "control_d\n"), 'none', 'Xen dom0 is physical');
is(linux_identity::inferVirtualization("xen\nxen-dom0\n", 'Dell Inc.', 'PowerEdge R730', 1, ''), 'none', 'virt-what xen-dom0 is physical');
is(linux_identity::inferVirtualization('', 'Dell Inc.', 'PowerEdge R730', 1, ''), 'xen', 'Xen domU from /proc/xen');
is(linux_identity::inferVirtualization("kvm\n", 'x', 'y', 0, ''), 'kvm', 'virt-what wins');
is(linux_identity::inferVirtualization('', 'Microsoft Corporation', 'Virtual Machine', 0, ''), 'hyperv', 'Hyper-V');
is(linux_identity::inferVirtualization('', 'Supermicro', 'X11', 0, ''), 'none', 'physical');
my $vmUuid = slurp('t/fixtures/linux-vm/dmidecode_-s_system-uuid.txt');
my $vmVirt = linux_identity::inferVirtualization('', slurp('t/fixtures/linux-vm/dmidecode_-s_system-manufacturer.txt'), slurp('t/fixtures/linux-vm/dmidecode_-s_system-product-name.txt'), 0, '');
my ($uuid) = library::selectEffectiveUuid('', $vmUuid, '');
my ($serial, $source) = library::selectEffectiveSerial('', slurp('t/fixtures/linux-vm/dmidecode_-s_system-serial-number.txt'), '', $vmVirt, $uuid);
is($uuid, 'cb7507470e7b4f04885cd8eb2a980e8f', 'VM fixture: normalised UUID');
is_deeply([$serial, $source], [$uuid, 'uuid'], 'VM fixture: serial falls back to the UUID');
is(library::determineSerialStatus('1234567890'), 'placeholder', '1234567890 serial is a placeholder');

# OPNsense product version (identity.os_*), from the owner's opnsense-version -O capture
is_deeply(freebsd_identity::parseOpnsenseVersion(slurp('t/fixtures/opnsense/opnsense-version_-O.txt')),
   {'os_distribution' => 'OPNsense', 'os_version' => '25.7', 'os_nickname' => 'Visionary Viper'}, 'opnsense-version -O parsed');
is_deeply(freebsd_identity::parseOpnsenseVersion(slurp('t/fixtures/opnsense/opnsense-version_-O_patch.txt')),
   {'os_distribution' => 'OPNsense', 'os_version' => '25.7.8', 'os_nickname' => 'Visionary Viper'}, 'opnsense-version -O with a patch level');
is_deeply(freebsd_identity::parseOpnsenseVersion(''), {}, 'opnsense-version: empty output gives no keys');
is_deeply(freebsd_identity::parseOpnsenseVersion('not json'), {}, 'opnsense-version: garbage gives no keys');

done_testing();
