Skip to content

Commit a9e49b0

Browse files
dzerikclaude
andcommitted
fix: 4 находки из глубокого ревью конвертера
Глубокий adversarial-проход по алгоритму IR-кодирования (compressor greedy/optimal, decoder, encoder, base64, round-trip) нашёл 4 подтверждённых недочёта. Три критичные подсистемы (LZ77-компрессор, DP-парсер, decoder-рефакторинг) прошли ревью без замечаний. - tuya-encoder.ts: фильтр `t < MAX_SIGNAL_VALUE` → `t <= MAX_SIGNAL_VALUE`. Тайминг ровно 65535 (max uint16 LE) — валидное значение, но силентно отбрасывался. Golden-тесты по-прежнему совпадают — фикстура не содержит таких значений. - smartir.ts (processCommands): non-string листья (число, boolean, null) и массивы больше не проходят молчаливым passthrough. Раньше вход `{"commands":{"heat":{"17":42}}}` давал внешне валидный JSON с `{"17":42}` — MOES UFO-R11 такой payload не принял бы, но пользователь узнавал об этом только на устройстве. Теперь бросается IRCodeError с указанием ключа и типа. - golden.test.ts: тест "should decode correct number of timings" раньше сверял константы фикстуры сами с собой (BroadlinkDecoder не вызывался). Теперь реально декодирует testInput и сравнивает с фикстурой — off-by-one в parseTimings был бы пойман. - +5 новых тестов: boundary encoder (65535 сохраняется, >65535 отсеивается), 4 случая с non-string/массивом в SmartIR commands. Всего: 36 → 42 tests, все зелёные. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e5ea960 commit a9e49b0

6 files changed

Lines changed: 73 additions & 20 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "btu-frontend",
3-
"version": "1.1.0",
3+
"version": "1.1.1",
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbopack",

frontend/src/__tests__/converter/encoder-errors.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'vitest';
22
import { IRConverter, IRCodeError } from '../../lib/converter';
3+
import { TuyaEncoder } from '../../lib/converter/tuya-encoder';
34

