Skip to content

Commit 2e5ded5

Browse files
committed
Sort unrecorded years last, and stop claiming a copy that failed
Both reported by Codex on #22, both confirmed before fixing. normaliseWine collapses a missing year to 0, and the missing-value rule only recognised null and "". So 0 sorted as a number smaller than every real year, and sorting by vintage ascending led with every NV bottle in the cellar - the exact thing that rule exists to prevent. Zero now counts as absent for Vintage, BeginConsume and EndConsume, where there is no such year. Valuation is deliberately left alone: a bottle really can be worth nothing recorded, and cheapest-first should still show it. The copy button reported success unconditionally. `navigator.clipboard` requires a secure context, and a great many Home Assistant installs are reached over plain http on a LAN address - so `written` was undefined, the else branch ran, and the button said "Copied" having copied nothing. That is worse than doing nothing: the user walks to the rack and pastes whatever was there before. There is a real execCommand fallback now, and the label follows what actually happened. Finding the second one meant fixing the harness first. It set `globalThis.navigator`, which node 21+ ignores in silence because it ships its own getter-only navigator - so every clipboard fixture was testing the same world and the "missing" case looked like it passed. It uses defineProperty now and throws if the stub does not take. The copy tests assert what reached the clipboard, not just what the button says, so "said Copied" cannot pass for "copied something". 441 tests, ruff clean, mypy clean. Re-checked in Chromium: vintage ascending now ends with the NV bottles, and Copy bin really does put the bin on the clipboard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLPEGFSy3fLEuXNUPAPWR4
1 parent a9ee95d commit 2e5ded5

4 files changed

Lines changed: 197 additions & 15 deletions

File tree

custom_components/cellar_tracker/www/cellar.html

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,19 @@
461461
return value == null || value === '';
462462
}
463463

464+
// normaliseWine collapses an unrecorded year to 0, and 0 is smaller than
465+
// every real one - so sorting by vintage ascending led with every NV
466+
// bottle in the cellar. There is no year zero, so 0 means "not recorded"
467+
// for these three and nothing else. A *valuation* of 0 is left alone: a
468+
// bottle really can be worth nothing recorded, and cheapest-first should
469+
// still show it.
470+
const UNRECORDED_AS_ZERO = { Vintage: true, BeginConsume: true, EndConsume: true };
471+
472+
function isMissing(value, key) {
473+
if (isBlank(value)) return true;
474+
return UNRECORDED_AS_ZERO[key] === true && Number(value) === 0;
475+
}
476+
464477
// Decorated with the arrival index and compared on it last, so ties keep
465478
// the order they came in whichever way the column is pointing. A cellar
466479
// holds six identical bottles of the same wine; they should not shuffle
@@ -475,9 +488,11 @@
475488

476489
// A bin nobody filled in sorts last in both directions rather
477490
// than heading the list because "" precedes every letter.
478-
if (isBlank(left) || isBlank(right)) {
479-
if (isBlank(left) && isBlank(right)) return a.index - b.index;
480-
return isBlank(left) ? 1 : -1;
491+
const leftMissing = isMissing(left, key);
492+
const rightMissing = isMissing(right, key);
493+
if (leftMissing || rightMissing) {
494+
if (leftMissing && rightMissing) return a.index - b.index;
495+
return leftMissing ? 1 : -1;
481496
}
482497

483498
if (typeof left === 'string') left = left.toLowerCase();
@@ -695,16 +710,45 @@
695710
return drawer;
696711
}
697712

