@raidiant/notifai 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,9 @@ import {
17
17
  buildQuestions,
18
18
  assessReadiness,
19
19
  capabilitiesCommand,
20
+ configExplainCommand,
20
21
  configSetCommand,
22
+ configShowCommand,
21
23
  contradictingAnswer,
22
24
  describeHookFailure,
23
25
  doctorCommand,
@@ -38,6 +40,8 @@ import {
38
40
  import { applyPlan, buildHookConfig } from './install-hooks.js'
39
41
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
40
42
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
43
+ import { CONFIG_KEYS } from './config.js'
44
+ import type { Tone } from './ui/theme.js'
41
45
 
42
46
  class CapturedIo implements CommandIo {
43
47
  outLines: string[] = []
@@ -72,7 +76,7 @@ class InteractiveIo extends CapturedIo {
72
76
  intros: string[] = []
73
77
  outros: string[] = []
74
78
  spinnerEvents: string[] = []
75
- checks: { ok: boolean; message: string }[] = []
79
+ checks: { ok: boolean; message: string; tone?: Tone }[] = []
76
80
 
77
81
  override async confirm(question: string) {
78
82
  this.prompts.push(question)
@@ -111,8 +115,8 @@ class InteractiveIo extends CapturedIo {
111
115
  }
112
116
  }
113
117
 
114
- async check(ok: boolean, message: string) {
115
- this.checks.push({ ok, message })
118
+ async check(ok: boolean, message: string, tone?: Tone) {
119
+ this.checks.push({ ok, message, ...(tone === undefined ? {} : { tone }) })
116
120
  }
117
121
  }
118
122
 
@@ -919,6 +923,67 @@ describe('projectSlugFrom', () => {
919
923
  })
920
924
  })
921
925
 
926
+ describe('config surfaces', () => {
927
+ function configDeps(io: CapturedIo): CommandDeps {
928
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
929
+ return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
930
+ }
931
+
932
+ it('gives an agent the same flat key = value output it always had', () => {
933
+ // The whole point of gating the readable rendering on a human terminal.
934
+ // Anything parsing this today must keep parsing it tomorrow.
935
+ const io = new CapturedIo()
936
+ expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
937
+ expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
938
+ expect(io.outLines[0]).toMatch(/^base_url = "/)
939
+ for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
940
+ })
941
+
942
+ it('explains each setting once a human is at the terminal', () => {
943
+ const io = new InteractiveIo()
944
+ expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
945
+ const text = io.outLines.join('\n')
946
+ expect(text).toContain('Questions & presence')
947
+ expect(text).toContain('Whether sitting at this keyboard holds a question back')
948
+ })
949
+
950
+ it('keeps --plain available to a human who wants the parseable form', () => {
951
+ const io = new InteractiveIo()
952
+ expect(configShowCommand(configDeps(io), { plain: true })).toBe(EXIT.ok)
953
+ expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
954
+ })
955
+
956
+ it('explains one setting, and says so in JSON when asked', () => {
957
+ const io = new InteractiveIo()
958
+ expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
959
+ const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
960
+ expect(parsed['key']).toBe('require_idle')
961
+ expect(parsed['accepts']).toBe('true or false')
962
+ expect(parsed['detail']).toContain('question waits while you are using this machine')
963
+ })
964
+
965
+ it('rejects an unknown setting and points at the nearest real one', () => {
966
+ const io = new CapturedIo()
967
+ expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
968
+ expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
969
+ })
970
+
971
+ it('refuses an enum value the sender would later reject', async () => {
972
+ // `config set sound whatever` used to be written straight to disk: the
973
+ // typo only surfaced when a notification failed to carry the sound.
974
+ const io = new CapturedIo()
975
+ expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
976
+ EXIT.usage,
977
+ )
978
+ expect(io.errLines[0]).toContain('default, done, attention, alert, none')
979
+ })
980
+
981
+ it('still accepts a legal enum value', async () => {
982
+ const io = new CapturedIo()
983
+ expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
984
+ })
985
+ })
986
+
922
987
  describe('interactive command UX', () => {
923
988
  it('styles login pairing progress for a human terminal', async () => {
924
989
  const io = new InteractiveIo()
@@ -1052,7 +1117,9 @@ describe('interactive command UX', () => {
1052
1117
  )
1053
1118
  expect(io.errLines).toEqual([
1054
1119
  'ask_grace_seconds must be between 0 and 540.',
1055
- '"1.5" is not an integer.',
1120
+ // Names the key and its range: `"1.5" is not an integer` left the reader
1121
+ // to work out which of the two settings they had just mistyped.
1122
+ 'ask_grace_seconds takes a whole number from 0s–540s, not "1.5".',
1056
1123
  ])
1057
1124
  expect(existsSync(configFile)).toBe(false)
1058
1125
  })
@@ -1081,6 +1148,18 @@ describe('interactive command UX', () => {
1081
1148
  expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
1082
1149
  expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
1083
1150
  expect(io.outLines).toEqual([])
1151
+
1152
+ // Four tones, not two. A boolean has to round `optional-gap` and `unknown`
1153
+ // to pass or fail, and rounding them to pass put a tick beside things the
1154
+ // user had declined and beside things nothing had checked.
1155
+ const tone = (prefix: string): Tone | undefined =>
1156
+ io.checks.find((check) => check.message.startsWith(prefix))?.tone
1157
+ expect(tone('This machine:')).toBe('bad')
1158
+ expect(tone('Protocol version:')).toBe('ok')
1159
+ // Never evaluated: the account cannot be checked without a credential.
1160
+ expect(tone('Account:')).toBe('pending')
1161
+ // Legitimately declined rather than broken.
1162
+ expect(tone('Project identity:')).toBe('warn')
1084
1163
  })
1085
1164
 
1086
1165
  it('keeps doctor JSON as one machine-readable stdout document', async () => {
@@ -1181,7 +1260,7 @@ describe('init', () => {
1181
1260
  source: 'Raidiant-io/notifai',
1182
1261
  sourceType: 'github',
1183
1262
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1184
- ref: 'v0.2.1',
1263
+ ref: 'v0.3.0',
1185
1264
  }
1186
1265
  }
1187
1266
 
@@ -1284,7 +1363,7 @@ describe('init', () => {
1284
1363
  })
1285
1364
 
1286
1365
  it('pins the skill installer to the tagged public release syntax', () => {
1287
- expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.2.1')
1366
+ expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.3.0')
1288
1367
  expect(SKILLS_SOURCE).not.toContain('@v')
1289
1368
  })
1290
1369
 
@@ -1307,7 +1386,7 @@ describe('init', () => {
1307
1386
  source: 'Raidiant-io/notifai',
1308
1387
  sourceType: 'github',
1309
1388
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1310
- ref: 'v0.2.1',
1389
+ ref: 'v0.3.0',
1311
1390
  },
1312
1391
  ],
1313
1392
  }),
@@ -2207,7 +2286,7 @@ describe('asking before the hooks have ever run', () => {
2207
2286
  source: 'Raidiant-io/notifai',
2208
2287
  sourceType: 'github',
2209
2288
  sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2210
- ref: 'v0.2.1',
2289
+ ref: 'v0.3.0',
2211
2290
  },
2212
2291
  ]
2213
2292
  : [],
package/src/commands.ts CHANGED
@@ -41,8 +41,21 @@ import {
41
41
  type ConfigKey,
42
42
  type FlagOverrides,
43
43
  } from './config.js'
44
+ import { acceptedValues, configInfo } from './config-schema.js'
45
+ import {
46
+ renderConfigExplain,
47
+ renderConfigList,
48
+ renderConfigPlain,
49
+ } from './ui/config-view.js'
44
50
  import type { CredentialStore, MachineCredential } from './credentials.js'
45
- import { firstBlocker, openItems, type Readiness, type ReadinessState } from './readiness.js'
51
+ import {
52
+ firstBlocker,
53
+ openItems,
54
+ type Readiness,
55
+ type ReadinessState,
56
+ type StateStatus,
57
+ } from './readiness.js'
58
+ import type { Tone } from './ui/theme.js'
46
59
  import {
47
60
  handleSessionEnd,
48
61
  handleStop,
@@ -113,7 +126,13 @@ export interface CommandIo {
113
126
  outro?(message: string): Promise<void>
114
127
  note?(message: string, title?: string): Promise<void>
115
128
  spinner?(message: string): Promise<CommandSpinner | null>
116
- check?(ok: boolean, message: string): Promise<void>
129
+ /**
130
+ * A report line. `tone` distinguishes the two states that are neither pass
131
+ * nor fail — something optional the user declined, and something that was
132
+ * never evaluated — which a boolean has to round to one or the other.
133
+ * Optional so existing implementations keep type-checking.
134
+ */
135
+ check?(ok: boolean, message: string, tone?: Tone): Promise<void>
117
136
  }
118
137
 
119
138
  export interface CommandSpinner {
@@ -387,6 +406,32 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
387
406
  }
388
407
  }
389
408
 
409
+ /**
410
+ * Devices as data, for surfaces that render them themselves.
411
+ *
412
+ * `devicesCommand` prints and returns an exit code, which is the right shape
413
+ * for a command and the wrong one for a menu that wants to show names beside
414
+ * checkboxes. Silent on every failure by design: the interactive app already
415
+ * shows the credential and connectivity state on its status card, so a second
416
+ * error line here would be reporting the same fault twice.
417
+ */
418
+ export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
419
+ if (!deps.store.load()) return null
420
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
421
+ const authed = authedClient(deps, config)
422
+ if (!authed) return null
423
+ try {
424
+ return (await authed.client.listDevices()).devices
425
+ } catch {
426
+ return null
427
+ }
428
+ }
429
+
430
+ /** Whether a device is in a state where a notification would actually arrive. */
431
+ export function canDeviceReceive(device: RoutableDevice): boolean {
432
+ return deviceCanReceive(device)
433
+ }
434
+
390
435
  export async function capabilitiesCommand(
391
436
  deps: CommandDeps,
392
437
  flags: { json?: boolean; platform?: Platform },
@@ -1750,7 +1795,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
1750
1795
 
1751
1796
  export function configShowCommand(
1752
1797
  deps: CommandDeps,
1753
- flags: { json?: boolean; explain?: boolean },
1798
+ flags: { json?: boolean; explain?: boolean; plain?: boolean },
1754
1799
  ): number {
1755
1800
  const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1756
1801
  if (flags.json) {
@@ -1760,14 +1805,111 @@ export function configShowCommand(
1760
1805
  deps.io.out(JSON.stringify(output, null, 2))
1761
1806
  return EXIT.ok
1762
1807
  }
1763
- for (const key of CONFIG_KEYS) {
1764
- const entry = config[key]
1765
- const provenance = flags.explain ? ` [${entry.source}]` : ''
1766
- deps.io.out(`${key} = ${JSON.stringify(entry.value)}${provenance}`)
1808
+ // Anything that is not a person at a terminal keeps the flat `key = value`
1809
+ // form it has always had. Scripts parse this, and a prettier layout for an
1810
+ // audience that cannot see it would only be a breaking change.
1811
+ if (deps.io.interactive !== true || flags.plain === true) {
1812
+ for (const line of renderConfigPlain(config, flags.explain === true)) deps.io.out(line)
1813
+ return EXIT.ok
1814
+ }
1815
+ for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
1816
+ deps.io.out(line)
1767
1817
  }
1768
1818
  return EXIT.ok
1769
1819
  }
1770
1820
 
1821
+ /**
1822
+ * One setting, explained in full.
1823
+ *
1824
+ * The gap this closes: `config show` prints `require_idle = true` and there was
1825
+ * nowhere to go from there. Every key already had a careful explanation — in a
1826
+ * TypeScript comment, read by everyone except the person who needed it.
1827
+ */
1828
+ export function configExplainCommand(
1829
+ deps: CommandDeps,
1830
+ key: string,
1831
+ flags: { json?: boolean } = {},
1832
+ ): number {
1833
+ if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
1834
+ deps.io.err(`Unknown setting "${key}".`)
1835
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
1836
+ return EXIT.usage
1837
+ }
1838
+ const configKey = key as ConfigKey
1839
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env })
1840
+ const info = configInfo(configKey)
1841
+ const entry = config[configKey]
1842
+
1843
+ if (flags.json) {
1844
+ deps.io.out(
1845
+ JSON.stringify(
1846
+ {
1847
+ key: configKey,
1848
+ label: info.label,
1849
+ group: info.group,
1850
+ kind: info.kind,
1851
+ summary: info.summary,
1852
+ detail: info.detail,
1853
+ accepts: acceptedValues(configKey),
1854
+ ...(info.choices !== undefined ? { choices: info.choices } : {}),
1855
+ value: entry.value,
1856
+ source: entry.source,
1857
+ },
1858
+ null,
1859
+ 2,
1860
+ ),
1861
+ )
1862
+ return EXIT.ok
1863
+ }
1864
+
1865
+ if (deps.io.interactive !== true) {
1866
+ deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`)
1867
+ deps.io.out(info.detail.replace(/\n\n/g, '\n'))
1868
+ deps.io.out(`accepts: ${acceptedValues(configKey)}`)
1869
+ return EXIT.ok
1870
+ }
1871
+ for (const line of renderConfigExplain(configKey, config)) deps.io.out(line)
1872
+ return EXIT.ok
1873
+ }
1874
+
1875
+ /**
1876
+ * Closest config key by edit distance, or null when nothing is close.
1877
+ *
1878
+ * The threshold matters more than the algorithm: suggesting a key that shares
1879
+ * three letters with the typo sends the reader to the wrong setting with
1880
+ * confidence, which is worse than listing all fourteen and letting them look.
1881
+ */
1882
+ function nearestKey(input: string): string | null {
1883
+ let best: string | null = null
1884
+ let bestDistance = Number.POSITIVE_INFINITY
1885
+ for (const candidate of CONFIG_KEYS) {
1886
+ const distance = editDistance(input.toLowerCase(), candidate)
1887
+ if (distance < bestDistance) {
1888
+ bestDistance = distance
1889
+ best = candidate
1890
+ }
1891
+ }
1892
+ return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null
1893
+ }
1894
+
1895
+ function editDistance(a: string, b: string): number {
1896
+ const previous = Array.from({ length: b.length + 1 }, (_, i) => i)
1897
+ for (let i = 1; i <= a.length; i += 1) {
1898
+ let diagonal = previous[0]!
1899
+ previous[0] = i
1900
+ for (let j = 1; j <= b.length; j += 1) {
1901
+ const above = previous[j]!
1902
+ previous[j] = Math.min(
1903
+ previous[j]! + 1,
1904
+ previous[j - 1]! + 1,
1905
+ diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
1906
+ )
1907
+ diagonal = above
1908
+ }
1909
+ }
1910
+ return previous[b.length]!
1911
+ }
1912
+
1771
1913
  export async function configSetCommand(
1772
1914
  deps: CommandDeps,
1773
1915
  key: string,
@@ -1775,30 +1917,43 @@ export async function configSetCommand(
1775
1917
  flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
1776
1918
  ): Promise<number> {
1777
1919
  if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
1778
- deps.io.err(`Unknown key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`)
1920
+ deps.io.err(`Unknown setting "${key}".`)
1921
+ const near = nearestKey(key)
1922
+ if (near !== null) deps.io.err(`Did you mean "${near}"?`)
1923
+ deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
1924
+ deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
1779
1925
  return EXIT.usage
1780
1926
  }
1927
+ const configKey = key as ConfigKey
1928
+ const info = configInfo(configKey)
1781
1929
  let value: unknown = rawValue
1782
- if (NUMERIC_CONFIG_KEYS.includes(key as ConfigKey)) {
1930
+ if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
1783
1931
  const numeric = Number(rawValue)
1784
1932
  if (!Number.isInteger(numeric)) {
1785
- deps.io.err(`"${rawValue}" is not an integer.`)
1933
+ deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`)
1786
1934
  return EXIT.usage
1787
1935
  }
1788
- const bounds = configBounds(key as ConfigKey)
1936
+ const bounds = configBounds(configKey)
1789
1937
  if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
1790
1938
  deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`)
1791
1939
  return EXIT.usage
1792
1940
  }
1793
1941
  value = numeric
1794
1942
  }
1795
- if (BOOLEAN_CONFIG_KEYS.includes(key as ConfigKey)) {
1943
+ if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
1796
1944
  if (rawValue !== 'true' && rawValue !== 'false') {
1797
1945
  deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`)
1798
1946
  return EXIT.usage
1799
1947
  }
1800
1948
  value = rawValue === 'true'
1801
1949
  }