45
/**
56
* Строит валидный Broadlink Base64 из массива таймингов (в единицах ~unit).
@@ -43,4 +44,22 @@ describe('TuyaEncoder — фильтрация и граничные случа
4344
expect(typeof result).toBe('string');
4445
expect(result.length).toBeGreaterThan(0);
4546
});
47+
48+
it('тайминг ровно 65535 (max uint16) НЕ отсеивается', () => {
49+
// Тестирую границу напрямую в encoder'е: [500, 65535, 500] должен
50+
// упаковать все три сэмпла, а не два.
51+
const encoder = new TuyaEncoder();
52+
const result = encoder.encode([500, 65535, 500]);
53+
expect(result.length).toBeGreaterThan(0);
54+
// Обратный маркер: если бы 65535 отбрасывался, вход [500, 500] дал
55+
// бы КОРОЧЕ результат. Сравним.
56+
const shorter = encoder.encode([500, 500]);
57+
expect(result.length).toBeGreaterThan(shorter.length);
58+
});
59+
60+
it('тайминги > 65535 отсеиваются', () => {
61+
const encoder = new TuyaEncoder();
62+
expect(() => encoder.encode([70000, 80000])).toThrow(IRCodeError);
63+
expect(() => encoder.encode([70000, 80000])).toThrow(/All timings filtered/);
64+
});
4665
});

frontend/src/__tests__/converter/golden.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import { describe, it, expect } from 'vitest';
22
import { IRConverter, CompressionLevel } from '../../lib/converter';
3+
import { BroadlinkDecoder } from '../../lib/converter/broadlink-decoder';
34
import goldenData from '../fixtures/golden-data.json';
45

56
describe('Golden tests — output must match Python converter', () => {
67
const testInput = goldenData.test_input;
78

89
it('should decode correct number of timings', () => {
9-
const converter = new IRConverter(CompressionLevel.BALANCED);
10-
// Access decoder indirectly through a known output
11-
// The decoded_timings_count is 200 and first10 are known
12-
expect(goldenData.decoded_timings_count).toBe(200);
13-
expect(goldenData.decoded_timings_first10).toEqual([
14-
4447, 4386, 579, 1584, 579, 518, 549, 1584, 579, 1584,
15-
]);
10+
// Реально вызываем decoder на testInput и сравниваем с фикстурой —
11+
// раньше тест сверял константы фикстуры сами с собой и пропустил бы
12+
// любую регрессию в parseTimings.
13+
const decoder = new BroadlinkDecoder();
14+
const timings = decoder.decode(testInput);
15+
expect(timings).toHaveLength(goldenData.decoded_timings_count);
16+
expect(timings.slice(0, 10)).toEqual(goldenData.decoded_timings_first10);
1617
});
1718

1819
it('should match Python output at compression level NONE (0)', () => {

frontend/src/__tests__/converter/smartir.test.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import {
33
IRConverter,
4+
IRCodeError,
45
CompressionLevel,
56
isSmartIRData,
67
countSmartIRCommands,
@@ -108,14 +109,17 @@ describe('IRConverter.processSmartIRData', () => {
108109
it('prototype pollution через __proto__ не подменяет [[Prototype]]', () => {
109110
// Payload с ключом "__proto__" на верхнем уровне commands: без защиты
110111
// processed["__proto__"] = innerObject через нативный setter меняет
111-
// [[Prototype]] аккумулятора → getters с prototype-цепочки начинают
112-
// возвращать "evil" данные. С Object.create(null) __proto__ становится
113-
// обычным own-property, и .evil остаётся undefined.
114-
const payload = JSON.parse('{"commands":{"__proto__":{"evil":true}}}');
112+
// [[Prototype]] аккумулятора → доступ через prototype-цепочку возвращает
113+
// "внешние" данные. С Object.create(null) __proto__ становится обычным
114+
// own-property, и посторонний ключ остаётся невидимым.
115+
const payload = JSON.parse(
116+
`{"commands":{"__proto__":{"pwned":${JSON.stringify(validCode)}}}}`
117+
);
115118
const result = converter.processSmartIRData(payload);
116-
expect((result.commands as Record<string, unknown>).evil).toBeUndefined();
117-
// Object.prototype глобально не загрязнён:
118-
expect(({} as Record<string, unknown>).evil).toBeUndefined();
119+
// pwned не доступен как свойство результата через prototype-цепочку:
120+
expect((result.commands as Record<string, unknown>).pwned).toBeUndefined();
121+
// И Object.prototype глобально не загрязнён:
122+
expect(({} as Record<string, unknown>).pwned).toBeUndefined();
119123
});
120124

121125
it('commands=null не бросает TypeError (валидатор отсеивает раньше)', () => {
@@ -124,4 +128,18 @@ describe('IRConverter.processSmartIRData', () => {
124128
const result = converter.processSmartIRData({ commands: undefined });
125129
expect(result.commands).toEqual({});
126130
});
131+
132+
it.each([
133+
['число как лист', { commands: { heat: { '17': 42 } } }, /number/],
134+
['boolean как лист', { commands: { power: true as unknown } }, /boolean/],
135+
['null как лист', { commands: { off: null } }, /null/],
136+
['массив в команде', { commands: { turn_on: ['some-code'] } }, /array/],
137+
])('бросает IRCodeError на %s вместо тихого passthrough', (_desc, input, expected) => {
138+
expect(() =>
139+
converter.processSmartIRData(input as SmartIRData)
140+
).toThrow(IRCodeError);
141+
expect(() =>
142+
converter.processSmartIRData(input as SmartIRData)
143+
).toThrow(expected as RegExp);
144+
});
127145
});

frontend/src/lib/converter/smartir.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IRConverter } from './index';
2+
import { IRCodeError } from './errors';
23

34
export interface SmartIRData {
45
[key: string]: unknown;
@@ -79,16 +80,28 @@ function processCommands(
7980
processed[key] = wrapWithIrCode
8081
? `{"ir_code_to_send": "${irCode}"}`
8182
: irCode;
82-
} else if (Array.isArray(value)) {
83-
processed[key] = value;
84-
} else if (value !== null && typeof value === 'object') {
83+
} else if (
84+
typeof value === 'object' &&
85+
value !== null &&
86+
!Array.isArray(value)
87+
) {
8588
processed[key] = processCommands(
8689
value as Record<string, unknown>,
8790
converter,
8891
wrapWithIrCode
8992
);
9093
} else {
91-
processed[key] = value;
94+
// Массивы, числа, boolean, null — не поддерживаются как значения
95+
// команд. Молчаливый passthrough давал внешне валидный JSON, но
96+
// MOES UFO-R11 не смог бы его обработать. Явная ошибка лучше.
97+
const kind = value === null
98+
? 'null'
99+
: Array.isArray(value)
100+
? 'array'
101+
: typeof value;
102+
throw new IRCodeError(
103+
`Unsupported command value type at "${key}": ${kind} (expected string or nested object)`
104+
);
92105
}
93106
}
94107

frontend/src/lib/converter/tuya-encoder.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ export class TuyaEncoder {
1515
throw new IRCodeError('Empty timings list');
1616
}
1717

18-
const filtered = timings.filter(t => t < MAX_SIGNAL_VALUE);
18+
// <= вместо <: MAX_SIGNAL_VALUE (0xFFFF) — сам максимум uint16 LE,
19+
// а не эксклюзивная верхняя граница; setUint16 принимает и 65535.
20+
const filtered = timings.filter(t => t <= MAX_SIGNAL_VALUE);
1921
if (!filtered.length) {
2022
throw new IRCodeError('All timings filtered out');
2123
}

0 commit comments

Comments
 (0)