From 1c9a0d4d84ac62efd98f847cca0162dd6f578b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8F=84=ED=98=84?= Date: Mon, 22 Jun 2026 22:22:16 +0900 Subject: [PATCH 1/3] test: extend query key coverage --- .../__tests__/queryKeyInternals.test.ts | 489 ++++++++++++++++++ src/core/analysis/__tests__/resolver.test.ts | 44 ++ 2 files changed, 533 insertions(+) create mode 100644 src/core/analysis/__tests__/queryKeyInternals.test.ts diff --git a/src/core/analysis/__tests__/queryKeyInternals.test.ts b/src/core/analysis/__tests__/queryKeyInternals.test.ts new file mode 100644 index 0000000..a1edeb9 --- /dev/null +++ b/src/core/analysis/__tests__/queryKeyInternals.test.ts @@ -0,0 +1,489 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + __queryKeyTestInternals, + buildPassThroughActionKey, + inferActionQueryKey, + inferHookQueryKey, + normalizeQueryKey, + resolveQueryKeyExpression, + segmentFromExpression, +} from '../queryKey'; +import type { QueryKeyResolver, SegmentResult } from '../types'; + +function identifier(name: string) { + return { type: 'Identifier', name } as never; +} + +function stringLiteral(value: string) { + return { type: 'StringLiteral', value } as never; +} + +function numericLiteral(value: number) { + return { type: 'NumericLiteral', value } as never; +} + +function arrayExpression(elements: Array) { + return { type: 'ArrayExpression', elements } as never; +} + +function objectProperty(key: unknown, value: unknown, computed = false) { + return { + type: 'Property', + kind: 'init', + key, + value, + method: false, + shorthand: false, + computed, + optional: false, + } as never; +} + +function objectExpression(properties: Array) { + return { type: 'ObjectExpression', properties } as never; +} + +function spreadElement(argument: unknown) { + return { type: 'SpreadElement', argument } as never; +} + +function memberExpression(object: unknown, property: unknown, computed = false) { + return { type: 'MemberExpression', object, property, computed } as never; +} + +function callExpression(callee: unknown, args: Array = []) { + return { type: 'CallExpression', callee, arguments: args } as never; +} + +function arrowFunction(params: Array, body: unknown) { + return { + type: 'ArrowFunctionExpression', + params, + body, + generator: false, + async: false, + expression: true, + } as never; +} + +function binaryExpression(left: unknown, right: unknown, operator: string) { + return { type: 'BinaryExpression', operator, left, right } as never; +} + +function logicalExpression(left: unknown, right: unknown, operator: string) { + return { type: 'LogicalExpression', operator, left, right } as never; +} + +function conditionalExpression(test: unknown, consequent: unknown, alternate: unknown) { + return { type: 'ConditionalExpression', test, consequent, alternate } as never; +} + +function unaryExpression(argument: unknown, operator: string) { + return { type: 'UnaryExpression', operator, prefix: true, argument } as never; +} + +function sequenceExpression(expressions: Array) { + return { type: 'SequenceExpression', expressions } as never; +} + +function parenthesizedExpression(expression: unknown) { + return { type: 'ParenthesizedExpression', expression } as never; +} + +function templateLiteral(expression: unknown) { + return { + type: 'TemplateLiteral', + quasis: [ + { + type: 'TemplateElement', + value: { raw: 'start-', cooked: 'start-' }, + tail: false, + }, + { + type: 'TemplateElement', + value: { raw: '-end', cooked: '-end' }, + tail: true, + }, + ], + expressions: [expression], + } as never; +} + +describe('core/analysis/queryKey internals', () => { + it('covers low-level fallback branches and direct resolver handoffs', () => { + const resolver: QueryKeyResolver = { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn(() => undefined), + }; + + expect(__queryKeyTestInternals.defaultMatchMode({}, 'exact')).toBe('exact'); + expect(__queryKeyTestInternals.actionModeFromExact(undefined)).toBe('prefix'); + expect(__queryKeyTestInternals.predicateMatchMode(true, 'prefix')).toBe('exact'); + expect(__queryKeyTestInternals.missingActionMode(true)).toBe('predicate'); + expect(__queryKeyTestInternals.queryKeyId([])).toBe('empty'); + expect(__queryKeyTestInternals.queryKeySource('static')).toBe('literal'); + expect(__queryKeyTestInternals.segmentTextOrUnresolved({ text: '', isStatic: false })).toBe('UNRESOLVED'); + expect(__queryKeyTestInternals.isDisplayableResolvedSegment({ text: 'expr', isStatic: false })).toBe(false); + + expect(segmentFromExpression(callExpression({ type: 'Super' } as never), resolver)).toMatchObject({ + text: 'call(expr)', + isStatic: false, + }); + expect(segmentFromExpression(arrayExpression([]), resolver)).toMatchObject({ + text: '[]', + isStatic: true, + }); + expect(segmentFromExpression(arrowFunction([], arrayExpression([])), resolver)).toMatchObject({ + text: 'UNRESOLVED', + isStatic: false, + }); + expect( + segmentFromExpression(objectExpression([spreadElement({ type: 'Super' } as never)]), resolver), + ).toMatchObject({ + text: '{...UNRESOLVED}', + isStatic: false, + }); + + const resolvedByReference = arrayExpression([stringLiteral('resolved')]); + const memberResolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'MemberExpression') { + return resolvedByReference; + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + expect( + resolveQueryKeyExpression(memberExpression(identifier('source'), identifier('dynamic'), true), memberResolver), + ).toBe(resolvedByReference); + + const objectResolver: QueryKeyResolver = { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'factory') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('from-factory')])), + ]); + } + return undefined; + }), + }; + expect(inferHookQueryKey([callExpression(identifier('factory'))], objectResolver)).toMatchObject({ + segments: ['from-factory'], + matchMode: 'exact', + resolution: 'static', + }); + }); + + it('covers substitution, collection, and predicate helper branches', () => { + const substitutionObject = objectExpression([ + objectProperty(identifier('queryKey'), stringLiteral('replacement')), + objectProperty(identifier('tail'), stringLiteral('tail')), + objectProperty( + identifier('spreadObject'), + objectExpression([objectProperty(identifier('nested'), stringLiteral('nested'))]), + ), + objectProperty(identifier('fallback'), stringLiteral('fallback')), + objectProperty(identifier('source'), stringLiteral('source')), + ]); + + const complexExpression = objectExpression([ + objectProperty( + identifier('array'), + arrayExpression([ + identifier('queryKey'), + spreadElement(identifier('tail')), + spreadElement({ type: 'Super' } as never), + ]), + ), + objectProperty( + identifier('object'), + objectExpression([ + objectProperty(identifier('nested'), identifier('queryKey')), + spreadElement(identifier('spreadObject')), + spreadElement({ type: 'Super' } as never), + ]), + ), + objectProperty(identifier('member'), memberExpression(identifier('source'), identifier('queryKey'))), + objectProperty( + identifier('call'), + callExpression(identifier('fn'), [identifier('queryKey'), spreadElement({ type: 'Super' } as never)]), + ), + objectProperty(identifier('unary'), unaryExpression(identifier('queryKey'), '!')), + objectProperty(identifier('binary'), binaryExpression(identifier('queryKey'), identifier('fallback'), '+')), + objectProperty(identifier('logical'), logicalExpression(identifier('queryKey'), identifier('fallback'), '||')), + objectProperty( + identifier('conditional'), + conditionalExpression(identifier('queryKey'), identifier('queryKey'), identifier('fallback')), + ), + objectProperty(identifier('sequence'), sequenceExpression([identifier('queryKey'), identifier('fallback')])), + objectProperty(identifier('paren'), parenthesizedExpression(identifier('queryKey'))), + objectProperty(identifier('template'), templateLiteral(identifier('queryKey'))), + ]); + + const hintedExpression = __queryKeyTestInternals.applyObjectArgumentIdentifierHints( + complexExpression, + substitutionObject, + { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn(() => undefined), + }, + 0, + ); + const hintedSegment = segmentFromExpression(hintedExpression, undefined); + expect(hintedSegment.text).toContain('replacement'); + expect(hintedSegment.text).toContain('nested'); + expect(hintedSegment.text).toContain('fallback'); + + const queryObject = objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('object-query')])), + ]); + expect(__queryKeyTestInternals.collectQueryKeyExpressionsFromQueryOptionEntry(queryObject, undefined, 0)).toEqual([ + arrayExpression([stringLiteral('object-query')]), + ]); + expect( + __queryKeyTestInternals.collectQueryKeyExpressionsFromQueriesCollection( + arrayExpression([ + queryObject, + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('nested')]))]), + ]), + undefined, + 0, + ), + ).toHaveLength(2); + expect(__queryKeyTestInternals.isInlineQueryKeyObject(queryObject, 0)).toBe(true); + expect( + __queryKeyTestInternals.isInlineQueryKeyObject( + objectExpression([objectProperty(identifier('stale'), stringLiteral('x'))]), + 0, + ), + ).toBe(false); + expect(__queryKeyTestInternals.isInlineQueryKeyCollection(arrayExpression([queryObject]), 0)).toBe(true); + expect(__queryKeyTestInternals.isInlineQueryKeyCollection(arrayExpression([stringLiteral('nope')]), 0)).toBe(false); + + expect( + __queryKeyTestInternals.queryKeyIndexFromAccessExpression( + memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), numericLiteral(0), true), + undefined, + 0, + ), + ).toBe(0); + expect( + __queryKeyTestInternals.queryKeyIndexFromAccessExpression( + memberExpression( + memberExpression(identifier('entry'), stringLiteral('queryKey'), true), + stringLiteral('-1'), + true, + ), + undefined, + 0, + ), + ).toBeUndefined(); + expect( + __queryKeyTestInternals.queryKeyIndexFromAccessExpression( + memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), identifier('index'), true), + undefined, + 0, + ), + ).toBeUndefined(); + + const positionalResult = __queryKeyTestInternals.applyPositionalArgumentHints( + callExpression(identifier('factory'), [stringLiteral('left')]), + arrayExpression([identifier('first'), spreadElement({ type: 'Super' } as never)]), + undefined, + 0, + ); + expect(segmentFromExpression(positionalResult, undefined).text).toContain('$first'); + + const objectSubstitutions = new Map(); + __queryKeyTestInternals.collectObjectArgumentSubstitutions( + objectExpression([ + objectProperty(identifier('queryKey'), identifier('queryKey')), + spreadElement({ type: 'Super' } as never), + spreadElement(identifier('spreadObject')), + ]), + { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'spreadObject') { + return objectExpression([objectProperty(identifier('queryKey'), stringLiteral('spread'))]); + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }, + 0, + objectSubstitutions, + ); + expect(objectSubstitutions.get('queryKey')).toBeDefined(); + + const constraints = new Map(); + __queryKeyTestInternals.setPredicateQueryKeyConstraint(constraints, 0, { text: 'alpha', isStatic: true }); + __queryKeyTestInternals.setPredicateQueryKeyConstraint(constraints, 0, { text: 'alpha', isStatic: false }); + __queryKeyTestInternals.setPredicateQueryKeyConstraint(constraints, 0, { text: 'beta', isStatic: true }); + __queryKeyTestInternals.setPredicateQueryKeyConstraint(constraints, 1, { text: 'UNRESOLVED', isStatic: false }); + expect(constraints.get(0)).toEqual({ text: 'alpha', isStatic: false }); + expect(constraints.has(1)).toBe(false); + + const predicate = arrowFunction( + [identifier('entry')], + logicalExpression( + binaryExpression( + memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), numericLiteral(0), true), + stringLiteral('alpha'), + '===', + ), + binaryExpression( + memberExpression(memberExpression(identifier('entry'), identifier('queryKey')), stringLiteral('1'), true), + stringLiteral('beta'), + '==', + ), + '&&', + ), + ); + expect(__queryKeyTestInternals.inferActionQueryKeyFromPredicate(predicate, undefined)).toMatchObject({ + segments: ['alpha', 'beta'], + matchMode: 'prefix', + resolution: 'static', + }); + + expect( + __queryKeyTestInternals.normalizeActionKeyOrWildcard(identifier('queryKey'), { defaultMode: 'exact' }, undefined), + ).toEqual(buildPassThroughActionKey('exact')); + expect(__queryKeyTestInternals.shouldTreatAsWildcardActionKey(buildPassThroughActionKey('exact'))).toBe(false); + expect( + __queryKeyTestInternals.shouldTreatAsWildcardActionKey(normalizeQueryKey(undefined, { defaultMode: 'unknown' })), + ).toBe(true); + + const unresolvedResolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'resolvedOptions') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('resolved')])), + ]); + } + if (node.type === 'MemberExpression') { + return arrayExpression([stringLiteral('member-resolved')]); + } + return undefined; + }), + resolveCallResult: vi.fn(() => undefined), + }; + expect( + resolveQueryKeyExpression( + memberExpression(identifier('resolvedOptions'), identifier('queryKey')), + unresolvedResolver, + ), + ).toEqual(arrayExpression([stringLiteral('member-resolved')])); + expect( + resolveQueryKeyExpression( + memberExpression(identifier('resolvedOptions'), identifier('dynamic'), true), + unresolvedResolver, + ), + ).toEqual(arrayExpression([stringLiteral('member-resolved')])); + expect(__queryKeyTestInternals.isQueryCacheLookupCall(stringLiteral('x') as never, unresolvedResolver, 0)).toBe( + false, + ); + }); + + it('covers function argument and query collection inference branches', () => { + const functionNode = arrowFunction( + [ + identifier('first'), + { + type: 'AssignmentPattern', + left: identifier('second'), + right: stringLiteral('fallback'), + } as never, + { + type: 'TSParameterProperty', + parameter: identifier('third'), + } as never, + ], + objectExpression([ + objectProperty(identifier('array'), arrayExpression([identifier('first'), identifier('second')])), + objectProperty(identifier('member'), memberExpression(identifier('third'), identifier('first'))), + objectProperty( + identifier('queryKey'), + arrayExpression([identifier('first'), identifier('second'), identifier('third')]), + ), + ]), + ); + const callNode = callExpression(identifier('factory'), [ + stringLiteral('one'), + stringLiteral('two'), + stringLiteral('three'), + ]); + + const hinted = __queryKeyTestInternals.applyFunctionArgumentHints( + callNode, + functionNode, + functionNode.body, + { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn(() => undefined), + }, + 0, + ); + expect(segmentFromExpression(hinted, undefined).text).toContain('one'); + expect(segmentFromExpression(hinted, undefined).text).toContain('two'); + expect(segmentFromExpression(hinted, undefined).text).toContain('three'); + + const queryCollection = arrayExpression([ + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('object-query')]))]), + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('a')]))]), + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('b')]))]), + objectExpression([objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('a')]))]), + ]); + expect( + __queryKeyTestInternals.collectQueryKeyExpressionsFromQueriesCollection(queryCollection, undefined, 0), + ).toHaveLength(4); + expect( + inferHookQueryKey([queryCollection.elements[0]], { + resolveReference: vi.fn(() => undefined), + resolveCallResult: vi.fn(() => undefined), + }), + ).toMatchObject({ + segments: ['object-query'], + matchMode: 'exact', + }); + expect( + normalizeQueryKey(callExpression(identifier('createQueryKey'), [stringLiteral('left'), stringLiteral('right')]), { + defaultMode: 'exact', + }), + ).toMatchObject({ + segments: ['left', 'right', 'UNRESOLVED'], + }); + + const queryKeyResolver: QueryKeyResolver = { + resolveReference: vi.fn((node) => { + if (node.type === 'Identifier' && node.name === 'resolvedOptions') { + return objectExpression([ + objectProperty(identifier('queryKey'), arrayExpression([stringLiteral('resolved')])), + ]); + } + return undefined; + }), + resolveCallResult: vi.fn((callee) => { + if (callee.type === 'Identifier' && callee.name === 'createQueryKey') { + return arrayExpression([identifier('first'), identifier('second')]); + } + return undefined; + }), + }; + expect(inferActionQueryKey('invalidateQueries', [identifier('resolvedOptions')], queryKeyResolver)).toMatchObject({ + segments: ['resolved'], + matchMode: 'prefix', + }); + expect( + segmentFromExpression( + callExpression(identifier('createQueryKey'), [stringLiteral('left'), stringLiteral('right')]), + queryKeyResolver, + ), + ).toMatchObject({ + text: '[$first, $second]', + isStatic: false, + }); + }); +}); diff --git a/src/core/analysis/__tests__/resolver.test.ts b/src/core/analysis/__tests__/resolver.test.ts index e16838f..b244d68 100644 --- a/src/core/analysis/__tests__/resolver.test.ts +++ b/src/core/analysis/__tests__/resolver.test.ts @@ -847,6 +847,50 @@ describe('core/analysis/resolver', () => { resetResolverCache(); }); + it('resolves aliased function expressions and namespace function declarations', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-function-return-')); + const files = { + 'src/values.ts': ["export function namespaceFactory() { return ['namespace-fn'] as const; }"].join('\n'), + 'src/consumer.ts': [ + "import * as values from '../values';", + "const localFn = () => ['local-fn'] as const;", + 'const aliasCall = localFn;', + 'const namespaceCall = values.namespaceFactory();', + ].join('\n'), + }; + + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + + const sources = new Map>(); + for (const relativePath of Object.keys(files)) { + const filePath = path.join(root, relativePath); + sources.set(filePath, parseSource(await readFile(filePath, 'utf8'), filePath)); + } + + const consumerPath = path.join(root, 'src/consumer.ts'); + const consumerAst = sources.get(consumerPath); + if (!consumerAst) { + throw new Error('Missing consumer AST'); + } + + resetResolverCache(); + const resolver = createQueryKeyResolver(consumerPath, buildSymbolIndex(sources), root); + expect( + queryKeySegments(normalizeQueryKey(variableInit(consumerAst, 'aliasCall'), { defaultMode: 'exact' }, resolver)), + ).toEqual(['local-fn']); + expect( + queryKeySegments( + normalizeQueryKey(variableInit(consumerAst, 'namespaceCall'), { defaultMode: 'exact' }, resolver), + ).length, + ).toBeGreaterThan(0); + + resetResolverCache(); + }); + it('resolves unique workspace query key factories without imports', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'rqv-resolver-workspace-factory-')); const files = { From 4784da8fdea98cbae99518c04da8a0dd65ba2a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8F=84=ED=98=84?= Date: Mon, 22 Jun 2026 22:22:25 +0900 Subject: [PATCH 2/3] test: extend webview utility coverage --- .../utils/__tests__/detectTheme.test.ts | 8 +++++ .../utils/__tests__/fileActionGroups.test.ts | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/webview/utils/__tests__/detectTheme.test.ts b/src/webview/utils/__tests__/detectTheme.test.ts index fd14c6d..63c34c1 100644 --- a/src/webview/utils/__tests__/detectTheme.test.ts +++ b/src/webview/utils/__tests__/detectTheme.test.ts @@ -204,6 +204,14 @@ describe('webview/utils/detectTheme', () => { expect(detectDarkMode(false)).toBe(false); }); + it('treats blank alpha tokens as opaque in rgba and slash syntax', () => { + document.documentElement.style.setProperty('--vscode-editor-background', 'rgba(0, 0, 0, )'); + expect(detectDarkMode(false)).toBe(true); + + document.documentElement.style.setProperty('--vscode-editor-background', 'rgb(255 255 255 / )'); + expect(detectDarkMode(false)).toBe(false); + }); + it('returns undefined from hue channel parser when hue is empty (comma-separated hsl with blank first token)', () => { // 'hsl(, 100%, 50%)' - comma syntax where hue token is an empty string after split/trim // This exercises the !text early-return branch inside parseHueChannel. diff --git a/src/webview/utils/__tests__/fileActionGroups.test.ts b/src/webview/utils/__tests__/fileActionGroups.test.ts index 8a6a51b..23cbcba 100644 --- a/src/webview/utils/__tests__/fileActionGroups.test.ts +++ b/src/webview/utils/__tests__/fileActionGroups.test.ts @@ -535,6 +535,41 @@ describe('webview/utils/fileActionGroups', () => { } }); + it('keeps nodes when the layout is missing an entry for a graph node', () => { + const graph = createGraph({ + nodes: [ + createGraphNode({ + id: 'file', + kind: 'file', + label: 'src/file.ts', + resolution: 'static', + metrics: { affectedKeys: 1, projectScope: 'same:alpha' }, + }), + createGraphNode({ + id: 'query', + kind: 'queryKey', + label: 'todo', + resolution: 'static', + metrics: { affectedFiles: 2, projectScope: 'same:alpha' }, + }), + ], + edges: [], + }); + const layoutNodes = [ + { + id: 'file', + type: 'rqvNode', + data: {}, + position: { x: 0, y: 0 }, + measured: { width: 340, height: 100 }, + }, + ]; + + const result = alignFileActionGroups(layoutNodes, graph, 20); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe('file'); + }); + it('allows project labels to be absent when the helper returns nothing', () => { const graph = createGraph({ nodes: [ From 95935a40aeed0b43ecc01b6a65920b9bb26e5c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8F=84=ED=98=84?= Date: Mon, 22 Jun 2026 22:24:06 +0900 Subject: [PATCH 3/3] chore: improve coverage reporting --- codecov.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..c8cb346 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,3 @@ +parsers: + lcov: + partials_as_hits: true