From 402278ecfd6a91950d575bdc07d7a252d31f736b Mon Sep 17 00:00:00 2001 From: martino <32328813+f3rs3n@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:54:04 +0200 Subject: [PATCH] fix(storage): qualify SMART status and report explanations --- .../tests/test_command_descriptions.py | 12 ++ AppImage/components/storage-overview.tsx | 10 +- AppImage/messages/en/common.json | 7 ++ scripts/storage/smart-disk-test.sh | 2 +- tests/storage/test_nvme_status_message.py | 35 ++++++ tests/test_storage_messages.cjs | 111 ++++++++++++++++++ 6 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 tests/storage/test_nvme_status_message.py create mode 100644 tests/test_storage_messages.cjs diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index 30d9b38e..f8d48418 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -90,6 +90,18 @@ class CommandDescriptionsTests(unittest.TestCase): path = root / lang / "common.json" path.parent.mkdir() shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path) + # Model steady state after automation fills this intentional new key. + # Runtime missing-key fallback is tested separately in test_storage_messages.cjs. + if lang != "en": + messages = json.loads(path.read_text()) + messages["storage"].setdefault("savedSmartData", catalog("en")["storage"]["savedSmartData"]) + report = messages["storage"]["smartReport"] + source_report = catalog("en")["storage"]["smartReport"] + for key in ("passedAssessment", "noReallocatedSectorsReported", "passedMeaningTitle", "passedMeaning"): + report.setdefault(key, source_report[key]) + for key in ("passedTitle", "passedText"): + report["recommendations"].setdefault(key, source_report["recommendations"][key]) + path.write_text(json.dumps(messages)) before = {p: p.read_bytes() for p in root.glob("*/common.json")} argv = [str(SCRIPT), "--source", str(root / "en/common.json"), "--messages-dir", str(root), "--languages", languages, "--sleep", "0"] diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 3cf275a7..277a13d6 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -2065,7 +2065,7 @@ export function StorageOverview() {

{t("storage.wearLifetime")} {smartJsonData?.has_data && !wi && ( - {t("storage.realTest")} + {t("storage.savedSmartData")} )}

@@ -2777,7 +2777,7 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri // Build recommendations const recommendations: string[] = [] if (isHealthy) { - recommendations.push(`
✓
${t("storage.smartReport.recommendations.healthyTitle")}

${t("storage.smartReport.recommendations.healthyText")}

`) + recommendations.push(`
✓
${t("storage.smartReport.recommendations.passedTitle")}

${t("storage.smartReport.recommendations.passedText")}

`) } else { recommendations.push(`
✗
${t("storage.smartReport.recommendations.criticalTitle")}

${t("storage.smartReport.recommendations.criticalText")}

