Files
orca/src/shared/structural-value-equality.test.ts
T
NeilandOrca 5a6837fce4 refactor(store): unify the duplicated catalog equality and identity-key helpers (#13804)
* refactor(store): unify the catalog structural-equality walks

Three near-identical structural deep-equality walks had landed independently in
the same window: areValuesEqual (#13744, repo-identity-reconcile.ts),
areCatalogEntriesEqual (#13770, repos.ts — already folded into the first on this
branch's base) and catalogValuesEqual (#13662,
worktree-catalog-reconciliation.ts). All three walk plain records and arrays and
fall back to reference equality for anything exotic.

They are not interchangeable. Two axes genuinely differ, and each caller depends
on its own side:

- Own-key set. #13744/#13770 require equal own-key counts plus hasOwnProperty,
  so an absent key differs from a key present and holding `undefined`. #13662
  compares the union of both sides' keys, so those are equal. The strict side is
  load-bearing: the repo/project merges branch on
  `'localWindowsRuntimePreference' in project` (repos-project-runtime.test.ts
  "clears stale local runtime preferences"), and projects are now reconciled
  with this comparator. The loose side is test-pinned by
  worktree-catalog-reconciliation.test.ts "reuses rows with equivalent nested
  catalog data", where a locally built row carries `optional: undefined` that
  the host omits.
- Leaf comparison. #13744/#13770 use `===` (NaN never equal, 0 equals -0);
  #13662 uses `Object.is` (the reverse).

So instead of picking a winner, src/shared/structural-value-equality.ts holds
one walk parameterised by those two axes and exports the two policies as
`structuralValuesEqual` and `structuralValuesEqualIgnoringUndefined`. Every
caller keeps its exact current semantics; the ~40 duplicated lines and the
silent divergence go away. src/shared/persisted-ui-equality.ts (a fourth copy
with a Set branch and no plain-object guard) is deliberately left alone: it
gates a disk write in main with no direct test coverage.

Also folded, all provably behaviour-identical:

- The `${hostId}\0${repoId}` composite key had three copies
  (getRepoHostIdentityForParts, repoOwnerKey, getEntryKey) that must agree or
  repos silently stop reconciling. Moved to src/shared/repo-host-identity.ts
  because one of them lives in src/shared; the renderer module re-exports it.
- mergeFetchedReposForHost's hand-inlined upsert loop now calls mergeByIdentity.
  mergeByIdentity additionally skips replacing a structurally equal row, which
  cannot change the result here: reconcileFetchedRepos runs immediately after
  over the same identities in the same order and restores exactly those rows.
- Renamed repos.ts's `catalogRowsUnchanged` to `arrayElementsUnchanged`. It is a
  pure element-identity compare, two files away from
  `catalogRowsEqual`, which is a full structural compare.

src/shared/structural-value-equality.test.ts pins both policies over arrays,
nested records, null-prototype records, absent-vs-undefined keys, symbol keys,
and non-plain objects (Date/Map/Set/class) falling back to reference equality.

* fix(store): keep merged sourceRepoIds order host-independent

Prefixing the cross-host remainder made a cross-host project's sourceRepoIds
order a function of the refreshing host, so the projects reconcile never reused
the row. Also pins the repo-derived host-id contribution the new per-project
slice feeds the host-id resolvers.

Co-authored-by: Orca <help@stably.ai>

* refactor(store): migrate call sites that landed after this branch

github.ts and ai-vault-session-identity.ts began using areValuesEqual on main
while this branch was stale, and repo-identity-reconcile's record reconciler
still called its own deleted walker. All three now use structuralValuesEqual;
reuseEqualCatalogRows keeps its duplicate-id cap and calls the ignoring-undefined
variant, which is the key-union semantics catalogValuesEqual had.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-13 22:15:20 -07:00

97 lines
4.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
structuralValuesEqual,
structuralValuesEqualIgnoringUndefined
} from './structural-value-equality'
const comparators = [
['structuralValuesEqual', structuralValuesEqual],
['structuralValuesEqualIgnoringUndefined', structuralValuesEqualIgnoringUndefined]
] as const
describe.each(comparators)('%s', (_name, valuesEqual) => {
it('compares primitives and identical references', () => {
expect(valuesEqual('a', 'a')).toBe(true)
expect(valuesEqual('a', 'b')).toBe(false)
expect(valuesEqual(null, null)).toBe(true)
expect(valuesEqual(null, {})).toBe(false)
expect(valuesEqual(undefined, null)).toBe(false)
const fn = (): void => {}
expect(valuesEqual(fn, fn)).toBe(true)
expect(valuesEqual(fn, (): void => {})).toBe(false)
})
it('walks arrays element-wise and never mixes them with records', () => {
expect(valuesEqual([1, [2, { a: 'b' }]], [1, [2, { a: 'b' }]])).toBe(true)
expect(valuesEqual([1, 2], [1, 2, 3])).toBe(false)
expect(valuesEqual([1, 2], [2, 1])).toBe(false)
expect(valuesEqual([], {})).toBe(false)
expect(valuesEqual({ 0: 1, length: 1 }, [1])).toBe(false)
})
it('walks nested plain records rebuilt by structured clone', () => {
const record = { id: 'a', nested: { labels: ['one', 'two'], flags: { on: true } } }
expect(valuesEqual(record, structuredClone(record))).toBe(true)
expect(
valuesEqual(record, { ...record, nested: { labels: ['one'], flags: { on: true } } })
).toBe(false)
})
it('walks null-prototype records like literals', () => {
const nullProto: Record<string, unknown> = Object.create(null)
nullProto.a = 1
expect(valuesEqual(nullProto, { a: 1 })).toBe(true)
expect(valuesEqual(nullProto, { a: 2 })).toBe(false)
})
it('falls back to reference equality for non-plain objects', () => {
const date = new Date(0)
expect(valuesEqual(date, date)).toBe(true)
expect(valuesEqual(date, new Date(0))).toBe(false)
expect(valuesEqual(new Map([['a', 1]]), new Map([['a', 1]]))).toBe(false)
expect(valuesEqual(new Set([1]), new Set([1]))).toBe(false)
class Row {
x = 1
}
expect(valuesEqual(new Row(), new Row())).toBe(false)
expect(valuesEqual(new Row(), { x: 1 })).toBe(false)
})
it('ignores symbol keys', () => {
const key = Symbol('marker')
expect(valuesEqual({ [key]: 1, a: 1 }, { [key]: 2, a: 1 })).toBe(true)
})
})
describe('structuralValuesEqual', () => {
it('treats an absent key as different from a key holding undefined', () => {
// Why: repo/project merges branch on `'key' in project`, so key presence is load-bearing.
expect(structuralValuesEqual({ a: 1 }, { a: 1, b: undefined })).toBe(false)
expect(structuralValuesEqual({ a: 1, b: undefined }, { a: 1 })).toBe(false)
expect(structuralValuesEqual({ a: 1, b: undefined }, { a: 1, c: undefined })).toBe(false)
expect(structuralValuesEqual({ a: 1, b: undefined }, { a: 1, b: undefined })).toBe(true)
})
it('compares leaves with === so NaN is never equal and -0 matches 0', () => {
expect(structuralValuesEqual({ a: Number.NaN }, { a: Number.NaN })).toBe(false)
expect(structuralValuesEqual({ a: 0 }, { a: -0 })).toBe(true)
})
})
describe('structuralValuesEqualIgnoringUndefined', () => {
it('treats an absent key as equal to a key holding undefined', () => {
// Why: locally built worktree rows carry explicit undefined fields the host omits.
expect(structuralValuesEqualIgnoringUndefined({ a: 1 }, { a: 1, b: undefined })).toBe(true)
expect(structuralValuesEqualIgnoringUndefined({ a: 1, b: undefined }, { a: 1 })).toBe(true)
expect(
structuralValuesEqualIgnoringUndefined({ a: 1, b: undefined }, { a: 1, c: undefined })
).toBe(true)
expect(structuralValuesEqualIgnoringUndefined({ a: 1 }, { a: 1, b: 2 })).toBe(false)
})
it('compares leaves with Object.is so NaN is equal and -0 differs from 0', () => {
expect(structuralValuesEqualIgnoringUndefined({ a: Number.NaN }, { a: Number.NaN })).toBe(true)
expect(structuralValuesEqualIgnoringUndefined({ a: 0 }, { a: -0 })).toBe(false)
})
})