use test_util::prelude::sim_assert_eq;
use super::*;
#[test]
fn destructured_range_map_input_does_not_become_output_array() {
let src = indoc! {r"
apiVersion: v1
kind: Pod
spec:
containers:
- name: test
image: busybox
env:
{{- range $key, $value := .Values.environment }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
"};
let values_yaml = indoc! {"
environment:
INBUCKET_LOGLEVEL: debug
"};
let schema = schema_for_values_yaml(parse_ir(src), Some(values_yaml));
let environment = schema
.pointer("/properties/environment")
.expect("environment present");
let map_arm = ranged_arm_of_type(environment, "object")
.unwrap_or_else(|| panic!("environment object arm missing, got {environment}"));
sim_assert_eq!(
have: map_arm
.pointer("/additionalProperties/type")
.and_then(Value::as_str),
want: Some("string"),
"environment should generalize to an open string map when the chart ranges over its entries, got {environment}"
);
assert!(
ranged_arm_of_type(environment, "integer").is_none(),
"a destructured range must not admit integer counts, got {environment}"
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "the complete fixture scenario is clearest as one contiguous test"
)]
fn destructured_range_with_len_guard_preserves_shape_erased_members() {
let src = indoc! {r"
apiVersion: v1
kind: Pod
spec:
containers:
- name: test
image: busybox
{{- if (gt (len .Values.environment) 0) }}
env:
{{- range $key, $value := .Values.environment }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
"};
let values_yaml = indoc! {"
environment:
INBUCKET_LOGLEVEL: debug
"};
let schema = schema_for_values_yaml(parse_ir(src), Some(values_yaml));
let mut properties = serde_json::Map::new();
properties.insert(
"environment".to_string(),
serde_json::json!({
"allOf": [{
"if": {
"anyOf": [
{ "type": "object" },
{ "$ref": "#/$defs/t" },
]
},
"then": {
"type": ["array", "null", "object"]
}
}]
}),
);
let range_condition = serde_json::json!({
"properties": {
"environment": {
"anyOf": [
{ "type": "object" },
{ "$ref": "#/$defs/t" },
]
}
},
"required": ["environment"],
"type": "object",
});
let all_of = vec![
serde_json::json!({
"if": range_condition,
"then": { "allOf": [
root_property_schema(
"environment",
serde_json::json!({
"anyOf": [
{
"propertyNames": {
"allOf": plain_token_exclusions(true),
},
"type": "object",
},
{ "type": "array" },
{ "type": "null" },
]
}),
),
root_property_schema(
"environment",
serde_json::json!({ "type": ["array", "null", "object"] }),
),
] },
}),
root_property_schema(
"environment",
serde_json::json!({
"anyOf": [
{ "type": "object" },
{ "maxItems": 0, "type": "array" },
{ "type": "null" },
]
}),
),
root_property_schema(
"environment",
serde_json::json!({ "not": { "type": "boolean" } }),
),
root_property_schema(
"environment",
serde_json::json!({ "not": { "type": "integer" } }),
),
root_property_schema(
"environment",
serde_json::json!({ "not": { "type": "number" } }),
),
navigated_host_clause(&["environment"]),
];
sim_assert_eq!(
have: &schema,
want: &expected_values_schema(properties, all_of, true)
);
for environment in [
serde_json::json!({ "LOG_LEVEL": "debug" }),
serde_json::json!({ "RETRIES": 7 }),
] {
assert!(
schema_accepts_instance(&schema, &serde_json::json!({ "environment": environment })),
"quoted range values accept every input shape: {schema}"
);
}
}
#[test]
fn collection_selection_projects_item_conversion_to_source_items() {
let src = indoc! {r"
apiVersion: v1
kind: Pod
metadata:
name: test
spec:
containers:
- name: test
image: busybox
env:
{{- range initial .Values.teams }}
- name: TEAM
value: {{ . | quote }}
{{- end }}
- name: LAST_TEAM
value: {{ .Values.teams | last | quote }}
{{- if .Values.strict }}
{{- range .Values.teams }}
- name: STRICT_TEAM
value: {{ . | b64enc | quote }}
{{- end }}
{{- end }}
"};
let values_yaml = indoc! {"
teams:
- first
- last
strict: false
"};
let schema = schema_for_values_yaml(parse_ir(src), Some(values_yaml));
for teams in [
serde_json::json!([]),
serde_json::json!([7, 8]),
serde_json::json!([{ "name": "first" }, { "name": "last" }]),
] {
let instance = serde_json::json!({ "teams": teams, "strict": false });
assert!(
schema_accepts_instance(&schema, &instance),
"initial/range and last/quote observe formatted item text: \
instance={instance}; schema={schema}"
);
}
assert!(
!schema_accepts_instance(
&schema,
&serde_json::json!({ "teams": [7], "strict": true })
),
"a live independent b64enc consumer still requires string items: {schema}"
);
assert!(
schema_accepts_instance(
&schema,
&serde_json::json!({ "teams": ["secret"], "strict": true })
),
"string items satisfy the independent strict consumer: {schema}"
);
}
#[test]
fn scalar_item_range_keeps_provider_array_metadata() {
let src = indoc! {r"
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: test
spec:
accessModes:
{{- range .Values.accessModes }}
- {{ . | quote }}
{{- end }}
"};
let values_yaml = indoc! {"
accessModes:
- ReadWriteOnce
"};
let schema = schema_for_values_yaml(parse_ir(src), Some(values_yaml));
let access_modes = schema
.pointer("/properties/accessModes")
.expect("accessModes present");
let array_arm = schema_variant_matching(access_modes, |variant| {
variant.get("type").and_then(Value::as_str) == Some("array")
&& variant.get("description").is_some()
})
.unwrap_or_else(|| panic!("provider array arm missing, got {access_modes}"));
sim_assert_eq!(
have: array_arm.get("items"),
want: Some(&serde_json::json!({})),
"quoted items render any input through strval, so the provider string typing must not flow back, got {access_modes}"
);
assert!(
array_arm
.pointer("/description")
.and_then(Value::as_str)
.is_some(),
"accessModes should keep the provider description, got {access_modes}"
);
sim_assert_eq!(
have: array_arm
.pointer("/x-kubernetes-list-type")
.and_then(Value::as_str),
want: Some("atomic"),
"accessModes should keep the provider list metadata, got {access_modes}"
);
}
#[test]
fn scalar_range_wrapped_into_object_items_keeps_scalar_preimage() {
let src = indoc! {r"
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: test
spec:
rules:
{{- range .Values.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
pathType: Prefix
backend:
service:
name: app
port:
number: 80
{{- end }}
{{- end }}
"};
let values_yaml = indoc! {"
hosts:
- host: example.test
paths:
- /
"};
let schema = schema_for_values_yaml(parse_ir(src), Some(values_yaml));
let hosts = schema.pointer("/properties/hosts").expect("hosts present");
let hosts_arm = ranged_arm_of_type(hosts, "array")
.unwrap_or_else(|| panic!("hosts array arm missing, got {hosts}"));
let host_paths = hosts_arm
.pointer("/items/properties/paths")
.expect("hosts[].paths present");
let paths_arm = ranged_arm_of_type(host_paths, "array")
.unwrap_or_else(|| panic!("hosts[].paths array arm missing, got {host_paths}"));
let path_items = paths_arm.get("items").expect("hosts[].paths items");
assert!(
schema_contains_type(path_items, "string"),
"hosts[].paths items should retain the provider string branch, got {host_paths}"
);
assert!(
schema_accepts_instance(path_items, &serde_json::json!({ "segment": "value" })),
"a YAML-safe Go-formatted mapping still renders as a scalar, got {host_paths}"
);
assert!(
!schema_accepts_instance(path_items, &serde_json::json!(["/nested"])),
"a Go-formatted sequence renders as a flow sequence, got {host_paths}"
);
}
#[test]
fn scalar_range_with_root_helper_stays_scalar_array() {
let src = indoc! {r#"
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: test
spec:
rules:
{{- range .Values.hosts }}
{{- $url := splitList "/" . }}
- host: {{ first $url }}
http:
paths:
- path: /{{ rest $url | join "/" }}
pathType: Prefix
backend:
service:
name: {{ include "fullname" $ }}
port:
number: 80
{{- end }}
"#};
let helpers = indoc! {r#"
{{- define "fullname" -}}
{{- .Chart.Name -}}
{{- end -}}
"#};
let values_yaml = indoc! {"
hosts:
- /
"};
let schema = schema_for_values_yaml(parse_ir_with_helpers(src, helpers), Some(values_yaml));
let hosts = schema.pointer("/properties/hosts").expect("hosts present");
let array_arm = ranged_arm_of_type(hosts, "array")
.unwrap_or_else(|| panic!("hosts array arm missing, got {hosts}"));
sim_assert_eq!(
have: array_arm.pointer("/items/type").and_then(Value::as_str),
want: Some("string"),
"hosts items should stay strings, got {hosts}"
);
assert!(
array_arm.pointer("/items/properties/Chart").is_none(),
"root helper fields must not be projected onto range items, got {hosts}"
);
}
#[test]
fn map_entry_range_over_values_path_keeps_object_map_schema() {
let src = indoc! {r"
apiVersion: v1
kind: ConfigMap
metadata:
name: test
data:
{{- range $key, $value := .Values.controller.config }}
{{- $key | nindent 2 }}: {{ tpl (toString $value) $ | quote }}
{{- end }}
"};
let values_yaml = indoc! {"
controller:
config: {}
"};
let contract = parse_ir(src);
let signals = schema_signals_for(&contract);
let facts = signals
.evidence_for("controller.config")
.map(|evidence| evidence.facts)
.expect("controller.config fact present");
assert!(
facts.is_ranged_source,
"range header should mark controller.config as a ranged source, facts={facts:#?}"
);
assert!(
facts.used_as_fragment,
"map-entry range should mark controller.config as a rendered fragment, facts={facts:#?}"
);
let schema = schema_for_values_yaml(&contract, Some(values_yaml));
let config = schema
.pointer("/properties/controller/properties/config")
.expect("controller.config schema present");
assert!(
schema_contains_type(config, "object"),
"controller.config should retain an object-valued branch, got {config}"
);
assert!(
!schema_contains_type(config, "string"),
"controller.config should not collapse to a scalar string branch, got {config}"
);
assert!(
schema_accepts_instance(
&schema,
&serde_json::json!({"controller": {"config": {"allow-snippet-annotations": "true"}}}),
),
"controller.config should accept arbitrary ConfigMap data keys: {schema:#}"
);
assert!(
!schema_accepts_instance(
&schema,
&serde_json::json!({"controller": {"config": "allow-snippet-annotations=true"}}),
),
"controller.config should not collapse to a scalar string: {schema:#}"
);
}
#[test]
fn wildcard_source_path_types_both_collection_lanes_without_empty_variant() {
let uses = vec![ContractUse {
source_expr: "image.pullSecrets.*".to_string(),
path: helm_schema_ir::YamlPath(vec![
"spec".to_string(),
"imagePullSecrets[*]".to_string(),
"name".to_string(),
]),
kind: ValueKind::Scalar,
condition: helm_schema_core::GuardDnf::from_guards(Vec::new()),
resource: Some(ResourceRef::concrete("v1".to_string(), "Pod".to_string())),
provenance: Vec::new(),
stringified: false,
template_supplied_member_keys: std::collections::BTreeSet::default(),
split_segment: None,
merge_layers: None,
range_key: false,
nil_omitting: false,
omitted_members: std::collections::BTreeMap::default(),
digest: false,
merge_operand: false,
}];
let values_yaml = indoc! {"
image:
pullSecrets: []
"};
let schema = schema_for_values_yaml(&uses, Some(values_yaml));
let pull_secrets = schema
.pointer("/properties/image/properties/pullSecrets")
.expect("image.pullSecrets present");
let array_items = pull_secrets
.pointer("/anyOf/0/items")
.expect("array items carry the rendered scalar preimage");
let map_values = pull_secrets
.pointer("/anyOf/1/additionalProperties")
.expect("map values carry the rendered scalar preimage");
sim_assert_eq!(have: array_items, want: map_values);
for value in [
serde_json::json!("secret"),
serde_json::json!("secret # comment"),
] {
assert!(
schema_accepts_instance(array_items, &value),
"both safe scalar spellings satisfy the rendered name preimage: {array_items}"
);
}
for value in [
serde_json::json!(7),
serde_json::json!(true),
serde_json::json!([]),
] {
assert!(
!schema_accepts_instance(array_items, &value),
"the rendered name preimage rejects non-string node kinds: {array_items}"
);
}
assert!(
schema_accepts_instance(array_items, &serde_json::json!({})),
"an empty mapping is Go-formatted into a YAML string: {array_items}"
);
let arms = pull_secrets
.get("anyOf")
.and_then(Value::as_array)
.expect("two-lane union");
sim_assert_eq!(have: arms.len(), want: 2);
assert!(
arms.iter()
.all(|arm| !crate::schema_model::is_empty_schema(arm)),
"no empty artifact arm may survive: {pull_secrets}"
);
}