`) } @@ -3284,7 +3284,7 @@ function pmxPrint(){

${t("storage.smartReport.healthAssessment")}

${isHealthy - ? t("storage.smartReport.healthyAssessment", { uptime: powerOnFormatted, temperature: disk.temperature > 0 ? disk.temperature + '°C' : na, sectors: (disk.reallocated_sectors ?? 0) === 0 ? t("storage.smartReport.noBadSectors") : t("storage.smartReport.reallocatedSectors", { count: disk.reallocated_sectors ?? 0 }) }) + ? t("storage.smartReport.passedAssessment", { uptime: powerOnFormatted, temperature: disk.temperature > 0 ? disk.temperature + '°C' : na, sectors: (disk.reallocated_sectors ?? 0) === 0 ? t("storage.smartReport.noReallocatedSectorsReported") : t("storage.smartReport.reallocatedSectors", { count: disk.reallocated_sectors ?? 0 }) }) : t("storage.smartReport.failedAssessment") }

@@ -3294,11 +3294,11 @@ function pmxPrint(){
- ${isHealthy ? t("storage.smartReport.healthyMeaningTitle") : (hasCritical ? t("storage.smartReport.attentionTitle") : t("storage.smartReport.monitorTitle"))} + ${isHealthy ? t("storage.smartReport.passedMeaningTitle") : (hasCritical ? t("storage.smartReport.attentionTitle") : t("storage.smartReport.monitorTitle"))}

${isHealthy - ? t("storage.smartReport.healthyMeaning") + ? t("storage.smartReport.passedMeaning") : (hasCritical ? t("storage.smartReport.criticalMeaning") : t("storage.smartReport.warningMeaning") diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 20a9af22..b578a021 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -233,6 +233,7 @@ "life": "life", "lifeRemaining": "Life Remaining", "realTest": "Real Test", + "savedSmartData": "Saved SMART data", "wear": { "label": "Wear", "percentageUsed": "Percentage Used", @@ -362,6 +363,10 @@ "healthAssessment": "Disk Health Assessment", "passedUpper": "PASSED", "testAgeWarning": "This report is based on a SMART test performed {days} days ago ({date}). Disk health may have changed since then. We recommend running a new SMART test for up-to-date results.", + "passedAssessment": "The supplied SMART status is PASSED. This status alone does not establish that every attribute is within range or that the disk is free of faults. Power-on time shown: {uptime}. Temperature shown: {temperature}. {sectors}", + "noReallocatedSectorsReported": "The supplied reallocated-sector value is zero or unavailable; this does not establish that there are no bad sectors.", + "passedMeaningTitle": "What does a PASSED status mean?", + "passedMeaning": "PASSED is the overall status supplied to this report, not proof of a completed self-test or a guarantee against failure. Review the attributes, error logs and self-test results, and keep current backups.", "healthyAssessment": "This disk is operating within normal parameters. All SMART attributes are within acceptable thresholds. The disk has been powered on for approximately {uptime} and is currently operating at {temperature}. {sectors}", "failedAssessment": "This disk has reported a SMART health failure. Immediate action is required. Back up all critical data and plan for disk replacement.", "noBadSectors": "No bad sectors have been detected.", @@ -718,6 +723,8 @@ "backgroundScanStatus": "Status of the SCSI background media scan. Runs continuously to detect surface defects." }, "recommendations": { + "passedTitle": "Review the reported results", + "passedText": "A PASSED status can coexist with warnings or recorded errors. Review the report details, continue regular monitoring and keep current backups.", "healthyTitle": "Disk is Healthy", "healthyText": "All SMART attributes are within normal ranges. Continue regular monitoring.", "criticalTitle": "Critical: Disk Health Issue Detected", diff --git a/scripts/storage/smart-disk-test.sh b/scripts/storage/smart-disk-test.sh index a4e37b36..0faf093c 100644 --- a/scripts/storage/smart-disk-test.sh +++ b/scripts/storage/smart-disk-test.sh @@ -232,7 +232,7 @@ while true; do else HEALTH=$(echo "$OUTPUT" | grep -i "critical_warning" | awk '{print $NF}') if [[ "$HEALTH" == "0" ]]; then - msg_ok "$(translate 'NVMe health status: PASSED')" + msg_ok "$(translate 'NVMe critical_warning is 0 (no critical warnings reported).')" else msg_warn "$(translate 'NVMe health status: WARNING (critical_warning =') $HEALTH)" fi diff --git a/tests/storage/test_nvme_status_message.py b/tests/storage/test_nvme_status_message.py new file mode 100644 index 00000000..7ee3ef8b --- /dev/null +++ b/tests/storage/test_nvme_status_message.py @@ -0,0 +1,35 @@ +"""Execute only the actual pure HEALTH decision; never source the admin script.""" +import pathlib +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SOURCE = ROOT / 'scripts/storage/smart-disk-test.sh' + + +class NvmeStatusMessage(unittest.TestCase): + def render(self, health): + source = SOURCE.read_text() + start = source.index(' if [[ "$HEALTH" == "0" ]]; then') + end = source.index('\n fi', start) + len('\n fi') + seam = source[start:end] + # Only printf-backed display/translation stubs are available to this seam. + harness = '''translate() { printf '%s' "$1"; } +msg_ok() { printf 'OK:%s\\n' "$1"; } +msg_warn() { printf 'WARN:%s\\n' "$1"; } +HEALTH=$1 +''' + return subprocess.run(['bash', '-c', harness + seam, 'fixture', health], + check=True, capture_output=True, text=True).stdout.strip() + + def test_zero_describes_critical_warning_not_self_test(self): + self.assertEqual(self.render('0'), 'OK:NVMe critical_warning is 0 (no critical warnings reported).') + + def test_nonzero_and_missing_keep_existing_warning_branch(self): + for health in ['1', '2', '255', '', '0x00']: + with self.subTest(health=health): + self.assertEqual(self.render(health), f'WARN:NVMe health status: WARNING (critical_warning = {health})') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_storage_messages.cjs b/tests/test_storage_messages.cjs new file mode 100644 index 00000000..9f35c3f7 --- /dev/null +++ b/tests/test_storage_messages.cjs @@ -0,0 +1,111 @@ +// Offline actual JSX/provider seams. No component imports or effects. +// NODE_PATH may point to an isolated TypeScript installation. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const root = path.resolve(__dirname, '..'); +const read = p => fs.readFileSync(path.join(root, p), 'utf8'); +const parse = p => ts.createSourceFile(p, read(p), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const print = ts.createPrinter(); +const emit = (node, source) => print.printNode(ts.EmitHint.Unspecified, node, source); +const compile = code => ts.transpileModule(code, {compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.React, +}}).outputText; +const provider = parse('AppImage/lib/i18n/provider.tsx'); +const helpers = provider.statements.filter(n => ts.isFunctionDeclaration(n) && ['getMessage', 'interpolate'].includes(n.name?.text)); +assert.equal(helpers.length, 2); +let lookup; +function walk(node, fn) { fn(node); ts.forEachChild(node, n => walk(n, fn)); } +walk(provider, n => { + if (ts.isVariableDeclaration(n) && n.name.getText(provider) === 't' && + n.initializer && ts.isCallExpression(n.initializer) && n.initializer.expression.getText(provider) === 'useCallback') lookup = n.initializer.arguments[0]; +}); +assert.ok(lookup); +const catalogs = Object.fromEntries(fs.readdirSync(path.join(root, 'AppImage/messages')).filter(locale => fs.existsSync(path.join(root, 'AppImage/messages', locale, 'common.json'))).map(locale => + [locale, JSON.parse(read(`AppImage/messages/${locale}/common.json`))])); +// Only trusted checked-out source enters Function bodies; fixture/catalog values are arguments. +const translator = (language, messages = catalogs) => new Function('MESSAGE_CATALOG', 'language', + compile(helpers.map(n => emit(n, provider)).join('\n') + '\nconst t = ' + emit(lookup, provider)) + '\nreturn t;')(messages, language); +const source = parse('AppImage/components/storage-overview.tsx'); +let badge; +walk(source, n => { + if (ts.isJsxExpression(n) && n.expression && ts.isBinaryExpression(n.expression) && + n.expression.left.getText(source) === 'smartJsonData?.has_data && !wi') badge = n.expression; +}); +assert.ok(badge, 'actual availability guard and badge'); +const render = new Function('React', 'Badge', 't', 'smartJsonData', 'wi', + compile('const result = ' + emit(badge, source)) + '\nreturn result;'); +const React = {createElement: (tag, props, ...children) => children.join('')}; +const text = (locale, data = {has_data: true}, wi = false, messages = catalogs) => + render(React, 'badge', translator(locale, messages), data, wi); +assert.equal(text('en'), 'Saved SMART data'); +assert.equal(text('missing', undefined, false, {...catalogs, missing: {}}), 'Saved SMART data'); +for (const locale of Object.keys(catalogs)) { + if (catalogs[locale].storage?.savedSmartData === undefined) assert.equal(text(locale), 'Saved SMART data'); +} +assert.equal(text('en', {has_data: false}), false); +assert.equal(text('en', {has_data: true}, true), false); +assert.equal(text('en', null), undefined); +assert.equal(text('synthetic', undefined, false, {...catalogs, synthetic: {storage: {savedSmartData: 'SAVED_DATA_TRANSLATED'}}}), 'SAVED_DATA_TRANSLATED'); +assert.equal(catalogs.en.storage.realTest, 'Real Test', 'legacy label preserved'); +console.log('PASS: saved-data badge guards and actual provider fallback/translation'); + +// Execute the actual printable-report function with inert browser sinks, not component effects. +const report = source.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'openSmartReport'); +assert.ok(report); +let reportHtml; +const openReport = new Function('window', 'Blob', 'URL', 'setTimeout', 'getDiskTempThresholdsSync', 'getNvmeSmartAttributeKey', + compile('const APP_VERSION = "fixture";\n' + emit(report, source)) + '\nreturn openSmartReport;')( + {location: {origin: 'http://fixture.invalid'}, open() {}}, + class { constructor(parts) { reportHtml = parts.join(''); } }, + {createObjectURL() { return 'blob:fixture'; }, revokeObjectURL() {}}, () => {}, + () => ({warning: 55, critical: 65}), () => undefined); +const expectedReport = { + passedAssessment: 'The supplied SMART status is PASSED. This status alone does not establish that every attribute is within range or that the disk is free of faults. Power-on time shown: {uptime}. Temperature shown: {temperature}. {sectors}', + noReallocatedSectorsReported: 'The supplied reallocated-sector value is zero or unavailable; this does not establish that there are no bad sectors.', + passedMeaningTitle: 'What does a PASSED status mean?', + passedMeaning: 'PASSED is the overall status supplied to this report, not proof of a completed self-test or a guarantee against failure. Review the attributes, error logs and self-test results, and keep current backups.', + recommendations: { + passedTitle: 'Review the reported results', + passedText: 'A PASSED status can coexist with warnings or recorded errors. Review the report details, continue regular monitoring and keep current backups.', + }, +}; +function leaves(value, prefix = '') { + return Object.entries(value).flatMap(([k, v]) => typeof v === 'string' ? [[prefix + k, v]] : leaves(v, prefix + k + '.')); +} +function renderReport(diskOverrides = {}, status = 'passed', locale = 'en', messages = catalogs, smartData = {}) { + openReport({name: 'sda', model: 'Fixture disk', size: 100, rotation_rate: 7200, temperature: 35, power_on_hours: 24, ...diskOverrides}, + {smart_status: status, smart_data: smartData}, [], [], undefined, undefined, false, undefined, translator(locale, messages)); + return reportHtml; +} +const html = renderReport(); +for (const [key, value] of leaves(expectedReport)) { + const expected = value.replace('{uptime}', '1d (24h)').replace('{temperature}', '35°C').replace('{sectors}', expectedReport.noReallocatedSectorsReported); + assert.ok(html.includes(expected), `report consumer: ${key}`); +} +assert.ok(html.includes('The supplied SMART status is PASSED.')); +assert.ok(!html.includes('All SMART attributes are within')); +assert.ok(!html.includes('Your disk is healthy!')); +for (const sectors of [undefined, 0]) assert.ok(renderReport({reallocated_sectors: sectors}).includes(expectedReport.noReallocatedSectorsReported)); +assert.ok(!renderReport({reallocated_sectors: 3}).includes(expectedReport.noReallocatedSectorsReported)); +for (const status of ['failed', 'unknown', 'warning']) assert.ok(!renderReport({}, status).includes(expectedReport.passedMeaning)); +for (const locale of ['synthetic', ...Object.keys(catalogs).filter(l => l !== 'en')]) { + const messages = {...catalogs, synthetic: {}}; + const rendered = renderReport({}, 'passed', locale, messages); + if (locale === 'synthetic') assert.ok(rendered.includes(expectedReport.passedAssessment.replace('{uptime}', '1d (24h)').replace('{temperature}', '35°C').replace('{sectors}', expectedReport.noReallocatedSectorsReported))); + for (const [key, value] of leaves(expectedReport)) { + const localValue = ('storage.smartReport.' + key).split('.').reduce((v, k) => v?.[k], messages[locale]); + if (localValue === undefined && !value.includes('{')) assert.ok(rendered.includes(value), `${locale}: ${key}`); + } +} +for (const temperature of [undefined, 0]) assert.ok(renderReport({temperature}).includes('Temperature shown: N/A.')); +assert.ok(renderReport({name: 'nvme0n1'}, 'passed').includes(expectedReport.passedMeaning)); +const translated = Object.fromEntries(leaves(expectedReport).filter(([k]) => !k.includes('.')).map(([k]) => [k, `TRANSLATED_${k}`])); +translated.passedAssessment += ' {uptime} {temperature} {sectors}'; +translated.recommendations = {passedTitle: 'TRANSLATED_REC_TITLE', passedText: 'TRANSLATED_REC_TEXT'}; +const localized = renderReport({}, 'passed', 'synthetic', {...catalogs, synthetic: {storage: {smartReport: translated}}}); +for (const [, value] of leaves(translated)) assert.ok(localized.includes(value.split(' {')[0]), value); +assert.ok(renderReport({name: 'nvme0n1'}, 'passed', 'en', catalogs, {nvme_raw: {critical_warning: 0, media_errors: 7}}).includes(expectedReport.passedMeaning)); +if (process.env.STORAGE_REPORT_HTML) fs.writeFileSync(process.env.STORAGE_REPORT_HTML, renderReport()); +console.log('PASS: actual printable report, passed/failed/unknown/warning, missing/zero/nonzero sectors, NVMe errors and provider fallback');