698-
function copyText(text, button, original) {
699-
const done = () => {
700-
button.textContent = 'Copied';
701-
setTimeout(() => { button.textContent = original; }, 1200);
702-
};
713+
// execCommand is deprecated, and it is also the only thing that works
714+
// without a secure context. A great many Home Assistant installs are
715+
// reached over plain http on a LAN address, where navigator.clipboard does
716+
// not exist at all - so this is the common path here, not the fallback.
717+
function legacyCopy(text) {
703718
try {
704-
const written = navigator.clipboard && navigator.clipboard.writeText(text);
705-
if (written && written.then) written.then(done, () => {});
706-
else done();
707-
} catch (e) { /* no clipboard permission; the bin is on screen anyway */ }
719+
const field = document.createElement('textarea');
720+
field.value = text;
721+
field.setAttribute('readonly', '');
722+
field.style.position = 'fixed';
723+
field.style.opacity = '0';
724+
document.body.appendChild(field);
725+
field.select();
726+
const copied = document.execCommand('copy') === true;
727+
document.body.removeChild(field);
728+
return copied;
729+
} catch (e) {
730+
return false;
731+
}
732+
}
733+
734+
// Resolves to whether the text actually reached a clipboard. Saying
735+
// "Copied" when nothing was copied is worse than saying nothing: the user
736+
// walks to the rack and pastes whatever was there before.
737+
function writeToClipboard(text) {
738+
if (navigator.clipboard && navigator.clipboard.writeText) {
739+
try {
740+
return Promise.resolve(navigator.clipboard.writeText(text))
741+
.then(() => true, () => legacyCopy(text));
742+
} catch (e) { /* fall through to the legacy path */ }
743+
}
744+
return Promise.resolve(legacyCopy(text));
745+
}
746+
747+
function copyText(text, button, original) {
748+
writeToClipboard(text).then((copied) => {
749+
button.textContent = copied ? 'Copied' : 'Could not copy';
750+
setTimeout(() => { button.textContent = original; }, copied ? 1200 : 2000);
751+
});
708752
}
709753

