workspace: niveau du vehicule par le concierge (option A), simulation par drapeau

- concierge-write.js : setVehicleLevel (entier 0-100, refus sans correction,
  relecture), ZERO dans SELECTOR_LABELS ; tests 13/13.
- concierge-live.js answer : reconnait « NN % », ecrit le niveau, lignes
  « 🔋 Niveau noté » / « ⚠️ Niveau NON noté ».
- Drapeau workspace/.simulation lu par concierge-live.js et airtria-ha-vehicule.js :
  aucune ecriture HA pendant un banc agent.
- airtria-ha-vehicule.js : validation de tous les arguments avant ecriture.
- AGENTS.md et reference-concierge.md : vehicule et niveau passent par le concierge.
- Journaux de tri Orange et AIRTRIA, DREAMS.
This commit is contained in:
2026-10-03 00:19:15 +02:00
parent aabd6b77e6
commit 5dc72349e5
9 changed files with 251 additions and 39 deletions

View File

@@ -1,7 +1,7 @@
// concierge-write.test.js — Test de PORTÉE D'ÉCRITURE (garde-fou du canal HA).
// Doit ÉCHOUER si une écriture peut sortir des DEUX constantes autorisées :
// airtria_vehicle_label (étiquette) et victron_evcs_vehicle (sélecteur,
// ajouté 19/09/2026). À faire tourner à CHAQUE modification (cf. EVCS advisor).
// Doit ÉCHOUER si une écriture peut sortir des TROIS constantes autorisées :
// airtria_vehicle_label (étiquette), victron_evcs_vehicle (sélecteur, ajouté
// 19/09/2026) et victron_evcs_vehicle_level_pct (niveau annoncé, 03/10/2026). À faire tourner à CHAQUE modification (cf. EVCS advisor).
// Statique + require + fetch STUBBÉ : aucun réseau, aucun token, aucune écriture.
'use strict';
@@ -21,49 +21,54 @@ const check = (name, fn) => queue.push([name, fn]);
console.log('=== Test de portée d\'écriture concierge-write.js ===');
// 1. Les constantes sont exactement les entités attendues.
check('constantes = airtria_vehicle_label + victron_evcs_vehicle', () => {
check('constantes = airtria_vehicle_label + victron_evcs_vehicle + niveau', () => {
assert.strictEqual(mod.HELPER_ENTITY, 'input_text.airtria_vehicle_label');
assert.strictEqual(mod.SELECTOR_ENTITY, 'input_select.victron_evcs_vehicle');
assert.deepStrictEqual(mod.SELECTOR_LABELS, ['VW', 'JEEP']);
assert.strictEqual(mod.LEVEL_ENTITY, 'input_number.victron_evcs_vehicle_level_pct');
assert.deepStrictEqual(mod.SELECTOR_LABELS, ['VW', 'JEEP', 'ZERO']);
});
// 2. entity_id n'est JAMAIS accolé à autre chose que l'une des DEUX constantes
// (pas de littéral d'entité, pas d'interpolation, pas de concaténation).
check('entity_id uniquement = HELPER_ENTITY ou SELECTOR_ENTITY', () => {
check('entity_id uniquement = HELPER_ENTITY, SELECTOR_ENTITY ou LEVEL_ENTITY', () => {
const occ = [...src.matchAll(/entity_id\s*:\s*([^,}\n]+)/g)].map(m => m[1].trim());
assert(occ.length >= 2, `entity_id attendus >= 2, trouvés ${occ.length}`);
for (const v of occ) assert(v === 'HELPER_ENTITY' || v === 'SELECTOR_ENTITY',
assert(occ.length >= 3, `entity_id attendus >= 3, trouvés ${occ.length}`);
for (const v of occ) assert(['HELPER_ENTITY', 'SELECTOR_ENTITY', 'LEVEL_ENTITY'].includes(v),
`entity_id dynamique ou littéral interdit : ${v}`);
});
// 3. Aucune entité HA en dur hors les DEUX déclarations de constantes.
check('aucune entité HA littérale hors les deux constantes', () => {
check('aucune entité HA littérale hors les trois constantes', () => {
const decls = ["'input_text.airtria_vehicle_label'",
"'input_select.victron_evcs_vehicle'"];
"'input_select.victron_evcs_vehicle'",
"'input_number.victron_evcs_vehicle_level_pct'"];
const lits = [...src.matchAll(/['"`]([a-z_]+\.[a-z0-9_]+)['"`]/g)].map(m => m[0]);
for (const l of lits) assert(decls.includes(l),
`littéral d'entité interdit : ${l}`);
});
// 4. DEUX appels de service exactement : set_value puis select_option.
check('services = input_text/set_value + input_select/select_option', () => {
check('services = input_text/set_value + input_select/select_option + input_number/set_value', () => {
const services = [...src.matchAll(/\/api\/services\/([a-z_]+\/[a-z_]+)/g)]
.map(m => m[1]);
assert.deepStrictEqual(services.sort(),
['input_select/select_option', 'input_text/set_value'],
['input_number/set_value', 'input_select/select_option', 'input_text/set_value'],
`services inattendus : ${services.join(', ') || '(aucun)'}`);
});
// 5. La relecture existe pour CHAQUE écriture (garde-fou rejet silencieux).
check('relectures présentes (/api/states/ x2)', () => {
check('relectures présentes (/api/states/ x3)', () => {
const n = [...src.matchAll(/\/api\/states\//g)].length;
assert(n >= 2, `relectures attendues >= 2, trouvées ${n}`);
assert(n >= 3, `relectures attendues >= 3, trouvées ${n}`);
});
// ---- Comportement du sélecteur, fetch STUBBÉ (aucun réseau) ----
const calls = [];
global.fetch = async (url, opts) => {
calls.push({ url: String(url), body: opts && opts.body });
if (String(url).includes('/api/states/input_number.victron_evcs_vehicle_level_pct')) {
return { ok: true, json: async () => ({ state: '40.0' }) };
}
if (String(url).includes('/api/states/')) {
return { ok: true, json: async () => ({ state: 'VW' }) };
}
@@ -79,8 +84,8 @@ check('sélecteur refuse une source non humaine', async () => {
});
// 7. Refus des labels hors foyer, sans AUCUN appel réseau.
check('sélecteur refuse MEGANE / INVITE / ZERO', async () => {
for (const bad of ['MEGANE', 'INVITE', 'ZERO', 'vw', 'Jeep']) {
check('sélecteur refuse MEGANE / INVITE (ZERO accepte depuis le 03/10)', async () => {
for (const bad of ['MEGANE', 'INVITE', 'vw', 'Jeep', 'zero']) {
calls.length = 0;
await assert.rejects(() => mod.setVehicleSelector(bad, 'whatsapp'), /hors foyer/);
assert.strictEqual(calls.length, 0, `un fetch est parti pour '${bad}'`);
@@ -104,6 +109,38 @@ check('sélecteur détecte le refus silencieux HA', async () => {
/refusée en silence/);
});
// 10. Niveau : refus des valeurs invalides SANS correction ni appel réseau
// (02/10 : le modèle avait « corrigé » 150 en 100 avant de l'écrire).
check('niveau refuse 150, -1, 40.5, NaN — aucun fetch', async () => {
for (const bad of [150, -1, 40.5, NaN, '40']) {
calls.length = 0;
await assert.rejects(() => mod.setVehicleLevel(bad, 'whatsapp'), /invalide/);
assert.strictEqual(calls.length, 0, `un fetch est parti pour '${bad}'`);
}
});
// 11. Niveau : source non humaine refusée.
check('niveau refuse une source non humaine', async () => {
calls.length = 0;
await assert.rejects(() => mod.setVehicleLevel(40, 'deduction'), /non humaine/);
assert.strictEqual(calls.length, 0);
});
// 12. Niveau 40 : set_value sur la constante, puis relecture.
check('niveau 40 : écriture + relecture', async () => {
calls.length = 0;
const r = await mod.setVehicleLevel(40, 'whatsapp');
assert.strictEqual(r.entity, 'input_number.victron_evcs_vehicle_level_pct');
assert.strictEqual(calls.length, 2);
assert(calls[0].url.includes('/api/services/input_number/set_value'));
assert(calls[0].body.includes('"value":40'));
});
// 13. Niveau : relecture discordante = erreur (HA relit 40, on demandait 70).
check('niveau détecte le refus silencieux HA', async () => {
await assert.rejects(() => mod.setVehicleLevel(70, 'whatsapp'), /refusé en silence/);
});
(async () => {
for (const [name, fn] of queue) {
try { await fn(); console.log(` ok ${name}`); }