1950
+ // Enum keys were accepted unchecked, so `config set sound whatever` wrote a
1951
+ // value the sender would later reject — a typo that only surfaces at the
1952
+ // moment a notification fails to carry the sound you asked for.
1953
+ if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
1954
+ deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
1955
+ return EXIT.usage
1956
+ }
1802
1957
  if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
1803
1958
 
1804
1959
  let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
@@ -1857,7 +2012,7 @@ export async function configSetCommand(
1857
2012
  * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1858
2013
  * Keep this immutable and public because the command is printed to users.
1859
2014
  */
1860
- export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.1'
2015
+ export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.3.0'
1861
2016
 
1862
2017
  function skillSourceParts(): { source: string; ref: string } | null {
1863
2018
  const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
@@ -3012,7 +3167,13 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
3012
3167
  // costs nothing.
3013
3168
  if (deps.io.interactive === true && deps.io.check) {
3014
3169
  await deps.io.intro?.('Notifai doctor')
3015
- for (const s of readiness.states) await deps.io.check(s.status !== 'gap', line(s))
3170
+ for (const s of readiness.states) {
3171
+ // Four states, not two. Rendering `optional-gap` and `unknown` as
3172
+ // successes made a green report that had never checked half of what it
3173
+ // listed, and put a tick beside things the reader had deliberately
3174
+ // declined.
3175
+ await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status))
3176
+ }
3016
3177
  await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
3017
3178
  } else {
3018
3179
  for (const s of readiness.states) {
@@ -3031,6 +3192,20 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
3031
3192
  return ok ? EXIT.ok : EXIT.failed
3032
3193
  }
3033
3194
 
3195
+ /** Readiness status as a report tone. */
3196
+ function doctorTone(status: StateStatus): Tone {
3197
+ switch (status) {
3198
+ case 'ready':
3199
+ return 'ok'
3200
+ case 'gap':
3201
+ return 'bad'
3202
+ case 'optional-gap':
3203
+ return 'warn'
3204
+ case 'unknown':
3205
+ return 'pending'
3206
+ }
3207
+ }
3208
+
3034
3209
  /** One line telling the reader what to actually do about a state. */
3035
3210
  function remedyLine(state: ReadinessState): string {
3036
3211
  const remedy = state.remedy
@@ -3463,11 +3638,21 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
3463
3638
  error: (next) => progress.error(next),
3464
3639
  }
3465
3640
  },
