test_mic_controls.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Fast, no-model, no-mic tests for the ALSA capture-control survey (issue #46).
Everything here is stdlib-only string work: the parser turns real `amixer
scontents` output into structured controls, and the probe is exercised with an
injected `run` so no amixer or microphone is needed. Runs on a box with no numpy,
no PortAudio, and no sound card.
Run: .venv/bin/python test_mic_controls.py
Exit code is 0 only if every check passes.
"""
from __future__ import annotations
import subprocess
import sys
from mic_controls import (
capture_gain_control,
format_survey,
parse_amixer_scontents,
survey_card,
)
# A realistic USB capture mic: a capture volume control ('Mic'), a capture
# switch-only control ('Auto Gain Control' -- a mute lever, not a gain lever),
# and a playback control ('PCM') carrying a dB annotation and no switch.
FIXTURE = """Simple mixer control 'Mic',0
Capabilities: cvolume cswitch
Capture channels: Front Left - Front Right
Limits: Capture 0 - 65536
Front Left: Capture 32768 [50%] [on]
Front Right: Capture 32768 [50%] [on]
Simple mixer control 'Auto Gain Control',0
Capabilities: cswitch cswitch-joined
Capture channels: Mono
Mono: Capture [on]
Simple mixer control 'PCM',0
Capabilities: pvolume
Playback channels: Front Left - Front Right
Limits: Playback 0 - 65536
Front Left: Playback 52428 [80%] [-4.00dB]
Front Right: Playback 52428 [80%] [-4.00dB]
"""
# A speakerphone-style dual control: one selem carrying both a playback and a
# capture volume. Real amixer prints both ranges on ONE Limits line and both
# states on each channel line. This is the expected shape for the PowerConf S3,
# a speaker and mic in one USB device.
DUAL_FIXTURE = """Simple mixer control 'Headset',0
Capabilities: pvolume cvolume pswitch cswitch
Playback channels: Front Left - Front Right
Capture channels: Front Left - Front Right
Limits: Playback 0 - 31 Capture 0 - 39
Front Left: Playback 20 [65%] [-10.00dB] [on] Capture 30 [77%] [on]
Front Right: Playback 20 [65%] [-10.00dB] [on] Capture 30 [77%] [on]
"""
PASS, FAIL = "PASS", "FAIL"
results: list[tuple[str, str]] = []
def check(name: str, ok: bool, detail: str) -> bool:
results.append((PASS if ok else FAIL, name))
print(f" [{PASS if ok else FAIL}] {name}: {detail}")
return bool(ok)
def _fake_run(stdout="", stderr="", returncode=0, raises=None):
def run(*args, **kwargs):
if raises is not None:
raise raises
return subprocess.CompletedProcess(args[0], returncode, stdout, stderr)
return run
def test_parse():
controls = parse_amixer_scontents(FIXTURE)
check("parse: three controls", len(controls) == 3, f"got {len(controls)}")
names = [c.name for c in controls]
check(
"parse: names in order",
names == ["Mic", "Auto Gain Control", "PCM"],
f"{names}",
)
mic = controls[0]
check(
"parse: mic is a capture control",
mic.is_capture,
f"is_capture={mic.is_capture}",
)
check("parse: mic has a volume lever", mic.has_volume, f"caps={mic.capabilities}")
check("parse: mic limits", mic.limits == (0, 65536), f"{mic.limits}")
fl = mic.channels[0]
check(
"parse: channel value/percent/switch",
fl.value == 32768 and fl.percent == 50 and fl.switch is True,
f"{fl}",
)
def test_db_and_no_switch():
pcm = parse_amixer_scontents(FIXTURE)[2]
fl = pcm.channels[0]
check("parse: dB parsed", fl.db == -4.0, f"db={fl.db}")
check("parse: absent switch is None", fl.switch is None, f"switch={fl.switch}")
def test_switch_only_capture():
agc = parse_amixer_scontents(FIXTURE)[1]
check(
"parse: switch-only is capture", agc.is_capture, f"is_capture={agc.is_capture}"
)
check(
"parse: switch-only has no volume",
not agc.has_volume,
f"caps={agc.capabilities}",
)
check("parse: switch-only has no limits", agc.limits is None, f"{agc.limits}")
check(
"parse: switch-only channel value None",
agc.channels[0].value is None,
f"{agc.channels[0]}",
)
def test_gain_lever_selection():
controls = parse_amixer_scontents(FIXTURE)
gain = capture_gain_control(controls)
# Must pick the capture *volume* control, never the earlier capture switch
# ('Auto Gain Control') and never the playback control ('PCM').
check(
"gain: picks the capture volume control",
gain is not None and gain.name == "Mic",
f"{gain and gain.name}",
)
def test_no_gain_lever_when_only_switch_and_playback():
text = """Simple mixer control 'Auto Gain Control',0
Capabilities: cswitch cswitch-joined
Capture channels: Mono
Mono: Capture [on]
Simple mixer control 'PCM',0
Capabilities: pvolume
Playback channels: Mono
Limits: Playback 0 - 100
Mono: Playback 50 [50%]
"""
gain = capture_gain_control(parse_amixer_scontents(text))
check(
"gain: none when no capture volume exists",
gain is None,
f"{gain and gain.name}",
)
def test_survey_ok():
survey = survey_card("PowerConf", run=_fake_run(stdout=FIXTURE))
check("survey: ok on good output", survey.ok, f"error={survey.error}")
check(
"survey: capture control surfaced",
survey.capture_control is not None and survey.capture_control.name == "Mic",
f"{survey.capture_control and survey.capture_control.name}",
)
def test_survey_amixer_missing():
survey = survey_card("PowerConf", run=_fake_run(raises=FileNotFoundError()))
check("survey: amixer missing does not crash", not survey.ok, f"ok={survey.ok}")
check(
"survey: reports amixer not installed",
"not installed" in (survey.error or ""),
f"{survey.error}",
)
def test_survey_bad_card():
survey = survey_card(
"BadCard",
run=_fake_run(
stderr="amixer: Mixer attach BadCard error: No such file", returncode=1
),
)
check("survey: bad card is not ok", not survey.ok, f"ok={survey.ok}")
check(
"survey: bad card carries amixer's reason",
"Mixer attach" in (survey.error or ""),
f"{survey.error}",
)
def test_survey_empty_output():
survey = survey_card("PowerConf", run=_fake_run(stdout="\n"))
check(
"survey: empty output is not ok",
not survey.ok and survey.capture_control is None,
f"{survey}",
)
def test_dual_control_capture_range():
controls = parse_amixer_scontents(DUAL_FIXTURE)
check("dual: one control", len(controls) == 1, f"got {len(controls)}")
ctl = controls[0]
# The combined Limits line carries both ranges; a capture survey wants the
# capture range (0-39), not the playback range (0-31).
check("dual: capture range parsed", ctl.limits == (0, 39), f"{ctl.limits}")
gain = capture_gain_control(controls)
check(
"dual: picked as the gain lever",
gain is not None and gain.limits == (0, 39),
f"{gain and gain.limits}",
)
# The combined channel lines carry both states; a capture survey reads the
# capture side, so the current gain percentage survives (it is not dropped).
first = gain.channels[0] if gain and gain.channels else None
check(
"dual: capture channel state read",
first is not None and first.direction == "Capture" and first.percent == 77,
f"{first}",
)
def test_bare_common_volume_limits():
# A common-volume capture control: amixer prints the range with no direction.
text = """Simple mixer control 'Capture',0
Capabilities: cvolume cswitch
Capture channels: Mono
Limits: 0 - 4096
Mono: Capture 2048 [50%] [on]
"""
ctl = parse_amixer_scontents(text)[0]
check("bare: common-volume limits parsed", ctl.limits == (0, 4096), f"{ctl.limits}")
check(
"bare: picked as the gain lever",
capture_gain_control([ctl]) is ctl,
f"{ctl.name}",
)
def test_playback_volume_is_not_a_capture_lever():
# A capture control (it has a capture switch) whose only volume is playback
# (pvolume, no cvolume): not a capture gain lever, even once its range
# parses. This pins the guard against picking a playback volume for capture.
text = """Simple mixer control 'Sidetone',0
Capabilities: pvolume cswitch
Playback channels: Mono
Capture channels: Mono
Limits: Playback 0 - 10 Capture 0 - 10
Mono: Playback 5 [50%] Capture [on]
"""
controls = parse_amixer_scontents(text)
check(
"guard: control is a capture control",
controls[0].is_capture and not controls[0].has_capture_volume,
f"is_capture={controls[0].is_capture} has_capture_volume={controls[0].has_capture_volume}",
)
gain = capture_gain_control(controls)
check(
"guard: playback-only volume is not a capture lever",
gain is None,
f"{gain and gain.name}",
)
def test_undirected_common_volume_is_a_gain_lever():
# The genuine common-volume shape: an undirected "volume" capability, a bare
# Limits line, and a state line with NO Playback/Capture word. The direction
# comes only from the "Capture channels:" header. This is the shape the bare
# limits and the "volume" branch were built for; it must reach the gain lever.
text = """Simple mixer control 'Mic',0
Capabilities: volume
Playback channels: Mono
Capture channels: Mono
Limits: 0 - 4096
Mono: 2048 [50%] [on]
"""
ctl = parse_amixer_scontents(text)[0]
check(
"common: undirected state read as capture",
ctl.is_capture and ctl.has_capture_volume and ctl.limits == (0, 4096),
f"is_capture={ctl.is_capture} has_cv={ctl.has_capture_volume} limits={ctl.limits}",
)
check(
"common: undirected channel percent read",
ctl.channels
and ctl.channels[0].direction == "Capture"
and ctl.channels[0].percent == 50,
f"{ctl.channels}",
)
survey = survey_card("PowerConf", run=_fake_run(stdout=text))
check(
"common: selected as the gain lever",
survey.capture_control is not None and survey.capture_control.name == "Mic",
f"{survey.capture_control and survey.capture_control.name}",
)
def test_undirected_enum_line_is_not_a_channel():
# An enum item line has no numeric state; it must not be misread as a channel
# (which would otherwise inflate the channel list or fake a direction).
text = """Simple mixer control 'Input Source',0
Capabilities: enum
Items: 'Mic' 'Line'
Item0: 'Mic'
"""
ctl = parse_amixer_scontents(text)[0]
check("enum: no channels parsed", ctl.channels == (), f"{ctl.channels}")
def test_survey_amixer_not_executable():
# amixer is present but cannot launch (no execute permission -> PermissionError,
# an OSError). The probe must report it, not raise: its contract says so.
survey = survey_card(
"PowerConf", run=_fake_run(raises=PermissionError(13, "denied"))
)
check("survey: launch failure does not crash", not survey.ok, f"ok={survey.ok}")
check(
"survey: reports amixer could not run",
"could not run" in (survey.error or ""),
f"{survey.error}",
)
def test_capture_percent_reported_over_playback():
# Playback is stereo, capture is mono, so the two directions land on separate
# channel lines and the capture line is not first. The reported gain baseline
# must be the capture percentage (77%), not the leading playback one (65%).
text = """Simple mixer control 'Mic',0
Capabilities: pvolume cvolume pswitch cswitch
Playback channels: Front Left - Front Right
Capture channels: Mono
Limits: Playback 0 - 31 Capture 0 - 39
Front Left: Playback 20 [65%] [on]
Front Right: Playback 20 [65%] [on]
Mono: Capture 30 [77%] [on]
"""
out = format_survey(survey_card("PowerConf", run=_fake_run(stdout=text)))
check(
"percent: reports the capture side, not playback",
"at 77%" in out and "at 65%" not in out,
out.splitlines()[-1],
)
def test_enum_named_capture_item_is_not_a_channel():
# A stricter enum case than the 'Mic'/'Line' one: an enum whose OPTION text is
# literally 'Playback'/'Capture'. The direction word sits inside quotes with no
# numeric state, so it must not be read as a channel or flip is_capture true.
text = """Simple mixer control 'Direction',0
Capabilities: enum
Items: 'Playback' 'Capture'
Item0: 'Capture'
"""
ctl = parse_amixer_scontents(text)[0]
check(
"enum: capture-named item makes no channel",
ctl.channels == (),
f"{ctl.channels}",
)
check(
"enum: capture-named item is not a capture control",
not ctl.is_capture,
f"is_capture={ctl.is_capture}",
)
def test_directionless_enum_is_not_labeled_playback():
# Capabilities: enum has no capture or playback signal. The survey must not
# treat "not capture" as playback; that misstates Input Source and similar
# selectors on speakerphone cards.
text = """Simple mixer control 'Input Source',0
Capabilities: enum
Items: 'Mic' 'Line'
Item0: 'Mic'
"""
out = format_survey(survey_card("PowerConf", run=_fake_run(stdout=text)))
check(
"enum: survey does not label a directionless control as playback",
"[playback]" not in out,
out,
)
check(
"enum: survey uses a neutral tag",
"[undirected]" in out,
out,
)
pcm_out = format_survey(survey_card("PowerConf", run=_fake_run(stdout=FIXTURE)))
check(
"pcm: a real playback control still labels playback",
"[playback] 'PCM',0" in pcm_out,
pcm_out,
)
def test_common_volume_counts_as_has_volume():
# has_volume must agree with has_capture_volume on the common-volume shape
# ('Capabilities: volume'). Otherwise a consumer filtering volume controls on
# has_volume drops exactly the common playback/capture control this survey adds.
text = """Simple mixer control 'Mic',0
Capabilities: volume
Capture channels: Mono
Limits: 0 - 4096
Mono: 2048 [50%] [on]
"""
ctl = parse_amixer_scontents(text)[0]
check(
"has_volume: common volume counts as a volume control",
ctl.has_volume and ctl.has_capture_volume,
f"has_volume={ctl.has_volume} has_capture_volume={ctl.has_capture_volume}",
)
def test_survey_output_shows_control_index():
# Two controls share a name but differ by index. amixer targets a control as
# 'Name',index, so the survey must print the index or the reader cannot tell
# which selem it chose or reproduce the follow-up amixer command.
text = """Simple mixer control 'Mic',0
Capabilities: cvolume
Capture channels: Mono
Limits: Capture 0 - 100
Mono: Capture 50 [50%] [on]
Simple mixer control 'Mic',1
Capabilities: cvolume
Capture channels: Mono
Limits: Capture 0 - 100
Mono: Capture 60 [60%] [on]
"""
out = format_survey(survey_card("PowerConf", run=_fake_run(stdout=text)))
check("index: both indices rendered", "'Mic',0" in out and "'Mic',1" in out, out)
check(
"index: gain lever names its index",
"gain lever: 'Mic',0" in out,
out.splitlines()[-1],
)
def main() -> int:
test_parse()
test_db_and_no_switch()
test_switch_only_capture()
test_gain_lever_selection()
test_no_gain_lever_when_only_switch_and_playback()
test_dual_control_capture_range()
test_bare_common_volume_limits()
test_undirected_common_volume_is_a_gain_lever()
test_undirected_enum_line_is_not_a_channel()
test_enum_named_capture_item_is_not_a_channel()
test_directionless_enum_is_not_labeled_playback()
test_common_volume_counts_as_has_volume()
test_survey_output_shows_control_index()
test_capture_percent_reported_over_playback()
test_playback_volume_is_not_a_capture_lever()
test_survey_ok()
test_survey_amixer_missing()
test_survey_amixer_not_executable()
test_survey_bad_card()
test_survey_empty_output()
n_pass = sum(1 for r in results if r[0] == PASS)
n_total = len(results)
print(f"\n=== {n_pass}/{n_total} checks passed ===")
return 0 if n_pass == n_total else 1
if __name__ == "__main__":
sys.exit(main())