package main import ( "bytes" "errors" "strings" "testing" intrgb "netdome.biz/paul/qmk-rgb/internal/rgb" "netdome.biz/paul/qmk-rgb/internal/via" ) // colorProtocol reports the two byte color the keyboard holds for the color // value ID, and the single byte brightness for the brightness value ID, so a // test can describe a keyboard that applied something other than the request. type colorProtocol struct { colorWrites []colorWrite brightnessWrites []uint8 appliedColor map[via.Channel][2]uint8 appliedBright map[via.Channel]uint8 shortColorRead bool channels []via.Channel setColorErr error getErr error } type colorWrite struct { channel via.Channel hue uint8 sat uint8 } func (p *colorProtocol) SetValue(_ via.Channel, param, value uint8) error { if param == uint8(intrgb.Brightness) { p.brightnessWrites = append(p.brightnessWrites, value) } return nil } func (p *colorProtocol) SetColor(channel via.Channel, hue, sat uint8) error { if p.setColorErr != nil { return p.setColorErr } p.colorWrites = append(p.colorWrites, colorWrite{channel: channel, hue: hue, sat: sat}) return nil } func (p *colorProtocol) GetValue(channel via.Channel, param uint8) ([]byte, error) { if p.getErr != nil { return nil, p.getErr } if param == uint8(intrgb.ColorValue) { if p.shortColorRead { return []byte{0}, nil } c := p.appliedColor[channel] return []byte{c[0], c[1]}, nil } return []byte{p.appliedBright[channel]}, nil } func (p *colorProtocol) Close() error { return nil } func (p *colorProtocol) DetectChannels() ([]via.Channel, error) { if p.channels == nil { return []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio}, nil } return p.channels, nil } // A color is two bytes on the wire, so the read-back has to look at both. A // one byte answer would leave the saturation unverified. func TestSetColorVerifiedReadsBackHueAndSaturation(t *testing.T) { proto := &colorProtocol{ appliedColor: map[via.Channel][2]uint8{via.ChannelRgblight: {99, 255}}, } results, err := setColorVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 85, 255) if err != nil { t.Fatalf("setColorVerified() error = %v", err) } if len(results) != 1 { t.Fatalf("results = %d entries, want 1", len(results)) } got := results[0] if got.Name != "logo" { t.Errorf("results[0].Name = %q, want %q", got.Name, "logo") } if got.RequestedHue != 85 || got.RequestedSaturation != 255 { t.Errorf("requested = hue %d sat %d, want hue 85 sat 255", got.RequestedHue, got.RequestedSaturation) } if got.Hue != 99 || got.Saturation != 255 { t.Errorf("applied = hue %d sat %d, want hue 99 sat 255", got.Hue, got.Saturation) } if !got.Mismatch() { t.Error("Mismatch() = false, want true when the keyboard stored another hue") } } func TestSetColorVerifiedReportsNoMismatchWhenKeyboardAppliesTheRequest(t *testing.T) { proto := &colorProtocol{ appliedColor: map[via.Channel][2]uint8{via.ChannelRgblight: {0, 255}}, } results, err := setColorVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 0, 255) if err != nil { t.Fatalf("setColorVerified() error = %v", err) } if results[0].Mismatch() { t.Error("Mismatch() = true, want false when the keyboard applied the request") } } // A truncated answer means the keyboard did not report both components, so the // command must not claim it knows the applied color. func TestSetColorVerifiedRejectsATruncatedColorRead(t *testing.T) { proto := &colorProtocol{shortColorRead: true} _, err := setColorVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 0, 255) if err == nil { t.Fatal("setColorVerified() expected an error, got nil") } if !strings.Contains(err.Error(), "logo") { t.Errorf("error = %q, want it to name the zone whose read back was unusable", err) } } func TestSetColorVerifiedPropagatesWriteErrors(t *testing.T) { proto := &colorProtocol{setColorErr: errors.New("write refused")} _, err := setColorVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 0, 255) if err == nil { t.Fatal("setColorVerified() expected an error, got nil") } if !strings.Contains(err.Error(), "write refused") { t.Errorf("error = %q, want the keyboard's own reason", err) } } func runColor(t *testing.T, proto *colorProtocol, zoneFlag string, args ...string) (stdout, stderr string, err error) { t.Helper() t.Cleanup(stubColorTarget(t, proto, zoneFlag)) var out, errOut bytes.Buffer cmd := NewColorCmd() cmd.SetOut(&out) cmd.SetErr(&errOut) cmd.SetArgs(args) err = cmd.Execute() return out.String(), errOut.String(), err } func allZonesAppliedColor(hue, sat uint8) map[via.Channel][2]uint8 { return map[via.Channel][2]uint8{ via.ChannelRgblight: {hue, sat}, via.ChannelRgbMatrix: {hue, sat}, via.ChannelAudio: {hue, sat}, } } func TestColorReportsTheAppliedHueAndSaturation(t *testing.T) { // 00ff00 is hue 85 at full saturation. proto := &colorProtocol{appliedColor: allZonesAppliedColor(85, 255)} stdout, stderr, err := runColor(t, proto, "logo", "00ff00") if err != nil { t.Fatalf("color returned error: %v", err) } if strings.TrimSpace(stdout) != "Color set to hue 85 sat 255" { t.Errorf("stdout = %q, want the exact success message", stdout) } if stderr != "" { t.Errorf("stderr = %q, want nothing on stderr when the request was met", stderr) } } // The hex notations carry no brightness, so the command must not write one and // must not report one. func TestColorHexNotationLeavesBrightnessAlone(t *testing.T) { proto := &colorProtocol{appliedColor: allZonesAppliedColor(0, 255)} stdout, _, err := runColor(t, proto, "", "rgb:ff0000") if err != nil { t.Fatalf("color returned error: %v", err) } if len(proto.brightnessWrites) != 0 { t.Errorf("brightness writes = %v, want none for a hex notation", proto.brightnessWrites) } if strings.Contains(stdout, "brightness") { t.Errorf("stdout = %q, must not report a brightness the command did not set", stdout) } } // hsv: carries a value, and the keyboard has no value register for it, so the // value goes to the brightness of the same zones. func TestColorHSVNotationWritesTheValueAsBrightness(t *testing.T) { proto := &colorProtocol{ appliedColor: allZonesAppliedColor(85, 255), appliedBright: map[via.Channel]uint8{via.ChannelRgblight: 200, via.ChannelRgbMatrix: 200, via.ChannelAudio: 200}, } stdout, _, err := runColor(t, proto, "", "hsv:85,255,200") if err != nil { t.Fatalf("color returned error: %v", err) } if len(proto.brightnessWrites) != 3 { t.Fatalf("brightness writes = %v, want one per selected zone", proto.brightnessWrites) } for _, v := range proto.brightnessWrites { if v != 200 { t.Errorf("brightness write = %d, want 200", v) } } if strings.TrimSpace(stdout) != "Color set to hue 85 sat 255 brightness 200" { t.Errorf("stdout = %q, want hue, saturation and brightness", stdout) } } // The Impact 80 clamps brightness at 160 on logo and side and scales it up on // the backlight channel, so an hsv: value is reported the way brightness is. func TestColorHSVSummarisesTheBrightnessTheKeyboardApplied(t *testing.T) { proto := &colorProtocol{ appliedColor: allZonesAppliedColor(0, 255), appliedBright: map[via.Channel]uint8{via.ChannelRgblight: 160, via.ChannelRgbMatrix: 255, via.ChannelAudio: 160}, } stdout, _, err := runColor(t, proto, "", "hsv:0,255,200") if err != nil { t.Fatalf("color returned error: %v", err) } for _, want := range []string{ "logo hue 0 sat 255 brightness 160", "backlight hue 0 sat 255 brightness 255", "side hue 0 sat 255 brightness 160", "requested hue 0 sat 255 brightness 200", } { if !strings.Contains(stdout, want) { t.Errorf("stdout = %q, want it to contain %q", stdout, want) } } if strings.Contains(stdout, "set to") { t.Errorf("stdout = %q, must not claim the request was met when a zone clamped it", stdout) } if strings.Count(strings.TrimSpace(stdout), "\n") != 0 { t.Errorf("stdout = %q, want exactly one line", stdout) } } // A notation the tool cannot parse must fail before the keyboard is opened. func TestColorRejectsAnUnknownNotationWithoutOpeningTheDevice(t *testing.T) { proto := &colorProtocol{} opened := false originalTarget := openTarget originalZone := targetZone t.Cleanup(func() { openTarget = originalTarget targetZone = originalZone }) openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) { opened = true return proto, targetDeviceData{}, nil, nil } targetZone = "" var out bytes.Buffer cmd := NewColorCmd() cmd.SetOut(&out) cmd.SetErr(&out) cmd.SetArgs([]string{"xyz:1"}) err := cmd.Execute() if err == nil { t.Fatal("color expected an error, got nil") } if opened { t.Error("the keyboard was opened, want the notation rejected first") } if len(proto.colorWrites) != 0 { t.Errorf("color writes = %v, want none", proto.colorWrites) } } // stubColorTarget points the color command at one protocol and one --zone value. func stubColorTarget(t *testing.T, proto *colorProtocol, zoneFlag string) func() { t.Helper() originalTarget := openTarget originalZone := targetZone openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) { target := targetDeviceData{Display: impact80Display()} requested, err := resolveZoneName(zoneFlag, impact80Display(), nil) if err != nil { return nil, target, nil, err } target.Requested = requested resolved, err := resolveChannels(proto, target) if err != nil { return nil, target, nil, err } return proto, target, resolved, nil } targetZone = zoneFlag return func() { openTarget = originalTarget targetZone = originalZone } }