3466
- check: async (ok, message) => {
3641
+ check: async (ok, message, tone) => {
3467
3642
  if (!interactive()) return
3468
3643
  const { log } = await clack()
3469
- if (ok) log.success(message)
3470
- else log.error(message)
3644
+ switch (tone ?? (ok ? 'ok' : 'bad')) {
3645
+ case 'ok':
3646
+ return log.success(message)
3647
+ case 'warn':
3648
+ return log.warn(message)
3649
+ case 'pending':
3650
+ // Never checked. `log.info` reads as neutral, which is the honest
3651
+ // rendering for a state no evidence was gathered about.
3652
+ return log.info(message)
3653
+ case 'bad':
3654
+ return log.error(message)
3655
+ }
3471
3656
  },
3472
3657
  openUrl: (url) => {
3473
3658
  try {
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ BOOLEAN_CONFIG_KEYS,
4
+ CONFIG_KEYS,
5
+ NUMERIC_CONFIG_KEYS,
6
+ configBounds,
7
+ type ConfigKey,
8
+ } from './config.js'
9
+ import {
10
+ CONFIG_GROUPS,
11
+ acceptedValues,
12
+ configInfo,
13
+ configKeysByGroup,
14
+ describeSource,
15
+ formatDuration,
16
+ formatValue,
17
+ } from './config-schema.js'
18
+
19
+ describe('coverage', () => {
20
+ it('describes every configuration key', () => {
21
+ // The failure this prevents is silent: a key added to `config.ts` without
22
+ // an entry here still resolves, still applies, and still appears in
23
+ // `config show` — with no label, no summary and no way for the reader to
24
+ // find out what it does.
25
+ for (const key of CONFIG_KEYS) {
26
+ const info = configInfo(key)
27
+ expect(info.label, key).toBeTruthy()
28
+ expect(info.summary, key).toBeTruthy()
29
+ expect(info.detail, key).toBeTruthy()
30
+ expect(info.detail.length, key).toBeGreaterThan(info.summary.length)
31
+ }
32
+ })
33
+
34
+ it('files every key under exactly one declared group', () => {
35
+ const grouped = CONFIG_GROUPS.flatMap((group) => configKeysByGroup(group.id).map((info) => info.key))
36
+ expect([...grouped].sort()).toEqual([...CONFIG_KEYS].sort())
37
+ })
38
+
39
+ it('keeps summaries short enough to sit beside a value', () => {
40
+ for (const key of CONFIG_KEYS) {
41
+ expect(configInfo(key).summary.length, key).toBeLessThanOrEqual(80)
42
+ }
43
+ })
44
+
45
+ it('agrees with config.ts about which keys are numbers and toggles', () => {
46
+ // Two sources of truth for a key's type would let the prompt offer a text
47
+ // box for something `config set` will only accept as `true` or `false`.
48
+ for (const key of NUMERIC_CONFIG_KEYS) expect(configInfo(key).kind, key).toBe('integer')
49
+ for (const key of BOOLEAN_CONFIG_KEYS) expect(configInfo(key).kind, key).toBe('boolean')
50
+ })
51
+
52
+ it('offers choices for every enum key', () => {
53
+ for (const key of CONFIG_KEYS) {
54
+ const info = configInfo(key)
55
+ if (info.kind !== 'enum') continue
56
+ expect(info.choices, key).toBeDefined()
57
+ expect(info.choices!.length, key).toBeGreaterThan(1)
58
+ for (const choice of info.choices!) expect(info.choiceHints?.[choice], `${key}.${choice}`).toBeTruthy()
59
+ }
60
+ })
61
+ })
62
+
63
+ describe('acceptedValues', () => {
64
+ it('states the range for a bounded number', () => {
65
+ const bounds = configBounds('ask_grace_seconds')!
66
+ expect(acceptedValues('ask_grace_seconds')).toBe(
67
+ `a whole number from ${bounds.min}s–${bounds.max}s`,
68
+ )
69
+ })
70
+
71
+ it('lists the choices for an enum', () => {
72
+ expect(acceptedValues('sound')).toContain('none')
73
+ })
74
+
75
+ it('names both values for a toggle', () => {
76
+ expect(acceptedValues('require_idle')).toBe('true or false')
77
+ })
78
+ })
79
+
80
+ describe('formatValue', () => {
81
+ it('renders toggles as words rather than JSON', () => {
82
+ expect(formatValue('require_idle', true)).toBe('yes')
83
+ expect(formatValue('require_idle', false)).toBe('no')
84
+ })
85
+
86
+ it('explains what an unset value means instead of printing null', () => {
87
+ // `sound = null` is accurate and tells the reader nothing about what will
88
+ // happen; the whole complaint about the old output in one line.
89
+ expect(formatValue('sound', null)).toBe('each notification uses the sound for its kind')
90
+ expect(formatValue('devices', null)).toBe('every device that can receive')
91
+ })
92
+
93
+ it('renders durations in units a person reads', () => {
94
+ expect(formatValue('ask_grace_seconds', 300)).toBe('5m')
95
+ expect(formatValue('ttl_seconds', 86400)).toBe('1d')
96
+ expect(formatValue('wait_seconds', 10)).toBe('10s')
97
+ })
98
+
99
+ it('joins a device list', () => {
100
+ expect(formatValue('devices', ['dev_a', 'dev_b'])).toBe('dev_a, dev_b')
101
+ })
102
+ })
103
+
104
+ describe('formatDuration', () => {
105
+ it('keeps exact values readable across scales', () => {
106
+ expect(formatDuration(0, 's')).toBe('0s (immediately)')
107
+ expect(formatDuration(45, 's')).toBe('45s')
108
+ expect(formatDuration(120, 's')).toBe('2m')
109
+ expect(formatDuration(3600, 's')).toBe('1h')
110
+ expect(formatDuration(90, 's')).toBe('1m 30s')
111
+ })
112
+ })
113
+
114
+ describe('describeSource', () => {
115
+ it('turns a tagged path into words plus the file', () => {
116
+ expect(describeSource('default')).toEqual({ label: 'default', path: null })
117
+ expect(describeSource('global:/home/me/.config/notifai/config.toml')).toEqual({
118
+ label: 'this machine',
119
+ path: '/home/me/.config/notifai/config.toml',
120
+ })
121
+ expect(describeSource('project:/repo/.notifai/config.toml').label).toBe('this project (shared)')
122
+ expect(describeSource('project-local:/repo/.notifai/config.local.toml').label).toBe(
123
+ 'this project (personal)',
124
+ )
125
+ })
126
+
127
+ it('does not invent a label for an unrecognised layer', () => {
128
+ expect(describeSource('future-layer:/tmp/x').label).toBe('future-layer')
129
+ })
130
+ })
131
+
132
+ describe('examples', () => {
133
+ it('suggests a value `config set` would actually accept', () => {
134
+ // The explain screen prints `notifai config set <key> <example>` as a
135
+ // copyable line, so an example that fails validation is worse than none.
136
+ for (const key of CONFIG_KEYS as readonly ConfigKey[]) {
137
+ const info = configInfo(key)
138
+ if (info.example === undefined) continue
139
+ if (info.kind === 'integer') {
140
+ const bounds = configBounds(key)
141
+ const parsed = Number(info.example)
142
+ expect(Number.isInteger(parsed), key).toBe(true)
143
+ if (bounds) {
144
+ expect(parsed, key).toBeGreaterThanOrEqual(bounds.min)
145
+ expect(parsed, key).toBeLessThanOrEqual(bounds.max)
146
+ }
147
+ }
148
+ if (info.kind === 'boolean') expect(['true', 'false']).toContain(info.example)
149
+ if (info.kind === 'enum') expect(info.choices).toContain(info.example)
150
+ }
151
+ })
152
+ })