Тесты на семантику графа (graph semantics tests) для реактивного движка проверяют, насколько правильно и предсказуемо движок обрабатывает сложные связи, зависимости и логику обновления данных внутри графа.
Если говорить простыми словами, @pravosleva/reactive-engine строит «карту» (граф) зависимостей, где переменные и функции связаны друг с другом. Когда меняется один элемент, движок должен автоматически и в правильном порядке обновить все остальные. Тесты на семантику проверяют, что эта система работает логически безупречно в самых запутанных ситуациях.
Такой результат выводит движок на уровень высокой production-готовности. Оставшиеся 2 невыполненных теста — это редкие пограничные сценарии (edge cases), которые уже локализованы и находятся в бэклоге разработки. Открытая демонстрация этих метрик подчеркивает прозрачность проекта и честность перед open-source сообществом, давая разработчикам уверенность в предсказуемости поведения системы при решении сложных задач.
Бескомпромиссная надежность: 176 из 178
Важнейшим этапом в развитии @pravosleva/reactive-engine стало тестирование на семантику графа зависимостей. Движок успешно проходит 176 из 178 специализированных тестов (более 98% стабильности). Это доказывает, что архитектура библиотеки изначально спроектирована верно: она эффективно решает классические проблемы реактивного программирования, такие как «проблема ромба» (diamond problem), корректно обрабатывает динамическое изменение связей в реальном времени и защищает приложение от избыточных вычислений.
Архитектура библиотеки блестяще справляется с ключевыми вызовами реактивного программирования: она полностью изолирована от «проблемы ромба» (diamond problem), гарантирует строгий топологический порядок выполнения, корректно управляет динамическими зависимостями и оптимизирует вычисления за счет интеллектуального кэширования. По общему числу успешно пройденных тестов семантики движок занял второе место из 16, обогнав такие зрелые экосистемные решения, как @preact/signals-core, @reatom/core, @vue/reactivity и solid-js. Такое достижение уверенно выводит @pravosleva/reactive-engine на уровень высокой production-готовности для построения сложных интерфейсов и систем управления состоянием.
Проблема ромба
Проблема ромба (Diamond Problem) в реактивном программировании — это ситуация, когда обновление одной переменной приводит к тому, что зависимая от неё функция пересчитывается дважды вместо одного раза, причем на первом шаге она получает некорректное (устаревшее) промежуточное значение.
[ A ] (изменился)
/ \
[ Б ] [ В ] (зависят от А)
\ /
[ Г ] (зависит от Б и В)А— базовый сигнал (например, число).Бзависит отА(например,A × 2).Взависит отА(например,A + 10).Гзависит и отБ, и отВ(например,Б + В).
В чем именно заключается «глюк» (Glitch)?
Пусть изначально A = 1. Тогда:
Б= 1 × 2 = 2В= 1 + 10 = 11Г= 2 + 11 = 13
Теперь мы меняем значение А на 2.
❌ Поведение наивного (плохого) движка:
Аизменилось на 2. Движок видит, что отАзависятБиВ, и начинает их обновлять.- Движок обновляет
Б. Теперь Б = 4. - Движок сразу же видит, что
Бизменился, а отБзависитГ. Он тут же пересчитываетГ:
Г = Б (новое 4) + В (старое 11) = 15Это первый рендер/расчет Г (значение неверное!).
4. Движок возвращается к исходной очереди и обновляет В. Теперь В = 12.
5. Движок видит, что В изменился, и снова пересчитывает Г:
Г = Б (новое 4) + В (новое 12) = 16Это второй расчет Г (значение правильное).
Итог: Узел Г обновился два раза. На шаге №3 в системе на мгновение появилось «глючное» значение 15, которого в природе существовать не должно. Если к узлу Г привязан тяжелый сетевой запрос или рендер интерфейса, пользователь увидит мигание некорректных данных, а приложение выполнит лишнюю холостую работу.
Как эту проблему решает @pravosleva/reactive-engine?
Правильные реактивные движки используют топологическую сортировку графа (Topological Sort) и механизм батчинга (batching/транзакций).
✅ Поведение правильного движка:
- Когда
Аменяется, движок строит правильный порядок обновления элементов на основе их "глубины" в графе:[А] -> [Б, В] -> [Г]. - Движок обновляет
Б(4) иВ(12), но замораживает (маркирует как грязный / dirty) узелГ. - Только когда все родители (
БиВ) полностью завершили свои обновления, движок один-единственный раз выполняет расчет дляГ, который сразу же получает актуальные данные: 4 + 12 = 16.
В результатах тестов, о которых мы говорили ранее, успешное прохождение бенчмарков доказывает, что @pravosleva/reactive-engine гарантирует 100% защиту от проблемы ромба. Граф вычисляется строго в топологическом порядке, без «мигания» промежуточных состояний и без лишних рендеров компонентов.
Анализ пограничных сценариев (Edge Cases)
Два невыполненных теста локализованы в категории Inner Write (запись в сигналы изнутри реактивного контекста). Они представляют собой специфические сценарии, деструктурированные в таблице ниже:
| ID Теста | Название и описание сценария | Поведение на практике / Риски |
|---|---|---|
| #179 | computed self-increment: intra-run read-after-write values correctcomputed) при каждом чтении инкрементирует свой собственный базовый сигнал и должно сразу же вернуть актуализированное состояние. | Антипаттерн в архитектуре.computed строго не рекомендуются, так как вычисляемые свойства по стандарту должны оставаться чистыми функциями без побочных эффектов. |
| #186 | effect observes computed side-channel write during propagationcomputed и за сторонним сигналом, в который это же вычисляемое свойство осуществляет скрытую запись во время расчета графа. | Скрытые побочные каналы данных. |
Открытая публикация результатов тестирования подчеркивает прозрачность разработки движка. Оба случая уже зафиксированы в бэклоге проекта, а их последующее исправление отточит реактивное поведение системы в экстремальных условиях девелоперских экспериментов, не влияя на стабильность классических паттернов проектирования.
Демонстрация
import { signal, computed, effect } from '@pravosleva/reactive-engine'
// 1. Создаем корневой сигнал (Вершина А)
const signalA = signal(1, 'Signal_A')
// 2. Создаем промежуточные вычисляемые свойства (Стороны Б и В)
const computedB = computed(() => {
const a = signalA.value
return a * 2
}, 'Computed_B')
const computedC = computed(() => {
const a = signalA.value
return a + 10
}, 'Computed_C')
// 3. Создаем результирующее вычисляемое свойство (Основание Г)
const computedD = computed(() => {
const b = computedB.value
const c = computedC.value
return b + c
}, 'Computed_D')
// Переменная для подсчета реального количества запусков эффекта
let effectTriggerCount = 0
// 4. Подписываемся на финальный узел графа через эффект
effect(() => {
effectTriggerCount++
console.log(`[Effect Trigger #${effectTriggerCount}] Текущее значение Г = ${computedD.value}`)
}, 'Final_Effect')
console.log('--- Инициализация графа завершена ---')
console.log(`Стартовые значения -> А: ${signalA.value}, Б: ${computedB.value}, В: ${computedC.value}, Г: ${computedD.value}\n`)
// 5. Изменяем корневой сигнал А и проверяем отсутствие "глюка ромба"
console.log('=== Обновление значения: А = 2 ===')
signalA.value = 2
console.log('\n--- Проверка результатов топологической сортировки ---')
console.log(`Финальные значения -> А: ${signalA.value}, Б: ${computedB.value}, В: ${computedC.value}, Г: ${computedD.value}`)
console.log(`Всего вызовов эффекта: ${effectTriggerCount} (Ожидается ровно 2: 1 при старте + 1 при обновлении)`)Что вы увидите в консоли при запуске:
[Effect Trigger #1] Текущее значение Г = 13
--- Инициализация графа завершена ---
Стартовые значения -> А: 1, Б: 2, В: 11, Г: 13
=== Обновление значения: А = 2 ===
[Effect Trigger #2] Текущее значение Г = 16
--- Проверка результатов топологической сортировки ---
Финальные значения -> А: 2, Б: 4, В: 12, Г: 16
Всего вызовов эффекта: 2 (Ожидается ровно 2: 1 при старте + 1 при обновлении)Как этот код доказывает защиту от проблемы ромба:
- Если бы движок был наивным и страдал от проблемы ромба, в процессе обновления
signalA.value = 2эффект выполнился бы лишний раз посередине, выведя промежуточное неверное значение15(новое Б(4) + старое В(11)), а счетчикeffectTriggerCountстал бы равен3. - Архитектура
@pravosleva/reactive-engineблагодаря ранней dirty-маркировке и распределению приоритетов по уровням глубины (depth) заморозила выполнение эффекта до тех пор, покаComputed_BиComputed_Cне обновили свои версии. Эффект сработал строго один раз и сразу вывел корректный результат16.
Автоматический Vitest/Jest тест-кейс для проверки "проблемы ромба"
Ниже представлен готовый изолированный тест-кейс, который можно напрямую добавить в тестовую среду проекта (например, в файл diamond.test.ts). Тест программно верифицирует топологический порядок расчёта графа и гарантирует защиту от лишних вызовов и промежуточных глитчей.
import { describe, it, expect } from 'vitest' // или 'jest'
import { signal, computed, effect, resetGlobalReactiveEngine } from '@pravosleva/reactive-engine'
describe('Reactive Engine: Diamond Problem Mitigation', () => {
it('should resolve diamond dependency graph strictly in topological order with 0 glitches', () => {
// Гарантируем чистоту эпохи и графа перед выполнением тест-кейса
resetGlobalReactiveEngine()
// 1. Инициализируем граф в форме ромба (A -> B/C -> D)
const signalA = signal(1, 'A')
const computedB = computed(() => signalA.value * 2, 'B')
const computedC = computed(() => signalA.value + 10, 'C')
const computedD = computed(() => computedB.value + computedC.value, 'D')
let effectTriggerCount = 0
const capturedValues: number[] = []
// 2. Регистрируем эффект-потребитель основания ромба
effect(() => {
effectTriggerCount++
capturedValues.push(computedD.value)
}, 'Consumer_Effect')
// Проверяем состояние графа после первоначального pull-запуска
expect(effectTriggerCount).toBe(1)
expect(computedD.value).toBe(13)
expect(capturedValues).toEqual([13])
// 3. Изменяем корневой источник изменений
signalA.value = 2
// 4. Проверяем отсутствие промежуточных глитчей и дублирующих запусков
// Если бы движок был наивным, эффект сработал бы дважды (сначала записав глитч 15)
expect(effectTriggerCount).toBe(2)
expect(computedD.value).toBe(16)
// Проверяем, что в истории изменений не зафиксировано промежуточного значения 15
expect(capturedValues).not.toContain(15)
expect(capturedValues).toEqual([13, 16])
})
})