Files
morns-keyboard-basic/test/juloo.keyboard2/LightsProxyTest.java
T
Nyra 2ab0cd16d8 Lights page: three Home Assistant lights, lit/unlit glyph, state colours
Opened from the free fourth corner slot on the Alt key (switch_lights), closed
by the full-width Keyboard key. One key per light, registry-driven labels, so
the page renders whatever the lights-proxy reports rather than knowing any
entity ids. ON is blue, OFF is pink, anything unreachable is stale grey, and
the glyph flips between a lit lamp (0xE025) and an unlit twin (0xE026) so an
unreachable light can never look lit. Toggles are sent once and never retried.
2026-09-28 03:18:50 +00:00

443 lines
20 KiB
Java

package juloo.keyboard2;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
/** The lights page: the state parsing the page is driven by ([LightsProxy]), and the page itself
([LightsPage]). Android classes cannot be constructed in a plain unit test, so the drawing is
covered where it is pure - the colour mapping, the glyph choice, the label - and the theme
wiring is checked by reading res/values/themes.xml and the built APK instead. */
public class LightsProxyTest
{
// ------------------------------------------------------------------ parsing a /lights answer
@Test
public void theAnswerIsParsedIntoOneLightPerEntry()
{
LightsProxy.Light[] lights = LightsProxy.parseLights(
"{\"ok\": true, \"lights\": ["
+ "{\"id\": \"bedroom\", \"name\": \"Bedroom\", \"entity_id\": \"light.bedroom\","
+ " \"state\": \"on\", \"optimistic_state\": null, \"warning\": null},"
+ "{\"id\": \"stairs\", \"name\": \"Stairs\", \"entity_id\": \"light.stairs\","
+ " \"state\": \"off\", \"optimistic_state\": null, \"warning\": null}"
+ "]}");
assertNotNull(lights);
assertEquals(2, lights.length);
assertEquals("bedroom", lights[0].id);
assertEquals("Bedroom", lights[0].name);
assertEquals("light.bedroom", lights[0].entityId);
assertEquals(LightsProxy.State.ON, lights[0].state);
assertEquals(LightsProxy.State.OFF, lights[1].state);
assertNull("a read-back state needs no note", lights[0].note);
}
@Test
public void unavailableAndUnknownAreTheirOwnStatesAndNeverOff()
{
LightsProxy.Light[] lights = LightsProxy.parseLights(
"{\"lights\": ["
+ "{\"id\": \"a\", \"name\": \"A\", \"state\": \"unavailable\"},"
+ "{\"id\": \"b\", \"name\": \"B\", \"state\": \"unknown\"},"
+ "{\"id\": \"c\", \"name\": \"C\", \"state\": \" On \"},"
+ "{\"id\": \"d\", \"name\": \"D\", \"state\": \"nonsense\"},"
+ "{\"id\": \"e\", \"name\": \"E\"},"
+ "{\"id\": \"f\", \"name\": \"F\", \"state\": \"OFF\"}"
+ "]}");
assertNotNull(lights);
assertEquals(LightsProxy.State.UNKNOWN, lights[0].state);
assertEquals(LightsProxy.State.UNKNOWN, lights[1].state);
assertEquals(LightsProxy.State.ON, lights[2].state);
assertEquals(LightsProxy.State.UNKNOWN, lights[3].state);
assertEquals(LightsProxy.State.UNKNOWN, lights[4].state);
assertEquals(LightsProxy.State.OFF, lights[5].state);
for (LightsProxy.Light l : lights)
assertFalse("nothing unreadable may be reported as switched off",
l.state == LightsProxy.State.OFF && !l.id.equals("f"));
}
@Test
public void fieldsTheKeyDoesNotShowNeverBecomeItsLabel()
{
// A registry entry carries more than the page uses - when it last changed, a read age, and the
// service's error code. None of it may take the place of the light's name: while no token is in
// place every light is unavailable, and the page would otherwise say "no_token" twelve times
// instead of naming the lights. The stale colour already says nobody can see them.
LightsProxy.Light[] lights = LightsProxy.parseLights(
"{\"lights\": ["
+ "{\"id\": \"bedroom\", \"name\": \"Bedroom\", \"state\": \"unavailable\","
+ " \"last_changed\": \"2026-09-28T02:00:00Z\", \"age_s\": 42,"
+ " \"error\": {\"code\": \"no_token\", \"detail\": \"no ha token configured\"},"
+ " \"warning\": \"state could not be confirmed\"}"
+ "]}");
assertNotNull(lights);
assertEquals("Bedroom", lights[0].name);
assertNull("the answer to a read is not a note on the key", lights[0].note);
assertEquals("an unreachable light is the stale look, never off",
LightsProxy.State.UNKNOWN, lights[0].state);
}
@Test
public void anEntryForALightTheBoxCannotSeeKeepsItsName()
{
// A light whose entity is unreachable comes back with an error object instead of state.
LightsProxy.Light[] lights = LightsProxy.parseLights(
"{\"lights\": ["
+ "{\"id\": \"tv-room\", \"name\": \"TV room\", \"entity_id\": \"light.tv_room\","
+ " \"state\": \"unavailable\", \"error\": {\"code\": \"unreachable\", \"detail\": \"http\"}}"
+ "]}");
assertNotNull(lights);
assertEquals("TV room", lights[0].name);
assertEquals(LightsProxy.State.UNKNOWN, lights[0].state);
}
@Test
public void theAnswerIsReadWhateverTheLightWeightAndEscaping()
{
// Whitespace and escaped quotes inside a name must not derail the reader; the fields the page
// does not use (numbers, booleans, nested objects) are skipped harmlessly.
LightsProxy.Light[] lights = LightsProxy.parseLights(
"{ \"lights\" : [ {\n \"id\":\"a\",\n \"name\": \"Say \\\"hi\\\"\",\n \"brightness\": 128,\n"
+ " \"on\": true,\n \"attrs\": {\"a\": [1, 2, {\"b\": null}]},\n \"state\": \"on\" } ] }");
assertNotNull(lights);
assertEquals(1, lights.length);
assertEquals("Say \"hi\"", lights[0].name);
assertEquals(LightsProxy.State.ON, lights[0].state);
}
@Test
public void anEmptyWhitelistIsNoLightsNotNoAnswer()
{
LightsProxy.Light[] none = LightsProxy.parseLights("{\"lights\": []}");
assertNotNull("an empty list is an answer: the page has nothing to show", none);
assertEquals(0, none.length);
}
@Test
public void aBrokenOrUnexpectedAnswerIsNoDataNotZeroLights()
{
assertNull(LightsProxy.parseLights(null));
assertNull(LightsProxy.parseLights(""));
assertNull(LightsProxy.parseLights("<html>404</html>"));
assertNull(LightsProxy.parseLights("[1, 2, 3]"));
assertNull(LightsProxy.parseLights("{\"lights\": [{\"id\": \"a\""));
assertNull(LightsProxy.parseLights("{\"logs\": []}"));
}
// ------------------------------------------------------------------ parsing a toggle answer
@Test
public void aToggleThatWasReadBackReportsTheStateTheBoxGave()
{
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
"{\"ok\": true, \"id\": \"bedroom\", \"accepted\": true, \"state\": \"off\","
+ " \"confirmed\": true, \"optimistic_state\": \"off\", \"warning\": null}");
assertNotNull(r);
assertTrue(r.accepted);
assertTrue(r.confirmed);
assertEquals("the read-back, as Home Assistant gave it", "off", r.state);
assertEquals(LightsProxy.State.OFF, LightsProxy.stateOf(r.state));
assertNull("a read-back state needs no warning on the key", r.note());
}
@Test
public void aToggleTheBoxRefusedIsNotAState()
{
// The proxy refuses when Home Assistant cannot be reached, or when the token is missing - which
// is the state the endpoint is in until a token is put in place. The key must say why, and must
// NOT show the light as off.
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
"{\"ok\": false, \"id\": \"bedroom\", \"accepted\": false, \"state\": null,"
+ " \"confirmed\": false, \"optimistic_state\": null, \"retried\": false,"
+ " \"error\": {\"code\": \"no_token\", \"detail\": \"no ha token configured\"}}");
assertNotNull(r);
assertFalse(r.accepted);
assertFalse(r.confirmed);
assertNull("a refused toggle says nothing about the state", r.state);
assertEquals("the service's own reason is what the key says", "no_token", r.note());
}
@Test
public void theRefusalOfAServiceIsTheReasonTheKeyShows()
{
// The refusal the running service actually gives, recorded from
// POST http://127.0.0.1:8772/lights/no-such-light/toggle (HTTP 404): a flat code and a prose
// reason, and no state at all - so the key must show the code, not a guess.
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
"{\"ts\": \"2026-09-28T03:14:51Z\", \"error\": \"unknown id\", \"code\": \"unknown_id\","
+ " \"id\": \"no-such-light\", \"known_ids\": [\"bedroom\"], \"detail\": \"only ids listed"
+ " in lights.toml can be toggled\", \"accepted\": false, \"toggled\": false}");
assertNotNull(r);
assertFalse(r.accepted);
assertNull(r.state);
assertEquals("unknown_id", r.note());
}
@Test
public void aToggleNobodyCouldReadBackIsNotClaimedAsSuccess()
{
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
"{\"ok\": true, \"id\": \"bedroom\", \"accepted\": true, \"state\": null,"
+ " \"confirmed\": false, \"optimistic_state\": \"on\","
+ " \"warning\": \"state could not be confirmed after 10s\"}");
assertNotNull(r);
assertTrue(r.accepted);
assertFalse("nothing was read back, so nothing is confirmed", r.confirmed);
assertNull("the prediction is not the state", r.state);
assertEquals("the prose is kept, but a key says the short form of it",
"state could not be confirmed after 10s", r.warning);
assertEquals("unconfirmed", r.note());
}
@Test
public void aPredictionIsNeverMistakenForAState()
{
// optimistic_state is what the proxy thinks the light will be; only `state` is read back.
LightsProxy.ToggleResult r = LightsProxy.parseToggle(
"{\"accepted\": true, \"id\": \"a\", \"optimistic_state\": \"on\", \"state\": null,"
+ " \"confirmed\": false}");
assertNotNull(r);
assertNull(r.state);
assertNotNull("an unconfirmed toggle must say something", r.note());
}
@Test
public void aBrokenToggleAnswerIsNoAnswer()
{
assertNull(LightsProxy.parseToggle(null));
assertNull(LightsProxy.parseToggle(""));
assertNull(LightsProxy.parseToggle("nonsense"));
assertNull("a body that says neither yes, no, nor what it saw", LightsProxy.parseToggle("{}"));
assertNull(LightsProxy.parseToggle("{\"id\": \"a\"}"));
assertNull(LightsProxy.parseToggle("{\"ok\": true, \"retried\": false}"));
assertNull(LightsProxy.parseToggle("[1, 2]"));
}
// ------------------------------------------------------------------ the colour rule
@Test
public void theColourRuleIsOnBlueOffPinkAndEverythingElseStale()
{
// The rule, all of it, in one place. The three colours come from the theme; these are the same
// values res/values/themes.xml carries (colorLightOn is the Roku arrows' #00E5FF).
final int blue = 0xFF00E5FF, pink = 0xFFFF2D95, grey = 0xFF8A8D93;
assertEquals(blue, LightsProxy.colorFor(LightsProxy.State.ON, blue, pink, grey));
assertEquals(pink, LightsProxy.colorFor(LightsProxy.State.OFF, blue, pink, grey));
assertEquals("an unreachable light is neither colour",
grey, LightsProxy.colorFor(LightsProxy.State.UNKNOWN, blue, pink, grey));
assertEquals("and neither is one nobody has heard about",
grey, LightsProxy.colorFor(null, blue, pink, grey));
}
@Test
public void theStateGlyphIsTheLitLampOnlyWhenTheLightIsOn()
{
assertEquals(0xE025, LightsPage.glyphFor(LightsProxy.State.ON).charAt(0));
assertEquals(0xE026, LightsPage.glyphFor(LightsProxy.State.OFF).charAt(0));
assertEquals("an unreachable light must not look lit",
0xE026, LightsPage.glyphFor(LightsProxy.State.UNKNOWN).charAt(0));
assertEquals(0xE026, LightsPage.glyphFor(null).charAt(0));
assertEquals(1, LightsPage.glyphFor(LightsProxy.State.ON).length());
}
// ------------------------------------------------------------------ the labels
@Test
public void theLabelIsTheRegistryNameNotSomethingShorter()
{
// The short names the keyboard shows are the registry's own; there is no mapping from a light
// to a label on this side, so whatever the endpoint returns is what a key says.
assertEquals("Bedroom", LightsPage.displayName("Bedroom"));
assertEquals("TV room", LightsPage.displayName("TV room"));
assertEquals("Stairs", LightsPage.displayName("Stairs"));
assertEquals("space around a name is not part of it", "Bedroom",
LightsPage.displayName(" Bedroom\n"));
assertEquals("", LightsPage.displayName(null));
}
@Test
public void aNameTooLongForItsKeyIsCutVisibly()
{
assertEquals("Basement Stairs Light", LightsPage.ellipsize("Basement Stairs Light", 32));
String cut = LightsPage.displayName("Basement Stairs Light");
assertTrue("a cut label is marked as cut: " + cut, cut.endsWith("…"));
assertEquals(LightsPage.MAX_NAME_CHARS, cut.length());
}
// ------------------------------------------------------------------ the page
@Test
public void oneKeyPerLightThreeToARow()
{
KeyboardData page = LightsPage.build(template(), lights(4));
assertEquals("3 lights, 1 stretched to the whole width, and the Keyboard key",
3, page.rows.size());
assertEquals(3, page.rows.get(0).keys.size());
assertEquals(1, page.rows.get(1).keys.size());
assertEquals(1, page.rows.get(2).keys.size());
assertEquals("a full row of three is the whole width", 9.9f, page.rows.get(0).keysWidth, 0.001f);
assertEquals(3.3f, page.rows.get(0).keys.get(0).width, 0.001f);
assertEquals("the short row fills the keyboard", 9.9f, page.rows.get(1).keysWidth, 0.001f);
assertEquals("one key stretched to the whole width", 9.9f, page.rows.get(1).keys.get(0).width,
0.001f);
assertEquals("the way back is the template's own key", 9.9f,
page.rows.get(2).keys.get(0).width, 0.001f);
assertEquals("Keyboard", page.rows.get(2).keys.get(0).keys[0].getString());
assertEquals(KeyValue.Event.SWITCH_TEXT, page.rows.get(2).keys.get(0).keys[0].getEvent());
}
@Test
public void everyKeyShowsItsLightNameAndItsStateGlyph()
{
KeyboardData page = LightsPage.build(template(), lights(3));
assertEquals("the name the registry gave", "Bedroom",
page.rows.get(0).keys.get(0).keys[0].getString());
assertEquals("TV room", page.rows.get(0).keys.get(2).keys[0].getString());
// The name and the glyph sit on the same key and press the same light; the glyph is the value
// drawn in the special font, which is what tells the two apart.
for (int i = 0; i < 3; i++)
{
KeyboardData.Key k = page.rows.get(0).keys.get(i);
KeyValue name = k.keys[0], glyph = k.keys[LightsPage.GLYPH_SUB_INDEX];
assertTrue(LightsPage.isLightKey(name));
assertTrue(LightsPage.isStateGlyph(glyph));
assertFalse("the name is not the glyph", LightsPage.isStateGlyph(name));
assertEquals(LightsPage.eventFor(i + 1), name.getEvent());
assertEquals("both values press the same light", name.getEvent(), glyph.getEvent());
assertEquals(KeyValue.Kind.Event, name.getKind());
assertEquals("the stored glyph never claims a light is on",
(char)0xE026, glyph.getString().charAt(0));
assertEquals("the name is drawn in the ordinary font, not the glyph font",
false, name.hasFlagsAny(KeyValue.FLAG_KEY_FONT));
}
assertEquals(KeyValue.Event.LIGHT_3, page.rows.get(0).keys.get(2).keys[0].getEvent());
}
@Test
public void withNoAnswerYetThePlaceholdersStand()
{
KeyboardData page = LightsPage.build(template(), null);
assertEquals(2, page.rows.size());
assertEquals(3, page.rows.get(0).keys.size());
for (int i = 0; i < 3; i++)
{
KeyboardData.Key k = page.rows.get(0).keys.get(i);
assertEquals(LightsPage.PLACEHOLDER, k.keys[0].getString());
assertTrue("a slot before the first answer still carries the unlit lamp",
LightsPage.isStateGlyph(k.keys[LightsPage.GLYPH_SUB_INDEX]));
}
// Nothing has been heard from the proxy, so nothing is ON and the page is stale.
assertTrue(LightsProxy.isStaleNow());
assertEquals(LightsProxy.State.UNKNOWN, LightsProxy.stateAt(1));
assertEquals(LightsPage.glyphFor(LightsProxy.State.UNKNOWN),
LightsPage.glyphFor(LightsProxy.stateAt(1)));
}
@Test
public void buildingThePageLeavesTheTemplateAlone()
{
KeyboardData tpl = template();
LightsPage.build(tpl, lights(5));
assertEquals("KeyboardData.load caches the template, so it must survive being built from",
2, tpl.rows.size());
assertEquals(3, tpl.rows.get(0).keys.size());
assertEquals("the template's slot still points at its own light", KeyValue.Event.LIGHT_1,
tpl.rows.get(0).keys.get(0).keys[0].getEvent());
assertEquals(LightsPage.PLACEHOLDER, tpl.rows.get(0).keys.get(0).keys[0].getString());
assertNull("and the template's corner is still empty",
tpl.rows.get(0).keys.get(0).keys[LightsPage.GLYPH_SUB_INDEX]);
}
@Test
public void moreLightsThanSlotsIsSaidRatherThanDropped()
{
LightsProxy.Light[] many = lights(LightsPage.MAX_LIGHTS + 1);
KeyboardData page = LightsPage.build(template(), many);
assertEquals(5, page.rows.size());
String last = page.rows.get(3).keys.get(2).keys[0].getString();
assertTrue("the last key must say how many lights it cannot show: " + last, last.endsWith("+1"));
assertTrue(last.length() <= LightsPage.MAX_NAME_CHARS);
}
@Test
public void aKeyThePageDoesNotOwnIsNotALightsKey()
{
KeyValue door = KeyValue.getSpecialKeyByName("switch_lights");
KeyValue back = KeyValue.getSpecialKeyByName("lights_close");
assertFalse(LightsPage.isLightKey(door));
assertFalse(LightsPage.isStateGlyph(door));
assertFalse(LightsPage.isLightKey(back));
assertFalse(LightsPage.isLightKey(null));
assertEquals(0, LightsPage.slotOf(null));
assertEquals(0, LightsPage.slotOf(back.getEvent()));
assertEquals((char)0xE025, door.getString().charAt(0));
assertTrue("the door is drawn in the special font", door.hasFlagsAny(KeyValue.FLAG_KEY_FONT));
assertEquals("and the way back is the same key as the Roku page's",
KeyValue.Event.SWITCH_TEXT, back.getEvent());
}
// ------------------------------------------------------------------ what a key says
@Test
public void aNoteSurvivesARefreshUntilTheBoxReportsAState()
{
LightsProxy.Light noted = new LightsProxy.Light("a", "A", "switch.a",
LightsProxy.State.UNKNOWN, "no_token", 1000L);
LightsProxy.Light[] previous = { noted };
// A refresh that still cannot read a state keeps the last answer ...
LightsProxy.Light stillUnknown = new LightsProxy.Light("a", "A", "switch.a",
LightsProxy.State.UNKNOWN, null, 0L);
assertEquals("no_token", LightsProxy.mergeNote(stillUnknown, previous).note);
// ... a real state from the box supersedes it ...
LightsProxy.Light known = new LightsProxy.Light("a", "A", "switch.a",
LightsProxy.State.ON, null, 0L);
assertNull(LightsProxy.mergeNote(known, previous).note);
// ... a light that is not in the previous list stands on its own ...
assertNull(LightsProxy.mergeNote(stillUnknown, new LightsProxy.Light[0]).note);
// ... and the sentence expires, so an old answer cannot pass for the current state.
assertNotNull(noted.noteOrNull(1000L + LightsProxy.NOTE_MS - 1));
assertNull(noted.noteOrNull(1000L + LightsProxy.NOTE_MS));
}
// ------------------------------------------------------------------ helpers
/** The page skeleton, built by hand to the same shape as res/xml/lights.xml. */
private static KeyboardData template()
{
List<KeyboardData.Key> slots = new ArrayList<KeyboardData.Key>();
for (int i = 1; i <= LightsPage.SLOTS_PER_ROW; i++)
slots.add(key(KeyValue.getSpecialKeyByName("light_" + i), 3.3f, 0f));
List<KeyboardData.Key> close = new ArrayList<KeyboardData.Key>();
close.add(key(KeyValue.getSpecialKeyByName("lights_close"), 9.9f, 0f));
List<KeyboardData.Row> rows = new ArrayList<KeyboardData.Row>();
rows.add(new KeyboardData.Row(slots, 1.0f, 0f));
rows.add(new KeyboardData.Row(close, 1.0f, 0f));
return new KeyboardData(rows, 9.9f, null, null, null, "lights", false, false, false);
}
private static KeyboardData.Key key(KeyValue kv, float width, float shift)
{
KeyValue[] ks = new KeyValue[9];
ks[0] = kv;
return new KeyboardData.Key(ks, null, 0, width, shift, null, KeyboardData.Key.Role.Action);
}
/** [n] lights with short registry-style names, each row of three named in turn. */
private static LightsProxy.Light[] lights(int n)
{
String[] words = { "Bedroom", "Stairs", "TV room", "Light Four", "Light Five", "Light Six",
"Light Seven", "Light Eight", "Light Nine", "Light Ten", "Light Eleven", "Light Twelve",
"Light Thirteen" };
LightsProxy.Light[] out = new LightsProxy.Light[n];
for (int i = 0; i < n; i++)
out[i] = new LightsProxy.Light("id" + i, words[i], "switch." + i,
i % 2 == 0 ? LightsProxy.State.ON : LightsProxy.State.OFF, null, 0L);
return out;
}
}