710754
function buildBottle(wine) {

tests/dashboard_js.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def page_script() -> str:
8484
querySelectorAll() { return []; },
8585
closest() { return null; },
8686
focus() {},
87+
select() { el.selected = true; },
8788
};
8889
return el;
8990
}
@@ -114,9 +115,44 @@ def page_script() -> str:
114115
createDocumentFragment: () => makeElement('#fragment'),
115116
addEventListener() {},
116117
body: makeElement('body'),
118+
// The legacy path, and the only one that works without a secure context.
119+
execCommand(command) {
120+
if (fixture.execCommand === false) return false;
121+
if (command === 'copy') copied.push('exec');
122+
return fixture.execCommand !== false;
123+
},
117124
};
118125
119-
globalThis.navigator = { clipboard: { writeText: async () => {} } };
126+
// Home Assistant is very often served over plain http on a LAN address, which
127+
// is not a secure context - so navigator.clipboard is frequently absent
128+
// entirely. The fixture picks which world the page is running in.
129+
const copied = [];
130+
const clipboard = fixture.clipboard || 'async';
131+
// defineProperty, not assignment: node 21+ ships its own `navigator` as a
132+
// getter-only global, so `globalThis.navigator = ...` silently does nothing
133+
// and every clipboard fixture would quietly test the same world.
134+
Object.defineProperty(globalThis, 'navigator', {
135+
configurable: true,
136+
writable: true,
137+
value: clipboard === 'missing'
138+
? {}
139+
: {
140+
clipboard: {
141+
writeText: (text) => clipboard === 'rejects'
142+
? Promise.reject(new Error('denied'))
143+
: (copied.push(text), Promise.resolve()),
144+
},
145+
},
146+
});
147+
148+
// The stubs above are worthless if the runtime refused one of them, and a
149+
// refusal is silent outside strict mode. Fail loudly instead.
150+
if (clipboard === 'missing' && navigator.clipboard) {
151+
throw new Error('the navigator stub did not take effect');
152+
}
153+
if (clipboard !== 'missing' && !navigator.clipboard) {
154+
throw new Error('the navigator stub did not take effect');
155+
}
120156
121157
// The page fetches at load. Answer with the fixture so the render path runs;
122158
// an empty fixture answers 401, which is the existing tests' scenario.
@@ -138,9 +174,13 @@ def page_script() -> str:
138174
"""
139175

140176

141-
def run_js(checks: str, *, wines=None, search="") -> str:
177+
def run_js(checks: str, *, wines=None, search="", clipboard="async",
178+
exec_command=True) -> str:
142179
"""Load the page script, then run `checks`. Non-zero exit means a failure."""
143-
fixture = json.dumps({"wines": wines, "search": search})
180+
fixture = json.dumps(
181+
{"wines": wines, "search": search,
182+
"clipboard": clipboard, "execCommand": exec_command}
183+
)
144184
source = "\n".join(
145185
[
146186
PRELUDE % fixture,

tests/test_dashboard_filters.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,43 @@ def test_rows_missing_the_sort_key_sort_last_in_both_directions():
328328
+ equals("sortWines(rows, 'Bin', 'desc').map((w) => w.Wine)",
329329
["A", "C", "B"], "a missing bin should sort last descending too")
330330
)
331+
332+
333+
# --------------------------------------------------------------------------
334+
# Reported by Codex on #22
335+
# --------------------------------------------------------------------------
336+
UNRECORDED = (
337+
"[{Wine: 'Real Vintage', Vintage: 2019, EndConsume: 2030},"
338+
" {Wine: 'No Vintage', Vintage: 0, EndConsume: 0},"
339+
" {Wine: 'Older', Vintage: 2010, EndConsume: 2020}]"
340+
)
341+
342+
343+
@pytest.mark.parametrize("key", ["Vintage", "EndConsume"])
344+
@pytest.mark.parametrize("direction", ["asc", "desc"])
345+
def test_an_unrecorded_year_sorts_last_whichever_way_the_column_points(key, direction):
346+
"""normaliseWine collapses a missing year to 0, and 0 is smaller than 2010.
347+
348+
So an ascending sort by vintage led with every NV bottle in the cellar -
349+
the exact thing the missing-value rule was written to prevent, slipping
350+
through because the rule only recognised null and "".
351+
"""
352+
run_js(
353+
equals(
354+
f"sortWines({UNRECORDED}, '{key}', '{direction}').slice(-1)[0].Wine",
355+
"No Vintage",
356+
f"a bottle with no recorded {key} should sort last, not first",
357+
)
358+
)
359+
360+
361+
def test_a_valuation_of_zero_is_still_a_value():
362+
"""Unlike a year: there is no year 0, but a bottle really can be worth 0."""
363+
run_js(
364+
equals(
365+
"sortWines([{Wine: 'Free', Valuation: 0}, {Wine: 'Costly', Valuation: 99}],"
366+
" 'Valuation', 'asc').map((w) => w.Wine)",
367+
["Free", "Costly"],
368+
"a zero valuation was treated as missing rather than as cheap",
369+
)
370+
)

tests/test_dashboard_render.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,61 @@ def test_an_empty_result_says_so_instead_of_showing_nothing():
216216
+ check("document.getElementById('status').textContent.length > 0",
217217
"an empty list with no explanation reads as a broken page")
218218
)
219+
220+
221+
# --------------------------------------------------------------------------
222+
# Reported by Codex on #22
223+
# --------------------------------------------------------------------------
224+
def copy_scenario(clipboard: str, exec_command: bool) -> dict:
225+
"""Open a drawer, press Copy bin, and report the label and what was copied."""
226+
output = run_js(
227+
f"{PREAMBLE}\n{FIND}\n"
228+
f"loadWines({json.dumps(cellar())});\n"
229+
f"const bottle = {card('Ready Red')};\n"
230+
"bottle.children[0].dispatch('click');\n"
231+
"const copy = bottle.children[1].children[1].children[0];\n"
232+
"copy.dispatch('click', {stopPropagation() {}});\n"
233+
# Report the label and what actually reached a clipboard, so a test can
234+
# tell "said Copied" apart from "copied something".
235+
"setTimeout(() => {\n"
236+
" console.log(JSON.stringify({label: copy.textContent, copied: copied}));\n"
237+
"}, 10);\n",
238+
wines=None,
239+
clipboard=clipboard,
240+
exec_command=exec_command,
241+
)
242+
return json.loads(output.strip())
243+
244+
245+
def test_copying_says_so_when_it_worked():
246+
result = copy_scenario("async", True)
247+
assert result["label"] == "Copied"
248+
assert "A1" in result["copied"], "the bin never reached the clipboard"
249+
250+
251+
def test_a_missing_clipboard_api_falls_back_rather_than_failing():
252+
"""Home Assistant on plain http is not a secure context, so there is no
253+
navigator.clipboard at all - the common case here, not an edge one."""
254+
result = copy_scenario("missing", True)
255+
assert result["label"] == "Copied"
256+
assert result["copied"], "claimed success without using the fallback"
257+
258+
259+
def test_a_rejected_clipboard_write_still_falls_back():
260+
result = copy_scenario("rejects", True)
261+
assert result["label"] == "Copied"
262+
assert result["copied"], "gave up instead of trying the legacy path"
263+
264+
265+
def test_the_button_does_not_claim_success_when_nothing_was_copied():
266+
"""It used to say "Copied" whenever the clipboard API was absent."""
267+
result = copy_scenario("missing", False)
268+
assert not result["copied"], "the fixture should model a copy that fails"
269+
assert result["label"] != "Copied"
270+
271+
272+
def test_a_failed_copy_says_what_happened():
273+
label = copy_scenario("missing", False)["label"]
274+
assert label and label != "Copy bin", (
275+
"a copy that silently did nothing leaves the user believing it worked"
276+
)

0 commit comments

Comments
 (0)